fix: keep birthdays and show Shamsi dates

Imported customers keep their birthday, and assistant text plus remaining calendars use the Jalali date.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-13 18:04:53 +03:30
co-authored by Cursor
parent 5f4cc47c41
commit cf326d1ed9
34 changed files with 637 additions and 105 deletions
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Person" ADD COLUMN "birthDate" TIMESTAMP(3);
-- AlterTable
ALTER TABLE "Lead" ADD COLUMN "birthDate" TIMESTAMP(3);
+4
View File
@@ -498,6 +498,8 @@ model Person {
website String?
/// پیج اینستاگرام
instagram String?
/// تاریخ تولد
birthDate DateTime?
/// نام معرف
referrer String?
/// لیست آدرس‌ها (JSON array of strings)
@@ -2391,6 +2393,8 @@ model Lead {
region String?
/// پیج اینستاگرام
instagram String?
/// تاریخ تولد
birthDate DateTime?
/// لیست آدرس‌ها (JSON array of strings)
addresses Json @default("[]")
/// صنف
+14 -12
View File
@@ -32,7 +32,7 @@ import {
type PersonImportSource,
type UserRole,
} from '@mento/shared';
import { startOfJalaliMonth, toJalali } from '../common/jalali';
import { formatTehranJalali, rewriteGregorianDates, startOfJalaliMonth, toJalali } from '../common/jalali';
import { AiService } from './ai.service';
import {
classifyCountQuestion,
@@ -164,6 +164,7 @@ export class AiAskService {
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).
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.
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.
@@ -185,7 +186,7 @@ Write the answer in Persian, concise, with exact numbers and names when present.
.filter((item): item is CatalogItem => Boolean(item))
.slice(0, 4);
const answer = String(raw.answer ?? '').trim().slice(0, 2000);
const answer = rewriteGregorianDates(String(raw.answer ?? '').trim()).slice(0, 2000);
return {
answer: answer || 'با اطلاعات فعلی پرونده چیزی برای پاسخ پیدا نشد.',
sources,
@@ -206,12 +207,13 @@ Rules:
- Mention durable memory, last contact, open deals, or next follow-up when present.
- cta is one concrete next step, starting with «${brand.suggestionPrefixFa}».
- ctaKind is "call" if a phone follow-up is best, otherwise "activity".
- Do not invent facts.`,
- Do not invent facts.
- Every date must be Shamsi/Jalali like ۱۴۰۴/۰۷/۲۱. Never write Gregorian month names such as سپتامبر or September.`,
`Customer briefing context:\n${context}`,
);
const text = String(raw.text ?? '').trim().slice(0, 700);
const cta = String(raw.cta ?? '').trim().slice(0, 120);
const text = rewriteGregorianDates(String(raw.text ?? '').trim()).slice(0, 700);
const cta = rewriteGregorianDates(String(raw.cta ?? '').trim()).slice(0, 120);
const ctaKind = raw.ctaKind === 'activity' ? 'activity' : 'call';
return {
text: text || 'هنوز دادهٔ کافی برای خلاصه هوشمند این مشتری نیست.',
@@ -925,7 +927,7 @@ Rules:
.filter(Boolean);
const lines: string[] = [
`Today (Asia/Tehran): ${dayKey} | now=${now.toISOString()}`,
`Today (Asia/Tehran, Jalali): ${formatTehranJalali(now)} | gregorian_key=${dayKey}`,
`Periods: yesterday=${periods.yesterday.key} | this_week=${periods.week.key} (Sat–Fri) | this_month=${periods.month.key} (Jalali)`,
'Workspace facts (authoritative):',
`User: ${user?.fullName ?? '—'} | email: ${user?.email ?? '—'} | mobile: ${user?.mobile ?? '—'} | role in current environment: ${roleLabel(actor.role)}`,
@@ -983,7 +985,7 @@ Rules:
const label = `${p.firstName} ${p.lastName}`.trim();
catalog.push({ type: 'person', id: p.id, label });
lines.push(
`Person ${p.id} ${label} created=${p.createdAt.toISOString()} phone=${p.mobile || p.phone || '—'} email=${p.email ?? '—'}`,
`Person ${p.id} ${label} created_jalali=${formatTehranJalali(p.createdAt)} phone=${p.mobile || p.phone || '—'} email=${p.email ?? '—'}`,
);
}
}
@@ -1002,7 +1004,7 @@ Rules:
label: clip(note.title || note.body, 60),
});
lines.push(
`Note ${note.id} (${note.createdAt.toISOString()})${who ? ` [${who}]` : ''}: ${clip(note.title ? `${note.title} — ${note.body}` : note.body)}`,
`Note ${note.id} (jalali=${formatTehranJalali(note.createdAt)})${who ? ` [${who}]` : ''}: ${clip(note.title ? `${note.title} — ${note.body}` : note.body)}`,
);
}
for (const activity of activities) {
@@ -1011,7 +1013,7 @@ Rules:
: '';
catalog.push({ type: 'activity', id: activity.id, label: activity.title });
lines.push(
`Activity ${activity.id} type=${activity.type} status=${activity.status} due=${activity.dueAt?.toISOString() ?? '—'} title=${activity.title}${who ? ` person=${who}` : ''}${activity.description ? ` | ${clip(activity.description, 160)}` : ''}`,
`Activity ${activity.id} type=${activity.type} status=${activity.status} due_jalali=${activity.dueAt ? formatTehranJalali(activity.dueAt) : '—'} title=${activity.title}${who ? ` person=${who}` : ''}${activity.description ? ` | ${clip(activity.description, 160)}` : ''}`,
);
}
for (const deal of deals) {
@@ -1079,7 +1081,7 @@ Rules:
if (!person.firstName) missing.push('نام');
return {
intent,
summary: (raw.summary ?? '').trim() || `ساخت مشتری ${person.firstName} ${person.lastName}`.trim(),
summary: rewriteGregorianDates((raw.summary ?? '').trim()) || `ساخت مشتری ${person.firstName} ${person.lastName}`.trim(),
missing,
canConfirm: missing.length === 0,
person,
@@ -1096,7 +1098,7 @@ Rules:
if (!activity.title) missing.push('عنوان فعالیت');
return {
intent,
summary: (raw.summary ?? '').trim() || `ثبت فعالیت «${activity.title}»`,
summary: rewriteGregorianDates((raw.summary ?? '').trim()) || `ثبت فعالیت «${activity.title}»`,
missing,
canConfirm: missing.length === 0,
person: null,
@@ -1107,7 +1109,7 @@ Rules:
return {
intent: 'none',
summary:
(raw.summary ?? '').trim() ||
rewriteGregorianDates((raw.summary ?? '').trim()) ||
'در این مرحله فقط ساخت مشتری یا ثبت فعالیت از روی صوت انجام می‌شود.',
missing: [],
canConfirm: false,
+10 -7
View File
@@ -4,6 +4,7 @@ import {
ServiceUnavailableException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { rewriteGregorianDates } from '../common/jalali';
export type NoteAiExtract = {
nextAction: {
@@ -44,6 +45,7 @@ Rules:
- dueAt: ISO 8601 date-time if a deadline is mentioned; otherwise null.
- memoryItems: durable facts about the customer (preferences, budget, decisions, family, objections) — NOT tasks.
- Keep text in Persian when the note is Persian.
- If summary mentions a date, write it Shamsi/Jalali like ۱۴۰۴/۰۷/۲۱, never Gregorian month names such as سپتامبر.
- Do not invent facts not supported by the note.
- Maximum 5 memory items.`;
@@ -76,7 +78,8 @@ Rules:
- summary: 3–8 concise Persian sentences covering purpose, customer needs, outcomes, and commitments.
- nextAction: one short Persian follow-up if needed; otherwise null.
- Do not invent facts not in the transcript.
- Keep names, numbers, and amounts accurate.`;
- Keep names, numbers, and amounts accurate.
- Dates in summary and nextAction must be Shamsi/Jalali like ۱۴۰۴/۰۷/۲۱, never Gregorian month names such as سپتامبر.`;
const userParts = [
input.personName ? `Customer: ${input.personName}` : null,
@@ -89,12 +92,12 @@ Rules:
nextAction?: string | null;
}>(system, userParts.join('\n'));
const summary = String(raw.summary ?? '').trim().slice(0, 4000);
const summary = rewriteGregorianDates(String(raw.summary ?? '').trim()).slice(0, 4000);
if (!summary) {
throw new BadRequestException('خلاصه تماس خالی بود');
}
const nextAction = raw.nextAction
? String(raw.nextAction).trim().slice(0, 500) || null
? rewriteGregorianDates(String(raw.nextAction).trim()).slice(0, 500) || null
: null;
return { summary, nextAction };
}
@@ -169,9 +172,9 @@ Rules:
const nextAction =
raw.nextAction && typeof raw.nextAction === 'object'
? {
title: String(raw.nextAction.title ?? '').trim().slice(0, 200),
title: rewriteGregorianDates(String(raw.nextAction.title ?? '').trim()).slice(0, 200),
description: raw.nextAction.description
? String(raw.nextAction.description).trim().slice(0, 2000)
? rewriteGregorianDates(String(raw.nextAction.description).trim()).slice(0, 2000)
: undefined,
dueAt: raw.nextAction.dueAt
? String(raw.nextAction.dueAt).trim()
@@ -182,7 +185,7 @@ Rules:
const memoryItems = Array.isArray(raw.memoryItems)
? raw.memoryItems
.map((item) => ({
content: String(item?.content ?? '').trim().slice(0, 500),
content: rewriteGregorianDates(String(item?.content ?? '').trim()).slice(0, 500),
category: item?.category
? String(item.category).trim().slice(0, 40)
: undefined,
@@ -197,7 +200,7 @@ Rules:
return {
nextAction: cleanedNext,
memoryItems,
summary: raw.summary ? String(raw.summary).trim().slice(0, 500) : undefined,
summary: raw.summary ? rewriteGregorianDates(String(raw.summary).trim()).slice(0, 500) : undefined,
};
}
}
+22
View File
@@ -169,6 +169,28 @@ export function parseAmount(raw?: string): number | null {
return Number.isFinite(n) ? n : null;
}
/** تاریخ تولد: سال زیر ۱۷۰۰ شمسی است، سال ۱۷۰۰ به بعد میلادی. ظهر UTC تا روز جابه‌جا نشود. */
export function parseBirthDate(raw?: string | null): Date | null {
const text = toAsciiDigits(String(raw ?? '')).trim();
if (!text || text === '-' || text === '0' || text === '0000-00-00') return null;
const datePart = text.split(/[T\s]/)[0] ?? text;
const match = /^(\d{3,4})[\/\-.](\d{1,2})[\/\-.](\d{1,2})$/.exec(datePart);
if (!match) {
const fallback = new Date(text);
return Number.isNaN(fallback.getTime()) ? null : fallback;
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
if (month < 1 || month > 12 || day < 1 || day > 31) return null;
if (year >= 1700) {
return new Date(Date.UTC(year, month - 1, day, 12, 0, 0));
}
if (year < 1200) return null;
const { gy, gm, gd } = toGregorian(year, month, day);
return new Date(Date.UTC(gy, gm - 1, gd, 12, 0, 0));
}
export function parseJalaliDateTime(dateRaw?: string, timeRaw?: string): Date | null {
const dateText = toAsciiDigits(dateRaw ?? '').trim();
if (!dateText) return null;
+115
View File
@@ -103,6 +103,121 @@ export function jalaliMonthKey(d: Date): string {
return `${jy}-${String(jm).padStart(2, '0')}`;
}
const FA_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
function toAsciiDigits(value: string) {
return value
.replace(/[۰-۹]/g, (d) => String(FA_DIGITS.indexOf(d)))
.replace(/[٠-٩]/g, (d) => String('٠١٢٣٤٥٦٧٨٩'.indexOf(d)));
}
function toFaDigits(value: string | number) {
return String(value).replace(/\d/g, (d) => FA_DIGITS[Number(d)] ?? d);
}
function tehranYmd(d: Date) {
const [gy, gm, gd] = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Tehran',
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
.format(d)
.split('-')
.map(Number);
return { gy, gm, gd };
}
/** تاریخ شمسی تهران، مثل ۱۴۰۲/۰۶/۱۲ */
export function formatTehranJalali(d: Date): string {
const { gy, gm, gd } = tehranYmd(d);
const j = toJalali(gy, gm, gd);
return `${toFaDigits(j.jy)}/${toFaDigits(String(j.jm).padStart(2, '0'))}/${toFaDigits(String(j.jd).padStart(2, '0'))}`;
}
const GREG_MONTHS: Array<[string, number]> = [
['ژانویه', 1],
['january', 1],
['فوریه', 2],
['february', 2],
['مارس', 3],
['march', 3],
['آوریل', 4],
['april', 4],
['ژوئن', 6],
['june', 6],
['ژوئیه', 7],
['جولای', 7],
['july', 7],
['اوت', 8],
['آگوست', 8],
['آگست', 8],
['august', 8],
['سپتامبر', 9],
['september', 9],
['اکتبر', 10],
['october', 10],
['نوامبر', 11],
['november', 11],
['دسامبر', 12],
['december', 12],
['مه', 5],
['می', 5],
['may', 5],
];
function gregMonthNumber(name: string): number | null {
const key = name.trim().toLowerCase();
return GREG_MONTHS.find(([label]) => label === key)?.[1] ?? null;
}
function inferGregorianYear(month: number, day: number, now = new Date()): number {
const { gy, gm, gd } = tehranYmd(now);
if (month > gm || (month === gm && day > gd)) return gy - 1;
return gy;
}
function jalaliFromGregorian(year: number, month: number, day: number): string | null {
if (month < 1 || month > 12 || day < 1 || day > 31 || year < 1700 || year > 2500) return null;
const j = toJalali(year, month, day);
return `${toFaDigits(j.jy)}/${toFaDigits(String(j.jm).padStart(2, '0'))}/${toFaDigits(String(j.jd).padStart(2, '0'))}`;
}
/** «21 سپتامبر» و تاریخ میلادی عددی را در متن کاربر به شمسی تبدیل می‌کند. */
export function rewriteGregorianDates(text: string, now = new Date()): string {
const monthAlt = GREG_MONTHS.map(([name]) => name).join('|');
const dayMonth = new RegExp(
`(^|[^\\p{L}\\d])([0-9۰-۹]{1,2})\\s*(${monthAlt})(?![\\p{L}\\u200c])(?:\\s+([0-9۰-۹]{4}))?`,
'giu',
);
const monthDay = new RegExp(
`(^|[^\\p{L}\\d])(${monthAlt})(?![\\p{L}\\u200c])\\s+([0-9۰-۹]{1,2})(?:\\s*,?\\s*([0-9۰-۹]{4}))?`,
'giu',
);
const iso = /\b((?:19|20)\d{2})-(\d{2})-(\d{2})\b/g;
const fromParts = (dayRaw: string, monthName: string, yearRaw?: string) => {
const day = Number(toAsciiDigits(dayRaw));
const month = gregMonthNumber(monthName);
if (!month) return null;
const year = yearRaw ? Number(toAsciiDigits(yearRaw)) : inferGregorianYear(month, day, now);
return jalaliFromGregorian(year, month, day);
};
let out = text.replace(dayMonth, (full, lead: string, day: string, month: string, year?: string) => {
const next = fromParts(day, month, year);
return next ? `${lead}${next}` : full;
});
out = out.replace(monthDay, (full, lead: string, month: string, day: string, year?: string) => {
const next = fromParts(day, month, year);
return next ? `${lead}${next}` : full;
});
out = out.replace(iso, (full, year: string, month: string, day: string) => {
return jalaliFromGregorian(Number(year), Number(month), Number(day)) ?? full;
});
return out;
}
export function startOfJalaliMonth(jy: number, jm: number): Date {
return jalaliToDate(jy, jm, 1);
}
@@ -9,6 +9,7 @@ import {
} from '../common/custom-fields';
import {
formatJalaliDate,
parseBirthDate,
parseExcelMapping,
parseJalaliDateTime,
previewExcel,
@@ -22,6 +23,7 @@ import {
} from '../common/excel-import-session';
import { CompaniesService } from './companies.service';
import { PeopleService } from './people.service';
import { normalizeEmail, normalizePhone } from './normalize';
import { normalizePersonImportSource } from './person-source';
type ImportChunkOpts = {
@@ -112,6 +114,11 @@ const PERSON_ALIASES: Record<string, string> = {
تاریختولدمشتری: 'birthDate',
تاریختولد1: 'birthDate',
تاریختولد: 'birthDate',
تاریختولدشمسی: 'birthDate',
تاریخمیلادت: 'birthDate',
birthdate: 'birthDate',
dateofbirth: 'birthDate',
bdate: 'birthDate',
منبعاطلاعات: 'source',
نوعمشتری: 'customerType',
type: '_type',
@@ -162,6 +169,7 @@ const PERSON_FIELDS = [
{ key: 'homeAddress', label: 'آدرس منزل' },
{ key: 'instagram', label: 'اینستاگرام' },
{ key: 'registeredAt', label: 'تاریخ ثبت' },
{ key: 'birthDate', label: 'تاریخ تولد' },
{ key: 'notes', label: 'یادداشت' },
{ key: 'mood', label: 'وضعیت مشتری' },
];
@@ -393,7 +401,7 @@ export class ContactsExcelService {
visibility: 'همه افراد شرکت',
nationalId: extra.nationalId ?? '',
registeredAt: formatJalaliDate(p.createdAt),
birthDate: extra.birthDate ?? '',
birthDate: p.birthDate ? formatJalaliDate(p.birthDate) : (extra.birthDate ?? ''),
mood: extra.group || PERSON_MOOD_LABELS[p.mood || 'newcomer'],
otherPhones: extra.otherPhones ?? '',
address: p.addresses[0] ?? extra.address ?? '',
@@ -408,7 +416,7 @@ export class ContactsExcelService {
postalCode2: extra.postalCode ?? '',
referrer: extra.referrer ?? p.referrer ?? '',
homeAddress: p.addresses[2] ?? extra.homeAddress ?? '',
birthDate2: extra.birthDate ?? '',
birthDate2: p.birthDate ? formatJalaliDate(p.birthDate) : (extra.birthDate ?? ''),
instagram: p.instagram ?? extra.instagram ?? '',
group: extra.group ?? '',
colleagueContact: extra.colleagueContact ?? '',
@@ -578,6 +586,7 @@ export class ContactsExcelService {
position: row.position || undefined,
website: row.website || undefined,
instagram: row.instagram || undefined,
birthDate: parseBirthDate(row.birthDate)?.toISOString() ?? null,
addresses,
notes: row.notes || undefined,
mood: parseMood(row.mood) ?? parseMood(row.group),
@@ -597,6 +606,14 @@ export class ContactsExcelService {
}
} catch (e) {
if (e instanceof ConflictException) {
const birthDate = parseBirthDate(row.birthDate);
if (birthDate) {
await this.people.fillBirthDateIfEmpty(actor.organizationId, {
mobile: normalizePhone(row.mobile),
email: normalizeEmail(row.email),
birthDate,
});
}
result.skipped += 1;
} else {
result.errors.push({
+10
View File
@@ -293,6 +293,11 @@ export class CreatePersonDto {
@MaxLength(200)
instagram?: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
birthDate?: string | null;
@IsOptional()
@IsString()
@MaxLength(120)
@@ -401,6 +406,11 @@ export class UpdatePersonDto {
@MaxLength(200)
instagram?: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
birthDate?: string | null;
@IsOptional()
@IsString()
@MaxLength(120)
+74 -1
View File
@@ -1,7 +1,9 @@
import {
ConflictException,
Injectable,
Logger,
NotFoundException,
type OnModuleInit,
} from '@nestjs/common';
import type {
CreatePersonRequest,
@@ -32,6 +34,7 @@ import { AutomationsService } from '../automations/automations.service';
import { MessageAutomationsService } from '../message-automations/message-automations.service';
import { WebhooksService } from '../public-api/webhooks.service';
import { GamificationService } from '../gamification/gamification.service';
import { parseBirthDate } from '../common/excel';
import { normalizeTags } from '../common/tags';
import { buildSearchWhere, ci } from '../common/search';
import {
@@ -63,6 +66,7 @@ type PersonRow = {
position: string | null;
website: string | null;
instagram: string | null;
birthDate: Date | null;
referrer: string | null;
addresses: unknown;
notes: string | null;
@@ -83,7 +87,8 @@ type PersonRow = {
};
@Injectable()
export class PeopleService {
export class PeopleService implements OnModuleInit {
private readonly logger = new Logger(PeopleService.name);
constructor(
private readonly prisma: PrismaService,
private readonly companies: CompaniesService,
@@ -168,6 +173,71 @@ export class PeopleService {
.sort((a, b) => b.count - a.count);
}
onModuleInit() {
void this.backfillStoredBirthDates();
}
/** تاریخ تولدی که قبلاً در فیلد اضافی دیدار/اکسل یا پروفایل مونتیفای مانده، روی شخص می‌نشیند. */
async backfillStoredBirthDates() {
try {
const fromFields = await this.prisma.$queryRaw<Array<{ id: string; birth: string }>>`
SELECT id,
COALESCE(
NULLIF("customFields"->>'birthDate', ''),
NULLIF("customFields"->>'birthDate2', '')
) AS birth
FROM "Person"
WHERE "birthDate" IS NULL
AND (
COALESCE("customFields"->>'birthDate', '') <> ''
OR COALESCE("customFields"->>'birthDate2', '') <> ''
)
LIMIT 20000
`;
const fromMontify = await this.prisma.$queryRaw<Array<{ id: string; birth: string }>>`
SELECT p.id, m.data->>'birthday' AS birth
FROM "MontifyLink" m
JOIN "Person" p ON p.id = m."entityId"
WHERE m."entityType" = 'customer'
AND p."birthDate" IS NULL
AND COALESCE(m.data->>'birthday', '') <> ''
LIMIT 20000
`;
const seen = new Set<string>();
let filled = 0;
for (const row of [...fromFields, ...fromMontify]) {
if (!row.birth || seen.has(row.id)) continue;
seen.add(row.id);
const birthDate = parseBirthDate(row.birth);
if (!birthDate) continue;
const updated = await this.prisma.person.updateMany({
where: { id: row.id, birthDate: null },
data: { birthDate },
});
filled += updated.count;
}
if (filled) this.logger.log(`تاریخ تولد ${filled} مشتری از داده‌های ذخیره‌شده پر شد`);
} catch (err) {
this.logger.warn(
`پر کردن تاریخ تولد ذخیره‌شده ناموفق بود: ${err instanceof Error ? err.message : err}`,
);
}
}
async fillBirthDateIfEmpty(
organizationId: string,
input: { mobile?: string | null; email?: string | null; birthDate: Date },
) {
const or: Prisma.PersonWhereInput[] = [];
if (input.mobile) or.push({ mobile: input.mobile });
if (input.email) or.push({ email: input.email });
if (!or.length) return;
await this.prisma.person.updateMany({
where: { organizationId, birthDate: null, OR: or },
data: { birthDate: input.birthDate },
});
}
async get(actor: Actor, id: string): Promise<PersonDto> {
const row = await this.prisma.person.findFirst({
where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) },
@@ -198,6 +268,7 @@ export class PeopleService {
position: emptyToNull(dto.position),
website: emptyToNull(dto.website),
instagram: emptyToNull(dto.instagram),
birthDate: parseBirthDate(dto.birthDate),
referrer: emptyToNull(dto.referrer),
addresses: toJsonAddresses(dto.addresses),
notes: emptyToNull(dto.notes),
@@ -279,6 +350,7 @@ export class PeopleService {
if ('position' in dto) data.position = emptyToNull(dto.position);
if ('website' in dto) data.website = emptyToNull(dto.website);
if ('instagram' in dto) data.instagram = emptyToNull(dto.instagram);
if ('birthDate' in dto) data.birthDate = parseBirthDate(dto.birthDate);
if ('referrer' in dto) data.referrer = emptyToNull(dto.referrer);
if ('addresses' in dto) data.addresses = toJsonAddresses(dto.addresses);
if ('notes' in dto) data.notes = emptyToNull(dto.notes);
@@ -396,6 +468,7 @@ export class PeopleService {
position: row.position,
website: row.website,
instagram: row.instagram,
birthDate: row.birthDate?.toISOString() ?? null,
referrer: row.referrer,
addresses: (() => {
const fromCol = asAddresses(row.addresses);
@@ -15,6 +15,7 @@ import type {
MontifySyncResultDto,
MontifyTransactionDto,
} from '@mento/shared';
import { parseBirthDate } from '../../common/excel';
import { PrismaService } from '../../prisma/prisma.service';
import { normalizeEmail, normalizePhone } from '../../contacts/normalize';
import { carryPreviousSource } from '../../contacts/person-source';
@@ -487,6 +488,9 @@ export class MontifySyncService {
...(mobile && (ownedByStore || !person.mobile) ? { mobile } : {}),
...(email && (ownedByStore || !person.email) ? { email } : {}),
...(str(c.job) && !person.position ? { position: str(c.job) } : {}),
...(!person.birthDate && parseBirthDate(profile.birthday)
? { birthDate: parseBirthDate(profile.birthday) }
: {}),
addresses: merged as Prisma.InputJsonValue,
tags,
...(!existingLink && !ownedByStore && !person.previousImportSource
@@ -511,6 +515,7 @@ export class MontifySyncService {
tags: [row.label],
importSource: 'montify',
sourceRef: externalId,
birthDate: parseBirthDate(profile.birthday),
},
});
personId = created.id;
@@ -2,7 +2,9 @@ import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
type OnModuleInit,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
@@ -16,6 +18,7 @@ import {
MatchPersonDto,
SearchTahesabDto,
} from './dto';
import { parseBirthDate } from '../../common/excel';
import { normalizeIranMobile, SanadBuilders, type TahesabApi } from './tahesab-methods';
/** اندازهٔ هر صفحهٔ «همه» (بازهٔ کد) — امن برای پروکسی/nginx */
@@ -57,8 +60,21 @@ function pickTel(row: Record<string, unknown>): string {
return String(row.Tel ?? row.Phone ?? row.Mobile ?? row.tel ?? '');
}
function pickBirthDate(row: Record<string, unknown>): Date | null {
const raw =
row.BDate ??
row.bDate ??
row.Tarikh_Tavalod ??
row.tarikh_tavalod ??
row.BirthDate;
if (raw == null) return null;
return parseBirthDate(String(raw));
}
@Injectable()
export class TahesabPersonLinkService {
private readonly logger = new Logger(TahesabPersonLinkService.name);
constructor(
private readonly prisma: PrismaService,
private readonly connections: TahesabConnectionsService,
@@ -522,6 +538,7 @@ export class TahesabPersonLinkService {
...carryPreviousSource(existing, 'tahesab'),
importSource: 'tahesab',
sourceRef,
...(!existing.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
...(mobile
? {
mobile: existing.mobile || mobile,
@@ -558,6 +575,7 @@ export class TahesabPersonLinkService {
...carryPreviousSource(byMobile, 'tahesab'),
importSource: 'tahesab',
sourceRef,
...(!byMobile.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
},
});
personId = byMobile.id;
@@ -572,6 +590,7 @@ export class TahesabPersonLinkService {
notes: `واردشده از ته‌حساب (کد ${dto.moshtariCode})`,
importSource: 'tahesab',
sourceRef,
birthDate: pickBirthDate(row),
},
});
personId = created.id;
@@ -702,6 +721,13 @@ export class TahesabPersonLinkService {
try {
const linkedPersonId = linkedByCode.get(moshtariCode);
if (linkedPersonId) {
const birthDate = pickBirthDate(rowsByCode.get(moshtariCode) ?? {});
if (birthDate) {
await this.prisma.person.updateMany({
where: { id: linkedPersonId, birthDate: null },
data: { birthDate },
});
}
items.push({
moshtariCode,
ok: true,
@@ -740,4 +766,84 @@ export class TahesabPersonLinkService {
return { imported, skipped, failed, items };
}
/** مشتری‌های قبلاً لینک‌شده که تاریخ تولد ندارند را از فهرست ته‌حساب پر می‌کند. */
async fillMissingBirthDates() {
const connections = await this.prisma.accountingConnection.findMany({
where: { enabled: true },
select: { id: true, organizationId: true },
});
let filled = 0;
for (const connection of connections) {
try {
const links = await this.prisma.personAccountingLink.findMany({
where: { connectionId: connection.id, person: { birthDate: null } },
select: { personId: true, moshtariCode: true },
});
if (!links.length) continue;
const recent = await this.prisma.accountingSyncJob.findFirst({
where: {
connectionId: connection.id,
kind: 'birthdate',
createdAt: { gte: new Date(Date.now() - 12 * 60 * 60 * 1000) },
},
select: { id: true },
});
if (recent) continue;
await this.prisma.accountingSyncJob.create({
data: {
organizationId: connection.organizationId,
connectionId: connection.id,
kind: 'birthdate',
status: 'success',
},
});
const { api } = await this.connections.getApi(connection.organizationId, connection.id);
const codes = [...new Set(links.map((link) => link.moshtariCode))].sort((a, b) => a - b);
const personByCode = new Map(links.map((link) => [link.moshtariCode, link.personId]));
let index = 0;
while (index < codes.length) {
const from = codes[index]!;
let end = index;
while (
end + 1 < codes.length &&
codes[end + 1]! - from < 2000 &&
end - index < 400
) {
end += 1;
}
const to = codes[end]!;
const raw = await api.doListMoshtariByCode(from, to);
for (const row of asMoshtariList(raw)) {
const code = pickCode(row);
const personId = code == null ? undefined : personByCode.get(code);
const birthDate = pickBirthDate(row);
if (!personId || !birthDate) continue;
const updated = await this.prisma.person.updateMany({
where: { id: personId, birthDate: null },
data: { birthDate },
});
filled += updated.count;
}
index = end + 1;
}
} catch (err) {
this.logger.warn(
`تاریخ تولد ته‌حساب برای اتصال ${connection.id} خوانده نشد: ${
err instanceof Error ? err.message : err
}`,
);
}
}
if (filled) this.logger.log(`تاریخ تولد ${filled} مشتری از ته‌حساب پر شد`);
}
}
@Injectable()
export class TahesabBirthDateBackfill implements OnModuleInit {
constructor(private readonly links: TahesabPersonLinkService) {}
onModuleInit() {
void this.links.fillMissingBirthDates();
}
}
@@ -3,7 +3,10 @@ import { AuthModule } from '../../auth/auth.module';
import { TahesabCryptoService } from './tahesab-crypto.service';
import { TahesabHttpClient } from './tahesab-http.client';
import { TahesabConnectionsService } from './connections.service';
import { TahesabPersonLinkService } from './person-link.service';
import {
TahesabBirthDateBackfill,
TahesabPersonLinkService,
} from './person-link.service';
import { TahesabProductLinkService } from './product-link.service';
import { TahesabBalanceService } from './balance.service';
import { TahesabDocumentsService } from './documents.service';
@@ -17,6 +20,7 @@ import { TahesabController } from './tahesab.controller';
TahesabHttpClient,
TahesabConnectionsService,
TahesabPersonLinkService,
TahesabBirthDateBackfill,
TahesabProductLinkService,
TahesabBalanceService,
TahesabDocumentsService,
+2
View File
@@ -22,6 +22,7 @@ export class CreateLeadDto {
@IsOptional() @IsString() city?: string;
@IsOptional() @IsString() region?: string;
@IsOptional() @IsString() instagram?: string;
@IsOptional() @IsString() birthDate?: string | null;
@IsOptional() @IsArray() @IsString({ each: true }) addresses?: string[];
@IsOptional() @IsString() trade?: string;
@IsOptional() @IsString() source?: string;
@@ -42,6 +43,7 @@ export class UpdateLeadDto {
@IsOptional() @IsString() city?: string | null;
@IsOptional() @IsString() region?: string | null;
@IsOptional() @IsString() instagram?: string | null;
@IsOptional() @IsString() birthDate?: string | null;
@IsOptional() @IsArray() @IsString({ each: true }) addresses?: string[];
@IsOptional() @IsString() trade?: string | null;
@IsOptional() @IsString() source?: string | null;
@@ -4,6 +4,7 @@ import { Prisma } from '@prisma/client';
import type { LeadImportJobDto } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import {
parseBirthDate,
parseExcelMapping,
parseJalaliDateTime,
previewExcel,
@@ -55,6 +56,12 @@ const LEAD_ALIASES: Record<string, string> = {
اینستاگرام: 'instagram',
پیجاینستاگرام: 'instagram',
آیدیاینستاگرام: 'instagram',
birthdate: 'birthDate',
dateofbirth: 'birthDate',
bdate: 'birthDate',
تاریختولد: 'birthDate',
تاریختولدمشتری: 'birthDate',
تاریختولدشمسی: 'birthDate',
registeredat: 'registeredAt',
تاریخثبت: 'registeredAt',
تاریخثبتمشتری: 'registeredAt',
@@ -81,6 +88,7 @@ const LEAD_FIELDS: ExcelFieldOption[] = [
{ key: 'homeAddress', label: 'آدرس منزل' },
{ key: 'instagram', label: 'اینستاگرام' },
{ key: 'registeredAt', label: 'تاریخ ثبت' },
{ key: 'birthDate', label: 'تاریخ تولد' },
{ key: 'trade', label: 'صنف' },
{ key: 'source', label: 'منبع' },
{ key: 'notes', label: 'توضیحات' },
@@ -284,6 +292,9 @@ export class LeadsImportService {
address2: get('address2', 'آدرس ۲', 'آدرس2', 'آدرس دوم'),
homeAddress: get('homeAddress', 'آدرس منزل', 'آدرس خانه'),
instagram: get('instagram', 'اینستاگرام', 'پیج اینستاگرام', 'آی دی اینستاگرام'),
birthDate: parseBirthDate(
get('birthDate', 'تاریخ تولد', 'تاریخ تولد مشتری', 'تاریخ تولد شمسی', 'birthdate'),
)?.toISOString() ?? '',
registeredAt,
trade: get('trade', 'صنف', 'رسته'),
source: get('source', 'منبع', 'منبع لید'),
+34 -2
View File
@@ -5,6 +5,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { parseBirthDate } from '../common/excel';
import * as path from 'node:path';
import { randomUUID } from 'node:crypto';
import type {
@@ -268,6 +269,7 @@ export class LeadsService {
city?: string | null;
region?: string | null;
instagram?: string | null;
birthDate?: string | null;
addresses?: string[];
trade?: string | null;
source?: string | null;
@@ -314,6 +316,7 @@ export class LeadsService {
city: emptyToNull(dto.city),
region: emptyToNull(dto.region),
instagram: emptyToNull(dto.instagram),
birthDate: parseBirthDate(dto.birthDate),
addresses: toJsonAddresses(dto.addresses),
trade: emptyToNull(dto.trade),
source: emptyToNull(dto.source),
@@ -398,6 +401,9 @@ export class LeadsService {
...(dto.instagram !== undefined
? { instagram: emptyToNull(dto.instagram as string) }
: {}),
...(dto.birthDate !== undefined
? { birthDate: parseBirthDate(dto.birthDate as string | null) }
: {}),
...(dto.addresses !== undefined
? {
addresses: toJsonAddresses(
@@ -802,6 +808,7 @@ export class LeadsService {
mobile: lead.mobile,
phone: lead.phone,
instagram: lead.instagram,
birthDate: lead.birthDate,
addresses: toJsonAddresses(asAddresses(lead.addresses)),
notes: [
lead.notes,
@@ -819,6 +826,11 @@ export class LeadsService {
},
});
createdPerson = true;
} else if (!person.birthDate && lead.birthDate) {
await this.prisma.person.update({
where: { id: person.id },
data: { birthDate: lead.birthDate },
});
}
const convertedStage = await this.prisma.leadStage.findFirst({
@@ -985,6 +997,7 @@ export class LeadsService {
address2?: string;
homeAddress?: string;
instagram?: string;
birthDate?: string;
registeredAt?: string;
trade?: string;
source?: string;
@@ -996,11 +1009,20 @@ export class LeadsService {
const title = String(row.title ?? row.contactName ?? '').trim();
if (!mobile || !title) return 'error';
const birthDate = parseBirthDate(row.birthDate);
const existingLead = await this.prisma.lead.findFirst({
where: { organizationId, mobile },
select: { id: true },
});
if (existingLead) return 'duplicate';
if (existingLead) {
if (birthDate) {
await this.prisma.lead.updateMany({
where: { id: existingLead.id, birthDate: null },
data: { birthDate },
});
}
return 'duplicate';
}
const existingPerson = await this.prisma.person.findFirst({
where: {
@@ -1009,7 +1031,15 @@ export class LeadsService {
},
select: { id: true },
});
if (existingPerson) return 'duplicate';
if (existingPerson) {
if (birthDate) {
await this.prisma.person.updateMany({
where: { id: existingPerson.id, birthDate: null },
data: { birthDate },
});
}
return 'duplicate';
}
try {
const registeredAt = row.registeredAt ? new Date(row.registeredAt) : null;
@@ -1023,6 +1053,7 @@ export class LeadsService {
city: emptyToNull(row.city),
region: emptyToNull(row.region),
instagram: emptyToNull(row.instagram),
birthDate,
addresses: toJsonAddresses(
collectAddresses(row.address, row.address2, row.homeAddress),
),
@@ -1101,6 +1132,7 @@ export class LeadsService {
city: row.city,
region: row.region,
instagram: row.instagram,
birthDate: row.birthDate?.toISOString() ?? null,
addresses: asAddresses(row.addresses),
trade: row.trade,
source: row.source,
+20
View File
@@ -3,7 +3,9 @@ import assert from 'node:assert/strict';
import {
addJalaliMonths,
endOfJalaliMonth,
formatTehranJalali,
jalaliMonthName,
rewriteGregorianDates,
toGregorian,
toJalali,
} from '../src/common/jalali';
@@ -102,3 +104,21 @@ describe('یادآور چک', () => {
assert.ok(!isDueInDays(new Date('2026-10-12T12:00:00Z'), 3, now));
});
});
describe('بازنویسی تاریخ میلادی در متن', () => {
const now = new Date('2026-10-13T08:00:00Z');
it('۲۱ سپتامبر را شمسی می‌کند و مهر را دست نمی‌زند', () => {
const out = rewriteGregorianDates('آخرین خرید در تاریخ 21 سپتامبر انجام شده', now);
assert.equal(out.includes('سپتامبر'), false);
assert.match(out, /۱۴۰۵\/۰۶\/۳۰/);
assert.equal(rewriteGregorianDates('مهلت تا 21 مهر ۱۴۰۵', now), 'مهلت تا 21 مهر ۱۴۰۵');
assert.equal(rewriteGregorianDates('می‌شود 21 بار', now), 'می‌شود 21 بار');
});
it('September و تاریخ عددی میلادی را شمسی می‌کند', () => {
assert.match(rewriteGregorianDates('bought on September 21, 2025', now), /۱۴۰۴\/۰۶\/۳۰/);
assert.equal(rewriteGregorianDates('due 2026-09-21', now), 'due ۱۴۰۵/۰۶/۳۰');
assert.equal(formatTehranJalali(new Date('2026-09-21T08:00:00Z')), '۱۴۰۵/۰۶/۳۰');
});
});
+2 -1
View File
@@ -3,6 +3,7 @@ import { ref } from 'vue';
import { RouterLink } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { rewriteGregorianDates } from '@/lib/jalali';
import UiIcon from '@/components/UiIcon.vue';
import { brand, type AskMentoResponse, type AskMentoSourceDto } from '@mento/shared';
@@ -43,7 +44,7 @@ async function submit() {
personId: props.personId || undefined,
chatContext: props.chatContext?.trim() || undefined,
});
result.value = data;
result.value = { ...data, answer: rewriteGregorianDates(data.answer) };
} catch (e) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
+4 -8
View File
@@ -2,6 +2,7 @@
import { computed, onMounted, ref, watch } from 'vue';
import { RouterLink } from 'vue-router';
import { api } from '@/lib/api';
import { formatJalaliDateTime } from '@/lib/jalali';
import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
import { appendSpokenText } from '@/lib/spoken-text';
import type {
@@ -63,14 +64,9 @@ function noteTypeLabel(value?: string | null) {
}
function formatTime(iso: string) {
try {
return new Intl.DateTimeFormat('fa-IR', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(iso));
} catch {
return iso;
}
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return formatJalaliDateTime(d);
}
async function load() {
+4 -8
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import { brand, type MemoryItemDto, type PaginatedResponse } from '@mento/shared';
import { formatJalaliDateTime } from '@/lib/jalali';
const props = defineProps<{
personId: string;
@@ -97,14 +98,9 @@ async function removeItem(id: string) {
}
function formatWhen(value: string) {
try {
return new Intl.DateTimeFormat('fa-IR', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
} catch {
return value;
}
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
return formatJalaliDateTime(d);
}
const hasItems = computed(() => items.value.length > 0);
@@ -4,6 +4,7 @@ import axios from 'axios';
import { api } from '@/lib/api';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { brand, type NoteAiPayloadDto, type NoteAiSuggestionDto } from '@mento/shared';
import { rewriteGregorianDates } from '@/lib/jalali';
const props = defineProps<{
noteId: string;
@@ -142,7 +143,7 @@ async function dismiss() {
function formatPayloadSummary(value: NoteAiPayloadDto | null) {
if (!value?.summary) return '';
return value.summary;
return rewriteGregorianDates(value.summary);
}
</script>
@@ -5,7 +5,7 @@ import axios from 'axios';
import UiIcon from '@/components/UiIcon.vue';
import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { formatLocalDateTimeInput } from '@/lib/jalali';
import { formatLocalDateTimeInput, rewriteGregorianDates } from '@/lib/jalali';
import { UiButton, UiModal } from '@/components/ui';
import { api } from '@/lib/api';
import { brand, type ActivityType, type AskMentoSourceDto, type PersonDto, type VoiceCommandDraft } from '@mento/shared';
@@ -150,7 +150,7 @@ async function parseCommand(text: string, fallbackToAsk: boolean) {
}
voiceError.value = looksLikeAction(text)
? 'دستور را کامل نفهمیدم. نام و مشخصات را واضح‌تر بگویید تا قبل از ساخت، تأیید کنید.'
: data.summary;
: rewriteGregorianDates(data.summary);
return;
}
draft.value = data;
@@ -328,7 +328,7 @@ watch(
:title="draft?.intent === 'create_person' ? 'تأیید ساخت مشتری' : 'تأیید ثبت فعالیت'"
@close="cancelDraft"
>
<p v-if="draft" class="modal-lead">{{ draft.summary }}</p>
<p v-if="draft" class="modal-lead">{{ rewriteGregorianDates(draft.summary) }}</p>
<p class="edit-hint">قبل از تأیید می‌توانید متن را اصلاح کنید.</p>
<div v-if="draft?.person" class="modal-fields">
<label>نام <input v-model="draft.person.firstName" @input="onPersonNameEdit" /></label>
@@ -372,7 +372,7 @@ watch(
</UiModal>
<UiModal :open="answerOpen && Boolean(answer)" title="پاسخ دستیار" @close="closeAnswer">
<p class="modal-lead answer-body">{{ answer }}</p>
<p class="modal-lead answer-body">{{ rewriteGregorianDates(answer || '') }}</p>
<div v-if="sources?.length" class="sources">
<RouterLink
v-for="source in sources"
+4 -9
View File
@@ -3,7 +3,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import { api } from '@/lib/api';
import { formatJalaliShort, toFaDigits } from '@/lib/jalali';
import { formatJalaliDateTime, formatJalaliShort, toFaDigits } from '@/lib/jalali';
import UiIcon from '@/components/UiIcon.vue';
import MentoLogo from '@/components/brand/MentoLogo.vue';
import QuickCreate from '@/components/QuickCreate.vue';
@@ -350,14 +350,9 @@ function toggleNotif() {
}
function formatTime(iso: string) {
try {
return new Intl.DateTimeFormat('fa-IR', {
dateStyle: 'short',
timeStyle: 'short',
}).format(new Date(iso));
} catch {
return iso;
}
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return formatJalaliDateTime(d);
}
function submitSearch() {
+5 -7
View File
@@ -1,6 +1,6 @@
import type { GamificationEvent, GamificationTxDto } from '@mento/shared';
import { GAMIFICATION_EVENTS } from '@mento/shared';
import { toFaDigits } from '@/lib/jalali';
import { formatJalaliDateTime, formatJalaliDay, toFaDigits } from '@/lib/jalali';
export { apiError } from '@/lib/routines';
@@ -81,15 +81,13 @@ export function timeAgo(iso: string): string {
if (hours < 24) return `${toFaDigits(hours)} ساعت پیش`;
const days = Math.floor(hours / 24);
if (days < 7) return `${toFaDigits(days)} روز پیش`;
return new Intl.DateTimeFormat('fa-IR', { dateStyle: 'medium' }).format(d);
return formatJalaliDay(d);
}
export function formatDateTime(iso: string): string {
try {
return new Intl.DateTimeFormat('fa-IR', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(iso));
} catch {
return iso;
}
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return formatJalaliDateTime(d);
}
export const REWARD_ICONS = ['gift', 'star', 'trophy', 'medal', 'crown', 'calendar-sun', 'bag', 'target', 'fire'] as const;
+95
View File
@@ -204,6 +204,11 @@ export function formatJalaliDayClock(d: Date): string {
return `${toFaDigits(jd)} ${jalaliMonthName(jm)} ${formatClockFa(d)}`;
}
/** مثل: ۸ شهریور ۱۴۰۵، ۱۶:۳۰ */
export function formatJalaliDateTime(d: Date): string {
return `${formatJalaliDay(d)}، ${formatClockFa(d)}`;
}
export function calendarDaysBetween(from: Date, to: Date): number {
return Math.round(
(startOfLocalDay(to).getTime() - startOfLocalDay(from).getTime()) / 86_400_000,
@@ -270,3 +275,93 @@ export function formatJalaliNumeric(d: Date): string {
const { jy, jm, jd } = dateToJalali(d);
return `${toFaDigits(jy)}/${toFaDigits(String(jm).padStart(2, '0'))}/${toFaDigits(String(jd).padStart(2, '0'))}`;
}
const GREG_MONTHS: Array<[string, number]> = [
['ژانویه', 1],
['january', 1],
['فوریه', 2],
['february', 2],
['مارس', 3],
['march', 3],
['آوریل', 4],
['april', 4],
['ژوئن', 6],
['june', 6],
['ژوئیه', 7],
['جولای', 7],
['july', 7],
['آگوست', 8],
['آگست', 8],
['اوت', 8],
['august', 8],
['سپتامبر', 9],
['september', 9],
['اکتبر', 10],
['october', 10],
['نوامبر', 11],
['november', 11],
['دسامبر', 12],
['december', 12],
['مه', 5],
['می', 5],
['may', 5],
];
function toAsciiDigits(value: string) {
return value
.replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d)))
.replace(/[٠-٩]/g, (d) => String('٠١٢٣٤٥٦٧٨٩'.indexOf(d)));
}
function gregMonthNumber(name: string): number | null {
const key = name.trim().toLowerCase();
return GREG_MONTHS.find(([label]) => label === key)?.[1] ?? null;
}
function inferGregorianYear(month: number, day: number, now = new Date()): number {
const gy = now.getFullYear();
const gm = now.getMonth() + 1;
const gd = now.getDate();
if (month > gm || (month === gm && day > gd)) return gy - 1;
return gy;
}
/** «21 سپتامبر» و تاریخ میلادی عددی را در متن نمایشی به شمسی تبدیل می‌کند. */
export function rewriteGregorianDates(text: string, now = new Date()): string {
const monthAlt = GREG_MONTHS.map(([name]) => name).join('|');
const dayMonth = new RegExp(
`(^|[^\\p{L}\\d])([0-9۰-۹]{1,2})\\s*(${monthAlt})(?![\\p{L}\\u200c])(?:\\s+([0-9۰-۹]{4}))?`,
'giu',
);
const monthDay = new RegExp(
`(^|[^\\p{L}\\d])(${monthAlt})(?![\\p{L}\\u200c])\\s+([0-9۰-۹]{1,2})(?:\\s*,?\\s*([0-9۰-۹]{4}))?`,
'giu',
);
const iso = /\b((?:19|20)\d{2})-(\d{2})-(\d{2})\b/g;
const stamp = (year: number, month: number, day: number) => {
if (month < 1 || month > 12 || day < 1 || day > 31 || year < 1700 || year > 2500) return null;
const j = toJalali(year, month, day);
return `${toFaDigits(j.jy)}/${toFaDigits(String(j.jm).padStart(2, '0'))}/${toFaDigits(String(j.jd).padStart(2, '0'))}`;
};
const fromParts = (dayRaw: string, monthName: string, yearRaw?: string) => {
const day = Number(toAsciiDigits(dayRaw));
const month = gregMonthNumber(monthName);
if (!month) return null;
const year = yearRaw ? Number(toAsciiDigits(yearRaw)) : inferGregorianYear(month, day, now);
return stamp(year, month, day);
};
let out = text.replace(dayMonth, (full, lead: string, day: string, month: string, year?: string) => {
const next = fromParts(day, month, year);
return next ? `${lead}${next}` : full;
});
out = out.replace(monthDay, (full, lead: string, month: string, day: string, year?: string) => {
const next = fromParts(day, month, year);
return next ? `${lead}${next}` : full;
});
out = out.replace(iso, (full, year: string, month: string, day: string) => {
return stamp(Number(year), Number(month), Number(day)) ?? full;
});
return out;
}
+5 -10
View File
@@ -3,7 +3,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
import { useAuthStore } from '@/stores/auth';
import { api } from '@/lib/api';
import { formatMoney } from '@/lib/money';
import { toFaDigits } from '@/lib/jalali';
import { formatJalaliDateTime, rewriteGregorianDates, toFaDigits } from '@/lib/jalali';
import HomeHero from '@/components/dashboard/HomeHero.vue';
import HomeKpiRow from '@/components/dashboard/HomeKpiRow.vue';
import HomeSalesTrend from '@/components/dashboard/HomeSalesTrend.vue';
@@ -52,14 +52,9 @@ const greeting = computed(() => {
function formatWhen(value: string | null) {
if (!value) return '';
try {
return new Intl.DateTimeFormat('fa-IR', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
} catch {
return value;
}
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
return formatJalaliDateTime(d);
}
function formatKpiValue(key: string, value: number) {
@@ -117,7 +112,7 @@ async function submitAsk(preset?: string) {
askSources.value = [];
try {
const { data } = await api.post<AskMentoResponse>('/ai/ask', { question: q });
askAnswer.value = data.answer;
askAnswer.value = rewriteGregorianDates(data.answer);
askSources.value = data.sources ?? [];
await nextTick();
document
+14 -1
View File
@@ -3,7 +3,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { formatClockFa, formatJalaliNumeric, parseLocalInput, toFaDigits } from '@/lib/jalali';
import { formatClockFa, formatJalaliNumeric, formatLocalDateInput, parseLocalInput, toFaDigits } from '@/lib/jalali';
import { useAuthStore } from '@/stores/auth';
import { canManageSettings } from '@/lib/roles';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
@@ -69,6 +69,7 @@ const editForm = ref({
region: '',
source: '',
instagram: '',
birthDate: '',
trade: '',
notes: '',
});
@@ -131,6 +132,7 @@ function startEdit() {
region: lead.value.region ?? '',
source: lead.value.source ?? '',
instagram: lead.value.instagram ?? '',
birthDate: lead.value.birthDate ? formatLocalDateInput(new Date(lead.value.birthDate)) : '',
trade: lead.value.trade ?? '',
notes: lead.value.notes ?? '',
};
@@ -153,6 +155,9 @@ async function saveEdit() {
region: editForm.value.region.trim() || null,
source: editForm.value.source.trim() || null,
instagram: editForm.value.instagram.trim() || null,
birthDate: editForm.value.birthDate
? parseLocalInput(editForm.value.birthDate)?.toISOString() ?? null
: null,
trade: editForm.value.trade.trim() || null,
notes: editForm.value.notes.trim() || null,
});
@@ -511,6 +516,10 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
<dt><UiIcon name="user" :size="14" /> کارشناس</dt>
<dd>{{ lead.assigneeName || '—' }}</dd>
</div>
<div class="cell">
<dt><UiIcon name="calendar" :size="14" /> تاریخ تولد</dt>
<dd>{{ formatDate(lead.birthDate) }}</dd>
</div>
<div class="cell">
<dt><UiIcon name="calendar" :size="14" /> تاریخ ثبت</dt>
<dd>{{ formatDate(lead.createdAt) }}</dd>
@@ -610,6 +619,9 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
<UiField label="اینستاگرام">
<UiInput v-model="editForm.instagram" dir="ltr" />
</UiField>
<UiField label="تاریخ تولد">
<JalaliDateInput v-model="editForm.birthDate" mode="date" />
</UiField>
<UiField label="صنف">
<UiInput v-model="editForm.trade" />
</UiField>
@@ -635,6 +647,7 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
<div><dt>منبع</dt><dd>{{ lead.source || '—' }}</dd></div>
<div><dt>وضعیت</dt><dd>{{ lead.stageName }}</dd></div>
<div><dt>کارشناس</dt><dd>{{ lead.assigneeName || '—' }}</dd></div>
<div><dt>تاریخ تولد</dt><dd>{{ formatDate(lead.birthDate) }}</dd></div>
<div><dt>تاریخ ثبت</dt><dd>{{ formatDate(lead.createdAt) }}</dd></div>
<div><dt>آخرین تماس</dt><dd>{{ formatDate(lead.lastContactAt) }}</dd></div>
<div><dt>پیگیری بعدی</dt><dd>{{ formatDate(lead.nextFollowUpAt) }}</dd></div>
+9 -1
View File
@@ -5,7 +5,8 @@ import axios from 'axios';
import { api } from '@/lib/api';
import type { CompanyDto, CreatePersonRequest, PersonDto, PersonMood } from '@mento/shared';
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@/lib/person-mood';
import { toFaDigits } from '@/lib/jalali';
import { formatLocalDateInput, parseLocalInput, toFaDigits } from '@/lib/jalali';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import {
filterPhoneContacts,
isContactPickerSupported,
@@ -63,6 +64,7 @@ const mobile = ref('');
const position = ref('');
const website = ref('');
const instagram = ref('');
const birthDate = ref('');
const referrer = ref('');
const addresses = ref<string[]>(['']);
const notes = ref('');
@@ -209,6 +211,7 @@ function fill(p: PersonDto) {
position.value = p.position ?? '';
website.value = p.website ?? '';
instagram.value = p.instagram ?? '';
birthDate.value = p.birthDate ? formatLocalDateInput(new Date(p.birthDate)) : '';
referrer.value = p.referrer ?? '';
addresses.value = p.addresses?.length ? [...p.addresses] : [''];
notes.value = p.notes ?? '';
@@ -285,6 +288,7 @@ function payload(): CreatePersonRequest {
position: position.value || undefined,
website: website.value || undefined,
instagram: instagram.value || undefined,
birthDate: birthDate.value ? parseLocalInput(birthDate.value)?.toISOString() ?? null : null,
referrer: referrer.value || undefined,
addresses: addresses.value.map((a) => a.trim()).filter(Boolean),
notes: notes.value || undefined,
@@ -483,6 +487,10 @@ onMounted(load);
<UiInput id="instagram" v-model="instagram" dir="ltr" placeholder="@username یا لینک" />
</UiField>
<UiField label="تاریخ تولد" for-id="birth-date">
<JalaliDateInput id="birth-date" v-model="birthDate" mode="date" />
</UiField>
<UiField label="معرف" for-id="referrer">
<UiInput id="referrer" v-model="referrer" placeholder="نام معرف" />
</UiField>
+12 -2
View File
@@ -7,6 +7,7 @@ import {
calendarDaysBetween,
formatClockFa,
formatJalaliNumeric,
rewriteGregorianDates,
toFaDigits,
} from '@/lib/jalali';
import {
@@ -141,6 +142,9 @@ const mailHref = computed(() =>
const registeredAt = computed(() =>
person.value?.createdAt ? formatJalaliNumeric(new Date(person.value.createdAt)) : '—',
);
const birthDateLabel = computed(() =>
person.value?.birthDate ? formatJalaliNumeric(new Date(person.value.birthDate)) : '—',
);
const externalCode = computed(() => {
if (person.value?.sourceRef?.trim()) return person.value.sourceRef.trim();
const fields = person.value?.customFields ?? {};
@@ -239,8 +243,8 @@ const smart = computed(() =>
);
const shownSmart = computed(() => ({
text: aiSummary.value?.text || smart.value.text,
cta: aiSummary.value?.cta || smart.value.cta,
text: rewriteGregorianDates(aiSummary.value?.text || smart.value.text),
cta: rewriteGregorianDates(aiSummary.value?.cta || smart.value.cta),
ctaKind: aiSummary.value?.ctaKind || smart.value.ctaKind,
}));
@@ -367,6 +371,7 @@ const infoEmpty = computed(() => {
!p.position &&
!p.website &&
!p.instagram &&
!p.birthDate &&
!p.referrer &&
!(p.addresses?.length) &&
!p.company &&
@@ -687,6 +692,10 @@ watch(() => route.query.tab, readTab, { immediate: true });
<dt>سمت</dt>
<dd>{{ person.position || '—' }}</dd>
</div>
<div class="fact">
<dt>تاریخ تولد</dt>
<dd>{{ birthDateLabel }}</dd>
</div>
<div class="fact">
<dt>تاریخ ثبت</dt>
<dd>{{ registeredAt }}</dd>
@@ -993,6 +1002,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
</div>
<div><dt>شرکت</dt><dd>{{ person.company?.name || '—' }}</dd></div>
<div><dt>مسئول</dt><dd>{{ person.owner?.fullName || '—' }}</dd></div>
<div><dt>تاریخ تولد</dt><dd>{{ birthDateLabel }}</dd></div>
<div><dt>تاریخ ثبت</dt><dd>{{ registeredAt }}</dd></div>
<div>
<dt>باشگاه</dt>
+4 -8
View File
@@ -16,6 +16,7 @@ import type {
PortalTicketDto,
} from '@mento/shared';
import { brand } from '@mento/shared';
import { formatJalaliDateTime } from '@/lib/jalali';
const route = useRoute();
const router = useRouter();
@@ -54,14 +55,9 @@ const statusLabel: Record<string, string> = {
};
function formatWhen(iso: string) {
try {
return new Intl.DateTimeFormat('fa-IR', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(iso));
} catch {
return iso;
}
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return formatJalaliDateTime(d);
}
async function loadOrg() {
+2 -1
View File
@@ -11,6 +11,7 @@ import {
calendarDaysBetween,
formatJalaliDayClock,
formatNextSlot,
rewriteGregorianDates,
formatRelativeContact,
toFaDigits,
} from '@/lib/jalali';
@@ -435,7 +436,7 @@ async function loadAiBrief() {
question:
'وضعیت کاری امروز من را خلاصه کن: فعالیت‌های عقب‌افتاده، معاملات باز و مهم‌ترین پیگیری بعدی.',
});
aiBrief.value = data.answer;
aiBrief.value = rewriteGregorianDates(data.answer);
} catch {
aiBrief.value = '';
}
+4 -8
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { formatJalaliDateTime } from '@/lib/jalali';
import { canViewAllRecords } from '@/lib/roles';
import { useAuthStore } from '@/stores/auth';
import {
@@ -84,14 +85,9 @@ const priorityLabel: Record<TicketPriority, string> = {
};
function formatWhen(iso: string) {
try {
return new Intl.DateTimeFormat('fa-IR', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(iso));
} catch {
return iso;
}
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return formatJalaliDateTime(d);
}
async function loadFaqHints() {
+4 -9
View File
@@ -4,7 +4,7 @@ import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { formatJalaliDateTime, toFaDigits } from '@/lib/jalali';
import {
UiButton,
UiPageHeader,
@@ -57,14 +57,9 @@ const statusFilterLabel = computed(() =>
function formatSla(iso: string | null) {
if (!iso) return '';
try {
return new Intl.DateTimeFormat('fa-IR', {
dateStyle: 'short',
timeStyle: 'short',
}).format(new Date(iso));
} catch {
return iso;
}
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return formatJalaliDateTime(d);
}
async function load() {
+2 -2
View File
@@ -18,7 +18,7 @@ import {
UiPageHeader,
} from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import { formatJalaliNumeric, rewriteGregorianDates, toFaDigits } from '@/lib/jalali';
const loading = ref(false);
const saving = ref(false);
@@ -267,7 +267,7 @@ onMounted(() => {
{{ summaryLabel[c.summaryStatus] ?? c.summaryStatus ?? '—' }}
</span>
<p v-if="c.summary" class="summary-preview">
{{ c.summary.slice(0, 120) }}{{ c.summary.length > 120 ? '…' : '' }}
{{ rewriteGregorianDates(c.summary).slice(0, 120) }}{{ c.summary.length > 120 ? '…' : '' }}
</p>
<p v-if="c.summaryError && c.summaryStatus === 'failed'" class="warn">
{{ c.summaryError }}
+5
View File
@@ -857,6 +857,7 @@ export interface PersonDto {
position: string | null;
website: string | null;
instagram: string | null;
birthDate: string | null;
referrer: string | null;
addresses: string[];
notes: string | null;
@@ -884,6 +885,7 @@ export interface CreatePersonRequest {
position?: string;
website?: string;
instagram?: string;
birthDate?: string | null;
referrer?: string;
addresses?: string[];
notes?: string;
@@ -4759,6 +4761,7 @@ export interface LeadDto {
city: string | null;
region: string | null;
instagram: string | null;
birthDate: string | null;
addresses: string[];
trade: string | null;
source: string | null;
@@ -4797,6 +4800,7 @@ export interface CreateLeadRequest {
city?: string | null;
region?: string | null;
instagram?: string | null;
birthDate?: string | null;
addresses?: string[];
trade?: string | null;
source?: string | null;
@@ -4817,6 +4821,7 @@ export interface UpdateLeadRequest {
city?: string | null;
region?: string | null;
instagram?: string | null;
birthDate?: string | null;
addresses?: string[];
trade?: string | null;
source?: string | null;