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:
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Person" ADD COLUMN "birthDate" TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Lead" ADD COLUMN "birthDate" TIMESTAMP(3);
|
||||
@@ -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("[]")
|
||||
/// صنف
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', 'منبع', 'منبع لید'),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')), '۱۴۰۵/۰۶/۳۰');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 = '';
|
||||
}
|
||||
|
||||
@@ -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,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() {
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user