245 lines
9.9 KiB
TypeScript
245 lines
9.9 KiB
TypeScript
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: 'اصلاحیه',
|
||
|
|
};
|