feat: accounting integration (D27), SMS relay driver (D26), rebuild user apps
- Add Tahesab/Zarrin accounting sync module with admin panel UI - Add SMS relay driver as default provider, refactor SMS into driver pattern - Update customer services, orders, products, and catalog queries - Rebuild user-app and user-app-v2 dist assets - Add Prisma migration for accounting tables - Update docker-compose and env examples - Update parity docs (DECISIONS, ISSUES, PROGRESS) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
a72fa607a5
commit
19d490e790
@@ -0,0 +1,164 @@
|
||||
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;
|
||||
|
||||
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: {
|
||||
accountingStockAt: Date | null;
|
||||
accountingStockWeight: Prisma.Decimal | null;
|
||||
accountingStockCount: Prisma.Decimal | null;
|
||||
}) {
|
||||
if (!product.accountingStockAt) return false;
|
||||
const c = await this.config.get();
|
||||
if (c.provider === 'none' || !c.features.products) return false;
|
||||
const stock = product.accountingStockWeight ?? product.accountingStockCount;
|
||||
return stock !== null && new Prisma.Decimal(stock).lte(0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user