177 lines
7.4 KiB
TypeScript
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);
|
|
};
|
|
}
|
|
}
|