import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { AccountingEntityType, AccountingOutboxStatus, Prisma } from '@prisma/client'; import { PrismaService } from '../../prisma/prisma.service'; import { cleanUrl } from './accounting-config'; import { AccountingConfigService, JobName } from './accounting-config.service'; import { fetchServerCertificate } from './accounting-http'; import { AccountingSyncService } from './accounting-sync.service'; import { AccountingWorkerService, PULL_FEATURE } from './accounting-worker.service'; import { parseJalali, toJalali } from './jalali'; import { ACCOUNTING_PROVIDERS, ProviderName, isProviderName } from './providers'; const JOBS: JobName[] = ['customers', 'balances', 'products', 'outbox']; const STATUSES: AccountingOutboxStatus[] = ['PENDING', 'SENDING', 'SENT', 'FAILED', 'NEEDS_REVIEW', 'RESOLVED']; function page(v: unknown) { const n = Number(v); return Number.isInteger(n) && n > 0 ? n : 1; } function limit(v: unknown, max = 100) { const n = Number(v); return Number.isInteger(n) && n > 0 ? Math.min(n, max) : 25; } /** Backs the «اتصال حسابداری» admin screen (plan §5). */ @Injectable() export class AccountingAdminService { constructor( private readonly prisma: PrismaService, private readonly config: AccountingConfigService, private readonly sync: AccountingSyncService, private readonly worker: AccountingWorkerService, ) {} async overview() { const [config, state, counts, openIssues, raw] = await Promise.all([ this.config.getPublic(), this.config.getState(), this.prisma.accountingOutbox.groupBy({ by: ['status'], _count: { _all: true } }), this.prisma.accountingIssue.count({ where: { resolvedAt: null } }), this.config.get(), ]); return { config, inactiveReason: this.config.inactiveReason(raw), state, busy: this.worker.busy, realtime: this.worker.realtime, outbox: Object.fromEntries(counts.map((c) => [c.status, c._count._all])), openIssues, }; } async save(body: Record) { const { providerChanged } = await this.config.save(body); return { ...(await this.overview()), providerChanged }; } private async settingsOf(name: unknown) { const config = await this.config.get(); const provider = isProviderName(name) ? name : config.provider; if (provider === 'none') throw new BadRequestException('نرم‌افزار حسابداری انتخاب نشده است'); const settings = this.config.settingsFor(config, provider); if (!settings) throw new BadRequestException('آدرس یا توکن این نرم‌افزار تنظیم نشده (یا توکن قابل‌خواندن نیست)'); return { provider: ACCOUNTING_PROVIDERS[provider], settings, config }; } /** For the sync buttons on the lists: connected or not, nothing more. */ async connectionStatus() { const config = await this.config.get(); const reason = this.config.inactiveReason(config); return { connected: !reason, provider: reason || config.provider === 'none' ? null : ACCOUNTING_PROVIDERS[config.provider].label, }; } /** The list-page sync buttons (plan §12): run now, wait, report. */ async syncNow(jobs: ('customers' | 'balances' | 'products')[]) { const reason = this.config.inactiveReason(await this.config.get()); if (reason) throw new BadRequestException(`سینک با حسابداری انجام نمی‌شود: ${reason}`); const results = await this.worker.runPullsNow(jobs); if (!results) throw new BadRequestException('یک همگام‌سازی دیگر در حال اجراست؛ کمی بعد دوباره امتحان کنید'); return { ok: Object.values(results).every((r) => r.ok), results }; } /** Tests the connection as typed in the form (`connection`), falling back to what is saved. */ async test(name: unknown, connection?: Record) { if (!isProviderName(name)) throw new BadRequestException('نرم‌افزار حسابداری نامعتبر است'); const provider = ACCOUNTING_PROVIDERS[name]; const settings = this.config.draftSettings(await this.config.get(), name, connection); if (!settings) throw new BadRequestException('آدرس و توکن را وارد کنید'); const started = Date.now(); try { const info = await provider.health(settings); return { ok: true, provider: provider.label, ms: Date.now() - started, info }; } catch (err) { return { ok: false, provider: provider.label, ms: Date.now() - started, error: this.sync.describeError(err) }; } } /** * The certificate the server at `baseUrl` presents, for the admin to compare * and pin (a self-signed Tahesab install cannot be verified otherwise). */ async certificate(baseUrl: unknown) { const url = cleanUrl(baseUrl, 'آدرس'); if (!url) throw new BadRequestException('آدرس را وارد کنید'); try { return await fetchServerCertificate(url, 10_000); } catch (err) { throw new BadRequestException(`گواهی دریافت نشد: ${this.sync.describeError(err)}`); } } /** Dropdown options for the mapping tab; each list fails on its own. */ async options(name: unknown) { const { provider, settings } = await this.settingsOf(name); const safe = async (fn: () => Promise) => { try { return { ok: true as const, items: await fn() }; } catch (err) { return { ok: false as const, items: [] as never[], error: this.sync.describeError(err) }; } }; const [customerGroups, karSakhteGroups, karSakhteTypes] = await Promise.all([ safe(() => provider.listCustomerGroups(settings)), safe(() => provider.listKarSakhteGroups(settings)), safe(() => provider.listKarSakhteTypes(settings)), ]); const categories = await this.prisma.category.findMany({ select: { id: true, title: true }, orderBy: { title: 'asc' } }); return { provider: provider.name, customerGroups, karSakhteGroups, karSakhteTypes, categories }; } async run(job: string) { if (!JOBS.includes(job as JobName)) throw new BadRequestException('کار نامعتبر است'); const config = await this.config.get(); const reason = this.config.inactiveReason(config); if (reason) throw new BadRequestException(`همگام‌سازی انجام نمی‌شود: ${reason}`); if (job !== 'outbox' && !config.features[PULL_FEATURE[job as Exclude]]) { throw new BadRequestException('این قابلیت در تب «قابلیت‌ها و زمان‌بندی» خاموش است؛ اول روشن و ذخیره کنید'); } const started = this.worker.runNow(job as JobName); return { started, message: started ? 'شروع شد' : 'یک همگام‌سازی دیگر در حال اجراست؛ کمی بعد دوباره امتحان کنید' }; } // ── outbox log ────────────────────────────────────────────────────── async outbox(q: Record) { const where: Prisma.AccountingOutboxWhereInput = {}; if (typeof q.status === 'string' && STATUSES.includes(q.status as AccountingOutboxStatus)) { where.status = q.status as AccountingOutboxStatus; } if (typeof q.kind === 'string' && q.kind) where.kind = q.kind as Prisma.AccountingOutboxWhereInput['kind']; if (typeof q.entityId === 'string' && q.entityId) where.entityId = q.entityId; const p = page(q.page); const l = limit(q.limit); const [rows, total] = await Promise.all([ this.prisma.accountingOutbox.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (p - 1) * l, take: l }), this.prisma.accountingOutbox.count({ where }), ]); const labels = await this.labelsFor(rows.map((r) => ({ kind: r.kind, entityId: r.entityId }))); return { rows: rows.map((r) => ({ ...r, entityLabel: labels.get(r.entityId) ?? null })), total, page: p, limit: l }; } private async labelsFor(items: { kind: string; entityId: string }[]) { const orderIds = items.filter((i) => i.kind.startsWith('ORDER')).map((i) => i.entityId); const customerIds = items.filter((i) => i.kind === 'CUSTOMER_PUSH').map((i) => i.entityId); const productIds = items.filter((i) => i.kind === 'PRODUCT_PUSH').map((i) => i.entityId); const [orders, customers, products] = await Promise.all([ orderIds.length ? this.prisma.order.findMany({ where: { id: { in: orderIds } }, select: { id: true, orderNumber: true, customer: { select: { fullName: true } } } }) : [], customerIds.length ? this.prisma.customer.findMany({ where: { id: { in: customerIds } }, select: { id: true, fullName: true, mobile: true } }) : [], productIds.length ? this.prisma.product.findMany({ where: { id: { in: productIds } }, select: { id: true, title: true } }) : [], ]); const m = new Map(); for (const o of orders) m.set(o.id, `سفارش ${o.orderNumber} — ${o.customer.fullName}`); for (const c of customers) m.set(c.id, `${c.fullName} (${c.mobile})`); for (const p of products) m.set(p.id, p.title); return m; } async retry(id: string) { const row = await this.prisma.accountingOutbox.findUnique({ where: { id } }); if (!row) throw new NotFoundException('ردیف پیدا نشد'); if (row.status === 'SENT' || row.status === 'SENDING') throw new BadRequestException('این ردیف قابل تلاش دوباره نیست'); return this.prisma.accountingOutbox.update({ where: { id }, data: { status: 'PENDING', nextAttemptAt: new Date(), attempts: row.status === 'PENDING' ? row.attempts : 0 }, }); } async resolve(id: string, adminId: string, note?: string) { const row = await this.prisma.accountingOutbox.findUnique({ where: { id } }); if (!row) throw new NotFoundException('ردیف پیدا نشد'); if (row.status === 'SENDING') throw new BadRequestException('در حال ارسال است'); return this.prisma.accountingOutbox.update({ where: { id }, data: { status: 'RESOLVED', resolvedById: adminId, lastError: note ? `حل‌شده: ${note}` : row.lastError }, }); } // ── issues ────────────────────────────────────────────────────────── async issues(q: Record) { const where: Prisma.AccountingIssueWhereInput = q.resolved === '1' ? { resolvedAt: { not: null } } : { resolvedAt: null }; const p = page(q.page); const l = limit(q.limit); const [rows, total] = await Promise.all([ this.prisma.accountingIssue.findMany({ where, orderBy: { updatedAt: 'desc' }, skip: (p - 1) * l, take: l }), this.prisma.accountingIssue.count({ where }), ]); return { rows, total, page: p, limit: l }; } resolveIssue(id: string) { return this.prisma.accountingIssue.update({ where: { id }, data: { resolvedAt: new Date() } }); } // ── links ─────────────────────────────────────────────────────────── async links(q: Record) { const config = await this.config.get(); const where: Prisma.AccountingLinkWhereInput = {}; if (q.entityType === 'CUSTOMER' || q.entityType === 'PRODUCT') where.entityType = q.entityType; where.provider = isProviderName(q.provider) ? q.provider : config.provider === 'none' ? undefined : config.provider; if (typeof q.entityId === 'string' && q.entityId) where.entityId = q.entityId; if (typeof q.q === 'string' && q.q.trim()) { where.OR = [{ externalCode: { contains: q.q.trim() } }, { externalName: { contains: q.q.trim() } }]; } const p = page(q.page); const l = limit(q.limit); const [rows, total] = await Promise.all([ this.prisma.accountingLink.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (p - 1) * l, take: l }), this.prisma.accountingLink.count({ where }), ]); return { rows, total, page: p, limit: l }; } async manualLink(body: Record) { const config = await this.config.get(); if (config.provider === 'none') throw new BadRequestException('نرم‌افزار حسابداری انتخاب نشده است'); const provider = config.provider as ProviderName; const entityType = body.entityType === 'PRODUCT' ? AccountingEntityType.PRODUCT : AccountingEntityType.CUSTOMER; const entityId = String(body.entityId ?? '').trim(); const externalCode = String(body.externalCode ?? '').trim(); if (!entityId || !externalCode) throw new BadRequestException('شناسه و کد حسابداری الزامی است'); const exists = entityType === 'CUSTOMER' ? await this.prisma.customer.findUnique({ where: { id: entityId }, select: { id: true } }) : await this.prisma.product.findUnique({ where: { id: entityId }, select: { id: true } }); if (!exists) throw new NotFoundException('ردیف اپ پیدا نشد'); const taken = await this.prisma.accountingLink.findUnique({ where: { provider_entityType_externalCode: { provider, entityType, externalCode } }, }); if (taken && taken.entityId !== entityId) throw new BadRequestException('این کد به ردیف دیگری لینک است'); return this.prisma.accountingLink.upsert({ where: { provider_entityType_entityId: { provider, entityType, entityId } }, update: { externalCode, origin: 'MANUAL', syncedAt: new Date() }, create: { provider, entityType, entityId, externalCode, origin: 'MANUAL', externalName: (body.externalName as string) || null }, }); } async unlink(id: string) { await this.prisma.accountingLink.delete({ where: { id } }).catch(() => { throw new NotFoundException('لینک پیدا نشد'); }); return { success: true }; } // ── per-row panels ────────────────────────────────────────────────── async customer(id: string) { const config = await this.config.get(); const c = await this.prisma.customer.findUnique({ where: { id }, select: { id: true, goldDebtGrams: true, fiatDebtRial: true, accountingGoldBalance: true, accountingRialBalance: true, accountingBalanceAt: true, }, }); if (!c) throw new NotFoundException('مشتری یافت نشد'); const link = config.provider === 'none' ? null : await this.prisma.accountingLink.findUnique({ where: { provider_entityType_entityId: { provider: config.provider, entityType: 'CUSTOMER', entityId: id } }, }); const fresh = link ? await this.sync.customerBalance(id) : null; const jobs = await this.prisma.accountingOutbox.findMany({ where: { entityId: id, kind: 'CUSTOMER_PUSH' }, orderBy: { createdAt: 'desc' }, take: 5, }); return { provider: config.provider, link, internal: { gold: c.goldDebtGrams, rial: c.fiatDebtRial }, accounting: fresh ? { gold: fresh.gold, rial: fresh.rial, at: fresh.at } : c.accountingBalanceAt ? { gold: c.accountingGoldBalance, rial: c.accountingRialBalance, at: c.accountingBalanceAt } : null, jobs, }; } async productsStatus(idsCsv: string) { const ids = String(idsCsv ?? '') .split(',') .map((s) => s.trim()) .filter(Boolean) .slice(0, 500); const config = await this.config.get(); if (ids.length === 0) return {}; const [products, links] = await Promise.all([ this.prisma.product.findMany({ where: { id: { in: ids } }, select: { id: true, accountingStockWeight: true, accountingStockCount: true, accountingStockAt: true, _count: { select: { images: true } }, }, }), config.provider === 'none' ? [] : this.prisma.accountingLink.findMany({ where: { provider: config.provider, entityType: 'PRODUCT', entityId: { in: ids } } }), ]); const linkBy = new Map(links.map((l) => [l.entityId, l])); return Object.fromEntries( products.map((p) => { const l = linkBy.get(p.id); return [ p.id, { linked: !!l, locked: !!l && config.features.products, code: l?.externalCode ?? null, origin: l?.origin ?? null, stockWeight: p.accountingStockWeight, stockCount: p.accountingStockCount, stockAt: p.accountingStockAt, hasImage: p._count.images > 0, }, ]; }), ); } async movement(q: Record) { const a = await this.config.active(); if (!a) throw new BadRequestException('اتصال حسابداری فعال نیست'); const to = parseJalali(q.to as string) ?? toJalali(new Date()); const from = parseJalali(q.from as string) ?? toJalali(new Date(Date.now() - 30 * 24 * 3600 * 1000)); const rows = await a.provider.getKarSakhteMovement(a.settings, from, to); return { provider: a.provider.label, scope: a.provider.capabilities.movementScope, rows }; } }