import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException, type OnModuleInit, } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../../prisma/prisma.service'; import { carryPreviousSource } from '../../contacts/person-source'; import { TahesabConnectionsService } from './connections.service'; import { CreateInTahesabDto, ImportBulkFromTahesabDto, ImportFromTahesabDto, ManualLinkDto, MatchPersonDto, SearchTahesabDto, } from './dto'; import { parseBirthDate } from '../../common/excel'; import { normalizeIranMobile, SanadBuilders, type TahesabApi } from './tahesab-methods'; /** اندازهٔ هر صفحهٔ «همه» (بازهٔ کد) — امن برای پروکسی/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 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)); } @Injectable() export class TahesabPersonLinkService { private readonly logger = new Logger(TahesabPersonLinkService.name); constructor( private readonly prisma: PrismaService, private readonly connections: TahesabConnectionsService, ) {} 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 args = SanadBuilders.newMoshtari({ name: `${person.firstName} ${person.lastName}`.trim(), groupName: dto.groupName ?? 'CRM', tel, 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, }; } 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 results = 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, }; }); 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 }, ) { 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 parts = name.trim().split(/\s+/); const firstName = parts[0] || name; const lastName = parts.slice(1).join(' ') || '—'; const mobile = tel || null; 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, ...(nameChanged ? { previousFirstName: prevFirst || null, previousLastName: prevLast || null, } : {}), ...carryPreviousSource(existing, 'tahesab'), importSource: 'tahesab', sourceRef, ...(!existing.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}), ...(mobile ? { mobile: existing.mobile || mobile, phone: existing.phone || mobile, } : {}), }, }); } else { // اگر موبایل تکراری است → همان شخص؛ نام قبلی حفظ و نام ته‌حساب جایگزین. // شخصی که روی همین اتصال به کد دیگری لینک است کنار گذاشته می‌شود؛ // وگرنه upsert لینک، کد قبلی را جابه‌جا و آن مشتری را دوباره «واردنشده» می‌کند. const byMobile = mobile ? await this.prisma.person.findFirst({ where: { organizationId, OR: [{ mobile }, { phone: mobile }], accountingLinks: { none: { connectionId: dto.connectionId } }, }, }) : null; if (byMobile) { const nameChanged = `${byMobile.firstName} ${byMobile.lastName}`.trim() !== `${firstName} ${lastName}`.trim(); await this.prisma.person.update({ where: { id: byMobile.id }, data: { firstName, lastName, ...(nameChanged ? { previousFirstName: byMobile.firstName || null, previousLastName: byMobile.lastName || null, } : {}), ...carryPreviousSource(byMobile, 'tahesab'), importSource: 'tahesab', sourceRef, ...(!byMobile.birthDate && pickBirthDate(row) ? { birthDate: pickBirthDate(row) } : {}), }, }); personId = byMobile.id; } else { const created = await this.prisma.person.create({ data: { organizationId, firstName, lastName, mobile, phone: mobile, notes: `واردشده از ته‌حساب (کد ${dto.moshtariCode})`, importSource: 'tahesab', sourceRef, birthDate: pickBirthDate(row), }, }); 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.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) }; } async importBulk(organizationId: string, dto: ImportBulkFromTahesabDto) { let codes = (dto.moshtariCodes ?? []).filter((n) => Number.isFinite(n) && n >= 1); if (dto.listAll) { const { api } = await this.connections.getApi( organizationId, dto.connectionId, ); const listed = await this.listMoshtariPage( api, dto.page ?? 1, dto.pageSize, ); const listedCodes = listed.rows .map((row) => pickCode(row)) .filter((c): c is number => c != null); codes = [...new Set([...codes, ...listedCodes])]; } 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); const listed = asMoshtariList(raw) .map((row) => pickCode(row)) .filter((c): c is number => c != null); codes = [...new Set([...codes, ...listed])]; } 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 rowsByCode = new Map>(); if (pending.length) { bulkApi = (await this.connections.getApi(organizationId, dto.connectionId)).api; const minCode = Math.min(...pending); const maxCode = Math.max(...pending); 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 birthDate = pickBirthDate(rowsByCode.get(moshtariCode) ?? {}); if (birthDate) { await this.prisma.person.updateMany({ where: { id: linkedPersonId, birthDate: null }, data: { birthDate }, }); } 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, ); 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 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(); } }