Files
hedena_gold/apps/api/src/modules/accounting-sync/accounting-hooks.service.ts
T

177 lines
7.4 KiB
TypeScript

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<T extends { id: string; accountingStockAt: Date | null; accountingStockWeight: Prisma.Decimal | null; accountingStockCount: Prisma.Decimal | null }>(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);
};
}
}