- 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>
950 lines
44 KiB
TypeScript
950 lines
44 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import { AccountingOutbox, Prisma } from '@prisma/client';
|
|
import { PrismaService } from '../../prisma/prisma.service';
|
|
import { ActiveProvider, AccountingConfigService } from './accounting-config.service';
|
|
import { AccountingConfig } from './accounting-config';
|
|
import { JalaliDate, parseJalali, toJalali } from './jalali';
|
|
import {
|
|
AccountingProviderError,
|
|
CustomerPayload,
|
|
DocumentLine,
|
|
ExternalCustomer,
|
|
ExternalGroup,
|
|
ExternalKarSakhte,
|
|
ProviderName,
|
|
StatementRow,
|
|
normalizeMobile,
|
|
splitName,
|
|
} from './providers';
|
|
|
|
/** A job that must stop and wait for a human, not be retried. */
|
|
export class NeedsReviewError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'NeedsReviewError';
|
|
}
|
|
}
|
|
|
|
const MAX_CUSTOMER_PAGES = 1000;
|
|
// Real-time (owner, 2026-10-05): the change poll keeps stored figures a few
|
|
// seconds old; these windows only absorb a burst of reads of the same figure.
|
|
const STATEMENT_CACHE_MS = 10_000;
|
|
const BALANCE_FRESH_MS = 5_000;
|
|
const BALANCE_ON_DEMAND_TIMEOUT_MS = 3_000;
|
|
const STOCK_ON_DEMAND_TIMEOUT_MS = 2_500;
|
|
|
|
/** Lowercase alphanumeric, ≥ 20 chars: Tahesab lowercases Factor_Code and dedupes on it. */
|
|
export function factorCodeFor(orderId: string) {
|
|
return `gbord${orderId}`.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
}
|
|
|
|
function groupMatches(selected: ExternalGroup[], c: { groupId: string | null; groupName: string | null }) {
|
|
if (selected.length === 0) return true;
|
|
return selected.some(
|
|
(g) => (g.id !== '' && c.groupId !== null && g.id === c.groupId) || (!!g.name && !!c.groupName && g.name === c.groupName),
|
|
);
|
|
}
|
|
|
|
/** A real Iranian national code (10 digits, checksum), excluding the all-same placeholders. */
|
|
export function validNationalCode(v: string | null): string | null {
|
|
if (!v) return null;
|
|
const d = v.replace(/\D/g, '');
|
|
if (!/^\d{10}$/.test(d) || /^(\d)\1{9}$/.test(d)) return null;
|
|
const check = Number(d[9]);
|
|
const sum = d
|
|
.slice(0, 9)
|
|
.split('')
|
|
.reduce((s, ch, i) => s + Number(ch) * (10 - i), 0);
|
|
const r = sum % 11;
|
|
return (r < 2 ? check === r : check === 11 - r) ? d : null;
|
|
}
|
|
|
|
/**
|
|
* Everything the accounting integration does (D27, plan
|
|
* docs/ACCOUNTING-INTEGRATION-PLAN.md §4): pulls on a schedule, outbox jobs,
|
|
* and the reads the customer app makes. Each public pull takes the active
|
|
* provider so the worker resolves configuration once per run.
|
|
*/
|
|
@Injectable()
|
|
export class AccountingSyncService {
|
|
private readonly logger = new Logger('Accounting');
|
|
private readonly statementCache = new Map<string, { at: number; rows: StatementRow[] }>();
|
|
private itemCache: { provider: string; at: number; items: ExternalKarSakhte[] } | null = null;
|
|
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly config: AccountingConfigService,
|
|
) {}
|
|
|
|
// ════════════════════════════════════════════════════════════════════
|
|
// F1 — customers
|
|
// ════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* Reads every account (in the selected groups) and links or imports it.
|
|
* Two passes: all accounts first, so two accounts sharing one mobile are
|
|
* caught before either is linked.
|
|
*/
|
|
async pullCustomers(a: ActiveProvider) {
|
|
// No groups must not mean "all": cash boxes, staff and investors are
|
|
// accounts too, and would become app customers.
|
|
if (a.config.mapping.importCustomerGroups.length === 0) {
|
|
throw new Error('هیچ گروه مشتری برای ورود انتخاب نشده است (تب «نگاشت»)');
|
|
}
|
|
const provider = a.provider.name;
|
|
const all: ExternalCustomer[] = [];
|
|
let cursor: string | null = null;
|
|
for (let page = 0; page < MAX_CUSTOMER_PAGES; page += 1) {
|
|
const res = await a.provider.listCustomers(a.settings, cursor);
|
|
all.push(...res.items);
|
|
if (!res.next) break;
|
|
cursor = res.next;
|
|
}
|
|
|
|
const inScope = all.filter((c) => groupMatches(a.config.mapping.importCustomerGroups, c));
|
|
const byMobile = new Map<string, ExternalCustomer[]>();
|
|
for (const c of inScope) {
|
|
if (!c.mobile) continue;
|
|
byMobile.set(c.mobile, [...(byMobile.get(c.mobile) ?? []), c]);
|
|
}
|
|
|
|
const existingLinks = await this.prisma.accountingLink.findMany({
|
|
where: { provider, entityType: 'CUSTOMER' },
|
|
select: { id: true, entityId: true, externalCode: true },
|
|
});
|
|
const linkByCode = new Map(existingLinks.map((l) => [l.externalCode, l]));
|
|
const linkedCustomerIds = new Set(existingLinks.map((l) => l.entityId));
|
|
|
|
const summary = { scanned: all.length, inScope: inScope.length, refreshed: 0, linked: 0, imported: 0, issues: 0 };
|
|
|
|
for (const ext of inScope) {
|
|
const snapshot = ext as unknown as Prisma.InputJsonValue;
|
|
const link = linkByCode.get(ext.code);
|
|
if (link) {
|
|
await this.prisma.accountingLink.update({
|
|
where: { id: link.id },
|
|
data: { externalName: ext.name, snapshot, syncedAt: new Date() },
|
|
});
|
|
summary.refreshed += 1;
|
|
continue;
|
|
}
|
|
|
|
if (!ext.mobile) {
|
|
await this.issue(provider, 'CUSTOMER_NO_MOBILE', ext.code, `حساب «${ext.name}» (کد ${ext.code}) موبایل معتبر ندارد`, ext);
|
|
summary.issues += 1;
|
|
continue;
|
|
}
|
|
const siblings = byMobile.get(ext.mobile) ?? [];
|
|
if (siblings.length > 1) {
|
|
await this.issue(
|
|
provider,
|
|
'CUSTOMER_DUPLICATE_MOBILE',
|
|
ext.code,
|
|
`موبایل ${ext.mobile} در ${siblings.length} حساب حسابداری تکرار شده (کدها: ${siblings.map((s) => s.code).join('، ')})`,
|
|
siblings,
|
|
);
|
|
summary.issues += 1;
|
|
continue;
|
|
}
|
|
|
|
const appCustomer = await this.prisma.customer.findUnique({ where: { mobile: ext.mobile }, select: { id: true } });
|
|
if (appCustomer) {
|
|
if (linkedCustomerIds.has(appCustomer.id)) {
|
|
await this.issue(
|
|
provider,
|
|
'CUSTOMER_LINK_CONFLICT',
|
|
ext.code,
|
|
`مشتری با موبایل ${ext.mobile} قبلاً به حساب دیگری لینک شده؛ حساب ${ext.code} لینک نشد`,
|
|
ext,
|
|
appCustomer.id,
|
|
);
|
|
summary.issues += 1;
|
|
continue;
|
|
}
|
|
await this.prisma.accountingLink.create({
|
|
data: { provider, entityType: 'CUSTOMER', entityId: appCustomer.id, externalCode: ext.code, externalName: ext.name, origin: 'LINKED', snapshot },
|
|
});
|
|
linkedCustomerIds.add(appCustomer.id);
|
|
summary.linked += 1;
|
|
continue;
|
|
}
|
|
|
|
const created = await this.importCustomer(provider, ext);
|
|
linkedCustomerIds.add(created);
|
|
summary.imported += 1;
|
|
}
|
|
|
|
return summary;
|
|
}
|
|
|
|
/**
|
|
* An account that exists only in the accounting software becomes an app
|
|
* customer, keyed by mobile (owner: «اولویت با شماره هست نه کد ملی»). The
|
|
* national code is the account's own when it is a real one and still free,
|
|
* else the mobile — the fallback admin-customers and customer-auth already use.
|
|
* Credit limit 0 and UNVERIFIED: everything beyond identity is the app's.
|
|
*/
|
|
private async importCustomer(provider: ProviderName, ext: ExternalCustomer): Promise<string> {
|
|
const mobile = ext.mobile as string;
|
|
const candidates = [validNationalCode(ext.nationalCode), mobile, `${mobile}-${provider}-${ext.code}`].filter(
|
|
(v): v is string => !!v,
|
|
);
|
|
let nationalCode = candidates[candidates.length - 1];
|
|
for (const c of candidates) {
|
|
const taken = await this.prisma.customer.findUnique({ where: { nationalCode: c }, select: { id: true } });
|
|
if (!taken) {
|
|
nationalCode = c;
|
|
break;
|
|
}
|
|
}
|
|
const { firstName, lastName } = splitName(ext.name || mobile);
|
|
|
|
return this.prisma.$transaction(async (tx) => {
|
|
const customer = await tx.customer.create({
|
|
data: {
|
|
// The DB trigger rebuilds fullName from the parts (schema.prisma).
|
|
fullName: [firstName, lastName].filter(Boolean).join(' ') || mobile,
|
|
firstName: firstName || null,
|
|
lastName: lastName || null,
|
|
mobile,
|
|
nationalCode,
|
|
passwordHash: '',
|
|
phone: ext.phone,
|
|
address: ext.address,
|
|
},
|
|
select: { id: true },
|
|
});
|
|
await tx.accountingLink.create({
|
|
data: {
|
|
provider,
|
|
entityType: 'CUSTOMER',
|
|
entityId: customer.id,
|
|
externalCode: ext.code,
|
|
externalName: ext.name,
|
|
origin: 'IMPORTED',
|
|
snapshot: ext as unknown as Prisma.InputJsonValue,
|
|
},
|
|
});
|
|
return customer.id;
|
|
});
|
|
}
|
|
|
|
private customerPayload(
|
|
c: { firstName: string | null; lastName: string | null; fullName: string; mobile: string; phone: string | null; address: string | null; nationalCode: string },
|
|
group: ExternalGroup | null,
|
|
): CustomerPayload {
|
|
const parts = c.firstName || c.lastName ? { firstName: c.firstName ?? '', lastName: c.lastName ?? '' } : splitName(c.fullName);
|
|
return {
|
|
firstName: parts.firstName,
|
|
lastName: parts.lastName,
|
|
fullName: c.fullName || [parts.firstName, parts.lastName].filter(Boolean).join(' '),
|
|
mobile: normalizeMobile(c.mobile) ?? c.mobile,
|
|
phone: c.phone,
|
|
address: c.address,
|
|
nationalCode: validNationalCode(c.nationalCode),
|
|
group: { id: group?.id || null, name: group?.name || null },
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Makes sure the customer has an account in the accounting software and
|
|
* returns its code: existing link → update it (when `update`); else search
|
|
* by mobile → link; else create → link. Search-before-create is what makes a
|
|
* retried push safe on both providers.
|
|
*/
|
|
async ensureCustomerAccount(a: ActiveProvider, customerId: string, opts: { update: boolean }) {
|
|
const provider = a.provider.name;
|
|
const customer = await this.prisma.customer.findUnique({ where: { id: customerId } });
|
|
if (!customer) throw new NeedsReviewError('مشتری در اپ پیدا نشد');
|
|
|
|
const link = await this.prisma.accountingLink.findUnique({
|
|
where: { provider_entityType_entityId: { provider, entityType: 'CUSTOMER', entityId: customerId } },
|
|
});
|
|
|
|
if (link) {
|
|
if (opts.update) {
|
|
// Keep the account in its current group: Zarrin moves it otherwise.
|
|
const snap = (link.snapshot ?? {}) as Partial<ExternalCustomer>;
|
|
const group = snap.groupName || snap.groupId ? { id: snap.groupId ?? '', name: snap.groupName ?? '' } : a.config.mapping.newCustomerGroup;
|
|
const payload = this.customerPayload(customer, group);
|
|
const res = await a.provider.updateCustomer(a.settings, link.externalCode, payload);
|
|
await this.prisma.accountingLink.update({ where: { id: link.id }, data: { syncedAt: new Date() } });
|
|
return { code: link.externalCode, request: { action: 'update', code: link.externalCode, payload }, response: res.response };
|
|
}
|
|
return { code: link.externalCode, request: { action: 'none' }, response: null };
|
|
}
|
|
|
|
const mobile = normalizeMobile(customer.mobile);
|
|
if (!mobile) throw new NeedsReviewError(`موبایل مشتری (${customer.mobile}) معتبر نیست`);
|
|
|
|
const matches = await a.provider.findCustomersByMobile(a.settings, mobile);
|
|
const free = [];
|
|
for (const m of matches) {
|
|
const taken = await this.prisma.accountingLink.findUnique({
|
|
where: { provider_entityType_externalCode: { provider, entityType: 'CUSTOMER', externalCode: m.code } },
|
|
select: { id: true },
|
|
});
|
|
if (!taken) free.push(m);
|
|
}
|
|
if (free.length > 1) {
|
|
throw new NeedsReviewError(
|
|
`موبایل ${mobile} در چند حساب حسابداری هست (کدها: ${free.map((m) => m.code).join('، ')}). از «لینک دستی» یکی را انتخاب کنید.`,
|
|
);
|
|
}
|
|
if (free.length === 1) {
|
|
const m = free[0];
|
|
await this.prisma.accountingLink.create({
|
|
data: { provider, entityType: 'CUSTOMER', entityId: customerId, externalCode: m.code, externalName: m.name, origin: 'LINKED', snapshot: m as unknown as Prisma.InputJsonValue },
|
|
});
|
|
return { code: m.code, request: { action: 'link', mobile }, response: m };
|
|
}
|
|
|
|
if (!a.config.mapping.newCustomerGroup) {
|
|
throw new NeedsReviewError('گروه مشتری جدید در تب «نگاشت» تعیین نشده؛ بعد از تعیین، «تلاش دوباره» را بزنید');
|
|
}
|
|
const payload = this.customerPayload(customer, a.config.mapping.newCustomerGroup);
|
|
const res = await a.provider.createCustomer(a.settings, payload);
|
|
await this.prisma.accountingLink.create({
|
|
data: {
|
|
provider,
|
|
entityType: 'CUSTOMER',
|
|
entityId: customerId,
|
|
externalCode: res.code,
|
|
externalName: payload.fullName,
|
|
origin: 'PUSHED',
|
|
snapshot: { ...payload, groupId: payload.group.id, groupName: payload.group.name } as unknown as Prisma.InputJsonValue,
|
|
},
|
|
});
|
|
return { code: res.code, request: { action: 'create', payload }, response: res.response };
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════
|
|
// F2 — balances
|
|
// ════════════════════════════════════════════════════════════════════
|
|
|
|
async pullBalances(a: ActiveProvider) {
|
|
const links = await this.prisma.accountingLink.findMany({
|
|
where: { provider: a.provider.name, entityType: 'CUSTOMER' },
|
|
select: { entityId: true, externalCode: true },
|
|
});
|
|
if (links.length === 0) return { linked: 0, updated: 0 };
|
|
const balances = await a.provider.getBalances(
|
|
a.settings,
|
|
links.map((l) => l.externalCode),
|
|
);
|
|
const byCode = new Map(balances.map((b) => [b.code, b]));
|
|
const now = new Date();
|
|
let updated = 0;
|
|
for (const l of links) {
|
|
const b = byCode.get(l.externalCode);
|
|
if (!b) continue;
|
|
await this.prisma.customer.update({
|
|
where: { id: l.entityId },
|
|
data: { accountingGoldBalance: b.goldGrams, accountingRialBalance: b.rial, accountingBalanceAt: now },
|
|
});
|
|
updated += 1;
|
|
}
|
|
return { linked: links.length, updated };
|
|
}
|
|
|
|
/**
|
|
* The balance the customer sees (D27): the accounting software's, refreshed
|
|
* on demand when older than a minute, with a short timeout so the app never
|
|
* waits on a slow installation — on any failure the last stored figure is
|
|
* served. Null when the integration does not apply to this customer.
|
|
*/
|
|
async customerBalance(customerId: string) {
|
|
const a = await this.config.activeFor('balances');
|
|
if (!a) return null;
|
|
const link = await this.prisma.accountingLink.findUnique({
|
|
where: { provider_entityType_entityId: { provider: a.provider.name, entityType: 'CUSTOMER', entityId: customerId } },
|
|
});
|
|
if (!link) return null;
|
|
|
|
let c = await this.prisma.customer.findUnique({
|
|
where: { id: customerId },
|
|
select: { accountingGoldBalance: true, accountingRialBalance: true, accountingBalanceAt: true },
|
|
});
|
|
const stale = !c?.accountingBalanceAt || Date.now() - c.accountingBalanceAt.getTime() > BALANCE_FRESH_MS;
|
|
if (stale) {
|
|
try {
|
|
const [b] = await a.provider.getBalances(
|
|
{ ...a.settings, timeoutMs: Math.min(a.settings.timeoutMs, BALANCE_ON_DEMAND_TIMEOUT_MS) },
|
|
[link.externalCode],
|
|
);
|
|
if (b) {
|
|
c = await this.prisma.customer.update({
|
|
where: { id: customerId },
|
|
data: { accountingGoldBalance: b.goldGrams, accountingRialBalance: b.rial, accountingBalanceAt: new Date() },
|
|
select: { accountingGoldBalance: true, accountingRialBalance: true, accountingBalanceAt: true },
|
|
});
|
|
}
|
|
} catch (err) {
|
|
this.logger.warn(`On-demand balance for ${customerId} failed, serving the stored one: ${(err as Error).message}`);
|
|
}
|
|
}
|
|
if (!c?.accountingBalanceAt) return null;
|
|
return {
|
|
gold: c.accountingGoldBalance ?? new Prisma.Decimal(0),
|
|
rial: c.accountingRialBalance ?? new Prisma.Decimal(0),
|
|
at: c.accountingBalanceAt,
|
|
provider: a.provider.label,
|
|
};
|
|
}
|
|
|
|
|
|
// ════════════════════════════════════════════════════════════════════
|
|
// Real-time: change poll + live reads
|
|
// ════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* One poll of the software's change feed. Only the accounts and کارساخته
|
|
* that changed are re-read, so this is cheap enough to run every few
|
|
* seconds. Returns the new cursor and what the slow jobs should still do.
|
|
*/
|
|
async pollChanges(a: ActiveProvider, cursor: string | null) {
|
|
const batch = await a.provider.getChanges(a.settings, cursor);
|
|
const out = { cursor: batch.cursor, balances: 0, stocks: 0, fullBalances: false, fullProducts: false, newAccounts: false };
|
|
if (cursor === null) return out;
|
|
const provider = a.provider.name;
|
|
|
|
if (a.config.features.balances) {
|
|
if (batch.overflow) out.fullBalances = true;
|
|
else if (batch.accountCodes.length > 0) {
|
|
const links = await this.prisma.accountingLink.findMany({
|
|
where: { provider, entityType: 'CUSTOMER', externalCode: { in: batch.accountCodes } },
|
|
select: { entityId: true, externalCode: true },
|
|
});
|
|
out.newAccounts = links.length < batch.accountCodes.length;
|
|
if (links.length > 0) out.balances = await this.storeBalances(a, links);
|
|
}
|
|
}
|
|
|
|
if (a.config.features.products) {
|
|
if (batch.overflow) out.fullProducts = true;
|
|
else if (batch.itemNames.length > 0) {
|
|
const links = await this.prisma.accountingLink.findMany({
|
|
where: { provider, entityType: 'PRODUCT', externalCode: { in: batch.itemNames } },
|
|
select: { entityId: true, externalCode: true },
|
|
});
|
|
// A name with no product yet is a new کارساخته: the product pull imports it.
|
|
if (links.length < batch.itemNames.length) out.fullProducts = true;
|
|
for (const l of links) {
|
|
if (await this.storeStock(a, l.entityId, l.externalCode, l.externalCode)) out.stocks += 1;
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Reads the balances of these linked customers and stores them. */
|
|
private async storeBalances(a: ActiveProvider, links: { entityId: string; externalCode: string }[]) {
|
|
const balances = await a.provider.getBalances(a.settings, links.map((l) => l.externalCode));
|
|
const byCode = new Map(balances.map((b) => [b.code, b]));
|
|
const now = new Date();
|
|
let n = 0;
|
|
for (const l of links) {
|
|
const b = byCode.get(l.externalCode);
|
|
if (!b) continue;
|
|
await this.prisma.customer.update({
|
|
where: { id: l.entityId },
|
|
data: { accountingGoldBalance: b.goldGrams, accountingRialBalance: b.rial, accountingBalanceAt: now },
|
|
});
|
|
n += 1;
|
|
}
|
|
return n;
|
|
}
|
|
|
|
private async storeStock(a: ActiveProvider, productId: string, code: string, name: string, timeoutMs?: number) {
|
|
const settings = timeoutMs ? { ...a.settings, timeoutMs: Math.min(a.settings.timeoutMs, timeoutMs) } : a.settings;
|
|
const stock = await a.provider.getKarSakhteStock(settings, { code, name });
|
|
const known = stock.weight !== null || stock.count !== null;
|
|
await this.prisma.product.update({
|
|
where: { id: productId },
|
|
data: {
|
|
accountingStockWeight: stock.weight,
|
|
accountingStockCount: stock.count,
|
|
accountingStockAt: known ? new Date() : null,
|
|
},
|
|
});
|
|
return known;
|
|
}
|
|
|
|
/**
|
|
* At order time: the product's stock straight from the accounting software.
|
|
* If it cannot answer in time the order goes ahead on the last stored figure
|
|
* (owner's ruling) and the next change poll corrects it. Returns the product
|
|
* fields stockBlocked() reads, fresh when it could be.
|
|
*/
|
|
async liveStock<T extends { id: string; accountingStockAt: Date | null; accountingStockWeight: Prisma.Decimal | null; accountingStockCount: Prisma.Decimal | null }>(
|
|
product: T,
|
|
): Promise<T> {
|
|
const a = await this.config.activeFor('products');
|
|
if (!a) return product;
|
|
const link = await this.prisma.accountingLink.findUnique({
|
|
where: { provider_entityType_entityId: { provider: a.provider.name, entityType: 'PRODUCT', entityId: product.id } },
|
|
select: { externalCode: true, externalName: true },
|
|
});
|
|
if (!link) return product;
|
|
try {
|
|
await this.storeStock(a, product.id, link.externalCode, link.externalName ?? link.externalCode, STOCK_ON_DEMAND_TIMEOUT_MS);
|
|
const fresh = await this.prisma.product.findUnique({
|
|
where: { id: product.id },
|
|
select: { accountingStockAt: true, accountingStockWeight: true, accountingStockCount: true },
|
|
});
|
|
return fresh ? { ...product, ...fresh } : product;
|
|
} catch (err) {
|
|
this.logger.warn(`Live stock of product ${product.id} unavailable, using the stored figure: ${(err as Error).message}`);
|
|
return product;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The app's profile (plan §12): this one customer's balance, read from the
|
|
* accounting software on every call — no freshness window, no feature
|
|
* switch, only a connected provider. On no answer the stored figure is
|
|
* served and labelled as such.
|
|
*/
|
|
async customerBalanceLive(customerId: string) {
|
|
const none = { source: 'none' as const, gold: null, rial: null, at: null, provider: null };
|
|
const a = await this.config.active();
|
|
if (!a) return none;
|
|
const link = await this.prisma.accountingLink.findUnique({
|
|
where: { provider_entityType_entityId: { provider: a.provider.name, entityType: 'CUSTOMER', entityId: customerId } },
|
|
select: { externalCode: true },
|
|
});
|
|
if (!link) return none;
|
|
try {
|
|
const [b] = await a.provider.getBalances(
|
|
{ ...a.settings, timeoutMs: Math.min(a.settings.timeoutMs, BALANCE_ON_DEMAND_TIMEOUT_MS) },
|
|
[link.externalCode],
|
|
);
|
|
if (b) {
|
|
const at = new Date();
|
|
await this.prisma.customer.update({
|
|
where: { id: customerId },
|
|
data: { accountingGoldBalance: b.goldGrams, accountingRialBalance: b.rial, accountingBalanceAt: at },
|
|
});
|
|
return { source: 'accounting' as const, gold: b.goldGrams.toString(), rial: b.rial.toString(), at, provider: a.provider.label };
|
|
}
|
|
} catch (err) {
|
|
this.logger.warn(`Live balance for ${customerId} failed, serving the stored one: ${(err as Error).message}`);
|
|
}
|
|
const c = await this.prisma.customer.findUnique({
|
|
where: { id: customerId },
|
|
select: { accountingGoldBalance: true, accountingRialBalance: true, accountingBalanceAt: true },
|
|
});
|
|
if (!c?.accountingBalanceAt) return none;
|
|
return {
|
|
source: 'stored' as const,
|
|
gold: (c.accountingGoldBalance ?? 0).toString(),
|
|
rial: (c.accountingRialBalance ?? 0).toString(),
|
|
at: c.accountingBalanceAt,
|
|
provider: a.provider.label,
|
|
};
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════
|
|
// F6 — statement («گزارش»)
|
|
// ════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* The customer's statement from the accounting software, oldest first, or
|
|
* null when the integration does not apply (the report then falls back to
|
|
* the internal ledger). Cached for a minute per customer so paging through
|
|
* the report costs one call.
|
|
*/
|
|
async customerStatement(customerId: string, range?: { from?: string; to?: string }) {
|
|
const a = await this.config.activeFor('report');
|
|
if (!a) return null;
|
|
const link = await this.prisma.accountingLink.findUnique({
|
|
where: { provider_entityType_entityId: { provider: a.provider.name, entityType: 'CUSTOMER', entityId: customerId } },
|
|
});
|
|
if (!link) return null;
|
|
|
|
const to: JalaliDate = parseJalali(range?.to) ?? toJalali(new Date());
|
|
const from: JalaliDate =
|
|
parseJalali(range?.from) ?? toJalali(new Date(Date.now() - a.config.report.windowDays * 24 * 3600 * 1000));
|
|
const key = `${a.provider.name}:${link.externalCode}:${from.jy}-${from.jm}-${from.jd}:${to.jy}-${to.jm}-${to.jd}`;
|
|
const hit = this.statementCache.get(key);
|
|
if (hit && Date.now() - hit.at < STATEMENT_CACHE_MS) return { rows: hit.rows, code: link.externalCode, provider: a.provider.label };
|
|
|
|
const rows = await a.provider.getStatement(a.settings, { code: link.externalCode, from, to });
|
|
// SEC-16: never hand back a line that belongs to another account, whatever
|
|
// the software returned.
|
|
const own = rows.filter((r) => !r.accountCode || r.accountCode === link.externalCode);
|
|
this.statementCache.set(key, { at: Date.now(), rows: own });
|
|
if (this.statementCache.size > 500) this.statementCache.delete(this.statementCache.keys().next().value as string);
|
|
return { rows: own, code: link.externalCode, provider: a.provider.label };
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════
|
|
// F4 — کارساخته ↔ products
|
|
// ════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* Accounting items → app products. A new item becomes a product with no
|
|
* image and no variant, so it stays out of the app (F5) until an admin
|
|
* photographs it and adds its weights. Title, code and stock follow the
|
|
* accounting software; everything else is the app's (D27).
|
|
*/
|
|
async pullProducts(a: ActiveProvider) {
|
|
const provider = a.provider.name;
|
|
const items = await a.provider.listKarSakhte(a.settings);
|
|
const links = await this.prisma.accountingLink.findMany({ where: { provider, entityType: 'PRODUCT' } });
|
|
const linkByCode = new Map(links.map((l) => [l.externalCode, l]));
|
|
const linkedProductIds = new Set(links.map((l) => l.entityId));
|
|
const summary = { items: items.length, updated: 0, linked: 0, imported: 0, stockErrors: 0, issues: 0 };
|
|
const now = new Date();
|
|
const touched = new Set<string>();
|
|
|
|
for (const item of items) {
|
|
let stock = { weight: item.stockWeight, count: item.stockCount };
|
|
if (stock.weight === null && stock.count === null) {
|
|
try {
|
|
stock = await a.provider.getKarSakhteStock(a.settings, item);
|
|
} catch (err) {
|
|
summary.stockErrors += 1;
|
|
this.logger.warn(`Stock of «${item.name}» unavailable: ${(err as Error).message}`);
|
|
}
|
|
}
|
|
const stockData = {
|
|
accountingStockWeight: stock.weight,
|
|
accountingStockCount: stock.count,
|
|
accountingStockAt: stock.weight !== null || stock.count !== null ? now : null,
|
|
};
|
|
const snapshot = item as unknown as Prisma.InputJsonValue;
|
|
|
|
const link = linkByCode.get(item.code);
|
|
if (link) {
|
|
await this.applyItemToProduct(provider, link.entityId, item, stockData);
|
|
await this.prisma.accountingLink.update({
|
|
where: { id: link.id },
|
|
data: { externalName: item.name, snapshot, syncedAt: now },
|
|
});
|
|
touched.add(link.entityId);
|
|
summary.updated += 1;
|
|
continue;
|
|
}
|
|
|
|
// A product an admin created by the same name (in Tahesab an item only
|
|
// appears after its first document, which is how it gets linked).
|
|
const sameName = await this.prisma.product.findMany({ where: { title: item.name }, select: { id: true } });
|
|
const candidates = sameName.filter((p) => !linkedProductIds.has(p.id));
|
|
if (candidates.length > 1) {
|
|
await this.issue(provider, 'PRODUCT_DUPLICATE_TITLE', item.code, `چند محصول با عنوان «${item.name}» هست؛ کارساخته لینک نشد`, item);
|
|
summary.issues += 1;
|
|
continue;
|
|
}
|
|
if (candidates.length === 1) {
|
|
const productId = candidates[0].id;
|
|
await this.prisma.accountingLink.create({
|
|
data: { provider, entityType: 'PRODUCT', entityId: productId, externalCode: item.code, externalName: item.name, origin: 'LINKED', snapshot },
|
|
});
|
|
await this.applyItemToProduct(provider, productId, item, stockData);
|
|
linkedProductIds.add(productId);
|
|
touched.add(productId);
|
|
summary.linked += 1;
|
|
continue;
|
|
}
|
|
|
|
const productId = await this.prisma.$transaction(async (tx) => {
|
|
const product = await tx.product.create({
|
|
data: { title: item.name, ...stockData },
|
|
select: { id: true },
|
|
});
|
|
await tx.accountingLink.create({
|
|
data: { provider, entityType: 'PRODUCT', entityId: product.id, externalCode: item.code, externalName: item.name, origin: 'IMPORTED', snapshot },
|
|
});
|
|
return product.id;
|
|
});
|
|
await this.setProductCode(provider, productId, item);
|
|
linkedProductIds.add(productId);
|
|
touched.add(productId);
|
|
summary.imported += 1;
|
|
}
|
|
|
|
// Stock figures only mean something for products linked under the active
|
|
// provider; anything else (unlinked, or from a provider switched away
|
|
// from) must not keep blocking orders.
|
|
await this.prisma.product.updateMany({
|
|
where: { accountingStockAt: { not: null }, id: { notIn: [...touched] } },
|
|
data: { accountingStockWeight: null, accountingStockCount: null, accountingStockAt: null },
|
|
});
|
|
|
|
return summary;
|
|
}
|
|
|
|
private async applyItemToProduct(
|
|
provider: ProviderName,
|
|
productId: string,
|
|
item: ExternalKarSakhte,
|
|
stockData: Prisma.ProductUpdateInput,
|
|
) {
|
|
await this.prisma.product.update({ where: { id: productId }, data: { title: item.name, ...stockData } });
|
|
await this.setProductCode(provider, productId, item);
|
|
}
|
|
|
|
/**
|
|
* Zarrin items have a numeric id, which becomes the product's code. Tahesab
|
|
* items have none (the name is the key), so the code stays the app's.
|
|
*/
|
|
private async setProductCode(provider: ProviderName, productId: string, item: ExternalKarSakhte) {
|
|
if (provider !== 'zarrin') return;
|
|
const owner = await this.prisma.product.findUnique({ where: { productCode: item.code }, select: { id: true } });
|
|
if (owner && owner.id !== productId) {
|
|
await this.issue(provider, 'PRODUCT_CODE_TAKEN', item.code, `کد ${item.code} روی محصول دیگری است؛ کد محصول «${item.name}» عوض نشد`, item, productId);
|
|
return;
|
|
}
|
|
if (!owner) await this.prisma.product.update({ where: { id: productId }, data: { productCode: item.code } });
|
|
}
|
|
|
|
/**
|
|
* Makes sure a product has its کارساخته: existing link → done; an item by
|
|
* the same name → link; else create it (Zarrin). Tahesab cannot create items
|
|
* through its API — the item comes into being with the first document under
|
|
* the product's title, and the next pull links it by name.
|
|
*/
|
|
async ensureProductItem(a: ActiveProvider, productId: string): Promise<{ code: string; name: string; created: boolean } | null> {
|
|
const provider = a.provider.name;
|
|
const link = await this.prisma.accountingLink.findUnique({
|
|
where: { provider_entityType_entityId: { provider, entityType: 'PRODUCT', entityId: productId } },
|
|
});
|
|
const product = await this.prisma.product.findUnique({ where: { id: productId }, select: { id: true, title: true, categoryId: true } });
|
|
if (!product) throw new NeedsReviewError('محصول در اپ پیدا نشد');
|
|
if (link) return { code: link.externalCode, name: link.externalName ?? product.title, created: false };
|
|
|
|
if (!a.provider.capabilities.createKarSakhte) return null;
|
|
|
|
const existing = (await this.itemsCached(a)).filter((i) => i.name === product.title);
|
|
for (const item of existing) {
|
|
const taken = await this.prisma.accountingLink.findUnique({
|
|
where: { provider_entityType_externalCode: { provider, entityType: 'PRODUCT', externalCode: item.code } },
|
|
select: { id: true },
|
|
});
|
|
if (!taken) {
|
|
await this.prisma.accountingLink.create({
|
|
data: { provider, entityType: 'PRODUCT', entityId: productId, externalCode: item.code, externalName: item.name, origin: 'LINKED', snapshot: item as unknown as Prisma.InputJsonValue },
|
|
});
|
|
await this.setProductCode(provider, productId, item);
|
|
return { code: item.code, name: item.name, created: false };
|
|
}
|
|
}
|
|
|
|
const m = a.config.mapping;
|
|
const group = (product.categoryId && m.categoryGroups.find((x) => x.categoryId === product.categoryId)?.group) || m.newKarSakhteGroup;
|
|
if (!group?.id) throw new NeedsReviewError('گروه ساخته برای این دستهبندی/پیشفرض در تنظیمات حسابداری تعیین نشده');
|
|
if (!m.newKarSakhteType?.id) throw new NeedsReviewError('نوع کارساختهی پیشفرض در تنظیمات حسابداری تعیین نشده');
|
|
|
|
const res = await a.provider.createKarSakhte(a.settings, { name: product.title, groupId: group.id, typeId: m.newKarSakhteType.id });
|
|
const item: ExternalKarSakhte = {
|
|
code: res.code,
|
|
name: product.title,
|
|
groupId: group.id,
|
|
groupName: group.name,
|
|
typeId: m.newKarSakhteType.id,
|
|
typeName: m.newKarSakhteType.name,
|
|
saleWagePercent: null,
|
|
stockWeight: null,
|
|
stockCount: null,
|
|
};
|
|
await this.prisma.accountingLink.create({
|
|
data: { provider, entityType: 'PRODUCT', entityId: productId, externalCode: res.code, externalName: product.title, origin: 'PUSHED', snapshot: item as unknown as Prisma.InputJsonValue },
|
|
});
|
|
await this.setProductCode(provider, productId, item);
|
|
return { code: res.code, name: product.title, created: true };
|
|
}
|
|
|
|
/** Zarrin's listKarSakhte takes seconds; an order with several lines needs it once. */
|
|
private async itemsCached(a: ActiveProvider) {
|
|
const c = this.itemCache;
|
|
if (c && c.provider === a.provider.name && Date.now() - c.at < STATEMENT_CACHE_MS) return c.items;
|
|
const items = await a.provider.listKarSakhte(a.settings);
|
|
this.itemCache = { provider: a.provider.name, at: Date.now(), items };
|
|
return items;
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════
|
|
// F3 — order documents
|
|
// ════════════════════════════════════════════════════════════════════
|
|
|
|
async buildOrderDocument(a: ActiveProvider, orderId: string) {
|
|
const order = await this.prisma.order.findUnique({
|
|
where: { id: orderId },
|
|
include: { items: { include: { productVariant: { include: { product: true } } } } },
|
|
});
|
|
if (!order) throw new NeedsReviewError('سفارش در اپ پیدا نشد');
|
|
if (order.items.length === 0) throw new NeedsReviewError('سفارش قلمی ندارد');
|
|
|
|
const customer = await this.ensureCustomerAccount(a, order.customerId, { update: false });
|
|
|
|
const lines: DocumentLine[] = [];
|
|
let total = new Prisma.Decimal(0);
|
|
for (const item of order.items) {
|
|
const product = item.productVariant.product;
|
|
let ref: { code: string; name: string };
|
|
const ensured = await this.ensureProductItem(a, product.id);
|
|
if (ensured) ref = ensured;
|
|
else {
|
|
// Tahesab: the item is identified by name and created by this document.
|
|
ref = { code: product.title, name: product.title };
|
|
}
|
|
const qty = item.quantity;
|
|
const wage = new Prisma.Decimal(item.wagePercentSnapshot).sub(item.discountPercentSnapshot);
|
|
const weight = new Prisma.Decimal(item.rashWeightGramsSnapshot).mul(qty);
|
|
total = total.add(new Prisma.Decimal(item.computedWeightGrams).mul(qty));
|
|
lines.push({
|
|
itemCode: ref.code,
|
|
itemName: ref.name,
|
|
weightGrams: weight,
|
|
count: qty,
|
|
wagePercent: wage,
|
|
flatRial: new Prisma.Decimal(item.flatRialCharge).mul(qty),
|
|
karat: a.config.mapping.karat,
|
|
description: `${product.title} — ${item.productVariant.title} — سفارش ${order.orderNumber} — اجرت ${wage.toString()}٪`,
|
|
});
|
|
}
|
|
|
|
// Acceptance rule of F3: the gold the document moves must equal the
|
|
// order's own total, or the two books disagree from the first day.
|
|
const docGold = lines.reduce(
|
|
(s, l) => s.add(l.weightGrams.mul(new Prisma.Decimal(1).add(l.wagePercent.div(100)))),
|
|
new Prisma.Decimal(0),
|
|
);
|
|
const mismatch = docGold.sub(order.totalGoldWeightGrams).abs();
|
|
if (mismatch.gt('0.01')) {
|
|
throw new NeedsReviewError(
|
|
`جمع وزن سند (${docGold.toFixed(3)}) با وزن سفارش (${new Prisma.Decimal(order.totalGoldWeightGrams).toFixed(3)}) نمیخواند`,
|
|
);
|
|
}
|
|
|
|
return {
|
|
order,
|
|
input: {
|
|
factorCode: factorCodeFor(order.id),
|
|
customerCode: customer.code,
|
|
date: order.deliveredAt ?? new Date(),
|
|
description: `سفارش ${order.orderNumber} از اپ`,
|
|
temporary: true, // D27: the accountant finalises
|
|
lines,
|
|
},
|
|
check: { orderTotal: new Prisma.Decimal(order.totalGoldWeightGrams).toFixed(3), documentGold: docGold.toFixed(3), linesTotal: total.toFixed(3) },
|
|
};
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════
|
|
// outbox jobs
|
|
// ════════════════════════════════════════════════════════════════════
|
|
|
|
/** Runs one outbox job. Throws to signal failure; the worker decides retry vs review. */
|
|
async runJob(a: ActiveProvider, job: AccountingOutbox): Promise<{ externalRef?: string | null; request?: unknown; response?: unknown }> {
|
|
switch (job.kind) {
|
|
case 'CUSTOMER_PUSH': {
|
|
const res = await this.ensureCustomerAccount(a, job.entityId, { update: true });
|
|
return { externalRef: res.code, request: res.request, response: res.response };
|
|
}
|
|
|
|
case 'PRODUCT_PUSH': {
|
|
const res = await this.ensureProductItem(a, job.entityId);
|
|
return {
|
|
externalRef: res?.code ?? null,
|
|
request: { productId: job.entityId },
|
|
response: res ?? { note: `${a.provider.label} ساخت کارساخته ندارد؛ با اولین سند ساخته میشود` },
|
|
};
|
|
}
|
|
|
|
case 'ORDER_DOCUMENT': {
|
|
const doc = await this.buildOrderDocument(a, job.entityId);
|
|
const request = { ...doc.input, lines: doc.input.lines.map((l) => ({ ...l, weightGrams: l.weightGrams.toFixed(3), wagePercent: l.wagePercent.toString(), flatRial: l.flatRial.toFixed(0) })), check: doc.check };
|
|
await this.prisma.accountingOutbox.update({ where: { id: job.id }, data: { lastRequest: request as unknown as Prisma.InputJsonValue } });
|
|
const result = await a.provider.postOrderDocument(
|
|
a.settings,
|
|
doc.input,
|
|
(job.progress ?? {}) as Record<string, unknown>,
|
|
async (p) => {
|
|
await this.prisma.accountingOutbox.update({ where: { id: job.id }, data: { progress: p as unknown as Prisma.InputJsonValue } });
|
|
},
|
|
);
|
|
return { externalRef: result.ref, request, response: result.response };
|
|
}
|
|
|
|
case 'ORDER_DOCUMENT_DELETE': {
|
|
const sent = await this.prisma.accountingOutbox.findFirst({
|
|
where: { provider: job.provider, kind: 'ORDER_DOCUMENT', entityId: job.entityId, status: { in: ['SENT', 'SENDING', 'NEEDS_REVIEW', 'FAILED'] } },
|
|
orderBy: { createdAt: 'desc' },
|
|
});
|
|
if (!sent) return { response: { note: 'سندی برای حذف ثبت نشده بود' } };
|
|
// The order row may be gone (deleted from the admin panel); the sent
|
|
// document's own request says which account it went to.
|
|
const order = await this.prisma.order.findUnique({ where: { id: job.entityId }, select: { customerId: true, deliveredAt: true } });
|
|
let customerCode = (sent.lastRequest as { customerCode?: string } | null)?.customerCode ?? null;
|
|
if (!customerCode && order) {
|
|
const link = await this.prisma.accountingLink.findUnique({
|
|
where: { provider_entityType_entityId: { provider: job.provider, entityType: 'CUSTOMER', entityId: order.customerId } },
|
|
});
|
|
customerCode = link?.externalCode ?? null;
|
|
}
|
|
if (!customerCode) throw new NeedsReviewError('حساب مشتری در حسابداری معلوم نیست؛ سند را دستی حذف کنید');
|
|
const link = { externalCode: customerCode };
|
|
const factorCode = factorCodeFor(job.entityId);
|
|
|
|
// Only a document still temporary may be deleted automatically (plan
|
|
// F3). Tahesab reports that per row; Zarrin cannot, so a person checks.
|
|
if (job.provider === 'tahesab') {
|
|
// The document is dated when it was built (or at delivery, if earlier).
|
|
const posted = order?.deliveredAt && order.deliveredAt < sent.createdAt ? order.deliveredAt : sent.createdAt;
|
|
const date = toJalali(posted);
|
|
const rows = await a.provider.getStatement(a.settings, { code: link.externalCode, from: date, to: toJalali(new Date()) });
|
|
const docRows = rows.filter((r) => r.documentRef === factorCode);
|
|
if (docRows.some((r) => !r.temporary)) {
|
|
throw new NeedsReviewError('سند در حسابداری نهایی شده؛ حذف خودکار انجام نشد. حسابدار باید دستی اصلاح کند.');
|
|
}
|
|
} else {
|
|
throw new NeedsReviewError(
|
|
`${a.provider.label} وضعیت موقت/نهایی سند را گزارش نمیدهد؛ سند ${sent.externalRef ?? ''} را حسابدار بررسی و حذف کند.`,
|
|
);
|
|
}
|
|
const res = await a.provider.deleteDocument(a.settings, { factorCode, ref: sent.externalRef, customerCode: link.externalCode });
|
|
await this.prisma.accountingOutbox.update({
|
|
where: { id: sent.id },
|
|
data: { status: 'RESOLVED', lastError: 'سند بهخاطر لغو سفارش حذف شد' },
|
|
});
|
|
return { externalRef: sent.externalRef, request: { factorCode }, response: res.response };
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Is a job whose call may or may not have landed safe to send again?
|
|
* Customer/product pushes search before they create; Tahesab rejects a
|
|
* repeated document row; a Zarrin document has no such guard.
|
|
*/
|
|
isRetrySafe(provider: ProviderName, kind: AccountingOutbox['kind']) {
|
|
if (kind === 'ORDER_DOCUMENT') return provider === 'tahesab';
|
|
return true;
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════
|
|
|
|
async issue(provider: ProviderName, kind: string, externalCode: string, message: string, data?: unknown, entityId?: string) {
|
|
const json =
|
|
data === undefined || data === null ? Prisma.JsonNull : (JSON.parse(JSON.stringify(data)) as Prisma.InputJsonValue);
|
|
await this.prisma.accountingIssue.upsert({
|
|
where: { provider_kind_externalCode: { provider, kind, externalCode } },
|
|
update: { message, data: json, entityId: entityId ?? null, resolvedAt: null },
|
|
create: { provider, kind, externalCode, message, data: json, entityId: entityId ?? null },
|
|
});
|
|
}
|
|
|
|
describeError(err: unknown) {
|
|
if (err instanceof AccountingProviderError) return err.message;
|
|
if (err instanceof NeedsReviewError) return err.message;
|
|
return (err as Error)?.message ?? String(err);
|
|
}
|
|
|
|
/** For callers that only need to know the configuration. */
|
|
activeConfig(): Promise<AccountingConfig> {
|
|
return this.config.get();
|
|
}
|
|
}
|