- 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>
393 lines
16 KiB
TypeScript
393 lines
16 KiB
TypeScript
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.
|
|
*
|
|
* ── Why this is built on the internal ledger ───────────────────────────────
|
|
*
|
|
* In the reference this whole feature is a PROXY. `Customer\TahesabController`
|
|
* (henza_server app/Http/Controllers/Customer/TahesabController.php) forwards
|
|
* every call to an external accounting system — `hesab.henzagold.com` and a
|
|
* bare IP at `31.7.68.147:3030` — with `Http::withoutVerifying()` (TLS
|
|
* verification off), a bearer token hardcoded in the source, and a URL-embedded
|
|
* `sfx` secret. It computes nothing: the PWA renders whatever that system
|
|
* returns, keyed on `Customer.mcode`, its id in that other system.
|
|
*
|
|
* The target has no such system and no `mcode`. The owner chose option الف:
|
|
* build the reference's UI and flow over OUR OWN ledger, with no external
|
|
* connection. So this service reproduces the statement's SHAPE — the same field
|
|
* names the PWA binds to (`henza_pwa/src/type/accounting.ts`) — from
|
|
* `LedgerEntry`, which is the target's system of record for the same facts.
|
|
*
|
|
* Nothing here is invented: every figure is computed from confirmed ledger
|
|
* entries. Where the external system carried a column the target has no
|
|
* equivalent for (ارز/foreign currency, وزن پلاستیک, and the internal
|
|
* bookkeeping ids `gid`/`rec`/`reg`), the field is present and ZERO/null rather
|
|
* than fabricated, so the UI binds without pretending to know something it does
|
|
* not. Recorded as REP-01 in ISSUES.md.
|
|
*/
|
|
@Injectable()
|
|
export class CustomerReportService {
|
|
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;
|
|
|
|
/**
|
|
* "کلیات حساب" — one row per ledger entry, oldest first, each carrying the
|
|
* running balance AFTER it.
|
|
*
|
|
* The running balance is why this cannot be a plain paginated query: a row's
|
|
* `tahesab_T` depends on every entry before it. Entries are therefore folded
|
|
* in chronological order from the beginning of the account, and the requested
|
|
* page is sliced out afterwards. At the row counts this app deals with
|
|
* (one customer's own documents) that is cheap; if a customer ever
|
|
* accumulates enough entries for it to matter, the fold should move into a
|
|
* stored running total. Noted in ISSUES.md as REP-02.
|
|
*
|
|
* The reference's own list arrives oldest-first and is then `.reverse()`d for
|
|
* 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
|
|
// balance agrees with the figure shown everywhere else in the app.
|
|
where: { customerId, confirmed: true },
|
|
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
|
|
include: { order: { select: { orderNumber: true } } },
|
|
});
|
|
|
|
let goldRunning = new Prisma.Decimal(0);
|
|
let rialRunning = new Prisma.Decimal(0);
|
|
|
|
const rows = entries.map((e, index) => {
|
|
const amount = new Prisma.Decimal(e.amount);
|
|
const isGold = e.currency === 'GOLD_GRAM';
|
|
// DEBIT increases what the customer owes; CREDIT reduces it. The same
|
|
// convention the ledger itself documents (schema.prisma:94-97).
|
|
const signed = e.direction === 'DEBIT' ? amount : amount.negated();
|
|
|
|
if (isGold) goldRunning = goldRunning.plus(signed);
|
|
else rialRunning = rialRunning.plus(signed);
|
|
|
|
return {
|
|
id: index + 1,
|
|
// The reference keyed the detail lookup on factor_Code; here it is the
|
|
// entry's own id, so the detail call needs nothing else from the client.
|
|
factor_Code: e.id,
|
|
sh_factor: e.order?.orderNumber ?? e.id.slice(-8),
|
|
tarikh: e.createdAt.toISOString(),
|
|
|
|
// Gold in / out, as separate positive columns — the reference showed
|
|
// them on two lines (Accounting.vue:21-22).
|
|
geram_v: isGold && e.direction === 'CREDIT' ? amount.toFixed(3) : '0.000',
|
|
geram_kh: isGold && e.direction === 'DEBIT' ? amount.toFixed(3) : '0.000',
|
|
|
|
rial_v: !isGold && e.direction === 'CREDIT' ? amount.toFixed(2) : '0.00',
|
|
rial_kh: !isGold && e.direction === 'DEBIT' ? amount.toFixed(2) : '0.00',
|
|
|
|
// Running balances. The reference colours these by sign
|
|
// (Accounting.vue:29, :46), so the sign must be preserved.
|
|
tahesab_T: goldRunning.toFixed(3),
|
|
tahesab_P: rialRunning.toFixed(2),
|
|
|
|
// Present so the UI binds, but the target has no foreign-currency
|
|
// ledger — always zero rather than invented. REP-01.
|
|
rial_V2: '0.00',
|
|
rial_Kh2: '0.00',
|
|
tahesabP2: '0.00',
|
|
|
|
sourceType: e.sourceType,
|
|
note: e.note,
|
|
};
|
|
});
|
|
|
|
// Newest first, as the reference displayed it.
|
|
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,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* "جزئیات حساب" — the per-document detail.
|
|
*
|
|
* With no id, this is the whole list the second tab renders (the reference's
|
|
* `my_detail`); with one, it is the single document behind the «جزئیات» link
|
|
* (its `GetHesabDetailByFactorCode`).
|
|
*
|
|
* Scoped on customerId in the WHERE clause, so an id belonging to someone
|
|
* else simply returns nothing — ids stay unenumerable, and no id is trusted
|
|
* 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,
|
|
...(entryId ? { id: entryId } : {}),
|
|
};
|
|
|
|
const entries = await this.prisma.ledgerEntry.findMany({
|
|
where,
|
|
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
|
...(entryId
|
|
? {}
|
|
: {
|
|
skip: (Math.max(1, page) - 1) * CustomerReportService.LIMIT,
|
|
take: CustomerReportService.LIMIT,
|
|
}),
|
|
include: {
|
|
order: {
|
|
select: {
|
|
orderNumber: true,
|
|
totalGoldWeightGrams: true,
|
|
wageWeightGrams: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = entries.map((e) => {
|
|
const amount = new Prisma.Decimal(e.amount);
|
|
const isGold = e.currency === 'GOLD_GRAM';
|
|
|
|
return {
|
|
factor_Code: e.id,
|
|
sh_factor: e.order?.orderNumber ?? e.id.slice(-8),
|
|
tarikh: e.createdAt.toISOString(),
|
|
|
|
/// Net weight. For a melted-gold repayment the ledger keeps the weight
|
|
/// as handed over, BEFORE karat normalization (schema.prisma:685), which
|
|
/// is what a paper document states; `amount` is the normalized figure.
|
|
vazn: isGold ? (e.originalValue ?? amount).toFixed(3) : '0.000',
|
|
/// Purity, for the documents that carry one.
|
|
ayar: e.karatPerMille ?? null,
|
|
no: SOURCE_LABELS[e.sourceType] ?? e.sourceType,
|
|
/// Hallmark and lab — the target ledger carries both, from the same
|
|
/// legacy `ang` / `lab` columns the reference's document form had.
|
|
sh_sharti: e.hallmark,
|
|
name_az: e.labName,
|
|
|
|
/// Gold in / out for this document, normalized to 750 as the reference's
|
|
/// tabdil750 columns were.
|
|
tabdil750_v: isGold && e.direction === 'CREDIT' ? amount.toFixed(3) : '0.000',
|
|
tabdil750_kh: isGold && e.direction === 'DEBIT' ? amount.toFixed(3) : '0.000',
|
|
|
|
/// Unit price at the time, for the documents that have one.
|
|
fi: e.pricePerGramRialAtEntry ? e.pricePerGramRialAtEntry.toFixed(2) : null,
|
|
fiF: e.priceRial ? e.priceRial.toFixed(2) : null,
|
|
|
|
/// The making charge the order carried, where this entry came from one.
|
|
Darsad_Talayee: e.order?.wageWeightGrams ? e.order.wageWeightGrams.toFixed(3) : null,
|
|
|
|
accountName: e.accountName,
|
|
bankName: e.bankName,
|
|
transferTo: e.transferTo,
|
|
receivedAt: e.receivedAt ? e.receivedAt.toISOString() : null,
|
|
note: e.note,
|
|
|
|
/// No equivalent in the target — shown as null, never invented. REP-01.
|
|
nO_kar: null,
|
|
jens_felez: null,
|
|
Darsad: null,
|
|
maliatDarsad: null,
|
|
};
|
|
});
|
|
|
|
return { result, page: Math.max(1, page), limit: CustomerReportService.LIMIT };
|
|
}
|
|
|
|
/**
|
|
* "مانده" — the current balances.
|
|
*
|
|
* Ports the shape of TahesabController::mandeh, which answered a `result`
|
|
* array of `{arz, mandeh}`. The reference multiplied the external system's
|
|
* figures by -1 to flip its sign convention; here the cached customer
|
|
* balances are already in the app's own convention (positive = owes), so no
|
|
* 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 },
|
|
});
|
|
|
|
return {
|
|
status: 200,
|
|
message: '',
|
|
result: [
|
|
{ arz: 'IRR', mandeh: (c?.fiatDebtRial ?? new Prisma.Decimal(0)).toFixed(2) },
|
|
{ arz: 'gold', mandeh: (c?.goldDebtGrams ?? new Prisma.Decimal(0)).toFixed(3) },
|
|
],
|
|
};
|
|
}
|
|
}
|
|
|
|
/** The reference's `no` column held a document-type label. */
|
|
const SOURCE_LABELS: Record<string, string> = {
|
|
ORDER_STOCK: 'سفارش',
|
|
ORDER_PRE_ORDER: 'پیشسفارش',
|
|
REPAYMENT_MELTED_GOLD: 'طلای آبشده',
|
|
REPAYMENT_TRANSFER: 'حواله',
|
|
REPAYMENT_CHEQUE: 'چک',
|
|
REPAYMENT_CASH: 'نقدی',
|
|
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,
|
|
};
|
|
}
|