diff --git a/apps/api/src/integrations/tahesab/person-link.service.ts b/apps/api/src/integrations/tahesab/person-link.service.ts index 21c74df..38989c5 100644 --- a/apps/api/src/integrations/tahesab/person-link.service.ts +++ b/apps/api/src/integrations/tahesab/person-link.service.ts @@ -438,6 +438,77 @@ export class TahesabPersonLinkService { }; } + /** + * کدهایی که لینک ندارند ولی شماره‌شان مال مشتریِ از قبل لینک‌شدهٔ همین اتصال است. + * یک شخص فقط یک کد ته‌حساب دارد؛ این کدها مشتری تازه نیستند. + */ + private async duplicateOwners( + organizationId: string, + connectionId: string, + rows: Array<{ moshtariCode: number | null; tel: string }>, + ) { + const pending = rows.filter( + (row): row is { moshtariCode: number; tel: string } => row.moshtariCode != null, + ); + const keys = [ + ...new Set(pending.flatMap((row) => phoneIdentityKeys(storedPhoneNumber(row.tel)))), + ]; + const out = new Map< + number, + { moshtariCode: number; personId: string; personName: string } + >(); + if (!keys.length) return out; + + const people = await this.prisma.person.findMany({ + where: { + organizationId, + OR: [{ mobile: { in: keys } }, { phone: { in: keys } }], + accountingLinks: { some: { connectionId } }, + }, + select: { + id: true, + firstName: true, + lastName: true, + mobile: true, + phone: true, + accountingLinks: { + where: { connectionId }, + select: { moshtariCode: true }, + take: 1, + }, + }, + }); + const byKey = new Map< + string, + { moshtariCode: number; personId: string; personName: string } + >(); + for (const person of people) { + const link = person.accountingLinks[0]; + if (!link) continue; + const info = { + moshtariCode: link.moshtariCode, + personId: person.id, + personName: `${person.firstName} ${person.lastName}`.trim(), + }; + for (const key of [ + ...phoneIdentityKeys(person.mobile), + ...phoneIdentityKeys(person.phone), + ]) { + if (!byKey.has(key)) byKey.set(key, info); + } + } + for (const row of pending) { + for (const key of phoneIdentityKeys(storedPhoneNumber(row.tel))) { + const hit = byKey.get(key); + if (hit && hit.moshtariCode !== row.moshtariCode) { + out.set(row.moshtariCode, hit); + break; + } + } + } + return out; + } + async search(organizationId: string, dto: SearchTahesabDto) { const { api } = await this.connections.getApi( organizationId, @@ -516,7 +587,7 @@ export class TahesabPersonLinkService { }); const byCode = new Map(existing.map((e) => [e.moshtariCode, e])); - const results = mapped.map((row) => { + const linked = mapped.map((row) => { const link = row.moshtariCode != null ? byCode.get(row.moshtariCode) : undefined; return { ...row, @@ -527,6 +598,22 @@ export class TahesabPersonLinkService { : null, }; }); + const duplicates = await this.duplicateOwners( + organizationId, + dto.connectionId, + linked.filter((row) => !row.alreadyLinked), + ); + const results = linked.map((row) => { + if (row.alreadyLinked || row.moshtariCode == null) return row; + const dup = duplicates.get(row.moshtariCode); + if (!dup) return row; + return { + ...row, + duplicateOfCode: dup.moshtariCode, + duplicatePersonId: dup.personId, + duplicatePersonName: dup.personName, + }; + }); await this.prisma.accountingSyncJob.create({ data: { @@ -601,7 +688,6 @@ export class TahesabPersonLinkService { const sourceRef = String(dto.moshtariCode); let personId = dto.personId; - let preserveExistingLink = false; if (personId) { const existing = await this.getPerson(organizationId, personId); const prevFirst = existing.firstName; @@ -630,8 +716,8 @@ export class TahesabPersonLinkService { }); } else { // اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام ته‌حساب جایگزین. - // اگر همان شخص قبلاً به کد دیگری از این اتصال لینک شده، لینک جابه‌جا نمی‌شود - // تا کد قبلی دوباره «واردنشده» نشود و مشتری تکراری ساخته نشود. + // اگر همان شخص قبلاً به کد دیگری از این اتصال لینک شده، نام و لینک دست نمی‌خورند + // تا کد قبلی جابه‌جا نشود و این کد هم مشتری تکراری نسازد. const mobileKeys = phoneIdentityKeys(mobile); const matches = mobileKeys.length ? await this.prisma.person.findMany({ @@ -651,7 +737,22 @@ export class TahesabPersonLinkService { const byMobile = matches.find((person) => person.importSource === 'tahesab') ?? matches[0] ?? null; if (byMobile) { const linkedCode = byMobile.accountingLinks[0]?.moshtariCode; - preserveExistingLink = linkedCode != null && linkedCode !== dto.moshtariCode; + if (linkedCode != null && linkedCode !== dto.moshtariCode) { + const existing = await this.prisma.personAccountingLink.findFirst({ + where: { personId: byMobile.id, connectionId: dto.connectionId }, + include: { + connection: { select: { label: true, dbName: true, isDefault: true } }, + }, + }); + if (!existing) { + throw new NotFoundException('لینک ته‌حساب این مشتری یافت نشد.'); + } + return { + personId: byMobile.id, + link: this.linkDto(existing), + duplicateOf: linkedCode, + }; + } const nameChanged = `${byMobile.firstName} ${byMobile.lastName}`.trim() !== `${firstName} ${lastName}`.trim(); @@ -670,7 +771,7 @@ export class TahesabPersonLinkService { : {}), ...carryPreviousSource(byMobile, 'tahesab'), importSource: 'tahesab', - sourceRef: preserveExistingLink ? String(linkedCode) : sourceRef, + sourceRef, ...(!byMobile.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}), ...canonicalMobileUpdate(mobile, byMobile), }, @@ -696,32 +797,6 @@ export class TahesabPersonLinkService { } } - if (preserveExistingLink) { - await this.applyTahesabGroup(organizationId, dto.connectionId, personId, row); - const existing = await this.prisma.personAccountingLink.findFirst({ - where: { personId, connectionId: dto.connectionId }, - include: { - connection: { select: { label: true, dbName: true, isDefault: true } }, - }, - }); - if (!existing) { - throw new NotFoundException('لینک ته‌حساب این مشتری یافت نشد.'); - } - if (!options?.skipJob) { - await this.prisma.accountingSyncJob.create({ - data: { - organizationId, - connectionId: dto.connectionId, - kind: 'import', - status: 'success', - request: dto as unknown as Prisma.InputJsonValue, - result: { personId, linkId: existing.id, keptLink: true } as Prisma.InputJsonValue, - }, - }); - } - return { personId, link: this.linkDto(existing) }; - } - const link = await this.prisma.personAccountingLink.upsert({ where: { personId_connectionId: { @@ -822,6 +897,7 @@ export class TahesabPersonLinkService { let emptyStreak = 0; let imported = 0; let updated = 0; + let duplicate = 0; let failed = 0; const errors: string[] = []; const maxPages = 300; @@ -869,13 +945,14 @@ export class TahesabPersonLinkService { ); updated += 1; } else { - await this.importFromTahesab( + const created = await this.importFromTahesab( organizationId, { connectionId, moshtariCode: code }, { api, row }, { skipJob: true }, ); - imported += 1; + if (created.duplicateOf) duplicate += 1; + else imported += 1; } } catch (err) { failed += 1; @@ -891,20 +968,26 @@ export class TahesabPersonLinkService { errors.push('فهرست مشتریان از سقف خواندن خودکار بیشتر بود.'); } - const summary = `${imported} وارد شد، ${updated} به‌روز شد، ${failed} خطا`; + const summary = [ + `${imported} وارد شد`, + `${updated} به‌روز شد`, + ...(duplicate ? [`${duplicate} شماره تکراری`] : []), + `${failed} خطا`, + ].join('، '); await this.prisma.accountingSyncJob.create({ data: { organizationId, connectionId, kind: 'customer-sync', status: !imported && !updated && (failed > 0 || errors.length > 0) ? 'error' : 'success', - result: { imported, updated, failed, pages: page - 1 } as Prisma.InputJsonValue, + result: { imported, updated, duplicate, failed, pages: page - 1 } as Prisma.InputJsonValue, }, }); this.logger.log(`همگام‌سازی مشتریان ته‌حساب ${connectionId}: ${summary}`); return { imported, updated, + duplicate, failed, summary, error: errors.length ? errors.join('؛ ') : null, @@ -1029,6 +1112,16 @@ export class TahesabPersonLinkService { { connectionId: dto.connectionId, moshtariCode }, bulkApi ? { api: bulkApi, row: rowsByCode.get(moshtariCode) } : undefined, ); + if (r.duplicateOf) { + items.push({ + moshtariCode, + ok: true, + skipped: true, + personId: r.personId, + error: `شماره تکراری است و قبلاً با کد ${r.duplicateOf} لینک شده`, + }); + continue; + } items.push({ moshtariCode, ok: true, personId: r.personId }); } catch (e) { const message = diff --git a/apps/web/src/views/TahesabSettingsView.vue b/apps/web/src/views/TahesabSettingsView.vue index 467f379..5b87bcb 100644 --- a/apps/web/src/views/TahesabSettingsView.vue +++ b/apps/web/src/views/TahesabSettingsView.vue @@ -64,6 +64,9 @@ type SyncResult = { alreadyLinked?: boolean; personId?: string | null; personName?: string | null; + duplicateOfCode?: number | null; + duplicatePersonId?: string | null; + duplicatePersonName?: string | null; }; const syncResults = ref([]); /** هر درخواست listAll یک بازهٔ ۲۰۰۰ کدی است؛ بعد از دو بازهٔ خالی پشت‌سرهم، خواندن تمام می‌شود */ @@ -145,9 +148,61 @@ async function saveAutoSync() { autoSyncSaving.value = false; } } +/** همان قاعدهٔ سرور: موبایل ایران 09… است، بقیهٔ شماره‌ها فقط رقم‌اند. */ +function customerPhoneKey(value?: string | null): string { + const fa = '۰۱۲۳۴۵۶۷۸۹'; + const ar = '٠١٢٣٤٥٦٧٨٩'; + let digits = String(value ?? '') + .replace(/[۰-۹٠-٩]/g, (ch) => { + const faIndex = fa.indexOf(ch); + return String(faIndex >= 0 ? faIndex : ar.indexOf(ch)); + }) + .replace(/\D/g, ''); + if (!digits) return ''; + if (digits.startsWith('0098') && digits.length >= 14) digits = digits.slice(4); + else if (digits.startsWith('98') && digits.length >= 12) digits = digits.slice(2); + if (digits.length === 10 && digits.startsWith('9')) digits = `0${digits}`; + return digits; +} + +const linkedPhoneOwners = computed(() => { + const owners = new Map(); + for (const row of syncResults.value) { + if (!row.alreadyLinked || row.moshtariCode == null) continue; + const key = customerPhoneKey(row.tel); + if (key && !owners.has(key)) { + owners.set(key, { + code: row.moshtariCode, + personId: row.personId ?? null, + personName: row.personName ?? null, + }); + } + } + return owners; +}); + +function duplicateOwner(row: SyncResult) { + if (row.alreadyLinked || row.moshtariCode == null) return null; + if (row.duplicateOfCode != null) { + return { + code: row.duplicateOfCode, + personId: row.duplicatePersonId ?? null, + personName: row.duplicatePersonName ?? null, + }; + } + const key = customerPhoneKey(row.tel); + if (!key) return null; + const owner = linkedPhoneOwners.value.get(key); + if (!owner || owner.code === row.moshtariCode) return null; + return owner; +} + +const duplicateCustomerCount = computed( + () => syncResults.value.filter((row) => duplicateOwner(row)).length, +); const syncVisibleResults = computed(() => syncOnlyNew.value - ? syncResults.value.filter((r) => !r.alreadyLinked) + ? syncResults.value.filter((row) => !row.alreadyLinked && !duplicateOwner(row)) : syncResults.value, ); const syncTableTotalPages = computed(() => @@ -184,7 +239,7 @@ const productCategoryLabel = ref('کارساخته'); function pendingImportCodes() { return syncResults.value - .filter((r) => r.moshtariCode != null && !r.alreadyLinked) + .filter((r) => r.moshtariCode != null && !r.alreadyLinked && !duplicateOwner(r)) .map((r) => r.moshtariCode as number); } @@ -458,10 +513,12 @@ async function searchTahesab(mode: 'range' | 'filter') { ok.value = 'نتیجه‌ای یافت نشد.'; } else { const linked = syncResults.value.filter((r) => r.alreadyLinked).length; - const pending = syncResults.value.length - linked; + const duplicate = syncResults.value.filter((r) => duplicateOwner(r)).length; + const pending = syncResults.value.length - linked - duplicate; ok.value = `${toFaDigits(syncResults.value.length)} مشتری پیدا شد` + - ` · ${toFaDigits(linked)} قبلاً وارد شده · ${toFaDigits(pending)} واردنشده`; + ` · ${toFaDigits(linked)} قبلاً وارد شده · ${toFaDigits(pending)} واردنشده` + + (duplicate ? ` · ${toFaDigits(duplicate)} شماره تکراری` : ''); } } catch (e) { error.value = apiErr(e, 'جستجو ناموفق'); @@ -953,7 +1010,8 @@ onMounted(load);

تعداد دفعات در روز و ساعت هر بار را برای همین اتصال مشخص کنید. سرور در آن ساعت‌ها، به وقت تهران، مشتریان تازه را وارد می‌کند و نام مشتری‌های قبلی را هم با ته‌حساب تازه می‌کند. اگر شماره یکی باشد - همان مشتری می‌ماند، نام قبلی حفظ می‌شود و شماره به شکل ۰۹۱۲ ذخیره می‌شود. خاموش یعنی فقط ورود دستی. + همان مشتری می‌ماند و کد دیگر به‌عنوان مشتری تازه وارد نمی‌شود. شماره به شکل ۰۹۱۲ ذخیره می‌شود. + خاموش یعنی فقط ورود دستی.

نتایج جستجو ({{ syncVisibleResults.length }}) +

+ {{ toFaDigits(duplicateCustomerCount) }} مشتری شمارهٔ تکراری دارند و جداگانه وارد نمی‌شوند؛ همان شخص با کد دیگری از قبل لینک است. +

@@ -1120,7 +1181,9 @@ onMounted(load); - همهٔ مشتریان این نتایج قبلاً وارد شده‌اند. + + مشتری تازه‌ای برای ورود نیست. شماره‌های تکراری از قبل با کد دیگری لینک شده‌اند. + {{ r.moshtariCode }} @@ -1130,11 +1193,14 @@ onMounted(load); لینک شده{{ r.personName ? ` (${r.personName})` : '' }} + + شماره تکراری، کد {{ toFaDigits(duplicateOwner(r)!.code) }} + آماده ورود مشاهده شخص