Files
tehran_taj/apps/api/src/modules/customer-report/customer-report.service.ts
T

245 lines
9.9 KiB
TypeScript
Raw Normal View History

import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
/**
* "گزارش" — 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 {
constructor(private readonly prisma: PrismaService) {}
/** 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) {
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 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) {
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: 'اصلاحیه',
};