import { BadRequestException, Injectable, NotFoundException, } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import type { CustomerGroupSummaryDto, TahesabAsnadKind, TahesabAsnadLineDto, TahesabAsnadReportPageDto, TahesabAsnadSummaryDto, TahesabAsnadSyncJobDto, } from '@mento/shared'; import { TAHESAB_ASNAD_KIND_LABELS, TAHESAB_JENS_FELEZ_LABELS, } from '@mento/shared'; import { PrismaService } from '../../prisma/prisma.service'; import { TahesabConnectionsService } from './connections.service'; const ASNAD_COUNT_LAST = 200; const SYNC_CONCURRENCY = 3; const ASNAD_KINDS = new Set(['sale', 'return', 'purchase', 'other']); function normalizeShamsiYmd(input: string): string { const m = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(input.trim()); if (!m) { throw new BadRequestException( 'تاریخ از/تا باید شمسی با قالب YYYY-MM-DD باشد.', ); } return `${m[1]}-${m[2]!.padStart(2, '0')}-${m[3]!.padStart(2, '0')}`; } function asAsnadList(raw: unknown): Array> { if (!raw) return []; if (Array.isArray(raw)) return raw as Array>; if (typeof raw === 'object') { const obj = raw as Record; for (const key of ['DoListAsnad', 'Asnad', 'asnad', 'Items', 'items', 'Data']) { const nested = obj[key]; if (Array.isArray(nested)) { return nested.filter((v) => v && typeof v === 'object') as Array< Record >; } } return Object.values(obj).filter( (v) => v && typeof v === 'object' && !Array.isArray(v), ) as Array>; } return []; } /** طبقه‌بندی تقریبی — قابل اصلاح بعد از فیدبک کارفرما */ export function classifyAsnadNo(noRaw: unknown): TahesabAsnadKind { const s = String(noRaw ?? '') .replace(/\s+/g, ' ') .trim(); if (/مرجوع|برگشت/.test(s)) return 'return'; if (/فروش/.test(s)) return 'sale'; if (/خرید/.test(s)) return 'purchase'; return 'other'; } function pickNum(v: unknown): number | null { if (v == null || v === '' || v === 'null') return null; const n = typeof v === 'number' ? v : Number(String(v).replace(/,/g, '')); return Number.isFinite(n) ? n : null; } function pickInt(v: unknown): number | null { const n = pickNum(v); return n == null ? null : Math.trunc(n); } function pickStr(v: unknown): string | null { if (v == null) return null; const s = String(v).trim(); if (!s || s === 'null') return null; return s; } /** تاریخ ته‌حساب اغلب 1405/05/04 است → YYYY-MM-DD */ function normalizeTarikh(raw: unknown, fallbackFrom: string, fallbackTo: string): string { const s = pickStr(raw); if (!s) return fallbackFrom; const slash = /^(\d{4})[\/\-](\d{1,2})[\/\-](\d{1,2})/.exec(s); if (slash) { return `${slash[1]}-${slash[2]!.padStart(2, '0')}-${slash[3]!.padStart(2, '0')}`; } return fallbackFrom <= fallbackTo ? fallbackFrom : fallbackTo; } function buildLineKey(row: Record): string { const id = pickInt(row.ID ?? row.Id ?? row.id); if (id != null) return `id:${id}`; const factor = pickStr(row.Factor_Code) ?? ''; const radif = pickInt(row.Radif) ?? 0; const tarikh = pickStr(row.Tarikh) ?? ''; const no = pickStr(row.NO) ?? ''; const sh = pickInt(row.Sh_Factor ?? row.Sh_factor) ?? 0; return `f:${factor}|r:${radif}|t:${tarikh}|n:${no}|s:${sh}`; } function personDisplayName(p: { firstName: string | null; lastName: string | null; } | null): string | null { if (!p) return null; const name = [p.firstName, p.lastName].filter(Boolean).join(' ').trim(); return name || null; } type SyncProgress = { connectionId: string; from: string; to: string; processed: number; total: number; linesUpserted: number; truncatedPersonIds: string[]; }; @Injectable() export class TahesabAsnadReportService { constructor( private readonly prisma: PrismaService, private readonly connections: TahesabConnectionsService, ) {} private mapJob( job: { id: string; status: string; connectionId: string | null; request: unknown; result: unknown; errorMessage: string | null; createdAt: Date; updatedAt: Date; }, ): TahesabAsnadSyncJobDto { const req = (job.request ?? {}) as Record; const res = (job.result ?? {}) as Partial; return { id: job.id, status: job.status, connectionId: job.connectionId, from: String(req.from ?? res.from ?? ''), to: String(req.to ?? res.to ?? ''), processed: Number(res.processed ?? 0), total: Number(res.total ?? 0), linesUpserted: Number(res.linesUpserted ?? 0), truncatedPersonIds: Array.isArray(res.truncatedPersonIds) ? res.truncatedPersonIds.map(String) : [], errorMessage: job.errorMessage, createdAt: job.createdAt.toISOString(), updatedAt: job.updatedAt.toISOString(), }; } async startSync( organizationId: string, connectionId: string | undefined, from: string, to: string, ): Promise { const fromNorm = normalizeShamsiYmd(from); const toNorm = normalizeShamsiYmd(to); if (fromNorm > toNorm) { throw new BadRequestException('تاریخ «از» نباید بعد از «تا» باشد.'); } const resolvedId = await this.connections.resolveConnectionId( organizationId, connectionId, ); const job = await this.prisma.accountingSyncJob.create({ data: { organizationId, connectionId: resolvedId, kind: 'asnad-sync', status: 'pending', request: { from: fromNorm, to: toNorm, connectionId: resolvedId }, result: { connectionId: resolvedId, from: fromNorm, to: toNorm, processed: 0, total: 0, linesUpserted: 0, truncatedPersonIds: [], }, }, }); void this.runSync(job.id, organizationId, resolvedId, fromNorm, toNorm).catch( () => undefined, ); return this.mapJob(job); } async getSyncJob( organizationId: string, jobId: string, ): Promise { const job = await this.prisma.accountingSyncJob.findFirst({ where: { id: jobId, organizationId, kind: 'asnad-sync' }, }); if (!job) throw new NotFoundException('کار همگام‌سازی یافت نشد.'); return this.mapJob(job); } private async runSync( jobId: string, organizationId: string, connectionId: string, from: string, to: string, ) { const progress: SyncProgress = { connectionId, from, to, processed: 0, total: 0, linesUpserted: 0, truncatedPersonIds: [], }; try { await this.prisma.accountingSyncJob.update({ where: { id: jobId }, data: { status: 'running', result: progress }, }); const { api } = await this.connections.getApi(organizationId, connectionId); const links = await this.prisma.personAccountingLink.findMany({ where: { connectionId }, select: { id: true, personId: true, moshtariCode: true, }, orderBy: { moshtariCode: 'asc' }, }); progress.total = links.length; await this.prisma.accountingSyncJob.update({ where: { id: jobId }, data: { result: { ...progress } }, }); await this.prisma.accountingAsnadLine.deleteMany({ where: { organizationId, connectionId, tarikh: { gte: from, lte: to }, }, }); let cursor = 0; const workers = Array.from( { length: Math.min(SYNC_CONCURRENCY, Math.max(links.length, 1)) }, async () => { while (true) { const i = cursor; cursor += 1; if (i >= links.length) return; const link = links[i]!; try { const raw = await api.doListAsnad( ASNAD_COUNT_LAST, link.moshtariCode, from, to, ); const items = asAsnadList(raw); if (items.length >= ASNAD_COUNT_LAST) { progress.truncatedPersonIds.push(link.personId); } const rows: Prisma.AccountingAsnadLineCreateManyInput[] = []; for (const item of items) { const tarikh = normalizeTarikh(item.Tarikh, from, to); if (tarikh < from || tarikh > to) continue; rows.push({ organizationId, connectionId, personId: link.personId, linkId: link.id, moshtariCode: link.moshtariCode, lineKey: buildLineKey(item), tahesabId: pickInt(item.ID ?? item.Id ?? item.id), factorCode: pickStr(item.Factor_Code), shFactor: pickInt(item.Sh_Factor ?? item.Sh_factor), radif: pickInt(item.Radif), tarikh, noRaw: pickStr(item.NO) ?? '', kind: classifyAsnadNo(item.NO), jensFelez: pickInt(item.Jens_Felez), noKar: pickStr(item.No_Kar), vazn: pickNum(item.Vazn), tabdilVazn: pickNum(item.TabdilVazn), mali: pickNum(item.Mali), count: pickNum(item.Count), ayar: pickNum(item.Ayar), mazaneh: pickNum(item.Mazaneh), fi: pickNum(item.Fi), sharh1: pickStr(item.Sharh1), syncedAt: new Date(), }); } if (rows.length) { // createMany skipDuplicates در صورت برخورد نادر lineKey const result = await this.prisma.accountingAsnadLine.createMany({ data: rows, skipDuplicates: true, }); progress.linesUpserted += result.count; } } catch { // یک مشتری خراب کل sync را متوقف نکند } progress.processed += 1; if (progress.processed % 5 === 0 || progress.processed === progress.total) { await this.prisma.accountingSyncJob.update({ where: { id: jobId }, data: { result: { ...progress } }, }); } } }, ); await Promise.all(workers); await this.prisma.accountingSyncJob.update({ where: { id: jobId }, data: { status: 'done', result: { ...progress } }, }); } catch (err) { const message = err instanceof Error ? err.message : 'همگام‌سازی اسناد ناموفق بود.'; await this.prisma.accountingSyncJob.update({ where: { id: jobId }, data: { status: 'failed', errorMessage: message, result: { ...progress }, }, }); } } private async buildWhere(params: { organizationId: string; connectionId?: string; from: string; to: string; groupIds?: string[]; personId?: string; kinds?: string[]; jensFelez?: number; }): Promise { const from = normalizeShamsiYmd(params.from); const to = normalizeShamsiYmd(params.to); const connectionId = await this.connections.resolveConnectionId( params.organizationId, params.connectionId, ); const kinds = (params.kinds ?? []).filter((k) => ASNAD_KINDS.has(k)); const where: Prisma.AccountingAsnadLineWhereInput = { organizationId: params.organizationId, connectionId, tarikh: { gte: from, lte: to }, }; if (params.personId) where.personId = params.personId; if (kinds.length) where.kind = { in: kinds }; if (params.jensFelez != null && Number.isFinite(params.jensFelez)) { where.jensFelez = params.jensFelez; } if (params.groupIds?.length) { where.personId = { in: ( await this.prisma.personGroupLink.findMany({ where: { groupId: { in: params.groupIds }, person: { organizationId: params.organizationId }, }, select: { personId: true }, distinct: ['personId'], }) ).map((r) => r.personId), }; if (params.personId) { // تقاطع با شخص مشخص const allowed = where.personId as { in: string[] }; if (!allowed.in.includes(params.personId)) { where.personId = '__none__'; } else { where.personId = params.personId; } } } return where; } async query( organizationId: string, opts: { connectionId?: string; from: string; to: string; groupIds?: string[]; personId?: string; kinds?: string[]; jensFelez?: number; page?: number; pageSize?: number; }, ): Promise { const page = Math.max(1, opts.page ?? 1); const pageSize = Math.min(200, Math.max(1, opts.pageSize ?? 50)); const where = await this.buildWhere({ organizationId, ...opts }); const [total, rows] = await Promise.all([ this.prisma.accountingAsnadLine.count({ where }), this.prisma.accountingAsnadLine.findMany({ where, orderBy: [{ tarikh: 'desc' }, { shFactor: 'desc' }, { radif: 'asc' }], skip: (page - 1) * pageSize, take: pageSize, include: { person: { select: { firstName: true, lastName: true, groupLinks: { include: { group: { select: { id: true, name: true, color: true } }, }, }, }, }, }, }), ]); const items: TahesabAsnadLineDto[] = rows.map((r) => { const groups: CustomerGroupSummaryDto[] = (r.person?.groupLinks ?? []).map( (gl) => ({ id: gl.group.id, name: gl.group.name, color: gl.group.color, source: gl.source === 'tahesab' ? 'tahesab' : 'manual', }), ); return { id: r.id, connectionId: r.connectionId, personId: r.personId, personName: personDisplayName(r.person), moshtariCode: r.moshtariCode, tarikh: r.tarikh, noRaw: r.noRaw, kind: r.kind, jensFelez: r.jensFelez, noKar: r.noKar, vazn: r.vazn, tabdilVazn: r.tabdilVazn, mali: r.mali, count: r.count, shFactor: r.shFactor, factorCode: r.factorCode, groups, syncedAt: r.syncedAt.toISOString(), }; }); return { items, total, page, pageSize }; } async summary( organizationId: string, opts: { connectionId?: string; from: string; to: string; groupIds?: string[]; personId?: string; kinds?: string[]; jensFelez?: number; }, ): Promise { const where = await this.buildWhere({ organizationId, ...opts }); const rows = await this.prisma.accountingAsnadLine.findMany({ where, select: { kind: true, jensFelez: true, mali: true, vazn: true, noRaw: true, syncedAt: true, personId: true, person: { select: { groupLinks: { include: { group: { select: { id: true, name: true } }, }, }, }, }, }, }); let maliSum = 0; let vaznSum = 0; let lastSyncedAt: Date | null = null; const byKind = new Map(); const byMetal = new Map(); const byGroup = new Map(); const nos = new Set(); for (const r of rows) { const mali = r.mali ?? 0; const vazn = r.vazn ?? 0; maliSum += mali; vaznSum += vazn; if (!lastSyncedAt || r.syncedAt > lastSyncedAt) lastSyncedAt = r.syncedAt; if (r.noRaw) nos.add(r.noRaw); const k = r.kind || 'other'; const kb = byKind.get(k) ?? { lineCount: 0, maliSum: 0, vaznSum: 0 }; kb.lineCount += 1; kb.maliSum += mali; kb.vaznSum += vazn; byKind.set(k, kb); const metalKey = r.jensFelez == null ? 'none' : String(r.jensFelez); const metalLabel = r.jensFelez == null ? 'نامشخص' : TAHESAB_JENS_FELEZ_LABELS[r.jensFelez] ?? `فلز ${r.jensFelez}`; const mb = byMetal.get(metalKey) ?? { lineCount: 0, maliSum: 0, vaznSum: 0, label: metalLabel, }; mb.lineCount += 1; mb.maliSum += mali; mb.vaznSum += vazn; byMetal.set(metalKey, mb); const gls = r.person?.groupLinks ?? []; if (!gls.length) { const gb = byGroup.get('__none__') ?? { lineCount: 0, maliSum: 0, vaznSum: 0, label: 'بدون گروه', }; gb.lineCount += 1; gb.maliSum += mali; gb.vaznSum += vazn; byGroup.set('__none__', gb); } else { for (const gl of gls) { const gb = byGroup.get(gl.group.id) ?? { lineCount: 0, maliSum: 0, vaznSum: 0, label: gl.group.name, }; gb.lineCount += 1; gb.maliSum += mali; gb.vaznSum += vazn; byGroup.set(gl.group.id, gb); } } } return { totalLines: rows.length, maliSum, vaznSum, byKind: [...byKind.entries()].map(([key, v]) => ({ key, label: TAHESAB_ASNAD_KIND_LABELS[key as TahesabAsnadKind] ?? key, ...v, })), byMetal: [...byMetal.entries()].map(([key, v]) => ({ key, label: v.label, lineCount: v.lineCount, maliSum: v.maliSum, vaznSum: v.vaznSum, })), byGroup: [...byGroup.entries()].map(([key, v]) => ({ key, label: v.label, lineCount: v.lineCount, maliSum: v.maliSum, vaznSum: v.vaznSum, })), distinctNos: [...nos].sort((a, b) => a.localeCompare(b, 'fa')), lastSyncedAt: lastSyncedAt?.toISOString() ?? null, }; } }