diff --git a/apps/api/src/integrations/tahesab/__tests__/tahesab-unit.check.ts b/apps/api/src/integrations/tahesab/__tests__/tahesab-unit.check.ts index 1208ef1..28efa27 100644 --- a/apps/api/src/integrations/tahesab/__tests__/tahesab-unit.check.ts +++ b/apps/api/src/integrations/tahesab/__tests__/tahesab-unit.check.ts @@ -1,4 +1,11 @@ import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'; +import { + pickAddress, + pickCity, + pickNationalId, + pickRasteh, + tahesabProfilePatch, +} from '../moshtari-profile'; import { assertFactorCode, buildFactorCode, @@ -116,12 +123,52 @@ function testSanadBuilders() { assert(bank[12] === fc, 'bank factor index'); } +function testMoshtariProfile() { + const row = { + CodeMelli: '00112312311', + Address: 'خیابان ولیعصر', + City: 'تهران', + Rasteh: 2, + BDate: null, + }; + assert(pickNationalId(row) === '00112312311', 'national id'); + assert(pickAddress(row) === 'خیابان ولیعصر', 'address'); + assert(pickCity(row) === 'تهران', 'city'); + assert(pickRasteh(row) === 2, 'rasteh'); + assert(pickNationalId({ CodeMelli: '-1' }) == null, 'skip -1 national'); + + const patch = tahesabProfilePatch(row, { + birthDate: null, + addresses: [], + customFields: { keep: true }, + }); + assert(Array.isArray(patch.addresses) && patch.addresses[0] === 'خیابان ولیعصر، تهران', 'addr+city'); + const fields = patch.customFields as Record; + assert(fields.nationalId === '00112312311', 'cf national'); + assert(fields.city === 'تهران', 'cf city'); + assert(fields.rasteh === 2, 'cf rasteh'); + assert(fields.keep === true, 'cf merge'); + + const emptyKeep = tahesabProfilePatch( + { CodeMelli: '', Address: '', City: '', Rasteh: '' }, + { + birthDate: new Date('2000-01-01'), + addresses: ['قدیمی'], + customFields: { nationalId: '111' }, + }, + ); + assert(emptyKeep.addresses == null, 'empty does not wipe address'); + assert(emptyKeep.customFields == null, 'empty does not wipe custom'); + assert(emptyKeep.birthDate == null, 'existing birth kept'); +} + function main() { testCryptoRoundtrip(); testFactorCode(); testMobile(); testMandehNormalize(); testSanadBuilders(); + testMoshtariProfile(); console.log('tahesab unit checks: OK'); } diff --git a/apps/api/src/integrations/tahesab/moshtari-profile.ts b/apps/api/src/integrations/tahesab/moshtari-profile.ts new file mode 100644 index 0000000..82a513d --- /dev/null +++ b/apps/api/src/integrations/tahesab/moshtari-profile.ts @@ -0,0 +1,113 @@ +import { Prisma } from '@prisma/client'; +import { parseBirthDate } from '../../common/excel'; + +export function pickBirthDate(row: Record): 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)); +} + +function trimStr(raw: unknown): string | null { + if (raw == null) return null; + const s = String(raw).trim(); + if (!s || s === '-1') return null; + return s; +} + +/** کد ملی از پاسخ لیست مشتری ته‌حساب */ +export function pickNationalId(row: Record): string | null { + return ( + trimStr(row.CodeMelli) ?? + trimStr(row.CodeMeli) ?? + trimStr(row.codeMeli) ?? + trimStr(row.NationalId) ?? + null + ); +} + +export function pickAddress(row: Record): string | null { + return trimStr(row.Address) ?? trimStr(row.address) ?? null; +} + +export function pickCity(row: Record): string | null { + return trimStr(row.City) ?? trimStr(row.city) ?? trimStr(row.Shahr) ?? null; +} + +/** رسته حساب در ته‌حساب (عدد یا رشته) */ +export function pickRasteh(row: Record): string | number | null { + const raw = row.Rasteh ?? row.rasteh ?? row.Raste; + if (raw == null || raw === '') return null; + if (typeof raw === 'number' && Number.isFinite(raw)) return raw; + const s = String(raw).trim(); + if (!s || s === '-1') return null; + const n = Number(s); + return Number.isFinite(n) && String(n) === s ? n : s; +} + +function asCustomFieldRecord(raw: unknown): Record { + if (raw && typeof raw === 'object' && !Array.isArray(raw)) { + return { ...(raw as Record) }; + } + return {}; +} + +function asAddressList(raw: unknown): string[] { + if (!Array.isArray(raw)) return []; + return raw + .map((a) => (typeof a === 'string' ? a.trim() : '')) + .filter(Boolean); +} + +/** + * فیلدهای پروفایل قابل‌دریافت از DoListMoshtari را روی شخص می‌نشاند. + * مقدار خالی ته‌حساب، دادهٔ موجود CRM را پاک نمی‌کند. + */ +export function tahesabProfilePatch( + row: Record, + existing: { + birthDate: Date | null; + addresses: unknown; + customFields: unknown; + }, +): Prisma.PersonUpdateInput { + const data: Prisma.PersonUpdateInput = {}; + const birthDate = pickBirthDate(row); + if (!existing.birthDate && birthDate) data.birthDate = birthDate; + + const address = pickAddress(row); + const city = pickCity(row); + if (address) { + const line = city ? `${address}، ${city}` : address; + data.addresses = [line] as Prisma.InputJsonValue; + } else if (city && asAddressList(existing.addresses).length === 0) { + data.addresses = [city] as Prisma.InputJsonValue; + } + + const fields = asCustomFieldRecord(existing.customFields); + let fieldsChanged = false; + const nationalId = pickNationalId(row); + if (nationalId) { + fields.nationalId = nationalId; + fieldsChanged = true; + } + if (city) { + fields.city = city; + fieldsChanged = true; + } + if (address) { + fields.address = address; + fieldsChanged = true; + } + const rasteh = pickRasteh(row); + if (rasteh != null) { + fields.rasteh = rasteh; + fieldsChanged = true; + } + if (fieldsChanged) data.customFields = fields as Prisma.InputJsonValue; + return data; +} diff --git a/apps/api/src/integrations/tahesab/person-link.service.ts b/apps/api/src/integrations/tahesab/person-link.service.ts index 38989c5..3208657 100644 --- a/apps/api/src/integrations/tahesab/person-link.service.ts +++ b/apps/api/src/integrations/tahesab/person-link.service.ts @@ -26,8 +26,9 @@ import { SearchTahesabDto, SyncGroupsPageDto, } from './dto'; -import { formatJalaliDate, parseBirthDate } from '../../common/excel'; +import { formatJalaliDate } from '../../common/excel'; import { CustomerGroupsService } from '../../customer-groups/customer-groups.service'; +import { pickBirthDate, tahesabProfilePatch } from './moshtari-profile'; import { normalizeIranMobile, SanadBuilders, type TahesabApi } from './tahesab-methods'; /** موبایل ته‌حساب را به شکل 0912… ذخیره می‌کند، حتی اگر قبلاً 912… یا +98912… بوده باشد. */ @@ -83,17 +84,6 @@ function pickTel(row: Record): string { return String(row.Tel ?? row.Phone ?? row.Mobile ?? row.tel ?? ''); } -function pickBirthDate(row: Record): 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)); -} - function firstAddress(raw: unknown): string | undefined { if (!Array.isArray(raw)) return undefined; const first = raw.find((a) => typeof a === 'string' && a.trim()); @@ -710,8 +700,8 @@ export class TahesabPersonLinkService { ...carryPreviousSource(existing, 'tahesab'), importSource: 'tahesab', sourceRef, - ...(!existing.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}), ...canonicalMobileUpdate(mobile, existing), + ...tahesabProfilePatch(row, existing), }, }); } else { @@ -772,12 +762,17 @@ export class TahesabPersonLinkService { ...carryPreviousSource(byMobile, 'tahesab'), importSource: 'tahesab', sourceRef, - ...(!byMobile.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}), ...canonicalMobileUpdate(mobile, byMobile), + ...tahesabProfilePatch(row, byMobile), }, }); personId = byMobile.id; } else { + const profile = tahesabProfilePatch(row, { + birthDate: null, + addresses: [], + customFields: {}, + }); const created = await this.prisma.person.create({ data: { organizationId, @@ -790,7 +785,9 @@ export class TahesabPersonLinkService { notes: `واردشده از ته‌حساب (کد ${dto.moshtariCode})`, importSource: 'tahesab', sourceRef, - birthDate: pickBirthDate(row), + birthDate: (profile.birthDate as Date | undefined) ?? null, + addresses: (profile.addresses as Prisma.InputJsonValue) ?? [], + customFields: (profile.customFields as Prisma.InputJsonValue) ?? {}, }, }); personId = created.id; @@ -837,7 +834,7 @@ export class TahesabPersonLinkService { return { personId, link: this.linkDto(link) }; } - /** نام، موبایل و گروه مشتریِ از قبل لینک‌شده را با ردیف ته‌حساب تازه می‌کند. */ + /** پروفایل و گروه مشتریِ از قبل لینک‌شده را با ردیف ته‌حساب تازه می‌کند. */ private async refreshLinkedCustomer( organizationId: string, connectionId: string, @@ -878,10 +875,8 @@ export class TahesabPersonLinkService { ...carryPreviousSource(existing, 'tahesab'), importSource: 'tahesab', sourceRef: String(moshtariCode), - ...(!existing.birthDate && pickBirthDate(row) - ? { birthDate: pickBirthDate(row) } - : {}), ...canonicalMobileUpdate(storedPhoneNumber(pickTel(row)), existing), + ...tahesabProfilePatch(row, existing), }, }); await this.applyTahesabGroup(organizationId, connectionId, personId, row); @@ -1086,19 +1081,16 @@ 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 }, - }); + const row = rowsByCode.get(moshtariCode); + if (row) { + await this.refreshLinkedCustomer( + organizationId, + dto.connectionId, + linkedPersonId, + moshtariCode, + row, + ); } - await this.applyTahesabGroup( - organizationId, - dto.connectionId, - linkedPersonId, - rowsByCode.get(moshtariCode), - ); items.push({ moshtariCode, ok: true, diff --git a/apps/web/src/views/PersonFormView.vue b/apps/web/src/views/PersonFormView.vue index dffe9b9..c5651d6 100644 --- a/apps/web/src/views/PersonFormView.vue +++ b/apps/web/src/views/PersonFormView.vue @@ -97,6 +97,9 @@ const website = ref(''); const instagram = ref(''); const birthDate = ref(''); const referrer = ref(''); +const nationalId = ref(''); +const city = ref(''); +const rasteh = ref(''); const addresses = ref(['']); const notes = ref(''); const tagsRaw = ref(''); @@ -283,6 +286,16 @@ function fill(p: PersonDto) { mood.value = p.mood || 'newcomer'; groupIds.value = (p.groups ?? []).map((g) => g.id); customFields.value = { ...(p.customFields ?? {}) }; + const cf = customFields.value; + nationalId.value = + typeof cf.nationalId === 'string' + ? cf.nationalId + : typeof cf.nationalId === 'number' + ? String(cf.nationalId) + : ''; + city.value = typeof cf.city === 'string' ? cf.city : ''; + rasteh.value = + typeof cf.rasteh === 'string' || typeof cf.rasteh === 'number' ? String(cf.rasteh) : ''; loyaltyPoints.value = p.loyaltyPoints ?? 0; loyaltyTierName.value = p.loyaltyTier?.name ?? ''; } @@ -344,6 +357,18 @@ function parseTags(raw: string): string[] { } function payload(): CreatePersonRequest { + const fields: Record = { ...customFields.value }; + const nid = nationalId.value.trim(); + if (nid) fields.nationalId = nid; + else delete fields.nationalId; + const cityVal = city.value.trim(); + if (cityVal) fields.city = cityVal; + else delete fields.city; + const rastehRaw = rasteh.value.trim(); + if (rastehRaw) { + const n = Number(rastehRaw); + fields.rasteh = Number.isFinite(n) && String(n) === rastehRaw ? n : rastehRaw; + } else delete fields.rasteh; return { firstName: firstName.value, lastName: lastName.value, @@ -363,7 +388,7 @@ function payload(): CreatePersonRequest { companyId: companyId.value || null, mood: mood.value, groupIds: groupIds.value, - customFields: customFields.value, + customFields: fields, }; } @@ -545,6 +570,21 @@ onMounted(load); + + + + + + + + + + + + + + + آدرس‌ها diff --git a/apps/web/src/views/PersonProfileView.vue b/apps/web/src/views/PersonProfileView.vue index b8a48bf..47e541f 100644 --- a/apps/web/src/views/PersonProfileView.vue +++ b/apps/web/src/views/PersonProfileView.vue @@ -183,6 +183,18 @@ const externalCode = computed(() => { } return ''; }); +function customFieldText(...keys: string[]) { + const fields = person.value?.customFields ?? {}; + for (const key of keys) { + const raw = fields[key]; + if (typeof raw === 'string' && raw.trim()) return raw.trim(); + if (typeof raw === 'number' && Number.isFinite(raw)) return String(raw); + } + return ''; +} +const nationalIdLabel = computed(() => customFieldText('nationalId', 'CodeMelli', 'codeMeli')); +const cityLabel = computed(() => customFieldText('city', 'City')); +const rastehLabel = computed(() => customFieldText('rasteh', 'Rasteh')); const SOURCE_LABELS: Record = { manual: 'دستی', tahesab: 'ته‌حساب', @@ -444,6 +456,9 @@ const infoEmpty = computed(() => { !p.instagram && !p.birthDate && !p.referrer && + !nationalIdLabel.value && + !cityLabel.value && + !rastehLabel.value && !(p.addresses?.length) && !p.company && !externalCode.value && @@ -794,6 +809,18 @@ watch(() => route.query.tab, readTab, { immediate: true });
معرف
{{ person.referrer }}
+
+
کد ملی
+
{{ toFaDigits(nationalIdLabel) }}
+
+
+
شهر
+
{{ cityLabel }}
+
+
+
رسته
+
{{ toFaDigits(rastehLabel) }}
+
وب‌سایت
{{ person.website }}
@@ -1119,6 +1146,15 @@ watch(() => route.query.tab, readTab, { immediate: true });
وب‌سایت
{{ person.website || '—' }}
اینستاگرام
{{ person.instagram || '—' }}
معرف
{{ person.referrer || '—' }}
+
+
کد ملی
+
{{ nationalIdLabel ? toFaDigits(nationalIdLabel) : '—' }}
+
+
شهر
{{ cityLabel || '—' }}
+
+
رسته
+
{{ rastehLabel ? toFaDigits(rastehLabel) : '—' }}
+
آدرس‌ها