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? website String?
/// پیج اینستاگرام /// پیج اینستاگرام
instagram String? instagram String?
/// تاریخ تولد
birthDate DateTime?
/// نام معرف /// نام معرف
referrer String? referrer String?
/// لیست آدرس‌ها (JSON array of strings) /// لیست آدرس‌ها (JSON array of strings)
@@ -2391,6 +2393,8 @@ model Lead {
region String? region String?
/// پیج اینستاگرام /// پیج اینستاگرام
instagram String? instagram String?
/// تاریخ تولد
birthDate DateTime?
/// لیست آدرس‌ها (JSON array of strings) /// لیست آدرس‌ها (JSON array of strings)
addresses Json @default("[]") addresses Json @default("[]")
/// صنف /// صنف
+14 -12
View File
@@ -32,7 +32,7 @@ import {
type PersonImportSource, type PersonImportSource,
type UserRole, type UserRole,
} from '@mento/shared'; } from '@mento/shared';
import { startOfJalaliMonth, toJalali } from '../common/jalali'; import { formatTehranJalali, rewriteGregorianDates, startOfJalaliMonth, toJalali } from '../common/jalali';
import { AiService } from './ai.service'; import { AiService } from './ai.service';
import { import {
classifyCountQuestion, 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. 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. 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), 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. 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. 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. 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)) .filter((item): item is CatalogItem => Boolean(item))
.slice(0, 4); .slice(0, 4);
const answer = String(raw.answer ?? '').trim().slice(0, 2000); const answer = rewriteGregorianDates(String(raw.answer ?? '').trim()).slice(0, 2000);
return { return {
answer: answer || 'با اطلاعات فعلی پرونده چیزی برای پاسخ پیدا نشد.', answer: answer || 'با اطلاعات فعلی پرونده چیزی برای پاسخ پیدا نشد.',
sources, sources,
@@ -206,12 +207,13 @@ Rules:
- Mention durable memory, last contact, open deals, or next follow-up when present. - Mention durable memory, last contact, open deals, or next follow-up when present.
- cta is one concrete next step, starting with «${brand.suggestionPrefixFa}». - cta is one concrete next step, starting with «${brand.suggestionPrefixFa}».
- ctaKind is "call" if a phone follow-up is best, otherwise "activity". - 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}`, `Customer briefing context:\n${context}`,
); );
const text = String(raw.text ?? '').trim().slice(0, 700); const text = rewriteGregorianDates(String(raw.text ?? '').trim()).slice(0, 700);
const cta = String(raw.cta ?? '').trim().slice(0, 120); const cta = rewriteGregorianDates(String(raw.cta ?? '').trim()).slice(0, 120);
const ctaKind = raw.ctaKind === 'activity' ? 'activity' : 'call'; const ctaKind = raw.ctaKind === 'activity' ? 'activity' : 'call';
return { return {
text: text || 'هنوز دادهٔ کافی برای خلاصه هوشمند این مشتری نیست.', text: text || 'هنوز دادهٔ کافی برای خلاصه هوشمند این مشتری نیست.',
@@ -925,7 +927,7 @@ Rules:
.filter(Boolean); .filter(Boolean);
const lines: string[] = [ 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)`, `Periods: yesterday=${periods.yesterday.key} | this_week=${periods.week.key} (Sat–Fri) | this_month=${periods.month.key} (Jalali)`,
'Workspace facts (authoritative):', 'Workspace facts (authoritative):',
`User: ${user?.fullName ?? '—'} | email: ${user?.email ?? '—'} | mobile: ${user?.mobile ?? '—'} | role in current environment: ${roleLabel(actor.role)}`, `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(); const label = `${p.firstName} ${p.lastName}`.trim();
catalog.push({ type: 'person', id: p.id, label }); catalog.push({ type: 'person', id: p.id, label });
lines.push( 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), label: clip(note.title || note.body, 60),
}); });
lines.push( 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) { for (const activity of activities) {
@@ -1011,7 +1013,7 @@ Rules:
: ''; : '';
catalog.push({ type: 'activity', id: activity.id, label: activity.title }); catalog.push({ type: 'activity', id: activity.id, label: activity.title });
lines.push( 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) { for (const deal of deals) {
@@ -1079,7 +1081,7 @@ Rules:
if (!person.firstName) missing.push('نام'); if (!person.firstName) missing.push('نام');
return { return {
intent, intent,
summary: (raw.summary ?? '').trim() || `ساخت مشتری ${person.firstName} ${person.lastName}`.trim(), summary: rewriteGregorianDates((raw.summary ?? '').trim()) || `ساخت مشتری ${person.firstName} ${person.lastName}`.trim(),
missing, missing,
canConfirm: missing.length === 0, canConfirm: missing.length === 0,
person, person,
@@ -1096,7 +1098,7 @@ Rules:
if (!activity.title) missing.push('عنوان فعالیت'); if (!activity.title) missing.push('عنوان فعالیت');
return { return {
intent, intent,
summary: (raw.summary ?? '').trim() || `ثبت فعالیت «${activity.title}»`, summary: rewriteGregorianDates((raw.summary ?? '').trim()) || `ثبت فعالیت «${activity.title}»`,
missing, missing,
canConfirm: missing.length === 0, canConfirm: missing.length === 0,
person: null, person: null,
@@ -1107,7 +1109,7 @@ Rules:
return { return {
intent: 'none', intent: 'none',
summary: summary:
(raw.summary ?? '').trim() || rewriteGregorianDates((raw.summary ?? '').trim()) ||
'در این مرحله فقط ساخت مشتری یا ثبت فعالیت از روی صوت انجام می‌شود.', 'در این مرحله فقط ساخت مشتری یا ثبت فعالیت از روی صوت انجام می‌شود.',
missing: [], missing: [],
canConfirm: false, canConfirm: false,
+10 -7
View File
@@ -4,6 +4,7 @@ import {
ServiceUnavailableException, ServiceUnavailableException,
} from '@nestjs/common'; } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { rewriteGregorianDates } from '../common/jalali';
export type NoteAiExtract = { export type NoteAiExtract = {
nextAction: { nextAction: {
@@ -44,6 +45,7 @@ Rules:
- dueAt: ISO 8601 date-time if a deadline is mentioned; otherwise null. - 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. - memoryItems: durable facts about the customer (preferences, budget, decisions, family, objections) — NOT tasks.
- Keep text in Persian when the note is Persian. - 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. - Do not invent facts not supported by the note.
- Maximum 5 memory items.`; - Maximum 5 memory items.`;
@@ -76,7 +78,8 @@ Rules:
- summary: 3–8 concise Persian sentences covering purpose, customer needs, outcomes, and commitments. - summary: 3–8 concise Persian sentences covering purpose, customer needs, outcomes, and commitments.
- nextAction: one short Persian follow-up if needed; otherwise null. - nextAction: one short Persian follow-up if needed; otherwise null.
- Do not invent facts not in the transcript. - 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 = [ const userParts = [
input.personName ? `Customer: ${input.personName}` : null, input.personName ? `Customer: ${input.personName}` : null,
@@ -89,12 +92,12 @@ Rules:
nextAction?: string | null; nextAction?: string | null;
}>(system, userParts.join('\n')); }>(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) { if (!summary) {
throw new BadRequestException('خلاصه تماس خالی بود'); throw new BadRequestException('خلاصه تماس خالی بود');
} }
const nextAction = raw.nextAction const nextAction = raw.nextAction
? String(raw.nextAction).trim().slice(0, 500) || null ? rewriteGregorianDates(String(raw.nextAction).trim()).slice(0, 500) || null
: null; : null;
return { summary, nextAction }; return { summary, nextAction };
} }
@@ -169,9 +172,9 @@ Rules:
const nextAction = const nextAction =
raw.nextAction && typeof raw.nextAction === 'object' 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 description: raw.nextAction.description
? String(raw.nextAction.description).trim().slice(0, 2000) ? rewriteGregorianDates(String(raw.nextAction.description).trim()).slice(0, 2000)
: undefined, : undefined,
dueAt: raw.nextAction.dueAt dueAt: raw.nextAction.dueAt
? String(raw.nextAction.dueAt).trim() ? String(raw.nextAction.dueAt).trim()
@@ -182,7 +185,7 @@ Rules:
const memoryItems = Array.isArray(raw.memoryItems) const memoryItems = Array.isArray(raw.memoryItems)
? raw.memoryItems ? raw.memoryItems
.map((item) => ({ .map((item) => ({
content: String(item?.content ?? '').trim().slice(0, 500), content: rewriteGregorianDates(String(item?.content ?? '').trim()).slice(0, 500),
category: item?.category category: item?.category
? String(item.category).trim().slice(0, 40) ? String(item.category).trim().slice(0, 40)
: undefined, : undefined,
@@ -197,7 +200,7 @@ Rules:
return { return {
nextAction: cleanedNext, nextAction: cleanedNext,
memoryItems, 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; 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 { export function parseJalaliDateTime(dateRaw?: string, timeRaw?: string): Date | null {
const dateText = toAsciiDigits(dateRaw ?? '').trim(); const dateText = toAsciiDigits(dateRaw ?? '').trim();
if (!dateText) return null; if (!dateText) return null;
+115
View File
@@ -103,6 +103,121 @@ export function jalaliMonthKey(d: Date): string {
return `${jy}-${String(jm).padStart(2, '0')}`; 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 { export function startOfJalaliMonth(jy: number, jm: number): Date {
return jalaliToDate(jy, jm, 1); return jalaliToDate(jy, jm, 1);
} }
@@ -9,6 +9,7 @@ import {
} from '../common/custom-fields'; } from '../common/custom-fields';
import { import {
formatJalaliDate, formatJalaliDate,
parseBirthDate,
parseExcelMapping, parseExcelMapping,
parseJalaliDateTime, parseJalaliDateTime,
previewExcel, previewExcel,
@@ -22,6 +23,7 @@ import {
} from '../common/excel-import-session'; } from '../common/excel-import-session';
import { CompaniesService } from './companies.service'; import { CompaniesService } from './companies.service';
import { PeopleService } from './people.service'; import { PeopleService } from './people.service';
import { normalizeEmail, normalizePhone } from './normalize';
import { normalizePersonImportSource } from './person-source'; import { normalizePersonImportSource } from './person-source';
type ImportChunkOpts = { type ImportChunkOpts = {
@@ -112,6 +114,11 @@ const PERSON_ALIASES: Record<string, string> = {
تاریختولدمشتری: 'birthDate', تاریختولدمشتری: 'birthDate',
تاریختولد1: 'birthDate', تاریختولد1: 'birthDate',
تاریختولد: 'birthDate', تاریختولد: 'birthDate',
تاریختولدشمسی: 'birthDate',
تاریخمیلادت: 'birthDate',
birthdate: 'birthDate',
dateofbirth: 'birthDate',
bdate: 'birthDate',
منبعاطلاعات: 'source', منبعاطلاعات: 'source',
نوعمشتری: 'customerType', نوعمشتری: 'customerType',
type: '_type', type: '_type',
@@ -162,6 +169,7 @@ const PERSON_FIELDS = [
{ key: 'homeAddress', label: 'آدرس منزل' }, { key: 'homeAddress', label: 'آدرس منزل' },
{ key: 'instagram', label: 'اینستاگرام' }, { key: 'instagram', label: 'اینستاگرام' },
{ key: 'registeredAt', label: 'تاریخ ثبت' }, { key: 'registeredAt', label: 'تاریخ ثبت' },
{ key: 'birthDate', label: 'تاریخ تولد' },
{ key: 'notes', label: 'یادداشت' }, { key: 'notes', label: 'یادداشت' },
{ key: 'mood', label: 'وضعیت مشتری' }, { key: 'mood', label: 'وضعیت مشتری' },
]; ];
@@ -393,7 +401,7 @@ export class ContactsExcelService {
visibility: 'همه افراد شرکت', visibility: 'همه افراد شرکت',
nationalId: extra.nationalId ?? '', nationalId: extra.nationalId ?? '',
registeredAt: formatJalaliDate(p.createdAt), registeredAt: formatJalaliDate(p.createdAt),
birthDate: extra.birthDate ?? '', birthDate: p.birthDate ? formatJalaliDate(p.birthDate) : (extra.birthDate ?? ''),
mood: extra.group || PERSON_MOOD_LABELS[p.mood || 'newcomer'], mood: extra.group || PERSON_MOOD_LABELS[p.mood || 'newcomer'],
otherPhones: extra.otherPhones ?? '', otherPhones: extra.otherPhones ?? '',
address: p.addresses[0] ?? extra.address ?? '', address: p.addresses[0] ?? extra.address ?? '',
@@ -408,7 +416,7 @@ export class ContactsExcelService {
postalCode2: extra.postalCode ?? '', postalCode2: extra.postalCode ?? '',
referrer: extra.referrer ?? p.referrer ?? '', referrer: extra.referrer ?? p.referrer ?? '',
homeAddress: p.addresses[2] ?? extra.homeAddress ?? '', homeAddress: p.addresses[2] ?? extra.homeAddress ?? '',
birthDate2: extra.birthDate ?? '', birthDate2: p.birthDate ? formatJalaliDate(p.birthDate) : (extra.birthDate ?? ''),
instagram: p.instagram ?? extra.instagram ?? '', instagram: p.instagram ?? extra.instagram ?? '',
group: extra.group ?? '', group: extra.group ?? '',
colleagueContact: extra.colleagueContact ?? '', colleagueContact: extra.colleagueContact ?? '',
@@ -578,6 +586,7 @@ export class ContactsExcelService {
position: row.position || undefined, position: row.position || undefined,
website: row.website || undefined, website: row.website || undefined,
instagram: row.instagram || undefined, instagram: row.instagram || undefined,
birthDate: parseBirthDate(row.birthDate)?.toISOString() ?? null,
addresses, addresses,
notes: row.notes || undefined, notes: row.notes || undefined,
mood: parseMood(row.mood) ?? parseMood(row.group), mood: parseMood(row.mood) ?? parseMood(row.group),
@@ -597,6 +606,14 @@ export class ContactsExcelService {
} }
} catch (e) { } catch (e) {
if (e instanceof ConflictException) { 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; result.skipped += 1;
} else { } else {
result.errors.push({ result.errors.push({
+10
View File
@@ -293,6 +293,11 @@ export class CreatePersonDto {
@MaxLength(200) @MaxLength(200)
instagram?: string; instagram?: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
birthDate?: string | null;
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(120) @MaxLength(120)
@@ -401,6 +406,11 @@ export class UpdatePersonDto {
@MaxLength(200) @MaxLength(200)
instagram?: string; instagram?: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
birthDate?: string | null;
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(120) @MaxLength(120)
+74 -1
View File
@@ -1,7 +1,9 @@
import { import {
ConflictException, ConflictException,
Injectable, Injectable,
Logger,
NotFoundException, NotFoundException,
type OnModuleInit,
} from '@nestjs/common'; } from '@nestjs/common';
import type { import type {
CreatePersonRequest, CreatePersonRequest,
@@ -32,6 +34,7 @@ import { AutomationsService } from '../automations/automations.service';
import { MessageAutomationsService } from '../message-automations/message-automations.service'; import { MessageAutomationsService } from '../message-automations/message-automations.service';
import { WebhooksService } from '../public-api/webhooks.service'; import { WebhooksService } from '../public-api/webhooks.service';
import { GamificationService } from '../gamification/gamification.service'; import { GamificationService } from '../gamification/gamification.service';
import { parseBirthDate } from '../common/excel';
import { normalizeTags } from '../common/tags'; import { normalizeTags } from '../common/tags';
import { buildSearchWhere, ci } from '../common/search'; import { buildSearchWhere, ci } from '../common/search';
import { import {
@@ -63,6 +66,7 @@ type PersonRow = {
position: string | null; position: string | null;
website: string | null; website: string | null;
instagram: string | null; instagram: string | null;
birthDate: Date | null;
referrer: string | null; referrer: string | null;
addresses: unknown; addresses: unknown;
notes: string | null; notes: string | null;
@@ -83,7 +87,8 @@ type PersonRow = {
}; };
@Injectable() @Injectable()
export class PeopleService { export class PeopleService implements OnModuleInit {
private readonly logger = new Logger(PeopleService.name);
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly companies: CompaniesService, private readonly companies: CompaniesService,
@@ -168,6 +173,71 @@ export class PeopleService {
.sort((a, b) => b.count - a.count); .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> { async get(actor: Actor, id: string): Promise<PersonDto> {
const row = await this.prisma.person.findFirst({ const row = await this.prisma.person.findFirst({
where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) }, where: { id, organizationId: actor.organizationId, ...ownerWhere(actor) },
@@ -198,6 +268,7 @@ export class PeopleService {
position: emptyToNull(dto.position), position: emptyToNull(dto.position),
website: emptyToNull(dto.website), website: emptyToNull(dto.website),
instagram: emptyToNull(dto.instagram), instagram: emptyToNull(dto.instagram),
birthDate: parseBirthDate(dto.birthDate),
referrer: emptyToNull(dto.referrer), referrer: emptyToNull(dto.referrer),
addresses: toJsonAddresses(dto.addresses), addresses: toJsonAddresses(dto.addresses),
notes: emptyToNull(dto.notes), notes: emptyToNull(dto.notes),
@@ -279,6 +350,7 @@ export class PeopleService {
if ('position' in dto) data.position = emptyToNull(dto.position); if ('position' in dto) data.position = emptyToNull(dto.position);
if ('website' in dto) data.website = emptyToNull(dto.website); if ('website' in dto) data.website = emptyToNull(dto.website);
if ('instagram' in dto) data.instagram = emptyToNull(dto.instagram); 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 ('referrer' in dto) data.referrer = emptyToNull(dto.referrer);
if ('addresses' in dto) data.addresses = toJsonAddresses(dto.addresses); if ('addresses' in dto) data.addresses = toJsonAddresses(dto.addresses);
if ('notes' in dto) data.notes = emptyToNull(dto.notes); if ('notes' in dto) data.notes = emptyToNull(dto.notes);
@@ -396,6 +468,7 @@ export class PeopleService {
position: row.position, position: row.position,
website: row.website, website: row.website,
instagram: row.instagram, instagram: row.instagram,
birthDate: row.birthDate?.toISOString() ?? null,
referrer: row.referrer, referrer: row.referrer,
addresses: (() => { addresses: (() => {
const fromCol = asAddresses(row.addresses); const fromCol = asAddresses(row.addresses);
@@ -15,6 +15,7 @@ import type {
MontifySyncResultDto, MontifySyncResultDto,
MontifyTransactionDto, MontifyTransactionDto,
} from '@mento/shared'; } from '@mento/shared';
import { parseBirthDate } from '../../common/excel';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { normalizeEmail, normalizePhone } from '../../contacts/normalize'; import { normalizeEmail, normalizePhone } from '../../contacts/normalize';
import { carryPreviousSource } from '../../contacts/person-source'; import { carryPreviousSource } from '../../contacts/person-source';
@@ -487,6 +488,9 @@ export class MontifySyncService {
...(mobile && (ownedByStore || !person.mobile) ? { mobile } : {}), ...(mobile && (ownedByStore || !person.mobile) ? { mobile } : {}),
...(email && (ownedByStore || !person.email) ? { email } : {}), ...(email && (ownedByStore || !person.email) ? { email } : {}),
...(str(c.job) && !person.position ? { position: str(c.job) } : {}), ...(str(c.job) && !person.position ? { position: str(c.job) } : {}),
...(!person.birthDate && parseBirthDate(profile.birthday)
? { birthDate: parseBirthDate(profile.birthday) }
: {}),
addresses: merged as Prisma.InputJsonValue, addresses: merged as Prisma.InputJsonValue,
tags, tags,
...(!existingLink && !ownedByStore && !person.previousImportSource ...(!existingLink && !ownedByStore && !person.previousImportSource
@@ -511,6 +515,7 @@ export class MontifySyncService {
tags: [row.label], tags: [row.label],
importSource: 'montify', importSource: 'montify',
sourceRef: externalId, sourceRef: externalId,
birthDate: parseBirthDate(profile.birthday),
}, },
}); });
personId = created.id; personId = created.id;
@@ -2,7 +2,9 @@ import {
BadRequestException, BadRequestException,
ConflictException, ConflictException,
Injectable, Injectable,
Logger,
NotFoundException, NotFoundException,
type OnModuleInit,
} from '@nestjs/common'; } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
@@ -16,6 +18,7 @@ import {
MatchPersonDto, MatchPersonDto,
SearchTahesabDto, SearchTahesabDto,
} from './dto'; } from './dto';
import { parseBirthDate } from '../../common/excel';
import { normalizeIranMobile, SanadBuilders, type TahesabApi } from './tahesab-methods'; import { normalizeIranMobile, SanadBuilders, type TahesabApi } from './tahesab-methods';
/** اندازهٔ هر صفحهٔ «همه» (بازهٔ کد) — امن برای پروکسی/nginx */ /** اندازهٔ هر صفحهٔ «همه» (بازهٔ کد) — امن برای پروکسی/nginx */
@@ -57,8 +60,21 @@ function pickTel(row: Record<string, unknown>): string {
return String(row.Tel ?? row.Phone ?? row.Mobile ?? row.tel ?? ''); 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() @Injectable()
export class TahesabPersonLinkService { export class TahesabPersonLinkService {
private readonly logger = new Logger(TahesabPersonLinkService.name);
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly connections: TahesabConnectionsService, private readonly connections: TahesabConnectionsService,
@@ -522,6 +538,7 @@ export class TahesabPersonLinkService {
...carryPreviousSource(existing, 'tahesab'), ...carryPreviousSource(existing, 'tahesab'),
importSource: 'tahesab', importSource: 'tahesab',
sourceRef, sourceRef,
...(!existing.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
...(mobile ...(mobile
? { ? {
mobile: existing.mobile || mobile, mobile: existing.mobile || mobile,
@@ -558,6 +575,7 @@ export class TahesabPersonLinkService {
...carryPreviousSource(byMobile, 'tahesab'), ...carryPreviousSource(byMobile, 'tahesab'),
importSource: 'tahesab', importSource: 'tahesab',
sourceRef, sourceRef,
...(!byMobile.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}),
}, },
}); });
personId = byMobile.id; personId = byMobile.id;
@@ -572,6 +590,7 @@ export class TahesabPersonLinkService {
notes: `واردشده از ته‌حساب (کد ${dto.moshtariCode})`, notes: `واردشده از ته‌حساب (کد ${dto.moshtariCode})`,
importSource: 'tahesab', importSource: 'tahesab',
sourceRef, sourceRef,
birthDate: pickBirthDate(row),
}, },
}); });
personId = created.id; personId = created.id;
@@ -702,6 +721,13 @@ export class TahesabPersonLinkService {
try { try {
const linkedPersonId = linkedByCode.get(moshtariCode); const linkedPersonId = linkedByCode.get(moshtariCode);
if (linkedPersonId) { if (linkedPersonId) {
const birthDate = pickBirthDate(rowsByCode.get(moshtariCode) ?? {});
if (birthDate) {
await this.prisma.person.updateMany({
where: { id: linkedPersonId, birthDate: null },
data: { birthDate },
});
}
items.push({ items.push({
moshtariCode, moshtariCode,
ok: true, ok: true,
@@ -740,4 +766,84 @@ export class TahesabPersonLinkService {
return { imported, skipped, failed, items }; 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 { TahesabCryptoService } from './tahesab-crypto.service';
import { TahesabHttpClient } from './tahesab-http.client'; import { TahesabHttpClient } from './tahesab-http.client';
import { TahesabConnectionsService } from './connections.service'; 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 { TahesabProductLinkService } from './product-link.service';
import { TahesabBalanceService } from './balance.service'; import { TahesabBalanceService } from './balance.service';
import { TahesabDocumentsService } from './documents.service'; import { TahesabDocumentsService } from './documents.service';
@@ -17,6 +20,7 @@ import { TahesabController } from './tahesab.controller';
TahesabHttpClient, TahesabHttpClient,
TahesabConnectionsService, TahesabConnectionsService,
TahesabPersonLinkService, TahesabPersonLinkService,
TahesabBirthDateBackfill,
TahesabProductLinkService, TahesabProductLinkService,
TahesabBalanceService, TahesabBalanceService,
TahesabDocumentsService, TahesabDocumentsService,
+2
View File
@@ -22,6 +22,7 @@ export class CreateLeadDto {
@IsOptional() @IsString() city?: string; @IsOptional() @IsString() city?: string;
@IsOptional() @IsString() region?: string; @IsOptional() @IsString() region?: string;
@IsOptional() @IsString() instagram?: string; @IsOptional() @IsString() instagram?: string;
@IsOptional() @IsString() birthDate?: string | null;
@IsOptional() @IsArray() @IsString({ each: true }) addresses?: string[]; @IsOptional() @IsArray() @IsString({ each: true }) addresses?: string[];
@IsOptional() @IsString() trade?: string; @IsOptional() @IsString() trade?: string;
@IsOptional() @IsString() source?: string; @IsOptional() @IsString() source?: string;
@@ -42,6 +43,7 @@ export class UpdateLeadDto {
@IsOptional() @IsString() city?: string | null; @IsOptional() @IsString() city?: string | null;
@IsOptional() @IsString() region?: string | null; @IsOptional() @IsString() region?: string | null;
@IsOptional() @IsString() instagram?: string | null; @IsOptional() @IsString() instagram?: string | null;
@IsOptional() @IsString() birthDate?: string | null;
@IsOptional() @IsArray() @IsString({ each: true }) addresses?: string[]; @IsOptional() @IsArray() @IsString({ each: true }) addresses?: string[];
@IsOptional() @IsString() trade?: string | null; @IsOptional() @IsString() trade?: string | null;
@IsOptional() @IsString() source?: string | null; @IsOptional() @IsString() source?: string | null;
@@ -4,6 +4,7 @@ import { Prisma } from '@prisma/client';
import type { LeadImportJobDto } from '@mento/shared'; import type { LeadImportJobDto } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { import {
parseBirthDate,
parseExcelMapping, parseExcelMapping,
parseJalaliDateTime, parseJalaliDateTime,
previewExcel, previewExcel,
@@ -55,6 +56,12 @@ const LEAD_ALIASES: Record<string, string> = {
اینستاگرام: 'instagram', اینستاگرام: 'instagram',
پیجاینستاگرام: 'instagram', پیجاینستاگرام: 'instagram',
آیدیاینستاگرام: 'instagram', آیدیاینستاگرام: 'instagram',
birthdate: 'birthDate',
dateofbirth: 'birthDate',
bdate: 'birthDate',
تاریختولد: 'birthDate',
تاریختولدمشتری: 'birthDate',
تاریختولدشمسی: 'birthDate',
registeredat: 'registeredAt', registeredat: 'registeredAt',
تاریخثبت: 'registeredAt', تاریخثبت: 'registeredAt',
تاریخثبتمشتری: 'registeredAt', تاریخثبتمشتری: 'registeredAt',
@@ -81,6 +88,7 @@ const LEAD_FIELDS: ExcelFieldOption[] = [
{ key: 'homeAddress', label: 'آدرس منزل' }, { key: 'homeAddress', label: 'آدرس منزل' },
{ key: 'instagram', label: 'اینستاگرام' }, { key: 'instagram', label: 'اینستاگرام' },
{ key: 'registeredAt', label: 'تاریخ ثبت' }, { key: 'registeredAt', label: 'تاریخ ثبت' },
{ key: 'birthDate', label: 'تاریخ تولد' },
{ key: 'trade', label: 'صنف' }, { key: 'trade', label: 'صنف' },
{ key: 'source', label: 'منبع' }, { key: 'source', label: 'منبع' },
{ key: 'notes', label: 'توضیحات' }, { key: 'notes', label: 'توضیحات' },
@@ -284,6 +292,9 @@ export class LeadsImportService {
address2: get('address2', 'آدرس ۲', 'آدرس2', 'آدرس دوم'), address2: get('address2', 'آدرس ۲', 'آدرس2', 'آدرس دوم'),
homeAddress: get('homeAddress', 'آدرس منزل', 'آدرس خانه'), homeAddress: get('homeAddress', 'آدرس منزل', 'آدرس خانه'),
instagram: get('instagram', 'اینستاگرام', 'پیج اینستاگرام', 'آی دی اینستاگرام'), instagram: get('instagram', 'اینستاگرام', 'پیج اینستاگرام', 'آی دی اینستاگرام'),
birthDate: parseBirthDate(
get('birthDate', 'تاریخ تولد', 'تاریخ تولد مشتری', 'تاریخ تولد شمسی', 'birthdate'),
)?.toISOString() ?? '',
registeredAt, registeredAt,
trade: get('trade', 'صنف', 'رسته'), trade: get('trade', 'صنف', 'رسته'),
source: get('source', 'منبع', 'منبع لید'), source: get('source', 'منبع', 'منبع لید'),
+34 -2
View File
@@ -5,6 +5,7 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { parseBirthDate } from '../common/excel';
import * as path from 'node:path'; import * as path from 'node:path';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import type { import type {
@@ -268,6 +269,7 @@ export class LeadsService {
city?: string | null; city?: string | null;
region?: string | null; region?: string | null;
instagram?: string | null; instagram?: string | null;
birthDate?: string | null;
addresses?: string[]; addresses?: string[];
trade?: string | null; trade?: string | null;
source?: string | null; source?: string | null;
@@ -314,6 +316,7 @@ export class LeadsService {
city: emptyToNull(dto.city), city: emptyToNull(dto.city),
region: emptyToNull(dto.region), region: emptyToNull(dto.region),
instagram: emptyToNull(dto.instagram), instagram: emptyToNull(dto.instagram),
birthDate: parseBirthDate(dto.birthDate),
addresses: toJsonAddresses(dto.addresses), addresses: toJsonAddresses(dto.addresses),
trade: emptyToNull(dto.trade), trade: emptyToNull(dto.trade),
source: emptyToNull(dto.source), source: emptyToNull(dto.source),
@@ -398,6 +401,9 @@ export class LeadsService {
...(dto.instagram !== undefined ...(dto.instagram !== undefined
? { instagram: emptyToNull(dto.instagram as string) } ? { instagram: emptyToNull(dto.instagram as string) }
: {}), : {}),
...(dto.birthDate !== undefined
? { birthDate: parseBirthDate(dto.birthDate as string | null) }
: {}),
...(dto.addresses !== undefined ...(dto.addresses !== undefined
? { ? {
addresses: toJsonAddresses( addresses: toJsonAddresses(
@@ -802,6 +808,7 @@ export class LeadsService {
mobile: lead.mobile, mobile: lead.mobile,
phone: lead.phone, phone: lead.phone,
instagram: lead.instagram, instagram: lead.instagram,
birthDate: lead.birthDate,
addresses: toJsonAddresses(asAddresses(lead.addresses)), addresses: toJsonAddresses(asAddresses(lead.addresses)),
notes: [ notes: [
lead.notes, lead.notes,
@@ -819,6 +826,11 @@ export class LeadsService {
}, },
}); });
createdPerson = true; 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({ const convertedStage = await this.prisma.leadStage.findFirst({
@@ -985,6 +997,7 @@ export class LeadsService {
address2?: string; address2?: string;
homeAddress?: string; homeAddress?: string;
instagram?: string; instagram?: string;
birthDate?: string;
registeredAt?: string; registeredAt?: string;
trade?: string; trade?: string;
source?: string; source?: string;
@@ -996,11 +1009,20 @@ export class LeadsService {
const title = String(row.title ?? row.contactName ?? '').trim(); const title = String(row.title ?? row.contactName ?? '').trim();
if (!mobile || !title) return 'error'; if (!mobile || !title) return 'error';
const birthDate = parseBirthDate(row.birthDate);
const existingLead = await this.prisma.lead.findFirst({ const existingLead = await this.prisma.lead.findFirst({
where: { organizationId, mobile }, where: { organizationId, mobile },
select: { id: true }, 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({ const existingPerson = await this.prisma.person.findFirst({
where: { where: {
@@ -1009,7 +1031,15 @@ export class LeadsService {
}, },
select: { id: true }, 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 { try {
const registeredAt = row.registeredAt ? new Date(row.registeredAt) : null; const registeredAt = row.registeredAt ? new Date(row.registeredAt) : null;
@@ -1023,6 +1053,7 @@ export class LeadsService {
city: emptyToNull(row.city), city: emptyToNull(row.city),
region: emptyToNull(row.region), region: emptyToNull(row.region),
instagram: emptyToNull(row.instagram), instagram: emptyToNull(row.instagram),
birthDate,
addresses: toJsonAddresses( addresses: toJsonAddresses(
collectAddresses(row.address, row.address2, row.homeAddress), collectAddresses(row.address, row.address2, row.homeAddress),
), ),
@@ -1101,6 +1132,7 @@ export class LeadsService {
city: row.city, city: row.city,
region: row.region, region: row.region,
instagram: row.instagram, instagram: row.instagram,
birthDate: row.birthDate?.toISOString() ?? null,
addresses: asAddresses(row.addresses), addresses: asAddresses(row.addresses),
trade: row.trade, trade: row.trade,
source: row.source, source: row.source,
+20
View File
@@ -3,7 +3,9 @@ import assert from 'node:assert/strict';
import { import {
addJalaliMonths, addJalaliMonths,
endOfJalaliMonth, endOfJalaliMonth,
formatTehranJalali,
jalaliMonthName, jalaliMonthName,
rewriteGregorianDates,
toGregorian, toGregorian,
toJalali, toJalali,
} from '../src/common/jalali'; } from '../src/common/jalali';
@@ -102,3 +104,21 @@ describe('یادآور چک', () => {
assert.ok(!isDueInDays(new Date('2026-10-12T12:00:00Z'), 3, now)); 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 { RouterLink } from 'vue-router';
import axios from 'axios'; import axios from 'axios';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { rewriteGregorianDates } from '@/lib/jalali';
import UiIcon from '@/components/UiIcon.vue'; import UiIcon from '@/components/UiIcon.vue';
import { brand, type AskMentoResponse, type AskMentoSourceDto } from '@mento/shared'; import { brand, type AskMentoResponse, type AskMentoSourceDto } from '@mento/shared';
@@ -43,7 +44,7 @@ async function submit() {
personId: props.personId || undefined, personId: props.personId || undefined,
chatContext: props.chatContext?.trim() || undefined, chatContext: props.chatContext?.trim() || undefined,
}); });
result.value = data; result.value = { ...data, answer: rewriteGregorianDates(data.answer) };
} catch (e) { } catch (e) {
if (axios.isAxiosError(e)) { if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message; 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 { computed, onMounted, ref, watch } from 'vue';
import { RouterLink } from 'vue-router'; import { RouterLink } from 'vue-router';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { formatJalaliDateTime } from '@/lib/jalali';
import VoiceToTextButton from '@/components/VoiceToTextButton.vue'; import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
import { appendSpokenText } from '@/lib/spoken-text'; import { appendSpokenText } from '@/lib/spoken-text';
import type { import type {
@@ -63,14 +64,9 @@ function noteTypeLabel(value?: string | null) {
} }
function formatTime(iso: string) { function formatTime(iso: string) {
try { const d = new Date(iso);
return new Intl.DateTimeFormat('fa-IR', { if (Number.isNaN(d.getTime())) return iso;
dateStyle: 'medium', return formatJalaliDateTime(d);
timeStyle: 'short',
}).format(new Date(iso));
} catch {
return iso;
}
} }
async function load() { async function load() {
+4 -8
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue';
import axios from 'axios'; import axios from 'axios';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { brand, type MemoryItemDto, type PaginatedResponse } from '@mento/shared'; import { brand, type MemoryItemDto, type PaginatedResponse } from '@mento/shared';
import { formatJalaliDateTime } from '@/lib/jalali';
const props = defineProps<{ const props = defineProps<{
personId: string; personId: string;
@@ -97,14 +98,9 @@ async function removeItem(id: string) {
} }
function formatWhen(value: string) { function formatWhen(value: string) {
try { const d = new Date(value);
return new Intl.DateTimeFormat('fa-IR', { if (Number.isNaN(d.getTime())) return value;
dateStyle: 'medium', return formatJalaliDateTime(d);
timeStyle: 'short',
}).format(new Date(value));
} catch {
return value;
}
} }
const hasItems = computed(() => items.value.length > 0); const hasItems = computed(() => items.value.length > 0);
@@ -4,6 +4,7 @@ import axios from 'axios';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import JalaliDateInput from '@/components/JalaliDateInput.vue'; import JalaliDateInput from '@/components/JalaliDateInput.vue';
import { brand, type NoteAiPayloadDto, type NoteAiSuggestionDto } from '@mento/shared'; import { brand, type NoteAiPayloadDto, type NoteAiSuggestionDto } from '@mento/shared';
import { rewriteGregorianDates } from '@/lib/jalali';
const props = defineProps<{ const props = defineProps<{
noteId: string; noteId: string;
@@ -142,7 +143,7 @@ async function dismiss() {
function formatPayloadSummary(value: NoteAiPayloadDto | null) { function formatPayloadSummary(value: NoteAiPayloadDto | null) {
if (!value?.summary) return ''; if (!value?.summary) return '';
return value.summary; return rewriteGregorianDates(value.summary);
} }
</script> </script>
@@ -5,7 +5,7 @@ import axios from 'axios';
import UiIcon from '@/components/UiIcon.vue'; import UiIcon from '@/components/UiIcon.vue';
import VoiceToTextButton from '@/components/VoiceToTextButton.vue'; import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
import JalaliDateInput from '@/components/JalaliDateInput.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 { UiButton, UiModal } from '@/components/ui';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { brand, type ActivityType, type AskMentoSourceDto, type PersonDto, type VoiceCommandDraft } from '@mento/shared'; 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) voiceError.value = looksLikeAction(text)
? 'دستور را کامل نفهمیدم. نام و مشخصات را واضح‌تر بگویید تا قبل از ساخت، تأیید کنید.' ? 'دستور را کامل نفهمیدم. نام و مشخصات را واضح‌تر بگویید تا قبل از ساخت، تأیید کنید.'
: data.summary; : rewriteGregorianDates(data.summary);
return; return;
} }
draft.value = data; draft.value = data;
@@ -328,7 +328,7 @@ watch(
:title="draft?.intent === 'create_person' ? 'تأیید ساخت مشتری' : 'تأیید ثبت فعالیت'" :title="draft?.intent === 'create_person' ? 'تأیید ساخت مشتری' : 'تأیید ثبت فعالیت'"
@close="cancelDraft" @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> <p class="edit-hint">قبل از تأیید می‌توانید متن را اصلاح کنید.</p>
<div v-if="draft?.person" class="modal-fields"> <div v-if="draft?.person" class="modal-fields">
<label>نام <input v-model="draft.person.firstName" @input="onPersonNameEdit" /></label> <label>نام <input v-model="draft.person.firstName" @input="onPersonNameEdit" /></label>
@@ -372,7 +372,7 @@ watch(
</UiModal> </UiModal>
<UiModal :open="answerOpen && Boolean(answer)" title="پاسخ دستیار" @close="closeAnswer"> <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"> <div v-if="sources?.length" class="sources">
<RouterLink <RouterLink
v-for="source in sources" 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 { RouterLink, RouterView, useRoute, useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { api } from '@/lib/api'; 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 UiIcon from '@/components/UiIcon.vue';
import MentoLogo from '@/components/brand/MentoLogo.vue'; import MentoLogo from '@/components/brand/MentoLogo.vue';
import QuickCreate from '@/components/QuickCreate.vue'; import QuickCreate from '@/components/QuickCreate.vue';
@@ -350,14 +350,9 @@ function toggleNotif() {
} }
function formatTime(iso: string) { function formatTime(iso: string) {
try { const d = new Date(iso);
return new Intl.DateTimeFormat('fa-IR', { if (Number.isNaN(d.getTime())) return iso;
dateStyle: 'short', return formatJalaliDateTime(d);
timeStyle: 'short',
}).format(new Date(iso));
} catch {
return iso;
}
} }
function submitSearch() { function submitSearch() {
+5 -7
View File
@@ -1,6 +1,6 @@
import type { GamificationEvent, GamificationTxDto } from '@mento/shared'; import type { GamificationEvent, GamificationTxDto } from '@mento/shared';
import { GAMIFICATION_EVENTS } 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'; export { apiError } from '@/lib/routines';
@@ -81,15 +81,13 @@ export function timeAgo(iso: string): string {
if (hours < 24) return `${toFaDigits(hours)} ساعت پیش`; if (hours < 24) return `${toFaDigits(hours)} ساعت پیش`;
const days = Math.floor(hours / 24); const days = Math.floor(hours / 24);
if (days < 7) return `${toFaDigits(days)} روز پیش`; 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 { export function formatDateTime(iso: string): string {
try { const d = new Date(iso);
return new Intl.DateTimeFormat('fa-IR', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(iso)); if (Number.isNaN(d.getTime())) return iso;
} catch { return formatJalaliDateTime(d);
return iso;
}
} }
export const REWARD_ICONS = ['gift', 'star', 'trophy', 'medal', 'crown', 'calendar-sun', 'bag', 'target', 'fire'] as const; 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)}`; 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 { export function calendarDaysBetween(from: Date, to: Date): number {
return Math.round( return Math.round(
(startOfLocalDay(to).getTime() - startOfLocalDay(from).getTime()) / 86_400_000, (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); const { jy, jm, jd } = dateToJalali(d);
return `${toFaDigits(jy)}/${toFaDigits(String(jm).padStart(2, '0'))}/${toFaDigits(String(jd).padStart(2, '0'))}`; 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 { useAuthStore } from '@/stores/auth';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { formatMoney } from '@/lib/money'; 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 HomeHero from '@/components/dashboard/HomeHero.vue';
import HomeKpiRow from '@/components/dashboard/HomeKpiRow.vue'; import HomeKpiRow from '@/components/dashboard/HomeKpiRow.vue';
import HomeSalesTrend from '@/components/dashboard/HomeSalesTrend.vue'; import HomeSalesTrend from '@/components/dashboard/HomeSalesTrend.vue';
@@ -52,14 +52,9 @@ const greeting = computed(() => {
function formatWhen(value: string | null) { function formatWhen(value: string | null) {
if (!value) return ''; if (!value) return '';
try { const d = new Date(value);
return new Intl.DateTimeFormat('fa-IR', { if (Number.isNaN(d.getTime())) return value;
dateStyle: 'medium', return formatJalaliDateTime(d);
timeStyle: 'short',
}).format(new Date(value));
} catch {
return value;
}
} }
function formatKpiValue(key: string, value: number) { function formatKpiValue(key: string, value: number) {
@@ -117,7 +112,7 @@ async function submitAsk(preset?: string) {
askSources.value = []; askSources.value = [];
try { try {
const { data } = await api.post<AskMentoResponse>('/ai/ask', { question: q }); const { data } = await api.post<AskMentoResponse>('/ai/ask', { question: q });
askAnswer.value = data.answer; askAnswer.value = rewriteGregorianDates(data.answer);
askSources.value = data.sources ?? []; askSources.value = data.sources ?? [];
await nextTick(); await nextTick();
document document
+14 -1
View File
@@ -3,7 +3,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import axios from 'axios'; import axios from 'axios';
import { api } from '@/lib/api'; 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 { useAuthStore } from '@/stores/auth';
import { canManageSettings } from '@/lib/roles'; import { canManageSettings } from '@/lib/roles';
import JalaliDateInput from '@/components/JalaliDateInput.vue'; import JalaliDateInput from '@/components/JalaliDateInput.vue';
@@ -69,6 +69,7 @@ const editForm = ref({
region: '', region: '',
source: '', source: '',
instagram: '', instagram: '',
birthDate: '',
trade: '', trade: '',
notes: '', notes: '',
}); });
@@ -131,6 +132,7 @@ function startEdit() {
region: lead.value.region ?? '', region: lead.value.region ?? '',
source: lead.value.source ?? '', source: lead.value.source ?? '',
instagram: lead.value.instagram ?? '', instagram: lead.value.instagram ?? '',
birthDate: lead.value.birthDate ? formatLocalDateInput(new Date(lead.value.birthDate)) : '',
trade: lead.value.trade ?? '', trade: lead.value.trade ?? '',
notes: lead.value.notes ?? '', notes: lead.value.notes ?? '',
}; };
@@ -153,6 +155,9 @@ async function saveEdit() {
region: editForm.value.region.trim() || null, region: editForm.value.region.trim() || null,
source: editForm.value.source.trim() || null, source: editForm.value.source.trim() || null,
instagram: editForm.value.instagram.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, trade: editForm.value.trade.trim() || null,
notes: editForm.value.notes.trim() || null, notes: editForm.value.notes.trim() || null,
}); });
@@ -511,6 +516,10 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
<dt><UiIcon name="user" :size="14" /> کارشناس</dt> <dt><UiIcon name="user" :size="14" /> کارشناس</dt>
<dd>{{ lead.assigneeName || '—' }}</dd> <dd>{{ lead.assigneeName || '—' }}</dd>
</div> </div>
<div class="cell">
<dt><UiIcon name="calendar" :size="14" /> تاریخ تولد</dt>
<dd>{{ formatDate(lead.birthDate) }}</dd>
</div>
<div class="cell"> <div class="cell">
<dt><UiIcon name="calendar" :size="14" /> تاریخ ثبت</dt> <dt><UiIcon name="calendar" :size="14" /> تاریخ ثبت</dt>
<dd>{{ formatDate(lead.createdAt) }}</dd> <dd>{{ formatDate(lead.createdAt) }}</dd>
@@ -610,6 +619,9 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
<UiField label="اینستاگرام"> <UiField label="اینستاگرام">
<UiInput v-model="editForm.instagram" dir="ltr" /> <UiInput v-model="editForm.instagram" dir="ltr" />
</UiField> </UiField>
<UiField label="تاریخ تولد">
<JalaliDateInput v-model="editForm.birthDate" mode="date" />
</UiField>
<UiField label="صنف"> <UiField label="صنف">
<UiInput v-model="editForm.trade" /> <UiInput v-model="editForm.trade" />
</UiField> </UiField>
@@ -635,6 +647,7 @@ onBeforeUnmount(() => narrowMq?.removeEventListener('change', syncNarrow));
<div><dt>منبع</dt><dd>{{ lead.source || '—' }}</dd></div> <div><dt>منبع</dt><dd>{{ lead.source || '—' }}</dd></div>
<div><dt>وضعیت</dt><dd>{{ lead.stageName }}</dd></div> <div><dt>وضعیت</dt><dd>{{ lead.stageName }}</dd></div>
<div><dt>کارشناس</dt><dd>{{ lead.assigneeName || '—' }}</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.createdAt) }}</dd></div>
<div><dt>آخرین تماس</dt><dd>{{ formatDate(lead.lastContactAt) }}</dd></div> <div><dt>آخرین تماس</dt><dd>{{ formatDate(lead.lastContactAt) }}</dd></div>
<div><dt>پیگیری بعدی</dt><dd>{{ formatDate(lead.nextFollowUpAt) }}</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 { api } from '@/lib/api';
import type { CompanyDto, CreatePersonRequest, PersonDto, PersonMood } from '@mento/shared'; import type { CompanyDto, CreatePersonRequest, PersonDto, PersonMood } from '@mento/shared';
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@/lib/person-mood'; 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 { import {
filterPhoneContacts, filterPhoneContacts,
isContactPickerSupported, isContactPickerSupported,
@@ -63,6 +64,7 @@ const mobile = ref('');
const position = ref(''); const position = ref('');
const website = ref(''); const website = ref('');
const instagram = ref(''); const instagram = ref('');
const birthDate = ref('');
const referrer = ref(''); const referrer = ref('');
const addresses = ref<string[]>(['']); const addresses = ref<string[]>(['']);
const notes = ref(''); const notes = ref('');
@@ -209,6 +211,7 @@ function fill(p: PersonDto) {
position.value = p.position ?? ''; position.value = p.position ?? '';
website.value = p.website ?? ''; website.value = p.website ?? '';
instagram.value = p.instagram ?? ''; instagram.value = p.instagram ?? '';
birthDate.value = p.birthDate ? formatLocalDateInput(new Date(p.birthDate)) : '';
referrer.value = p.referrer ?? ''; referrer.value = p.referrer ?? '';
addresses.value = p.addresses?.length ? [...p.addresses] : ['']; addresses.value = p.addresses?.length ? [...p.addresses] : [''];
notes.value = p.notes ?? ''; notes.value = p.notes ?? '';
@@ -285,6 +288,7 @@ function payload(): CreatePersonRequest {
position: position.value || undefined, position: position.value || undefined,
website: website.value || undefined, website: website.value || undefined,
instagram: instagram.value || undefined, instagram: instagram.value || undefined,
birthDate: birthDate.value ? parseLocalInput(birthDate.value)?.toISOString() ?? null : null,
referrer: referrer.value || undefined, referrer: referrer.value || undefined,
addresses: addresses.value.map((a) => a.trim()).filter(Boolean), addresses: addresses.value.map((a) => a.trim()).filter(Boolean),
notes: notes.value || undefined, notes: notes.value || undefined,
@@ -483,6 +487,10 @@ onMounted(load);
<UiInput id="instagram" v-model="instagram" dir="ltr" placeholder="@username یا لینک" /> <UiInput id="instagram" v-model="instagram" dir="ltr" placeholder="@username یا لینک" />
</UiField> </UiField>
<UiField label="تاریخ تولد" for-id="birth-date">
<JalaliDateInput id="birth-date" v-model="birthDate" mode="date" />
</UiField>
<UiField label="معرف" for-id="referrer"> <UiField label="معرف" for-id="referrer">
<UiInput id="referrer" v-model="referrer" placeholder="نام معرف" /> <UiInput id="referrer" v-model="referrer" placeholder="نام معرف" />
</UiField> </UiField>
+12 -2
View File
@@ -7,6 +7,7 @@ import {
calendarDaysBetween, calendarDaysBetween,
formatClockFa, formatClockFa,
formatJalaliNumeric, formatJalaliNumeric,
rewriteGregorianDates,
toFaDigits, toFaDigits,
} from '@/lib/jalali'; } from '@/lib/jalali';
import { import {
@@ -141,6 +142,9 @@ const mailHref = computed(() =>
const registeredAt = computed(() => const registeredAt = computed(() =>
person.value?.createdAt ? formatJalaliNumeric(new Date(person.value.createdAt)) : '—', person.value?.createdAt ? formatJalaliNumeric(new Date(person.value.createdAt)) : '—',
); );
const birthDateLabel = computed(() =>
person.value?.birthDate ? formatJalaliNumeric(new Date(person.value.birthDate)) : '—',
);
const externalCode = computed(() => { const externalCode = computed(() => {
if (person.value?.sourceRef?.trim()) return person.value.sourceRef.trim(); if (person.value?.sourceRef?.trim()) return person.value.sourceRef.trim();
const fields = person.value?.customFields ?? {}; const fields = person.value?.customFields ?? {};
@@ -239,8 +243,8 @@ const smart = computed(() =>
); );
const shownSmart = computed(() => ({ const shownSmart = computed(() => ({
text: aiSummary.value?.text || smart.value.text, text: rewriteGregorianDates(aiSummary.value?.text || smart.value.text),
cta: aiSummary.value?.cta || smart.value.cta, cta: rewriteGregorianDates(aiSummary.value?.cta || smart.value.cta),
ctaKind: aiSummary.value?.ctaKind || smart.value.ctaKind, ctaKind: aiSummary.value?.ctaKind || smart.value.ctaKind,
})); }));
@@ -367,6 +371,7 @@ const infoEmpty = computed(() => {
!p.position && !p.position &&
!p.website && !p.website &&
!p.instagram && !p.instagram &&
!p.birthDate &&
!p.referrer && !p.referrer &&
!(p.addresses?.length) && !(p.addresses?.length) &&
!p.company && !p.company &&
@@ -687,6 +692,10 @@ watch(() => route.query.tab, readTab, { immediate: true });
<dt>سمت</dt> <dt>سمت</dt>
<dd>{{ person.position || '—' }}</dd> <dd>{{ person.position || '—' }}</dd>
</div> </div>
<div class="fact">
<dt>تاریخ تولد</dt>
<dd>{{ birthDateLabel }}</dd>
</div>
<div class="fact"> <div class="fact">
<dt>تاریخ ثبت</dt> <dt>تاریخ ثبت</dt>
<dd>{{ registeredAt }}</dd> <dd>{{ registeredAt }}</dd>
@@ -993,6 +1002,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
</div> </div>
<div><dt>شرکت</dt><dd>{{ person.company?.name || '—' }}</dd></div> <div><dt>شرکت</dt><dd>{{ person.company?.name || '—' }}</dd></div>
<div><dt>مسئول</dt><dd>{{ person.owner?.fullName || '—' }}</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><dd>{{ registeredAt }}</dd></div>
<div> <div>
<dt>باشگاه</dt> <dt>باشگاه</dt>
+4 -8
View File
@@ -16,6 +16,7 @@ import type {
PortalTicketDto, PortalTicketDto,
} from '@mento/shared'; } from '@mento/shared';
import { brand } from '@mento/shared'; import { brand } from '@mento/shared';
import { formatJalaliDateTime } from '@/lib/jalali';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -54,14 +55,9 @@ const statusLabel: Record<string, string> = {
}; };
function formatWhen(iso: string) { function formatWhen(iso: string) {
try { const d = new Date(iso);
return new Intl.DateTimeFormat('fa-IR', { if (Number.isNaN(d.getTime())) return iso;
dateStyle: 'medium', return formatJalaliDateTime(d);
timeStyle: 'short',
}).format(new Date(iso));
} catch {
return iso;
}
} }
async function loadOrg() { async function loadOrg() {
+2 -1
View File
@@ -11,6 +11,7 @@ import {
calendarDaysBetween, calendarDaysBetween,
formatJalaliDayClock, formatJalaliDayClock,
formatNextSlot, formatNextSlot,
rewriteGregorianDates,
formatRelativeContact, formatRelativeContact,
toFaDigits, toFaDigits,
} from '@/lib/jalali'; } from '@/lib/jalali';
@@ -435,7 +436,7 @@ async function loadAiBrief() {
question: question:
'وضعیت کاری امروز من را خلاصه کن: فعالیت‌های عقب‌افتاده، معاملات باز و مهم‌ترین پیگیری بعدی.', 'وضعیت کاری امروز من را خلاصه کن: فعالیت‌های عقب‌افتاده، معاملات باز و مهم‌ترین پیگیری بعدی.',
}); });
aiBrief.value = data.answer; aiBrief.value = rewriteGregorianDates(data.answer);
} catch { } catch {
aiBrief.value = ''; 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 { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios'; import axios from 'axios';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { formatJalaliDateTime } from '@/lib/jalali';
import { canViewAllRecords } from '@/lib/roles'; import { canViewAllRecords } from '@/lib/roles';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { import {
@@ -84,14 +85,9 @@ const priorityLabel: Record<TicketPriority, string> = {
}; };
function formatWhen(iso: string) { function formatWhen(iso: string) {
try { const d = new Date(iso);
return new Intl.DateTimeFormat('fa-IR', { if (Number.isNaN(d.getTime())) return iso;
dateStyle: 'medium', return formatJalaliDateTime(d);
timeStyle: 'short',
}).format(new Date(iso));
} catch {
return iso;
}
} }
async function loadFaqHints() { 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 { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios'; import axios from 'axios';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali'; import { formatJalaliDateTime, toFaDigits } from '@/lib/jalali';
import { import {
UiButton, UiButton,
UiPageHeader, UiPageHeader,
@@ -57,14 +57,9 @@ const statusFilterLabel = computed(() =>
function formatSla(iso: string | null) { function formatSla(iso: string | null) {
if (!iso) return ''; if (!iso) return '';
try { const d = new Date(iso);
return new Intl.DateTimeFormat('fa-IR', { if (Number.isNaN(d.getTime())) return iso;
dateStyle: 'short', return formatJalaliDateTime(d);
timeStyle: 'short',
}).format(new Date(iso));
} catch {
return iso;
}
} }
async function load() { async function load() {
+2 -2
View File
@@ -18,7 +18,7 @@ import {
UiPageHeader, UiPageHeader,
} from '@/components/ui'; } from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue'; import UiIcon from '@/components/UiIcon.vue';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali'; import { formatJalaliNumeric, rewriteGregorianDates, toFaDigits } from '@/lib/jalali';
const loading = ref(false); const loading = ref(false);
const saving = ref(false); const saving = ref(false);
@@ -267,7 +267,7 @@ onMounted(() => {
{{ summaryLabel[c.summaryStatus] ?? c.summaryStatus ?? '—' }} {{ summaryLabel[c.summaryStatus] ?? c.summaryStatus ?? '—' }}
</span> </span>
<p v-if="c.summary" class="summary-preview"> <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>
<p v-if="c.summaryError && c.summaryStatus === 'failed'" class="warn"> <p v-if="c.summaryError && c.summaryStatus === 'failed'" class="warn">
{{ c.summaryError }} {{ c.summaryError }}
+5
View File
@@ -857,6 +857,7 @@ export interface PersonDto {
position: string | null; position: string | null;
website: string | null; website: string | null;
instagram: string | null; instagram: string | null;
birthDate: string | null;
referrer: string | null; referrer: string | null;
addresses: string[]; addresses: string[];
notes: string | null; notes: string | null;
@@ -884,6 +885,7 @@ export interface CreatePersonRequest {
position?: string; position?: string;
website?: string; website?: string;
instagram?: string; instagram?: string;
birthDate?: string | null;
referrer?: string; referrer?: string;
addresses?: string[]; addresses?: string[];
notes?: string; notes?: string;
@@ -4759,6 +4761,7 @@ export interface LeadDto {
city: string | null; city: string | null;
region: string | null; region: string | null;
instagram: string | null; instagram: string | null;
birthDate: string | null;
addresses: string[]; addresses: string[];
trade: string | null; trade: string | null;
source: string | null; source: string | null;
@@ -4797,6 +4800,7 @@ export interface CreateLeadRequest {
city?: string | null; city?: string | null;
region?: string | null; region?: string | null;
instagram?: string | null; instagram?: string | null;
birthDate?: string | null;
addresses?: string[]; addresses?: string[];
trade?: string | null; trade?: string | null;
source?: string | null; source?: string | null;
@@ -4817,6 +4821,7 @@ export interface UpdateLeadRequest {
city?: string | null; city?: string | null;
region?: string | null; region?: string | null;
instagram?: string | null; instagram?: string | null;
birthDate?: string | null;
addresses?: string[]; addresses?: string[];
trade?: string | null; trade?: string | null;
source?: string | null; source?: string | null;