fix: share the dashboard assistant and count leads from data
The hero ask box now uses the same confirm-before-create path as the assistant, count questions use real filters, and lead mobile, Jalali follow-ups, and rial labels stay consistent. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -12,22 +12,33 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
type Actor,
|
||||
assigneeWhere,
|
||||
elevated,
|
||||
forScope,
|
||||
noteAccessWhere,
|
||||
dealAccessWhere,
|
||||
ownerWhere,
|
||||
projectCardAccessWhere,
|
||||
ticketAccessWhere,
|
||||
} from '../common/record-scope';
|
||||
import {
|
||||
BILLING_PLAN_LABELS,
|
||||
BUSINESS_FIELD_LABELS,
|
||||
PERSON_IMPORT_SOURCE_LABELS,
|
||||
USER_ROLE_LABELS,
|
||||
brand,
|
||||
brandSuggestionFa,
|
||||
isBusinessField,
|
||||
type BillingPlanCode,
|
||||
type PersonImportSource,
|
||||
type UserRole,
|
||||
} from '@mento/shared';
|
||||
import { startOfJalaliMonth, toJalali } from '../common/jalali';
|
||||
import { AiService } from './ai.service';
|
||||
import {
|
||||
classifyCountQuestion,
|
||||
personSourceKeys,
|
||||
type CountIntent,
|
||||
} from './ask-count';
|
||||
|
||||
type CatalogItem = AskMentoSourceDto;
|
||||
|
||||
@@ -136,6 +147,9 @@ export class AiAskService {
|
||||
personId?: string,
|
||||
chatContext?: string,
|
||||
): Promise<AskMentoResponse> {
|
||||
const direct = await this.directCount(actor, question);
|
||||
if (direct) return { answer: direct, sources: [] };
|
||||
|
||||
const { context, catalog } = await this.buildContext(actor, personId);
|
||||
const chatBlock = String(chatContext || '')
|
||||
.trim()
|
||||
@@ -146,10 +160,13 @@ export class AiAskService {
|
||||
}>(
|
||||
`You are ${brand.name}, a Persian CRM assistant for this user's account.
|
||||
"محیط" means a workspace/organization the user belongs to.
|
||||
"مشتری" / "مشتری جدید" means a Person (contact) record.
|
||||
"مشتری" means a Person record. "لید" means a Lead record. They are different. Never answer a lead question with the people/customer total.
|
||||
Person and company "by source" lines are authoritative. If the user names a source (دستی، اکسل، دیدار، تهحساب، گوشی، فرم، مونتیفای), use that source count only, never the grand total.
|
||||
Lead stage and lead source lines are authoritative the same way.
|
||||
Period stats use Asia/Tehran calendar: today, yesterday, this_week (Saturday–Friday), this_month (current Jalali month).
|
||||
Workspace facts and Period stats are complete and authoritative for counts of customers, companies, activities, deals, won deals, invoices, quotes, products, notes, and tickets.
|
||||
Answer ONLY from the CRM context for CRM facts. Never say a listed count is unavailable.
|
||||
Workspace facts and Period stats are complete and authoritative for counts.
|
||||
You cannot create, update, or delete records. Never say a customer, lead, or activity was created.
|
||||
Answer ONLY from the CRM context for CRM facts. Never say a listed count is unavailable. If a requested filter is not in the facts, say that filtered count is not available instead of substituting another total.
|
||||
If a "Recent conversation" block is provided, use it only to understand the user's question and tone; do not invent CRM facts from chat alone.
|
||||
Do not invent customers, amounts, dates, or promises.
|
||||
Return JSON: { "answer": string, "sources": [{ "type": "person"|"note"|"activity"|"deal"|"memory", "id": string, "label": string }] }
|
||||
@@ -203,6 +220,333 @@ Rules:
|
||||
};
|
||||
}
|
||||
|
||||
private leadWhere(actor: Actor) {
|
||||
const scoped = forScope(actor, 'leads');
|
||||
if (elevated(scoped.role) || scoped.dataVisibility === 'org') return {};
|
||||
if (scoped.dataVisibility === 'team' && scoped.visibleUserIds?.length) {
|
||||
return { assigneeId: { in: scoped.visibleUserIds } };
|
||||
}
|
||||
return { assigneeId: scoped.userId };
|
||||
}
|
||||
|
||||
private fa(n: number) {
|
||||
return new Intl.NumberFormat('fa-IR').format(n);
|
||||
}
|
||||
|
||||
private periodRange(key: NonNullable<CountIntent['period']>) {
|
||||
const periods = tehranPeriods();
|
||||
if (key === 'today') return periods.today;
|
||||
if (key === 'yesterday') return periods.yesterday;
|
||||
if (key === 'week') return periods.week;
|
||||
return periods.month;
|
||||
}
|
||||
|
||||
private async directCount(actor: Actor, question: string): Promise<string | null> {
|
||||
const intent = classifyCountQuestion(question);
|
||||
if (!intent) return null;
|
||||
const orgId = actor.organizationId;
|
||||
const created =
|
||||
intent.period != null
|
||||
? {
|
||||
createdAt: {
|
||||
gte: this.periodRange(intent.period).start,
|
||||
lte: this.periodRange(intent.period).end,
|
||||
},
|
||||
}
|
||||
: {};
|
||||
const periodNote =
|
||||
intent.period === 'today'
|
||||
? 'امروز'
|
||||
: intent.period === 'yesterday'
|
||||
? 'دیروز'
|
||||
: intent.period === 'week'
|
||||
? 'این هفته'
|
||||
: intent.period === 'month'
|
||||
? 'این ماه'
|
||||
: '';
|
||||
|
||||
if (intent.entity === 'person' || intent.entity === 'company') {
|
||||
const where = {
|
||||
organizationId: orgId,
|
||||
...(intent.entity === 'person' ? ownerWhere(actor) : ownerWhere(actor)),
|
||||
...(intent.personSource ? { importSource: intent.personSource } : {}),
|
||||
...(intent.entity === 'person' && intent.mood ? { mood: intent.mood } : {}),
|
||||
...created,
|
||||
};
|
||||
const n =
|
||||
intent.entity === 'person'
|
||||
? await this.prisma.person.count({ where })
|
||||
: await this.prisma.company.count({ where });
|
||||
const who = intent.entity === 'person' ? 'مشتری' : 'شرکت';
|
||||
const source = intent.personSource
|
||||
? PERSON_IMPORT_SOURCE_LABELS[intent.personSource]
|
||||
: '';
|
||||
const mood =
|
||||
intent.mood === 'happy'
|
||||
? 'خوشحال'
|
||||
: intent.mood === 'unhappy'
|
||||
? 'ناراضی'
|
||||
: intent.mood === 'special'
|
||||
? 'خاص'
|
||||
: intent.mood === 'newcomer'
|
||||
? 'تازهوارد'
|
||||
: '';
|
||||
const bits = [source, mood, periodNote].filter(Boolean).join(' ');
|
||||
const label = bits ? `${who}های ${bits}` : `کل ${who}ها`;
|
||||
return `${label}: ${this.fa(n)} تا.`;
|
||||
}
|
||||
|
||||
if (intent.entity === 'lead') {
|
||||
return this.directLeadCount(actor, intent, created, periodNote);
|
||||
}
|
||||
|
||||
if (intent.entity === 'deal') {
|
||||
const scope = dealAccessWhere(actor);
|
||||
const where = {
|
||||
organizationId: orgId,
|
||||
...scope,
|
||||
...created,
|
||||
...(intent.dealBucket === 'open' ? { status: 'open' as const } : {}),
|
||||
...(intent.dealBucket === 'won' ? { status: 'won' as const } : {}),
|
||||
...(intent.dealBucket === 'lost' ? { status: 'lost' as const } : {}),
|
||||
...(intent.dealBucket === 'stale'
|
||||
? { status: 'open' as const, OR: [{ isStale: true }, { isRotten: true }] }
|
||||
: {}),
|
||||
};
|
||||
const n = await this.prisma.deal.count({ where });
|
||||
const name =
|
||||
intent.dealBucket === 'open'
|
||||
? 'معاملات باز'
|
||||
: intent.dealBucket === 'won'
|
||||
? 'معاملات برنده'
|
||||
: intent.dealBucket === 'lost'
|
||||
? 'معاملات ازدسترفته'
|
||||
: intent.dealBucket === 'stale'
|
||||
? 'معاملات در خطر'
|
||||
: 'کل معاملات';
|
||||
return `${periodNote ? `${name} ${periodNote}` : name}: ${this.fa(n)} تا.`;
|
||||
}
|
||||
|
||||
const simple: Record<string, () => Promise<number>> = {
|
||||
invoice: () =>
|
||||
this.prisma.invoice.count({
|
||||
where: { organizationId: orgId, ...ownerWhere(actor, 'finance'), ...created },
|
||||
}),
|
||||
quote: () =>
|
||||
this.prisma.quote.count({
|
||||
where: { organizationId: orgId, ...ownerWhere(actor, 'finance'), ...created },
|
||||
}),
|
||||
product: () =>
|
||||
this.prisma.product.count({ where: { organizationId: orgId, ...created } }),
|
||||
activity: () =>
|
||||
this.prisma.activity.count({
|
||||
where: { organizationId: orgId, ...assigneeWhere(actor), ...created },
|
||||
}),
|
||||
ticket: () =>
|
||||
this.prisma.ticket.count({
|
||||
where: { organizationId: orgId, ...ticketAccessWhere(actor), ...created },
|
||||
}),
|
||||
note: () =>
|
||||
this.prisma.note.count({
|
||||
where: { organizationId: orgId, ...noteAccessWhere(actor), ...created },
|
||||
}),
|
||||
cheque: () => this.prisma.cheque.count({ where: { organizationId: orgId, ...created } }),
|
||||
reservation: () =>
|
||||
this.prisma.reservationAppointment.count({
|
||||
where: { organizationId: orgId, ...created },
|
||||
}),
|
||||
routine: () =>
|
||||
this.prisma.routineTask.count({ where: { organizationId: orgId, ...created } }),
|
||||
project: () =>
|
||||
this.prisma.projectCard.count({
|
||||
where: { organizationId: orgId, ...projectCardAccessWhere(actor), ...created },
|
||||
}),
|
||||
};
|
||||
const counter = simple[intent.entity];
|
||||
if (!counter) return null;
|
||||
const labels: Record<string, string> = {
|
||||
invoice: 'فاکتور',
|
||||
quote: 'پیشفاکتور',
|
||||
product: 'محصول',
|
||||
activity: 'فعالیت',
|
||||
ticket: 'تیکت',
|
||||
note: 'یادداشت',
|
||||
cheque: 'چک',
|
||||
reservation: 'رزرو',
|
||||
routine: 'کار روتین',
|
||||
project: 'کارت پروژه',
|
||||
};
|
||||
const n = await counter();
|
||||
const name = labels[intent.entity] ?? intent.entity;
|
||||
return `${periodNote ? `${name}های ${periodNote}` : `کل ${name}ها`}: ${this.fa(n)} تا.`;
|
||||
}
|
||||
|
||||
private async directLeadCount(
|
||||
actor: Actor,
|
||||
intent: CountIntent,
|
||||
created: { createdAt?: { gte: Date; lte: Date } },
|
||||
periodNote: string,
|
||||
) {
|
||||
const orgId = actor.organizationId;
|
||||
const base = { organizationId: orgId, ...this.leadWhere(actor), ...created };
|
||||
const [stages, sources] = await Promise.all([
|
||||
this.prisma.leadStage.findMany({
|
||||
where: { organizationId: orgId },
|
||||
select: { id: true, name: true },
|
||||
}),
|
||||
this.prisma.lead.groupBy({
|
||||
by: ['source'],
|
||||
where: base,
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
const compact = (value: string) => value.replace(/[\s]+/g, '').toLowerCase();
|
||||
const hint = compact(intent.hint).length >= 2 ? compact(intent.hint) : '';
|
||||
const stage = hint
|
||||
? stages.find((row) => {
|
||||
const name = compact(row.name);
|
||||
return name.length >= 2 && (name.includes(hint) || hint.includes(name));
|
||||
})
|
||||
: undefined;
|
||||
const sourceRow = hint
|
||||
? sources.find((row) => {
|
||||
const name = compact(row.source ?? '');
|
||||
return name.length >= 2 && (name.includes(hint) || hint.includes(name));
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (hint && !stage && !sourceRow && intent.leadBucket === 'summary') {
|
||||
return `لیدی با فیلتر «${intent.hint}» پیدا نشد: ${this.fa(0)} تا.`;
|
||||
}
|
||||
|
||||
if (intent.leadBucket === 'summary' && !stage && !sourceRow) {
|
||||
const [open, archived, converted, total] = await Promise.all([
|
||||
this.prisma.lead.count({
|
||||
where: { ...base, archivedAt: null, convertedPersonId: null },
|
||||
}),
|
||||
this.prisma.lead.count({ where: { ...base, archivedAt: { not: null } } }),
|
||||
this.prisma.lead.count({
|
||||
where: { ...base, convertedPersonId: { not: null }, archivedAt: null },
|
||||
}),
|
||||
this.prisma.lead.count({ where: base }),
|
||||
]);
|
||||
const when = periodNote ? ` ${periodNote}` : '';
|
||||
return `لیدهای باز${when}: ${this.fa(open)} تا. کل لیدها ${this.fa(total)} تا است؛ ${this.fa(archived)} بایگانی و ${this.fa(converted)} تبدیلشده.`;
|
||||
}
|
||||
|
||||
const where = {
|
||||
...base,
|
||||
...(intent.leadBucket === 'archived' ? { archivedAt: { not: null } } : {}),
|
||||
...(intent.leadBucket === 'converted'
|
||||
? { convertedPersonId: { not: null }, archivedAt: null }
|
||||
: {}),
|
||||
...(intent.leadBucket === 'open'
|
||||
? { archivedAt: null, convertedPersonId: null }
|
||||
: {}),
|
||||
...(stage ? { stageId: stage.id } : {}),
|
||||
...(sourceRow ? { source: sourceRow.source } : {}),
|
||||
};
|
||||
const n = await this.prisma.lead.count({ where });
|
||||
const bits = [
|
||||
stage?.name,
|
||||
sourceRow?.source || '',
|
||||
intent.leadBucket === 'archived'
|
||||
? 'بایگانی'
|
||||
: intent.leadBucket === 'converted'
|
||||
? 'تبدیلشده'
|
||||
: intent.leadBucket === 'open'
|
||||
? 'باز'
|
||||
: '',
|
||||
periodNote,
|
||||
].filter(Boolean);
|
||||
return `لیدهای ${bits.join(' ')}: ${this.fa(n)} تا.`;
|
||||
}
|
||||
|
||||
private async filterFactLines(actor: Actor) {
|
||||
const orgId = actor.organizationId;
|
||||
const personScope = ownerWhere(actor);
|
||||
const companyScope = ownerWhere(actor);
|
||||
const leadBase = { organizationId: orgId, ...this.leadWhere(actor) };
|
||||
const [peopleSources, companySources, stages, leadSources, leadCounts, extras] =
|
||||
await Promise.all([
|
||||
this.prisma.person.groupBy({
|
||||
by: ['importSource'],
|
||||
where: { organizationId: orgId, ...personScope },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.company.groupBy({
|
||||
by: ['importSource'],
|
||||
where: { organizationId: orgId, ...companyScope },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.leadStage.findMany({
|
||||
where: { organizationId: orgId },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { position: 'asc' },
|
||||
}),
|
||||
this.prisma.lead.groupBy({
|
||||
by: ['source'],
|
||||
where: leadBase,
|
||||
_count: { _all: true },
|
||||
}),
|
||||
Promise.all([
|
||||
this.prisma.lead.count({ where: leadBase }),
|
||||
this.prisma.lead.count({
|
||||
where: { ...leadBase, archivedAt: null, convertedPersonId: null },
|
||||
}),
|
||||
this.prisma.lead.count({ where: { ...leadBase, archivedAt: { not: null } } }),
|
||||
this.prisma.lead.count({
|
||||
where: { ...leadBase, convertedPersonId: { not: null }, archivedAt: null },
|
||||
}),
|
||||
]),
|
||||
Promise.all([
|
||||
this.prisma.cheque.count({ where: { organizationId: orgId } }),
|
||||
this.prisma.reservationAppointment.count({ where: { organizationId: orgId } }),
|
||||
this.prisma.routineTask.count({ where: { organizationId: orgId } }),
|
||||
this.prisma.projectCard.count({ where: { organizationId: orgId } }),
|
||||
]),
|
||||
]);
|
||||
|
||||
const sourceLine = (
|
||||
label: string,
|
||||
rows: Array<{ importSource: string; _count: { _all: number } }>,
|
||||
) => {
|
||||
const parts = personSourceKeys().map((key) => {
|
||||
const hit = rows.find((row) => (row.importSource || 'manual') === key);
|
||||
const name = PERSON_IMPORT_SOURCE_LABELS[key as PersonImportSource];
|
||||
return `${name}/${key}=${hit?._count._all ?? 0}`;
|
||||
});
|
||||
return `${label}: ${parts.join(' | ')}`;
|
||||
};
|
||||
|
||||
const stageCounts = await this.prisma.lead.groupBy({
|
||||
by: ['stageId'],
|
||||
where: { ...leadBase, archivedAt: null, convertedPersonId: null },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const stageLine = stages
|
||||
.map((stage) => {
|
||||
const hit = stageCounts.find((row) => row.stageId === stage.id);
|
||||
return `${stage.name}=${hit?._count._all ?? 0}`;
|
||||
})
|
||||
.join(' | ');
|
||||
const sourceBits = leadSources
|
||||
.map((row) => `${row.source?.trim() || '(بدون منبع)'}=${row._count._all}`)
|
||||
.join(' | ');
|
||||
const [leadTotal, leadOpen, leadArchived, leadConverted] = leadCounts;
|
||||
const [cheques, reservations, routines, projects] = extras;
|
||||
|
||||
return [
|
||||
'People by import source (مشتری؛ دستی=manual). Use the matching source, never the people total, when a source is named:',
|
||||
sourceLine('People sources', peopleSources),
|
||||
sourceLine('Company sources', companySources),
|
||||
`Leads are NOT people. open=${leadOpen} | archived=${leadArchived} | converted=${leadConverted} | total=${leadTotal}`,
|
||||
`Lead stages (open only): ${stageLine || 'none'}`,
|
||||
`Lead sources: ${sourceBits || 'none'}`,
|
||||
`Other totals: cheques=${cheques} | reservations=${reservations} | routine tasks=${routines} | project cards=${projects}`,
|
||||
];
|
||||
}
|
||||
|
||||
private async countInPeriods(
|
||||
count: (start: Date, end: Date) => Promise<number>,
|
||||
periods: ReturnType<typeof tehranPeriods>,
|
||||
@@ -589,6 +933,7 @@ Rules:
|
||||
`Environments: ${envLines.join(' | ') || 'none'}`,
|
||||
`Current environment: ${org?.name ?? '—'} | business field: ${field} | team members: ${teamCount}`,
|
||||
`Totals: people=${peopleTotal} | companies=${companiesTotal} | products=${productsTotal} | open deals=${openDealCount} | at-risk deals=${staleDealCount} | overdue activities=${overdueActivityCount} | activities due today=${activitiesDueToday}`,
|
||||
...(await this.filterFactLines(actor)),
|
||||
'Period stats (created/registered unless noted):',
|
||||
formatPeriodCounts('People / مشتریان جدید', peoplePeriods),
|
||||
peopleTodayNames.length
|
||||
|
||||
Reference in New Issue
Block a user