feat: separate prior records behind an owner switch and count last-period questions

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-15 10:48:30 +03:30
co-authored by Cursor
parent 3a8a684aa4
commit 6bc5e1bec7
29 changed files with 775 additions and 77 deletions
+91 -24
View File
@@ -32,13 +32,26 @@ import {
type PersonImportSource,
type UserRole,
} from '@mento/shared';
import { formatTehranJalali, rewriteGregorianDates, startOfJalaliMonth, toJalali } from '../common/jalali';
import {
addJalaliMonths,
formatTehranJalali,
rewriteGregorianDates,
startOfJalaliMonth,
toGregorian,
toJalali,
} from '../common/jalali';
import { AiService } from './ai.service';
import {
classifyCountQuestion,
personSourceKeys,
type CountIntent,
} from './ask-count';
import {
activityPriorWhere,
companyPriorWhere,
dealPriorWhere,
personPriorWhere,
} from '../common/prior-archive';
type CatalogItem = AskMentoSourceDto;
@@ -51,6 +64,10 @@ type PeriodCounts = {
month: number;
};
function gregorianKey(gy: number, gm: number, gd: number) {
return `${gy}-${String(gm).padStart(2, '0')}-${String(gd).padStart(2, '0')}`;
}
function clip(value: string | null | undefined, max = 280) {
const text = (value ?? '').replace(/\s+/g, ' ').trim();
if (!text) return '';
@@ -116,6 +133,14 @@ function tehranPeriods(now = new Date()) {
start: dayBoundsFromKey(weekStartKey).start,
end: dayBoundsFromKey(weekEndKey).end,
};
const lastWeekStartKey = addDaysToDayKey(weekStartKey, -7);
const lastWeekEndKey = addDaysToDayKey(weekStartKey, -1);
const lastWeek: PeriodRange = {
key: `${lastWeekStartKey}..${lastWeekEndKey}`,
label: 'last_week',
start: dayBoundsFromKey(lastWeekStartKey).start,
end: dayBoundsFromKey(lastWeekEndKey).end,
};
const { jy, jm } = (() => {
const [gy, gm, gd] = dayKey.split('-').map(Number);
return toJalali(gy!, gm!, gd!);
@@ -127,7 +152,21 @@ function tehranPeriods(now = new Date()) {
start: monthStart,
end: now,
};
return { now, dayKey, today, yesterday, week, month };
const prev = addJalaliMonths(jy, jm, -1);
const prevStart = toGregorian(prev.jy, prev.jm, 1);
const nextAfterPrev = addJalaliMonths(prev.jy, prev.jm, 1);
const nextStart = toGregorian(nextAfterPrev.jy, nextAfterPrev.jm, 1);
const lastMonthEndKey = addDaysToDayKey(
gregorianKey(nextStart.gy, nextStart.gm, nextStart.gd),
-1,
);
const lastMonth: PeriodRange = {
key: `${prev.jy}-${String(prev.jm).padStart(2, '0')}`,
label: 'last_jalali_month',
start: dayBoundsFromKey(gregorianKey(prevStart.gy, prevStart.gm, prevStart.gd)).start,
end: dayBoundsFromKey(lastMonthEndKey).end,
};
return { now, dayKey, today, yesterday, week, lastWeek, month, lastMonth };
}
function formatPeriodCounts(labelFa: string, counts: PeriodCounts) {
@@ -163,7 +202,7 @@ export class AiAskService {
"مشتری" 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).
Period stats use Asia/Tehran calendar: today, yesterday, this_week (Saturday–Friday), last_week (the previous Saturday–Friday), this_month (current Jalali month), last_month (the previous Jalali month).
Every date you write must be Shamsi/Jalali, copied from jalali dates in the context, like ۱۴۰۴/۰۷/۲۱. Never use Gregorian month names (ژانویه، سپتامبر، September) or ISO dates.
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.
@@ -240,6 +279,8 @@ Rules:
if (key === 'today') return periods.today;
if (key === 'yesterday') return periods.yesterday;
if (key === 'week') return periods.week;
if (key === 'last_week') return periods.lastWeek;
if (key === 'last_month') return periods.lastMonth;
return periods.month;
}
@@ -261,24 +302,38 @@ Rules:
? 'امروز'
: intent.period === 'yesterday'
? 'دیروز'
: intent.period === 'week'
? 'این هفته'
: intent.period === 'month'
? 'این ماه'
: intent.period === 'week'
? 'این هفته'
: intent.period === 'last_week'
? 'هفته گذشته'
: intent.period === 'month'
? 'این ماه'
: intent.period === 'last_month'
? 'ماه گذشته'
: '';
if (intent.entity === 'person' || intent.entity === 'company') {
const where = {
const shared = {
organizationId: orgId,
...(intent.entity === 'person' ? ownerWhere(actor) : 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 });
? await this.prisma.person.count({
where: {
...shared,
...(intent.mood ? { mood: intent.mood } : {}),
...(intent.personSource ? {} : personPriorWhere(actor.priorLens)),
},
})
: await this.prisma.company.count({
where: {
...shared,
...(intent.personSource ? {} : companyPriorWhere(actor.priorLens)),
},
});
const who = intent.entity === 'person' ? 'مشتری' : 'شرکت';
const source = intent.personSource
? PERSON_IMPORT_SOURCE_LABELS[intent.personSource]
@@ -305,15 +360,20 @@ Rules:
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 }] }
: {}),
AND: [
{
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 }] }
: {}),
},
dealPriorWhere(actor.priorLens),
],
};
const n = await this.prisma.deal.count({ where });
const name =
@@ -342,7 +402,12 @@ Rules:
this.prisma.product.count({ where: { organizationId: orgId, ...created } }),
activity: () =>
this.prisma.activity.count({
where: { organizationId: orgId, ...assigneeWhere(actor), ...created },
where: {
organizationId: orgId,
...assigneeWhere(actor),
...created,
...activityPriorWhere(actor.priorLens),
},
}),
ticket: () =>
this.prisma.ticket.count({
@@ -432,8 +497,10 @@ Rules:
}),
this.prisma.lead.count({ where: base }),
]);
const when = periodNote ? ` ${periodNote}` : '';
return `لیدهای باز${when}: ${this.fa(open)} تا. کل لیدها ${this.fa(total)} تا است؛ ${this.fa(archived)} بایگانی و ${this.fa(converted)} تبدیل‌شده.`;
if (periodNote) {
return `لیدهای ${periodNote}: ${this.fa(total)} تا. ${this.fa(open)} تا هنوز باز، ${this.fa(archived)} بایگانی و ${this.fa(converted)} تبدیل‌شده.`;
}
return `لیدهای باز: ${this.fa(open)} تا. کل لیدها ${this.fa(total)} تا است؛ ${this.fa(archived)} بایگانی و ${this.fa(converted)} تبدیل‌شده.`;
}
const where = {
+15 -4
View File
@@ -19,7 +19,7 @@ export type CountEntity =
| 'routine'
| 'project';
export type CountPeriod = 'today' | 'yesterday' | 'week' | 'month';
export type CountPeriod = 'today' | 'yesterday' | 'week' | 'last_week' | 'month' | 'last_month';
export type LeadBucket = 'summary' | 'open' | 'archived' | 'converted';
export type DealBucket = 'all' | 'open' | 'won' | 'lost' | 'stale';
@@ -51,6 +51,7 @@ export function normalizeAskText(raw: string) {
.replace(/ي/g, 'ی')
.replace(/ك/g, 'ک')
.replace(/‌/g, ' ')
.replace(/[؟?!.,،؛:]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
@@ -64,7 +65,7 @@ function detectEntity(q: string): CountEntity | null {
if (/لید|سرنخ/.test(q)) return 'lead';
if (/پیش\s*فاکتور/.test(q)) return 'quote';
if (/فاکتور/.test(q)) return 'invoice';
if (/معامله/.test(q)) return 'deal';
if (/معاملات|معامله/.test(q)) return 'deal';
if (/چک/.test(q)) return 'cheque';
if (/رزرو|نوبت/.test(q)) return 'reservation';
if (/روتین/.test(q)) return 'routine';
@@ -81,7 +82,9 @@ function detectEntity(q: string): CountEntity | null {
function detectPeriod(q: string): CountPeriod | undefined {
if (/دیروز/.test(q)) return 'yesterday';
if (/امروز/.test(q)) return 'today';
if (/هفته\s*(?:ی\s+)?(گذشته|قبل|پیش)/.test(q)) return 'last_week';
if (/این\s*هفته|هفته\s*جاری/.test(q)) return 'week';
if (/ماه\s*(?:ی\s+)?(گذشته|قبل|پیش)/.test(q)) return 'last_month';
if (/این\s*ماه|ماه\s*جاری/.test(q)) return 'month';
return undefined;
}
@@ -152,6 +155,9 @@ const HINT_STOP = [
'مرحله',
'وضعیت',
'هفته',
'گذشته',
'پیش',
'قبل',
'لطفا',
'الان',
'شده',
@@ -219,6 +225,11 @@ export function classifyCountQuestion(raw: string): CountIntent | null {
const entity = detectEntity(q);
if (!entity) return null;
const personSource = SOURCE_PATTERNS.find((item) => item.re.test(q))?.source;
const period = detectPeriod(q);
let hint = leftoverHint(q, entity);
if (period && entity === 'lead' && !/مرحله|وضعیت|ستون/.test(q)) {
hint = hint.replace(/(^|\s)جدید(?=\s|$)/g, ' ').replace(/\s+/g, ' ').trim();
}
return {
entity,
personSource:
@@ -226,8 +237,8 @@ export function classifyCountQuestion(raw: string): CountIntent | null {
mood: entity === 'person' ? detectMood(q) : undefined,
leadBucket: detectLeadBucket(q),
dealBucket: detectDealBucket(q),
period: detectPeriod(q),
hint: leftoverHint(q, entity),
period,
hint,
};
}