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:
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user