import { BadRequestException, ConflictException, Injectable, NotFoundException, } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../../prisma/prisma.service'; import { TahesabConnectionsService } from './connections.service'; import { ImportBulkProductsFromTahesabDto, ImportProductFromTahesabDto, SearchTahesabProductsDto, } from './dto'; /** دستهٔ پیش‌فرض برای نام‌های DoListNameKarsakhte (در طلا و جواهر: کارساخته) */ export const TAHSAB_KARSAKHTE_CATEGORY = 'کارساخته'; function asNameList(raw: unknown): Array> { if (!raw) return []; if (Array.isArray(raw)) return raw as Array>; if (typeof raw === 'object') { const vals = Object.values(raw as Record); if (vals.every((v) => v && typeof v === 'object')) { return vals as Array>; } } return []; } function pickKarName(row: Record): string { return String(row.Name ?? row.name ?? row.Name_Kar ?? '').trim(); } function pickAyar(row: Record): number | null { const n = Number(row.Ayar ?? row.ayar); return Number.isFinite(n) ? Math.round(n) : null; } function pickVaznSabet(row: Record): number | null { const n = Number(row.VaznSabet ?? row.vaznSabet); return Number.isFinite(n) ? n : null; } function pickLastSanad(row: Record): string { return String(row.LastSanad ?? row.lastSanad ?? '').trim(); } function normalizeQuery(q: string): string { return q.trim().toLowerCase(); } @Injectable() export class TahesabProductLinkService { constructor( private readonly prisma: PrismaService, private readonly connections: TahesabConnectionsService, ) {} private async getProduct(organizationId: string, productId: string) { const product = await this.prisma.product.findFirst({ where: { id: productId, organizationId }, }); if (!product) throw new NotFoundException('محصول یافت نشد.'); return product; } private async listKarsakhteRows(organizationId: string, connectionId: string) { const { api } = await this.connections.getApi(organizationId, connectionId); const raw = await api.doListNameKarsakhte(); const mapped = asNameList(raw) .map((row) => { const name = pickKarName(row); if (!name) return null; return { name, ayar: pickAyar(row), vaznSabet: pickVaznSabet(row), lastSanad: pickLastSanad(row), }; }) .filter((r): r is NonNullable => Boolean(r)); // یکتا بر اساس نام (آخرین ردیف برنده) const byName = new Map(); for (const row of mapped) byName.set(row.name, row); return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name, 'fa', { sensitivity: 'base', numeric: true }), ); } async search(organizationId: string, dto: SearchTahesabProductsDto) { const rows = await this.listKarsakhteRows(organizationId, dto.connectionId); const q = dto.q ? normalizeQuery(dto.q) : ''; const filtered = q ? rows.filter((r) => { if (r.name.toLowerCase().includes(q)) return true; if (r.ayar != null && String(r.ayar).includes(q)) return true; return false; }) : rows; const names = filtered.map((r) => r.name); const existing = names.length === 0 ? [] : await this.prisma.productAccountingLink.findMany({ where: { connectionId: dto.connectionId, externalName: { in: names }, }, select: { externalName: true, productId: true, product: { select: { name: true } }, }, }); const byName = new Map(existing.map((e) => [e.externalName, e])); const results = filtered.map((row) => { const link = byName.get(row.name); return { ...row, alreadyImported: Boolean(link), productId: link?.productId ?? null, productName: link?.product.name ?? null, }; }); await this.prisma.accountingSyncJob.create({ data: { organizationId, connectionId: dto.connectionId, kind: 'search_products', status: 'success', request: dto as unknown as Prisma.InputJsonValue, result: { count: results.length } as Prisma.InputJsonValue, }, }); return { results, categoryLabel: TAHSAB_KARSAKHTE_CATEGORY }; } async importOne(organizationId: string, dto: ImportProductFromTahesabDto) { const name = dto.name.trim(); if (!name) throw new BadRequestException('نام کارساخته الزامی است.'); const clash = await this.prisma.productAccountingLink.findFirst({ where: { connectionId: dto.connectionId, externalName: name }, include: { product: true }, }); if (clash && (!dto.productId || clash.productId !== dto.productId)) { throw new ConflictException( `این نام ته‌حساب قبلاً به محصول «${clash.product.name}» لینک شده است.`, ); } const rows = await this.listKarsakhteRows(organizationId, dto.connectionId); const row = rows.find((r) => r.name === name); if (!row) { throw new NotFoundException(`کارساخته «${name}» در ته‌حساب پیدا نشد.`); } return this.importRow(organizationId, dto.connectionId, row, dto.productId, true); } private async importRow( organizationId: string, connectionId: string, row: { name: string; ayar: number | null; vaznSabet: number | null; lastSanad: string; }, productId: string | undefined, logJob: boolean, ) { let resolvedProductId = productId; if (resolvedProductId) { await this.getProduct(organizationId, resolvedProductId); } else { const tags: string[] = ['ته‌حساب', TAHSAB_KARSAKHTE_CATEGORY]; if (row.ayar != null) tags.push(`عیار:${row.ayar}`); const descParts = [ 'واردشده از ته‌حساب (کارساخته)', row.ayar != null ? `عیار ${row.ayar}` : null, row.vaznSabet != null ? `وزن ثابت ${row.vaznSabet}` : null, row.lastSanad ? `آخرین سند ${row.lastSanad}` : null, ].filter(Boolean); const created = await this.prisma.product.create({ data: { organizationId, kind: 'product', name: row.name, category: TAHSAB_KARSAKHTE_CATEGORY, unit: row.vaznSabet != null || row.ayar != null ? 'گرم' : 'عدد', description: descParts.join(' · '), tags, isActive: true, }, }); resolvedProductId = created.id; } const link = await this.prisma.productAccountingLink.upsert({ where: { productId_connectionId: { productId: resolvedProductId, connectionId, }, }, create: { productId: resolvedProductId, connectionId, externalName: row.name, ayar: row.ayar, vaznSabet: row.vaznSabet, matchMethod: 'import', }, update: { externalName: row.name, ayar: row.ayar, vaznSabet: row.vaznSabet, matchMethod: 'import', linkedAt: new Date(), }, }); if (logJob) { await this.prisma.accountingSyncJob.create({ data: { organizationId, connectionId, kind: 'import_products', status: 'success', request: { connectionId, name: row.name } as Prisma.InputJsonValue, result: { productId: resolvedProductId, linkId: link.id, } as Prisma.InputJsonValue, }, }); } return { productId: resolvedProductId, linkId: link.id }; } async importBulk(organizationId: string, dto: ImportBulkProductsFromTahesabDto) { const allRows = await this.listKarsakhteRows(organizationId, dto.connectionId); const wanted = (dto.names ?? []).map((n) => n.trim()).filter(Boolean); const rows = wanted.length > 0 ? allRows.filter((r) => wanted.includes(r.name)) : allRows; if (!rows.length) { throw new BadRequestException('نام کارساخته‌ای برای وارد کردن مشخص نشده است.'); } if (rows.length > 1000) { throw new BadRequestException( `حداکثر ۱۰۰۰ کارساخته در هر بار وارد کنید (الان ${rows.length}).`, ); } const items: Array<{ name: string; ok: boolean; productId?: string; skipped?: boolean; error?: string; }> = []; for (const row of rows) { try { const existing = await this.prisma.productAccountingLink.findFirst({ where: { connectionId: dto.connectionId, externalName: row.name }, }); if (existing) { items.push({ name: row.name, ok: true, skipped: true, productId: existing.productId, }); continue; } const r = await this.importRow( organizationId, dto.connectionId, row, undefined, false, ); items.push({ name: row.name, ok: true, productId: r.productId }); } catch (e) { const message = e instanceof Error ? e.message : 'وارد کردن ناموفق بود'; items.push({ name: row.name, 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_products', 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 }; } }