import { Injectable } from '@nestjs/common'; import { AccountingEntityType, AccountingOutboxKind, AccountingOutboxStatus, Prisma } from '@prisma/client'; import { PrismaService } from '../../prisma/prisma.service'; import { AccountingConfigService } from './accounting-config.service'; import { AccountingFeatures, documentStatusRank } from './accounting-config'; import { AccountingSyncService } from './accounting-sync.service'; import { AccountingWorkerService } from './accounting-worker.service'; import { ACCOUNTING_PROVIDERS } from './providers'; type Tx = Prisma.TransactionClient | PrismaService; type StockFields = { accountingStockAt: Date | null; accountingStockWeight: Prisma.Decimal | null; accountingStockCount: Prisma.Decimal | null; }; const OPEN: AccountingOutboxStatus[] = ['PENDING', 'SENDING']; /** * The narrow surface the rest of the app calls into (D27): orders, customer * admin, product admin, catalog. Every write goes through the caller's own * transaction client, so the outbox row commits or rolls back with the change * that caused it. Nothing here talks to the accounting software. */ @Injectable() export class AccountingHooksService { constructor( private readonly prisma: PrismaService, private readonly config: AccountingConfigService, private readonly sync: AccountingSyncService, private readonly worker: AccountingWorkerService, ) {} private async enqueue(tx: Tx, feature: keyof AccountingFeatures, kind: AccountingOutboxKind, entityId: string) { const c = await this.config.get(); if (c.provider === 'none' || !c.features[feature]) return false; const open = await tx.accountingOutbox.findFirst({ where: { provider: c.provider, kind, entityId, status: { in: OPEN } }, select: { id: true }, }); if (open) return false; await tx.accountingOutbox.create({ data: { provider: c.provider, kind, entityId } }); this.worker.kickSoon(); return true; } /** * F3 — the order is now in `status` (created, advanced, or rolled back). * The owner's rule (`documents.triggerStatus`, «اتصال حسابداری») says from * which status on the temporary document (پیش‌فاکتور) belongs in the books: * at or past it the document is queued once; back below it, a document * already sent is withdrawn like a cancellation. */ async orderStatusReached(tx: Tx, orderId: string, status: string) { const c = await this.config.get(); if (c.provider === 'none' || !c.features.documents) return; const rank = documentStatusRank(status); if (!rank) return; if (rank >= documentStatusRank(c.documents.triggerStatus)) await this.queueOrderDocument(tx, c.provider, orderId); else await this.orderCancelled(tx, orderId); } private async queueOrderDocument(tx: Tx, provider: string, orderId: string) { const existing = await tx.accountingOutbox.findFirst({ where: { provider, kind: 'ORDER_DOCUMENT', entityId: orderId, status: { notIn: ['RESOLVED'] } }, select: { id: true }, }); if (existing) { // Withdrawn and wanted again: Tahesab deletes first (older job), then // the new one posts — and a duplicate is refused by its Factor_Code key. // Zarrin has no such key, so its document is never queued twice. const deleting = await tx.accountingOutbox.findFirst({ where: { provider, kind: 'ORDER_DOCUMENT_DELETE', entityId: orderId, status: { in: ['PENDING', 'SENDING'] } }, select: { id: true }, }); if (!(provider === 'tahesab' && deleting)) return; } await this.enqueue(tx, 'documents', 'ORDER_DOCUMENT', orderId); } /** * F3 — the order was cancelled. A document still waiting is simply dropped; * one that may already be in the books gets a delete job, which itself only * deletes a document that is still temporary (worker). */ async orderCancelled(tx: Tx, orderId: string) { const docs = await tx.accountingOutbox.findMany({ where: { kind: 'ORDER_DOCUMENT', entityId: orderId, status: { not: 'RESOLVED' } }, }); for (const doc of docs) { if (doc.status === 'PENDING') { await tx.accountingOutbox.update({ where: { id: doc.id }, data: { status: 'RESOLVED', lastError: 'سفارش پیش از ارسال سند لغو شد' }, }); continue; } const open = await tx.accountingOutbox.findFirst({ where: { provider: doc.provider, kind: 'ORDER_DOCUMENT_DELETE', entityId: orderId, status: { not: 'RESOLVED' } }, select: { id: true }, }); if (!open) { await tx.accountingOutbox.create({ data: { provider: doc.provider, kind: 'ORDER_DOCUMENT_DELETE', entityId: orderId }, }); } } } /** F1 — an admin marked the customer VERIFIED. */ async customerVerified(tx: Tx, customerId: string) { await this.enqueue(tx, 'customers', 'CUSTOMER_PUSH', customerId); } /** F1 — a linked customer's contact details changed. */ async customerEdited(tx: Tx, customerId: string) { const c = await this.config.get(); if (c.provider === 'none' || !c.features.customers) return; const link = await tx.accountingLink.findUnique({ where: { provider_entityType_entityId: { provider: c.provider, entityType: 'CUSTOMER', entityId: customerId } }, select: { id: true }, }); if (link) await this.enqueue(tx, 'customers', 'CUSTOMER_PUSH', customerId); } /** F4 — a product was created in the admin panel. */ async productCreated(tx: Tx, productId: string) { const c = await this.config.get(); if (c.provider === 'none' || !ACCOUNTING_PROVIDERS[c.provider].capabilities.createKarSakhte) return; await this.enqueue(tx, 'products', 'PRODUCT_PUSH', productId); } /** * F4 — title and code of a linked product belong to the accounting software * (D27); the admin form locks them. Null when the product is not linked. */ async productLink(productId: string) { const c = await this.config.get(); if (c.provider === 'none' || !c.features.products) return null; return this.prisma.accountingLink.findUnique({ where: { provider_entityType_entityId: { provider: c.provider, entityType: AccountingEntityType.PRODUCT, entityId: productId } }, }); } /** * Order time: the product with its stock read live from the accounting * software, or as last stored when that cannot answer in a moment. */ liveStock(product: T) { return this.sync.liveStock(product); } /** * F4 — is this product out of stock in the accounting software? Only * meaningful while the product sync is on; stock fields of products that are * not linked under the active provider are cleared by every product pull. */ async stockBlocked(product: StockFields) { return (await this.stockBlocker())(product); } /** * The same rule as stockBlocked, with the configuration read once — for * checking a whole list without an await per product. */ async stockBlocker(): Promise<(product: StockFields) => boolean> { const c = await this.config.get(); const active = c.provider !== 'none' && c.features.products; return (product) => { if (!active || !product.accountingStockAt) return false; const stock = product.accountingStockWeight ?? product.accountingStockCount; return stock !== null && new Prisma.Decimal(stock).lte(0); }; } }