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
@@ -1,6 +1,9 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { AccountingSyncService } from '../accounting-sync/accounting-sync.service';
|
||||
import { jalaliToGregorian, parseJalali } from '../accounting-sync/jalali';
|
||||
import type { StatementRow } from '../accounting-sync/providers';
|
||||
|
||||
/**
|
||||
* "گزارش" — the customer's account statement.
|
||||
@@ -30,7 +33,26 @@ import { PrismaService } from '../../prisma/prisma.service';
|
||||
*/
|
||||
@Injectable()
|
||||
export class CustomerReportService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
private readonly logger = new Logger(CustomerReportService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly accounting: AccountingSyncService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The accounting statement, or null to fall back to the internal ledger —
|
||||
* when the integration is off, the customer is not linked, or the software
|
||||
* cannot be reached (the report must still open).
|
||||
*/
|
||||
private async accountingStatement(customerId: string) {
|
||||
try {
|
||||
return await this.accounting.customerStatement(customerId);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Accounting statement unavailable for ${customerId}, using the ledger: ${(err as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The reference's page size (report/index.vue:96). */
|
||||
private static readonly LIMIT = 50;
|
||||
@@ -51,6 +73,21 @@ export class CustomerReportService {
|
||||
* display (report/index.vue:191), so the app shows newest first. Same here.
|
||||
*/
|
||||
async summary(customerId: string, page = 1) {
|
||||
// D27 — from the accounting software when the integration covers this
|
||||
// customer, so the statement agrees with the balance they are shown.
|
||||
const statement = await this.accountingStatement(customerId);
|
||||
if (statement) {
|
||||
const rows = statementToSummary(statement.rows).reverse();
|
||||
const start = (Math.max(1, page) - 1) * CustomerReportService.LIMIT;
|
||||
return {
|
||||
result: rows.slice(start, start + CustomerReportService.LIMIT),
|
||||
count: rows.length,
|
||||
page: Math.max(1, page),
|
||||
limit: CustomerReportService.LIMIT,
|
||||
source: 'accounting',
|
||||
};
|
||||
}
|
||||
|
||||
const entries = await this.prisma.ledgerEntry.findMany({
|
||||
// Only confirmed entries move a balance — the same rule the cached
|
||||
// Customer.goldDebtGrams / fiatDebtRial follow, so the statement's final
|
||||
@@ -129,6 +166,22 @@ export class CustomerReportService {
|
||||
* from the client beyond being a filter.
|
||||
*/
|
||||
async detail(customerId: string, entryId?: string, page = 1) {
|
||||
const statement = await this.accountingStatement(customerId);
|
||||
if (statement) {
|
||||
// SEC-16 holds: the rows are already limited to this customer's own
|
||||
// account (AccountingSyncService.customerStatement), and the id is only a
|
||||
// filter over them — another customer's document id matches nothing.
|
||||
let rows = statement.rows.map(statementToDetail).reverse();
|
||||
if (entryId) rows = rows.filter((r) => r.factor_Code === entryId);
|
||||
const start = (Math.max(1, page) - 1) * CustomerReportService.LIMIT;
|
||||
return {
|
||||
result: entryId ? rows : rows.slice(start, start + CustomerReportService.LIMIT),
|
||||
page: Math.max(1, page),
|
||||
limit: CustomerReportService.LIMIT,
|
||||
source: 'accounting',
|
||||
};
|
||||
}
|
||||
|
||||
const where: Prisma.LedgerEntryWhereInput = {
|
||||
customerId,
|
||||
confirmed: true,
|
||||
@@ -215,6 +268,22 @@ export class CustomerReportService {
|
||||
* flip is applied and the figure matches every other screen.
|
||||
*/
|
||||
async balances(customerId: string) {
|
||||
// D27 — the accounting software's balance when the integration covers
|
||||
// this customer; the same figure /customer/me shows.
|
||||
const accounting = await this.accounting.customerBalance(customerId);
|
||||
if (accounting) {
|
||||
return {
|
||||
status: 200,
|
||||
message: '',
|
||||
source: 'accounting',
|
||||
at: accounting.at,
|
||||
result: [
|
||||
{ arz: 'IRR', mandeh: accounting.rial.toFixed(2) },
|
||||
{ arz: 'gold', mandeh: accounting.gold.toFixed(3) },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const c = await this.prisma.customer.findUnique({
|
||||
where: { id: customerId },
|
||||
select: { goldDebtGrams: true, fiatDebtRial: true },
|
||||
@@ -242,3 +311,82 @@ const SOURCE_LABELS: Record<string, string> = {
|
||||
WALLET_BONUS: 'هدیه',
|
||||
ADJUSTMENT: 'اصلاحیه',
|
||||
};
|
||||
|
||||
// ── D27: accounting statement → the report's existing shape ─────────────
|
||||
//
|
||||
// The apps bind the reference's field names (henza_pwa/src/type/accounting.ts)
|
||||
// and format `tarikh` as an ISO instant, so the accounting rows are mapped onto
|
||||
// exactly what the ledger-based report already returns.
|
||||
|
||||
/** `1405/07/12` + `20:20:41` → ISO in Tehran time, which the apps' jdate() reads. */
|
||||
function isoFromJalali(date: string, time: string | null): string {
|
||||
const j = parseJalali(date);
|
||||
if (!j) return date;
|
||||
const g = jalaliToGregorian(j.jy, j.jm, j.jd);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const t = time && /^\d{1,2}:\d{2}(:\d{2})?$/.test(time) ? (time.length === 5 ? `${time}:00` : time) : '12:00:00';
|
||||
return `${g.gy}-${pad(g.gm)}-${pad(g.gd)}T${t.padStart(8, '0')}+03:30`;
|
||||
}
|
||||
|
||||
const fixed = (d: Prisma.Decimal | null, places: number) => (d ?? new Prisma.Decimal(0)).toFixed(places);
|
||||
|
||||
/**
|
||||
* Oldest first, with a running balance on every row. The software's own
|
||||
* running balance wins where it reports one; a row without one (a temporary
|
||||
* Tahesab document, which moves nothing yet) carries the previous figure.
|
||||
*/
|
||||
function statementToSummary(rows: StatementRow[]) {
|
||||
let gold = new Prisma.Decimal(0);
|
||||
let rial = new Prisma.Decimal(0);
|
||||
return rows.map((r, index) => {
|
||||
if (r.goldBalance) gold = r.goldBalance;
|
||||
else if (!r.temporary) gold = gold.add(r.goldOut).sub(r.goldIn);
|
||||
if (r.rialBalance) rial = r.rialBalance;
|
||||
else if (!r.temporary) rial = rial.add(r.rialOut).sub(r.rialIn);
|
||||
return {
|
||||
id: index + 1,
|
||||
factor_Code: r.documentRef || r.key,
|
||||
sh_factor: r.documentNo,
|
||||
tarikh: isoFromJalali(r.date, r.time),
|
||||
geram_v: fixed(r.goldIn, 3),
|
||||
geram_kh: fixed(r.goldOut, 3),
|
||||
rial_v: fixed(r.rialIn, 2),
|
||||
rial_kh: fixed(r.rialOut, 2),
|
||||
tahesab_T: gold.toFixed(3),
|
||||
tahesab_P: rial.toFixed(2),
|
||||
rial_V2: '0.00',
|
||||
rial_Kh2: '0.00',
|
||||
tahesabP2: '0.00',
|
||||
sourceType: 'ACCOUNTING',
|
||||
note: [r.title, r.description, r.temporary ? '(موقت — هنوز در مانده اثر ندارد)' : null].filter(Boolean).join(' — '),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function statementToDetail(r: StatementRow) {
|
||||
return {
|
||||
factor_Code: r.documentRef || r.key,
|
||||
sh_factor: r.documentNo,
|
||||
tarikh: isoFromJalali(r.date, r.time),
|
||||
vazn: fixed(r.weight, 3),
|
||||
ayar: r.karat,
|
||||
no: r.title,
|
||||
sh_sharti: null,
|
||||
name_az: null,
|
||||
tabdil750_v: fixed(r.goldIn, 3),
|
||||
tabdil750_kh: fixed(r.goldOut, 3),
|
||||
fi: r.unitPrice ? r.unitPrice.toFixed(2) : null,
|
||||
fiF: r.rialIn.gt(0) ? r.rialIn.toFixed(2) : r.rialOut.gt(0) ? r.rialOut.toFixed(2) : null,
|
||||
Darsad_Talayee: r.wagePercent ? r.wagePercent.toString() : null,
|
||||
accountName: null,
|
||||
bankName: null,
|
||||
transferTo: null,
|
||||
receivedAt: null,
|
||||
note: [r.description, r.temporary ? 'سند موقت' : null].filter(Boolean).join(' — ') || null,
|
||||
// REP-01: the accounting software does carry these.
|
||||
nO_kar: r.itemName,
|
||||
jens_felez: r.metal,
|
||||
Darsad: null,
|
||||
maliatDarsad: null,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user