import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException, type OnModuleInit, } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../../prisma/prisma.service'; import { carryPreviousSource, namesDiffer } from '../../contacts/person-source'; import { canonicalIranMobile, phoneIdentityKeys, storedPhoneNumber, } from '../../contacts/normalize'; import { applyPersonGenderTitle } from '@mento/shared'; import { TahesabConnectionsService } from './connections.service'; import { CreateInTahesabDto, ExportPeopleToTahesabDto, ImportBulkFromTahesabDto, ImportFromTahesabDto, ManualLinkDto, MatchPersonDto, SearchTahesabDto, SyncGroupsPageDto, } from './dto'; 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… بوده باشد. */ function canonicalMobileUpdate( incoming: string | null, current: { mobile: string | null; phone: string | null }, ): { mobile?: string; phone?: string } { const mobile = canonicalIranMobile(incoming) ?? canonicalIranMobile(current.mobile) ?? canonicalIranMobile(current.phone); if (!mobile) return {}; const phone = canonicalIranMobile(current.phone); return phone ? { mobile, phone } : { mobile }; } /** اندازهٔ هر صفحهٔ «همه» (بازهٔ کد) — امن برای پروکسی/nginx */ const LIST_PAGE_SIZE = 2000; const LIST_TIMEOUT_MS = 55_000; function asMoshtariList(raw: unknown): Array> { if (!raw) return []; if (Array.isArray(raw)) return raw as Array>; if (typeof raw === 'object') { const obj = raw as Record; // پاسخ گاهی دیکشنری با کلید عددی است const vals = Object.values(obj); if (vals.every((v) => v && typeof v === 'object')) { return vals as Array>; } } return []; } function pickCode(row: Record): number | null { const c = row.Moshtari_Code ?? row.Code ?? row.MCode ?? row.code ?? row.moshtari_code; const n = Number(c); return Number.isFinite(n) ? n : null; } function pickName(row: Record): string { return String( row.Moshtari_Name ?? row.Name ?? row.name ?? row.FullName ?? '', ); } function pickTel(row: Record): string { return String(row.Tel ?? row.Phone ?? row.Mobile ?? row.tel ?? ''); } function firstAddress(raw: unknown): string | undefined { if (!Array.isArray(raw)) return undefined; const first = raw.find((a) => typeof a === 'string' && a.trim()); return typeof first === 'string' ? first.trim() : undefined; } /** گروه مشتری در ته‌حساب (فیلد نام در پاسخ ته‌حساب «GoroupName» غلط‌املایی دارد) */ function pickGroup(row: Record): { gid: string | null; name: string } | null { const rawGid = row.GID ?? row.Gid ?? row.GroupID ?? row.GroupId; const rawName = row.GoroupName ?? row.GroupName ?? row.Group_Name ?? row.Goroup; if (rawGid == null && rawName == null) return null; const gid = rawGid == null ? null : String(rawGid).trim() || null; const name = rawName == null ? '' : String(rawName).trim(); if (!gid && !name) return null; return { gid, name }; } @Injectable() export class TahesabPersonLinkService { private readonly logger = new Logger(TahesabPersonLinkService.name); constructor( private readonly prisma: PrismaService, private readonly connections: TahesabConnectionsService, private readonly groups: CustomerGroupsService, ) {} /** * گروه ته‌حساب روی شخص: فقط گروهِ آمده از ته‌حساب عوض می‌شود و گروه‌های دستی می‌مانند. * @returns آیا گروه شخص عوض شد */ private async applyTahesabGroup( organizationId: string, connectionId: string, personId: string, row: Record | undefined, ): Promise { const group = row ? pickGroup(row) : null; if (!group) return false; try { const groupId = await this.groups.ensureTahesabGroup( organizationId, connectionId, group.gid, group.name, ); return await this.groups.setPersonTahesabGroup(personId, groupId); } catch (err) { this.logger.warn( `گروه ته‌حساب برای شخص ${personId} ثبت نشد: ${err instanceof Error ? err.message : err}`, ); return false; } } private async getPerson(organizationId: string, personId: string) { const person = await this.prisma.person.findFirst({ where: { id: personId, organizationId }, }); if (!person) throw new NotFoundException('شخص یافت نشد.'); return person; } private linkDto(link: { id: string; personId: string; connectionId: string; moshtariCode: number; matchMethod: string; linkedAt: Date; lastBalanceSyncedAt: Date | null; connection?: { label: string; dbName: string; isDefault: boolean }; }) { return { id: link.id, personId: link.personId, connectionId: link.connectionId, moshtariCode: link.moshtariCode, matchMethod: link.matchMethod, linkedAt: link.linkedAt.toISOString(), lastBalanceSyncedAt: link.lastBalanceSyncedAt?.toISOString() ?? null, connectionLabel: link.connection?.label, dbName: link.connection?.dbName, isDefaultConnection: link.connection?.isDefault, }; } async listLinks(organizationId: string, personId: string) { await this.getPerson(organizationId, personId); const links = await this.prisma.personAccountingLink.findMany({ where: { personId, connection: { organizationId, provider: 'tahesab' }, }, include: { connection: { select: { label: true, dbName: true, isDefault: true } }, }, orderBy: { linkedAt: 'desc' }, }); return links.map((l) => this.linkDto(l)); } async manualLink( organizationId: string, personId: string, dto: ManualLinkDto, ) { await this.getPerson(organizationId, personId); await this.connections.getOrThrow(organizationId, dto.connectionId); const clash = await this.prisma.personAccountingLink.findFirst({ where: { connectionId: dto.connectionId, moshtariCode: dto.moshtariCode, personId: { not: personId }, }, }); if (clash) { throw new ConflictException( 'این کد مشتری ته‌حساب قبلاً به شخص دیگری لینک شده است.', ); } const link = await this.prisma.personAccountingLink.upsert({ where: { personId_connectionId: { personId, connectionId: dto.connectionId, }, }, create: { personId, connectionId: dto.connectionId, moshtariCode: dto.moshtariCode, matchMethod: 'manual', }, update: { moshtariCode: dto.moshtariCode, matchMethod: 'manual', linkedAt: new Date(), }, include: { connection: { select: { label: true, dbName: true, isDefault: true } }, }, }); return this.linkDto(link); } async unlink( organizationId: string, personId: string, connectionId: string, ) { await this.getPerson(organizationId, personId); const link = await this.prisma.personAccountingLink.findFirst({ where: { personId, connectionId, connection: { organizationId }, }, }); if (!link) throw new NotFoundException('لینک یافت نشد.'); await this.prisma.personAccountingLink.delete({ where: { id: link.id } }); return { ok: true }; } async match( organizationId: string, personId: string, dto: MatchPersonDto, ) { const person = await this.getPerson(organizationId, personId); const { api } = await this.connections.getApi( organizationId, dto.connectionId, ); const candidates: Array<{ moshtariCode: number; name: string; tel: string; matchBy: 'mobile' | 'name'; raw: Record; }> = []; const mobile = normalizeIranMobile(person.mobile ?? person.phone); if (mobile) { const raw = await api.doListMoshtariByPhone(mobile); for (const row of asMoshtariList(raw)) { const code = pickCode(row); if (code == null) continue; candidates.push({ moshtariCode: code, name: pickName(row), tel: pickTel(row), matchBy: 'mobile', raw: row, }); } } if (candidates.length === 0) { const name = `${person.firstName} ${person.lastName}`.trim(); if (name) { const raw = await api.doListMoshtariByName(name); for (const row of asMoshtariList(raw)) { const code = pickCode(row); if (code == null) continue; candidates.push({ moshtariCode: code, name: pickName(row), tel: pickTel(row), matchBy: 'name', raw: row, }); } } } // unique by code const uniq = new Map(); for (const c of candidates) uniq.set(c.moshtariCode, c); const list = [...uniq.values()]; if (dto.autoLinkIfUnique && list.length === 1) { const only = list[0]!; const link = await this.manualLink(organizationId, personId, { connectionId: dto.connectionId, moshtariCode: only.moshtariCode, }); // override match method const updated = await this.prisma.personAccountingLink.update({ where: { id: link.id }, data: { matchMethod: only.matchBy }, include: { connection: { select: { label: true, dbName: true, isDefault: true }, }, }, }); return { status: 'linked' as const, candidates: list, link: this.linkDto(updated), }; } if (list.length === 0) { return { status: 'none' as const, candidates: list, link: null }; } if (list.length === 1) { return { status: 'unique' as const, candidates: list, link: null }; } return { status: 'ambiguous' as const, candidates: list, link: null }; } async createInTahesab( organizationId: string, personId: string, dto: CreateInTahesabDto, ) { const person = await this.getPerson(organizationId, personId); const existing = await this.prisma.personAccountingLink.findFirst({ where: { personId, connectionId: dto.connectionId }, }); if (existing) { throw new ConflictException( 'این شخص از قبل به این اتصال لینک شده است.', ); } const { api } = await this.connections.getApi( organizationId, dto.connectionId, ); const tel = normalizeIranMobile(person.mobile ?? person.phone) ?? person.mobile ?? person.phone ?? ''; const extra = (person.customFields ?? {}) as Record; const nationalId = typeof extra.nationalId === 'string' ? extra.nationalId.trim() : ''; const groupName = dto.groupName?.trim() || (await this.groups.firstGroupName(personId)) || 'CRM'; const args = SanadBuilders.newMoshtari({ name: `${person.firstName} ${person.lastName}`.trim(), groupName, tel, address: firstAddress(person.addresses), codeMeli: nationalId || undefined, tarikhTavalod: person.birthDate ? formatJalaliDate(person.birthDate) : undefined, jensFelez: dto.jensFelez ?? 0, }); const res = await api.doNewMoshtari(args); const code = Number(res?.OK); if (!Number.isFinite(code) || code <= 0) { throw new BadRequestException( 'ته‌حساب کد مشتری معتبر برنگرداند. پاسخ: ' + JSON.stringify(res), ); } const link = await this.prisma.personAccountingLink.create({ data: { personId, connectionId: dto.connectionId, moshtariCode: code, matchMethod: 'created', }, include: { connection: { select: { label: true, dbName: true, isDefault: true } }, }, }); return this.linkDto(link); } /** * یک صفحه از مشتریان بر اساس بازهٔ کد: * صفحه ۱ → ۱…pageSize، صفحه ۲ → pageSize+1…۲×pageSize، … * سقف ۵۰۰۰ حذف شده؛ با پیجینیت می‌توان همه را مرور کرد. */ private async listMoshtariPage( api: TahesabApi, page: number, pageSize?: number, ) { const size = Math.min( LIST_PAGE_SIZE, Math.max(100, Math.floor(pageSize ?? LIST_PAGE_SIZE) || LIST_PAGE_SIZE), ); const p = Math.max(1, Math.floor(page) || 1); const fromCode = (p - 1) * size + 1; const toCode = fromCode + size - 1; const raw = await api.doListMoshtariByCode(fromCode, toCode, { timeoutMs: LIST_TIMEOUT_MS, }); return { rows: asMoshtariList(raw), fromCode, toCode, page: p, pageSize: size, }; } /** * کدهایی که لینک ندارند ولی شماره‌شان مال مشتریِ از قبل لینک‌شدهٔ همین اتصال است. * یک شخص فقط یک کد ته‌حساب دارد؛ این کدها مشتری تازه نیستند. */ 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, dto.connectionId, ); let raw: unknown = []; let listMeta: | { page: number; pageSize: number; fromCode: number; toCode: number; } | undefined; if (dto.listAll) { const listed = await this.listMoshtariPage( api, dto.page ?? 1, dto.pageSize, ); raw = listed.rows; listMeta = { page: listed.page, pageSize: listed.pageSize, fromCode: listed.fromCode, toCode: listed.toCode, }; } else if (dto.fromCode != null && dto.toCode != null) { if (dto.toCode < dto.fromCode) { throw new BadRequestException('کد پایان باید بزرگ‌تر یا مساوی کد شروع باشد.'); } if (dto.toCode - dto.fromCode > 2000) { throw new BadRequestException('بازهٔ کد حداکثر ۲۰۰۰ عدد است (مثلاً ۱ تا ۲۰۰۰). برای همه، بازه را خالی بگذارید.'); } raw = await api.doListMoshtariByCode(dto.fromCode, dto.toCode, { timeoutMs: LIST_TIMEOUT_MS, }); } else if (dto.moshtariCode) { raw = await api.doListMoshtariByCode(dto.moshtariCode, dto.moshtariCode); } else if (dto.phone) { const m = normalizeIranMobile(dto.phone) ?? dto.phone; raw = await api.doListMoshtariByPhone(m); } else if (dto.name?.trim()) { raw = await api.doListMoshtariByName(dto.name.trim()); } else { throw new BadRequestException( 'یکی از موارد لازم است: بازه کد (از–تا)، کد مشتری، موبایل یا نام. برای همهٔ مشتریان بازه را خالی بگذارید.', ); } const mapped = asMoshtariList(raw).map((row) => ({ moshtariCode: pickCode(row), name: pickName(row), tel: pickTel(row), // raw فقط برای جستجوی محدود؛ در listAll حجم پاسخ را باد می‌کند و پروکسی را می‌ترکاند ...(dto.listAll ? {} : { raw: row }), })); const codes = mapped .map((r) => r.moshtariCode) .filter((c): c is number => c != null); const existing = codes.length === 0 ? [] : await this.prisma.personAccountingLink.findMany({ where: { connectionId: dto.connectionId, moshtariCode: { in: codes }, }, select: { moshtariCode: true, personId: true, person: { select: { firstName: true, lastName: true } }, }, }); const byCode = new Map(existing.map((e) => [e.moshtariCode, e])); const linked = mapped.map((row) => { const link = row.moshtariCode != null ? byCode.get(row.moshtariCode) : undefined; return { ...row, alreadyLinked: Boolean(link), personId: link?.personId ?? null, personName: link ? `${link.person.firstName} ${link.person.lastName}`.trim() : 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: { organizationId, connectionId: dto.connectionId, kind: 'search', status: 'success', request: dto as unknown as Prisma.InputJsonValue, result: { count: results.length, ...(listMeta ?? {}), } as Prisma.InputJsonValue, }, }); return { results, ...(listMeta ? { ...listMeta, hasMore: results.length > 0, note: `صفحه ${listMeta.page}: کد ${listMeta.fromCode} تا ${listMeta.toCode}. برای ادامه صفحه بعد را بزنید.`, } : {}), }; } async importFromTahesab( organizationId: string, dto: ImportFromTahesabDto, prefetched?: { api: TahesabApi; row?: Record }, options?: { skipJob?: boolean }, ) { const clash = await this.prisma.personAccountingLink.findFirst({ where: { connectionId: dto.connectionId, moshtariCode: dto.moshtariCode, }, include: { person: true }, }); if (clash && (!dto.personId || clash.personId !== dto.personId)) { throw new ConflictException( `این کد ته‌حساب قبلاً به «${clash.person.firstName} ${clash.person.lastName}» لینک شده است.`, ); } let row: Record; if (prefetched?.row) { row = prefetched.row; } else { const api = prefetched?.api ?? (await this.connections.getApi(organizationId, dto.connectionId)).api; const raw = await api.doListMoshtariByCode( dto.moshtariCode, dto.moshtariCode, ); const found = asMoshtariList(raw)[0]; if (!found) { throw new NotFoundException( `مشتری با کد ${dto.moshtariCode} در ته‌حساب یافت نشد.`, ); } row = found as Record; } const name = pickName(row) || `مشتری ته‌حساب ${dto.moshtariCode}`; const tel = pickTel(row); const named = applyPersonGenderTitle({ firstName: name, lastName: '' }); const firstName = named.firstName; const lastName = named.lastName; const mobile = storedPhoneNumber(tel); const sourceRef = String(dto.moshtariCode); let personId = dto.personId; if (personId) { const existing = await this.getPerson(organizationId, personId); const prevFirst = existing.firstName; const prevLast = existing.lastName; const nameChanged = `${prevFirst} ${prevLast}`.trim() !== `${firstName} ${lastName}`.trim(); await this.prisma.person.update({ where: { id: personId }, data: { firstName, lastName, gender: named.gender, showGenderInName: named.showGenderInName, ...(nameChanged ? { previousFirstName: prevFirst || null, previousLastName: prevLast || null, } : {}), ...carryPreviousSource(existing, 'tahesab'), importSource: 'tahesab', sourceRef, ...canonicalMobileUpdate(mobile, existing), ...tahesabProfilePatch(row, existing), }, }); } else { // اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام ته‌حساب جایگزین. // اگر همان شخص قبلاً به کد دیگری از این اتصال لینک شده، نام و لینک دست نمی‌خورند // تا کد قبلی جابه‌جا نشود و این کد هم مشتری تکراری نسازد. const mobileKeys = phoneIdentityKeys(mobile); const matches = mobileKeys.length ? await this.prisma.person.findMany({ where: { organizationId, OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }], }, include: { accountingLinks: { where: { connectionId: dto.connectionId }, select: { moshtariCode: true }, }, }, orderBy: { createdAt: 'asc' }, }) : []; const byMobile = matches.find((person) => person.importSource === 'tahesab') ?? matches[0] ?? null; if (byMobile) { const linkedCode = byMobile.accountingLinks[0]?.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(); await this.prisma.person.update({ where: { id: byMobile.id }, data: { firstName, lastName, gender: named.gender, showGenderInName: named.showGenderInName, ...(nameChanged ? { previousFirstName: byMobile.firstName || null, previousLastName: byMobile.lastName || null, } : {}), ...carryPreviousSource(byMobile, 'tahesab'), importSource: 'tahesab', sourceRef, ...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, firstName, lastName, gender: named.gender, showGenderInName: named.showGenderInName, mobile, phone: mobile, notes: `واردشده از ته‌حساب (کد ${dto.moshtariCode})`, importSource: 'tahesab', sourceRef, birthDate: (profile.birthDate as Date | undefined) ?? null, addresses: (profile.addresses as Prisma.InputJsonValue) ?? [], customFields: (profile.customFields as Prisma.InputJsonValue) ?? {}, }, }); personId = created.id; } } const link = await this.prisma.personAccountingLink.upsert({ where: { personId_connectionId: { personId, connectionId: dto.connectionId, }, }, create: { personId, connectionId: dto.connectionId, moshtariCode: dto.moshtariCode, matchMethod: 'manual', }, update: { moshtariCode: dto.moshtariCode, matchMethod: 'manual', linkedAt: new Date(), }, include: { connection: { select: { label: true, dbName: true, isDefault: true } }, }, }); await this.applyTahesabGroup(organizationId, dto.connectionId, personId, row); 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: link.id } as Prisma.InputJsonValue, }, }); } return { personId, link: this.linkDto(link) }; } /** پروفایل و گروه مشتریِ از قبل لینک‌شده را با ردیف ته‌حساب تازه می‌کند. */ private async refreshLinkedCustomer( organizationId: string, connectionId: string, personId: string, moshtariCode: number, row: Record, ) { const existing = await this.getPerson(organizationId, personId); const picked = pickName(row).trim(); const named = picked ? applyPersonGenderTitle({ firstName: picked, lastName: '' }) : null; const nameChanged = named != null && namesDiffer( existing.firstName, existing.lastName, named.firstName, named.lastName, ); await this.prisma.person.update({ where: { id: personId }, data: { ...(named ? { firstName: named.firstName, lastName: named.lastName, gender: named.gender, showGenderInName: named.showGenderInName, } : {}), ...(nameChanged ? { previousFirstName: existing.firstName || null, previousLastName: existing.lastName || null, } : {}), ...carryPreviousSource(existing, 'tahesab'), importSource: 'tahesab', sourceRef: String(moshtariCode), ...canonicalMobileUpdate(storedPhoneNumber(pickTel(row)), existing), ...tahesabProfilePatch(row, existing), }, }); await this.applyTahesabGroup(organizationId, connectionId, personId, row); } /** * همهٔ مشتریان ته‌حساب را صفحه به صفحه می‌خواند. * کد تازه وارد می‌شود؛ کد لینک‌شده نام و شماره و گروهش تازه می‌شود. */ async syncAllCustomers(organizationId: string, connectionId: string) { const { api } = await this.connections.getApi(organizationId, connectionId); let page = 1; let emptyStreak = 0; let imported = 0; let updated = 0; let duplicate = 0; let failed = 0; const errors: string[] = []; const maxPages = 300; while (emptyStreak < 2 && page <= maxPages) { let listed: Awaited>; try { listed = await this.listMoshtariPage(api, page, LIST_PAGE_SIZE); } catch (err) { const message = err instanceof Error ? err.message : 'خواندن ته‌حساب ناموفق بود'; errors.push(message); break; } if (!listed.rows.length) { emptyStreak += 1; page += 1; continue; } emptyStreak = 0; const rowsByCode = new Map>(); for (const row of listed.rows) { const code = pickCode(row); if (code != null && code >= 1) rowsByCode.set(code, row); } const links = rowsByCode.size ? await this.prisma.personAccountingLink.findMany({ where: { connectionId, moshtariCode: { in: [...rowsByCode.keys()] }, }, select: { moshtariCode: true, personId: true }, }) : []; const linkedByCode = new Map(links.map((link) => [link.moshtariCode, link.personId])); for (const [code, row] of rowsByCode) { try { const personId = linkedByCode.get(code); if (personId) { await this.refreshLinkedCustomer( organizationId, connectionId, personId, code, row, ); updated += 1; } else { const created = await this.importFromTahesab( organizationId, { connectionId, moshtariCode: code }, { api, row }, { skipJob: true }, ); if (created.duplicateOf) duplicate += 1; else imported += 1; } } catch (err) { failed += 1; if (errors.length < 5) { errors.push(err instanceof Error ? err.message : 'خطا'); } } } page += 1; } if (page > maxPages && emptyStreak < 2) { errors.push('فهرست مشتریان از سقف خواندن خودکار بیشتر بود.'); } const summary = [ `${imported} وارد شد`, `${updated} به‌روز شد`, ...(duplicate ? [`${duplicate} شماره تکراری`] : []), `${failed} خطا`, ].join('، '); const error = errors.length ? errors.join('؛ ') : null; await this.prisma.accountingSyncJob.create({ data: { organizationId, connectionId, kind: 'customer-sync', status: !imported && !updated && (failed > 0 || errors.length > 0) ? 'error' : 'success', result: { imported, updated, duplicate, failed, pages: page - 1 } as Prisma.InputJsonValue, }, }); await this.prisma.accountingConnection.update({ where: { id: connectionId }, data: { customerSyncLastRunAt: new Date(), customerSyncLastSummary: summary, customerSyncLastError: error, }, }); this.logger.log(`همگام‌سازی مشتریان ته‌حساب ${connectionId}: ${summary}`); return { imported, updated, duplicate, failed, summary, error, }; } async importBulk(organizationId: string, dto: ImportBulkFromTahesabDto) { let codes = (dto.moshtariCodes ?? []).filter((n) => Number.isFinite(n) && n >= 1); const rowsByCode = new Map>(); if (dto.listAll) { const { api } = await this.connections.getApi( organizationId, dto.connectionId, ); const listed = await this.listMoshtariPage( api, dto.page ?? 1, dto.pageSize, ); for (const row of listed.rows) { const code = pickCode(row); if (code != null) rowsByCode.set(code, row); } codes = [...new Set([...codes, ...rowsByCode.keys()])]; } else if (dto.fromCode != null && dto.toCode != null) { if (dto.toCode < dto.fromCode) { throw new BadRequestException('کد پایان باید بزرگ‌تر یا مساوی کد شروع باشد.'); } if (dto.toCode - dto.fromCode > 2000) { throw new BadRequestException( 'بازهٔ کد حداکثر ۲۰۰۰ عدد است. برای همه، بازه را خالی بگذارید.', ); } const { api } = await this.connections.getApi( organizationId, dto.connectionId, ); const raw = await api.doListMoshtariByCode(dto.fromCode, dto.toCode); for (const row of asMoshtariList(raw)) { const code = pickCode(row); if (code != null) rowsByCode.set(code, row); } codes = [...new Set([...codes, ...rowsByCode.keys()])]; } if (!codes.length) { throw new BadRequestException( 'کد مشتری یا بازهٔ کد برای وارد کردن مشخص نشده است.', ); } const maxImport = dto.listAll || (dto.fromCode != null && dto.toCode != null) ? 2000 : 500; if (codes.length > maxImport) { throw new BadRequestException( `حداکثر ${maxImport} مشتری در هر بار وارد کنید (الان ${codes.length}). صفحه یا بازه را کوچک‌تر کنید.`, ); } const items: Array<{ moshtariCode: number; ok: boolean; personId?: string; skipped?: boolean; error?: string; }> = []; const existingLinks = await this.prisma.personAccountingLink.findMany({ where: { connectionId: dto.connectionId, moshtariCode: { in: codes } }, select: { moshtariCode: true, personId: true }, }); const linkedByCode = new Map(existingLinks.map((l) => [l.moshtariCode, l.personId])); const pending = codes.filter((c) => !linkedByCode.has(c)); let bulkApi: TahesabApi | undefined; const missingRows = pending.filter((c) => !rowsByCode.has(c)); if (pending.length) { bulkApi = (await this.connections.getApi(organizationId, dto.connectionId)).api; } if (bulkApi && missingRows.length) { const minCode = Math.min(...missingRows); const maxCode = Math.max(...missingRows); if (maxCode - minCode <= 2000) { try { const raw = await bulkApi.doListMoshtariByCode(minCode, maxCode); for (const row of asMoshtariList(raw)) { const code = pickCode(row); if (code != null) rowsByCode.set(code, row); } } catch { /* fallback: per-code fetch inside importFromTahesab */ } } } for (const moshtariCode of codes) { try { const linkedPersonId = linkedByCode.get(moshtariCode); if (linkedPersonId) { const row = rowsByCode.get(moshtariCode); if (row) { await this.refreshLinkedCustomer( organizationId, dto.connectionId, linkedPersonId, moshtariCode, row, ); } items.push({ moshtariCode, ok: true, skipped: true, personId: linkedPersonId, }); continue; } const r = await this.importFromTahesab( organizationId, { 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 = e instanceof Error ? e.message : 'وارد کردن ناموفق بود'; items.push({ moshtariCode, ok: false, error: message }); } } const imported = items.filter((i) => i.ok && !i.skipped).length; const skipped = items.filter((i) => i.skipped).length; const failed = items.filter((i) => !i.ok).length; await this.prisma.accountingSyncJob.create({ data: { organizationId, connectionId: dto.connectionId, kind: 'import', status: failed && !imported ? 'error' : 'success', request: dto as unknown as Prisma.InputJsonValue, result: { imported, skipped, failed, count: items.length } as Prisma.InputJsonValue, }, }); return { imported, skipped, failed, items }; } /** یک صفحه (بازهٔ کد) از ته‌حساب: گروه مشتری‌های لینک‌شده به‌روز می‌شود */ async syncGroupsPage(organizationId: string, dto: SyncGroupsPageDto) { const { api } = await this.connections.getApi(organizationId, dto.connectionId); const listed = await this.listMoshtariPage(api, dto.page ?? 1, dto.pageSize); const rowsByCode = new Map>(); for (const row of listed.rows) { const code = pickCode(row); if (code != null) rowsByCode.set(code, row); } const links = rowsByCode.size ? await this.prisma.personAccountingLink.findMany({ where: { connectionId: dto.connectionId, moshtariCode: { in: [...rowsByCode.keys()] } }, select: { moshtariCode: true, personId: true }, }) : []; let updated = 0; for (const link of links) { const changed = await this.applyTahesabGroup( organizationId, dto.connectionId, link.personId, rowsByCode.get(link.moshtariCode), ); if (changed) updated += 1; } return { page: listed.page, fromCode: listed.fromCode, toCode: listed.toCode, rows: listed.rows.length, linked: links.length, updated, }; } /** اشخاصی که روی این اتصال به ته‌حساب لینک نیستند */ async unlinkedPeople(organizationId: string, connectionId: string) { await this.connections.getOrThrow(organizationId, connectionId); const rows = await this.prisma.person.findMany({ where: { organizationId, accountingLinks: { none: { connectionId } } }, select: { id: true }, orderBy: { createdAt: 'asc' }, }); return { ids: rows.map((r) => r.id), total: rows.length }; } /** * ثبت گروهی در ته‌حساب: برای هر نفر اول تطبیق (موبایل، بعد نام)؛ * تطبیق یکتا → لینک، چند نتیجه → رد می‌شود تا دستی بررسی شود، بدون نتیجه → ساخت مشتری. */ async exportPeople(organizationId: string, dto: ExportPeopleToTahesabDto) { await this.connections.getOrThrow(organizationId, dto.connectionId); const ids = [...new Set(dto.personIds)]; const people = await this.prisma.person.findMany({ where: { organizationId, id: { in: ids } }, select: { id: true, firstName: true, lastName: true, accountingLinks: { where: { connectionId: dto.connectionId }, select: { moshtariCode: true }, }, }, }); const byId = new Map(people.map((p) => [p.id, p])); const items: Array<{ personId: string; name: string; status: 'matched' | 'created' | 'ambiguous' | 'skipped' | 'error'; moshtariCode?: number; error?: string; }> = []; for (const personId of ids) { const person = byId.get(personId); if (!person) { items.push({ personId, name: '', status: 'error', error: 'شخص یافت نشد.' }); continue; } const name = `${person.firstName} ${person.lastName}`.trim(); const existing = person.accountingLinks[0]; if (existing) { items.push({ personId, name, status: 'skipped', moshtariCode: existing.moshtariCode }); continue; } try { const matched = await this.match(organizationId, personId, { connectionId: dto.connectionId, autoLinkIfUnique: true, }); if (matched.status === 'linked' && matched.link) { items.push({ personId, name, status: 'matched', moshtariCode: matched.link.moshtariCode, }); } else if (matched.status === 'none') { const link = await this.createInTahesab(organizationId, personId, { connectionId: dto.connectionId, }); items.push({ personId, name, status: 'created', moshtariCode: link.moshtariCode }); } else { items.push({ personId, name, status: 'ambiguous' }); } } catch (e) { items.push({ personId, name, status: 'error', error: e instanceof Error ? e.message : 'ثبت ناموفق بود', }); } } const count = (s: (typeof items)[number]['status']) => items.filter((i) => i.status === s).length; const summary = { matched: count('matched'), created: count('created'), ambiguous: count('ambiguous'), skipped: count('skipped'), failed: count('error'), }; await this.prisma.accountingSyncJob.create({ data: { organizationId, connectionId: dto.connectionId, kind: 'export', status: summary.failed && !summary.matched && !summary.created ? 'error' : 'success', request: { count: ids.length } as Prisma.InputJsonValue, result: summary as Prisma.InputJsonValue, }, }); return { ...summary, 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(); } }