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