parity(apps+api): report + report/detail on the internal ledger [ref: henza_pwa views/report]
Ports "گزارش" end to end (D24), the last missing app page.
In the reference this feature computes nothing: Customer\TahesabController
proxies an external accounting system (hesab.henzagold.com and a bare IP at
31.7.68.147:3030) with TLS verification disabled, a bearer token hardcoded in
the source and a URL-embedded secret, keyed on Customer.mcode. The target has
no such system and no mcode, so per the owner's option الف this is built on our
own ledger with no external connection.
API — customer/me/report{,/detail,/balances,/:id}
- one row per CONFIRMED ledger entry with the running gold and rial balance
after it, newest first as the reference displayed it
- only confirmed entries count, the same rule the cached
Customer.goldDebtGrams/fiatDebtRial follow, so the statement's closing
balance agrees with the figure shown everywhere else in the app
- fields the target has no source for (foreign currency, نام کارساخته,
جنس فلز, the external system's internal ids) are null/zero and the UI OMITS
the row rather than rendering a fabricated value (REP-01)
SEC-16, NOT ported: the reference's detail($id) took the factor code from the
URL and performed no ownership check at all — it never consults auth() — so any
signed-in customer could read any other customer's document by guessing a code.
Here the id is only a filter on top of a customerId scope from the token.
Tested in both directions.
Apps (both, template+script byte-identical, per-app styling only)
- ReportView with the reference's two tabs, its card rows and its «جزئیات»
modal; ReportDocument for one document
- routes /report and /report/detail, the latter opening the second tab
- dashboard entry (v1 tile, v2 row), shown to every signed-in customer since
the reference's hasMCode() gate has no target equivalent
Verified: build 5/5 · jest 45/45 · report 40/40 (new) ·
complete-profile 38/38 · apps smoke 43/43 · typecheck clean on all four apps.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5ad1083aee
commit
3f549f1749
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* "گزارش" — the account statement (D24).
|
||||
*
|
||||
* The reference proxied an external accounting system
|
||||
* (henza_server Customer/TahesabController). The owner chose option الف: build
|
||||
* the same UI and flow over the target's OWN ledger, with no external call.
|
||||
*
|
||||
* Covers what the owner asked for per page — opening, the empty state, the
|
||||
* error state and the main flow — plus what makes a statement trustworthy:
|
||||
*
|
||||
* 1. empty state: a customer with no entries gets an empty result, not a 500
|
||||
* 2. main flow: entries appear with the right in/out columns
|
||||
* 3. the RUNNING BALANCE is correct, and its last value agrees with the
|
||||
* cached Customer.goldDebtGrams / fiatDebtRial the rest of the app shows
|
||||
* 4. UNCONFIRMED entries are excluded, exactly as they are from the cached
|
||||
* balance
|
||||
* 5. the detail list and the single-document read
|
||||
* 6. SEC-16: another customer's document id returns NOTHING. The reference's
|
||||
* detail() took the factor code from the URL with no ownership check at
|
||||
* all, so any signed-in customer could read anyone's document.
|
||||
* 7. no token is a 401 on every route
|
||||
* 8. balances endpoint agrees with the customer row
|
||||
*
|
||||
* Every Persian string is a literal here and travels as UTF-8 JSON (D15).
|
||||
*
|
||||
* Requires the API running WITH OTP_MASTER_CODE set.
|
||||
* Usage: node scripts/test-report.cjs [apiBaseUrl] [masterCode]
|
||||
*/
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
const API = (process.argv[2] || 'http://localhost:3000/api').replace(/\/$/, '');
|
||||
const MASTER = process.argv[3] || '1379';
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const failures = [];
|
||||
const chk = (c, m) => {
|
||||
if (c) { pass++; console.log(' PASS ' + m); }
|
||||
else { fail++; failures.push(m); console.log(' FAIL ' + m); }
|
||||
};
|
||||
const step = (t) => console.log('\n' + t);
|
||||
|
||||
const call = async (method, p, body, token) => {
|
||||
const res = await fetch(`${API}/${p}`, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: 'Bearer ' + token } : {}),
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const text = await res.text();
|
||||
let payload = null;
|
||||
try { payload = JSON.parse(text); } catch { /* empty */ }
|
||||
return { status: res.status, ok: res.ok, body: payload, raw: text };
|
||||
};
|
||||
|
||||
const signIn = (mobile) =>
|
||||
call('POST', 'customer/auth/verify-otp', { username: mobile, password: MASTER });
|
||||
|
||||
const made = { customers: [], entries: [] };
|
||||
const uniq = String(Date.now()).slice(-7);
|
||||
|
||||
(async () => {
|
||||
step('SETUP — two customers; one with a ledger, one with none');
|
||||
|
||||
const mkCustomer = async (suffix, name) => {
|
||||
const c = await prisma.customer.create({
|
||||
data: {
|
||||
fullName: name,
|
||||
firstName: name.split(' ')[0],
|
||||
lastName: name.split(' ').slice(1).join(' '),
|
||||
mobile: `0919${uniq.slice(0, 3)}${suffix}`,
|
||||
nationalCode: `nc${uniq}${suffix}`,
|
||||
passwordHash: '', kind: 'APP_SHOPKEEPER', isActive: true,
|
||||
goldCreditLimitGrams: '500',
|
||||
},
|
||||
});
|
||||
made.customers.push(c.id);
|
||||
return c;
|
||||
};
|
||||
|
||||
const owner = await mkCustomer('11', 'مشتری گزارش');
|
||||
const other = await mkCustomer('22', 'مشتری دیگر');
|
||||
const empty = await mkCustomer('33', 'مشتری بدون سند');
|
||||
console.log(` owner: ${owner.fullName} · other: ${other.fullName} · empty: ${empty.fullName}`);
|
||||
|
||||
// ── 7. NO TOKEN ─────────────────────────────────────────────────────────
|
||||
step('7 — every route requires a session');
|
||||
for (const path of ['customer/me/report', 'customer/me/report/detail', 'customer/me/report/balances']) {
|
||||
const r = await call('GET', path);
|
||||
chk(r.status === 401, `GET ${path} with no token -> ${r.status} (expected 401)`);
|
||||
}
|
||||
|
||||
// ── 1. EMPTY STATE ──────────────────────────────────────────────────────
|
||||
step('1 — EMPTY STATE: a customer with no ledger entries');
|
||||
await call('POST', 'customer/auth/request-otp', { username: empty.mobile });
|
||||
const emptyLogin = await signIn(empty.mobile);
|
||||
chk(emptyLogin.ok, `the empty customer signs in -> ${emptyLogin.status}`);
|
||||
const emptyToken = emptyLogin.body?.token;
|
||||
|
||||
const emptyReport = await call('GET', 'customer/me/report', undefined, emptyToken);
|
||||
chk(emptyReport.ok, `GET report -> ${emptyReport.status}`);
|
||||
chk(Array.isArray(emptyReport.body?.result) && emptyReport.body.result.length === 0,
|
||||
'the statement is an empty list, not an error');
|
||||
chk(emptyReport.body?.count === 0, `count is 0 (got ${emptyReport.body?.count})`);
|
||||
|
||||
const emptyDetail = await call('GET', 'customer/me/report/detail', undefined, emptyToken);
|
||||
chk(emptyDetail.ok && emptyDetail.body?.result?.length === 0,
|
||||
`the detail tab is empty too -> ${emptyDetail.status}`);
|
||||
|
||||
// ── BUILD A LEDGER ──────────────────────────────────────────────────────
|
||||
step('SETUP — a ledger for the owner: gold out, gold in, rial in, plus one UNCONFIRMED');
|
||||
|
||||
const mkEntry = async (data) => {
|
||||
const e = await prisma.ledgerEntry.create({ data: { customerId: owner.id, ...data } });
|
||||
made.entries.push(e.id);
|
||||
return e;
|
||||
};
|
||||
|
||||
// +12.5 gold owed
|
||||
await mkEntry({
|
||||
currency: 'GOLD_GRAM', direction: 'DEBIT', sourceType: 'ORDER_STOCK',
|
||||
amount: '12.5', confirmed: true, confirmedAt: new Date(),
|
||||
note: 'سفارش اول',
|
||||
});
|
||||
// -4.25 gold (a melted-gold repayment, with the document metadata)
|
||||
await mkEntry({
|
||||
currency: 'GOLD_GRAM', direction: 'CREDIT', sourceType: 'REPAYMENT_MELTED_GOLD',
|
||||
amount: '4.25', confirmed: true, confirmedAt: new Date(),
|
||||
karatPerMille: 750, originalValue: '4.3', labName: 'آزمایشگاه مرکزی', hallmark: 'A-1001',
|
||||
note: 'طلای آبشده',
|
||||
});
|
||||
// +1,000,000 rial owed
|
||||
await mkEntry({
|
||||
currency: 'RIAL', direction: 'DEBIT', sourceType: 'ADJUSTMENT',
|
||||
amount: '1000000', confirmed: true, confirmedAt: new Date(),
|
||||
note: 'اصلاحیه ریالی',
|
||||
});
|
||||
// An UNCONFIRMED entry that must NOT appear anywhere.
|
||||
const pending = await mkEntry({
|
||||
currency: 'GOLD_GRAM', direction: 'DEBIT', sourceType: 'ORDER_STOCK',
|
||||
amount: '99.999', confirmed: false,
|
||||
note: 'سند تاییدنشده',
|
||||
});
|
||||
|
||||
// The cached balances the rest of the app shows.
|
||||
await prisma.customer.update({
|
||||
where: { id: owner.id },
|
||||
data: { goldDebtGrams: '8.25', fiatDebtRial: '1000000' },
|
||||
});
|
||||
|
||||
// One document for the OTHER customer, for the cross-read test.
|
||||
const foreign = await prisma.ledgerEntry.create({
|
||||
data: {
|
||||
customerId: other.id, currency: 'GOLD_GRAM', direction: 'DEBIT',
|
||||
sourceType: 'ORDER_STOCK', amount: '7', confirmed: true, confirmedAt: new Date(),
|
||||
note: 'سند مشتری دیگر',
|
||||
},
|
||||
});
|
||||
made.entries.push(foreign.id);
|
||||
|
||||
// ── 2/3/4. MAIN FLOW ────────────────────────────────────────────────────
|
||||
step('2 — MAIN FLOW: the statement');
|
||||
await call('POST', 'customer/auth/request-otp', { username: owner.mobile });
|
||||
const login = await signIn(owner.mobile);
|
||||
chk(login.ok, `the owner signs in -> ${login.status}`);
|
||||
const token = login.body?.token;
|
||||
|
||||
const rep = await call('GET', 'customer/me/report', undefined, token);
|
||||
chk(rep.ok, `GET report -> ${rep.status}`);
|
||||
const rows = rep.body?.result ?? [];
|
||||
chk(rows.length === 3, `three CONFIRMED entries came back (got ${rows.length})`);
|
||||
|
||||
// Newest first, as the reference displayed it.
|
||||
chk(rows[0]?.note === 'اصلاحیه ریالی', `newest first: top row is "${rows[0]?.note}"`);
|
||||
|
||||
const gold = rows.find((r) => r.note === 'سفارش اول');
|
||||
chk(gold?.geram_kh === '12.500', `a gold DEBIT lands in geram_kh (${gold?.geram_kh})`);
|
||||
chk(gold?.geram_v === '0.000', 'and not in geram_v');
|
||||
|
||||
const back = rows.find((r) => r.note === 'طلای آبشده');
|
||||
chk(back?.geram_v === '4.250', `a gold CREDIT lands in geram_v (${back?.geram_v})`);
|
||||
|
||||
const rialRow = rows.find((r) => r.note === 'اصلاحیه ریالی');
|
||||
chk(rialRow?.rial_kh === '1000000.00', `a rial DEBIT lands in rial_kh (${rialRow?.rial_kh})`);
|
||||
|
||||
step('4 — UNCONFIRMED entries are excluded');
|
||||
chk(!rows.some((r) => r.note === 'سند تاییدنشده'),
|
||||
'the unconfirmed 99.999g entry does NOT appear in the statement');
|
||||
|
||||
step('3 — the RUNNING BALANCE');
|
||||
// Oldest first: +12.5, then -4.25 => 8.25. Rows come back newest-first.
|
||||
chk(gold?.tahesab_T === '12.500', `after the first entry the gold balance is 12.500 (got ${gold?.tahesab_T})`);
|
||||
chk(back?.tahesab_T === '8.250', `after the repayment it is 8.250 (got ${back?.tahesab_T})`);
|
||||
|
||||
const cached = await prisma.customer.findUnique({ where: { id: owner.id } });
|
||||
chk(String(Number(back.tahesab_T)) === String(Number(cached.goldDebtGrams)),
|
||||
`and it agrees with the cached Customer.goldDebtGrams (${cached.goldDebtGrams})`);
|
||||
chk(rialRow?.tahesab_P === '1000000.00',
|
||||
`the rial balance is 1000000.00 (got ${rialRow?.tahesab_P})`);
|
||||
chk(String(Number(rialRow.tahesab_P)) === String(Number(cached.fiatDebtRial)),
|
||||
`and it agrees with the cached Customer.fiatDebtRial (${cached.fiatDebtRial})`);
|
||||
|
||||
step('8 — the balances endpoint agrees with the customer row');
|
||||
const bal = await call('GET', 'customer/me/report/balances', undefined, token);
|
||||
chk(bal.ok, `GET balances -> ${bal.status}`);
|
||||
const goldBal = bal.body?.result?.find((r) => r.arz === 'gold');
|
||||
const rialBal = bal.body?.result?.find((r) => r.arz === 'IRR');
|
||||
chk(goldBal?.mandeh === '8.250', `gold balance ${goldBal?.mandeh}`);
|
||||
chk(rialBal?.mandeh === '1000000.00', `rial balance ${rialBal?.mandeh}`);
|
||||
|
||||
// ── 5. DETAIL ───────────────────────────────────────────────────────────
|
||||
step('5 — the detail tab and one document');
|
||||
const det = await call('GET', 'customer/me/report/detail', undefined, token);
|
||||
chk(det.ok, `GET detail -> ${det.status}`);
|
||||
chk(det.body?.result?.length === 3, `three documents (got ${det.body?.result?.length})`);
|
||||
chk(!det.body?.result?.some((d) => d.note === 'سند تاییدنشده'),
|
||||
'the unconfirmed entry is excluded here too');
|
||||
|
||||
const melted = det.body?.result?.find((d) => d.note === 'طلای آبشده');
|
||||
chk(melted?.ayar === 750, `the melted-gold document carries its عیار (${melted?.ayar})`);
|
||||
chk(melted?.name_az === 'آزمایشگاه مرکزی', `and its lab name (${melted?.name_az})`);
|
||||
chk(melted?.sh_sharti === 'A-1001', `and its hallmark (${melted?.sh_sharti})`);
|
||||
chk(melted?.vazn === '4.300',
|
||||
`وزن خالص is the PRE-normalization weight, as a paper document states (${melted?.vazn})`);
|
||||
chk(melted?.nO_kar === null && melted?.jens_felez === null,
|
||||
'fields the target cannot know are null, never invented (REP-01)');
|
||||
|
||||
const one = await call('GET', `customer/me/report/${back.factor_Code}`, undefined, token);
|
||||
chk(one.ok, `GET one document -> ${one.status}`);
|
||||
chk(one.body?.result?.length === 1, `exactly one row came back (got ${one.body?.result?.length})`);
|
||||
chk(one.body?.result?.[0]?.factor_Code === back.factor_Code, 'and it is the one asked for');
|
||||
|
||||
// ── 6. SEC-16 ───────────────────────────────────────────────────────────
|
||||
step("6 — SEC-16: another customer's document is NOT readable");
|
||||
const stolen = await call('GET', `customer/me/report/${foreign.id}`, undefined, token);
|
||||
chk(stolen.ok, `the request itself succeeds -> ${stolen.status}`);
|
||||
chk(stolen.body?.result?.length === 0,
|
||||
"but it returns NOTHING — the id is only a filter on top of the token's scope");
|
||||
|
||||
// And the reverse direction, so the scope is not accidentally one-way.
|
||||
await call('POST', 'customer/auth/request-otp', { username: other.mobile });
|
||||
const otherLogin = await signIn(other.mobile);
|
||||
const otherToken = otherLogin.body?.token;
|
||||
const reverse = await call('GET', `customer/me/report/${back.factor_Code}`, undefined, otherToken);
|
||||
chk(reverse.body?.result?.length === 0,
|
||||
"and the other customer cannot read the owner's document either");
|
||||
|
||||
const otherRep = await call('GET', 'customer/me/report', undefined, otherToken);
|
||||
chk(otherRep.body?.result?.length === 1,
|
||||
`the other customer sees only their OWN one entry (got ${otherRep.body?.result?.length})`);
|
||||
|
||||
// ── CLEANUP ─────────────────────────────────────────────────────────────
|
||||
step('CLEANUP');
|
||||
await prisma.ledgerEntry.deleteMany({ where: { id: { in: made.entries } } });
|
||||
await prisma.customerOtp.deleteMany({ where: { customerId: { in: made.customers } } });
|
||||
await prisma.customer.deleteMany({ where: { id: { in: made.customers } } });
|
||||
console.log(
|
||||
` deleted ${made.entries.length} ledger entr(ies) and ${made.customers.length} customer(s). ` +
|
||||
'All were created by this run; no pre-existing data was touched.',
|
||||
);
|
||||
void pending;
|
||||
|
||||
console.log(`\n${'='.repeat(60)}`);
|
||||
console.log(`REPORT: passed ${pass} failed ${fail}`);
|
||||
if (fail) { console.log('\nFailures:'); failures.forEach((f) => console.log(' - ' + f)); }
|
||||
console.log('='.repeat(60));
|
||||
|
||||
await prisma.$disconnect();
|
||||
process.exit(fail ? 1 : 0);
|
||||
})().catch(async (e) => {
|
||||
console.error('\nABORTED:', e);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { CustomersModule } from './modules/customers/customers.module';
|
||||
import { CustomerAuthModule } from './modules/customer-auth/customer-auth.module';
|
||||
import { SalesExpertAppModule } from './modules/sales-expert-app/sales-expert-app.module';
|
||||
import { CustomerProfileModule } from './modules/customer-profile/customer-profile.module';
|
||||
import { CustomerReportModule } from './modules/customer-report/customer-report.module';
|
||||
import { SmsModule } from './modules/sms/sms.module';
|
||||
import { OrdersModule } from './modules/orders/orders.module';
|
||||
import { AccountingModule } from './modules/accounting/accounting.module';
|
||||
@@ -56,6 +57,7 @@ import { SalesAgentModule } from './modules/sales-agent/sales-agent.module';
|
||||
CustomerAuthModule,
|
||||
SalesExpertAppModule,
|
||||
CustomerProfileModule,
|
||||
CustomerReportModule,
|
||||
SalesAgentModule,
|
||||
OrdersModule,
|
||||
AccountingModule,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||
import { CustomerReportService } from './customer-report.service';
|
||||
import { CurrentCustomer, CustomerAuth } from '../customer-auth/customer.guards';
|
||||
import type { AuthenticatedCustomer } from '../customer-auth/customer-jwt.strategy';
|
||||
|
||||
/**
|
||||
* "گزارش" — the customer's own account statement.
|
||||
*
|
||||
* Legacy endpoints, all inside the `auth:api-customer` group
|
||||
* (henza_server routes/api.php:108-110):
|
||||
* GET tahesab/me -> TahesabController::mandeh
|
||||
* GET fin_tahesab/table -> TahesabController::index
|
||||
* GET fin_tahesab/my_detail/{n} -> TahesabController::my_detail
|
||||
* GET fin_tahesab/detail/{id} -> TahesabController::detail
|
||||
*
|
||||
* Every one of those proxied an external accounting system keyed on
|
||||
* `Customer.mcode`. These read the target's own ledger instead (option الف,
|
||||
* D24) — no external call, no `mcode`, and the customer always comes from the
|
||||
* token. `fin_tahesab/detail/{id}` took the factor code straight from the URL
|
||||
* with **no ownership check at all** (`detail()` never looks at `auth()`), so
|
||||
* any signed-in customer could read any other customer's document by guessing
|
||||
* a code. Here the id is only ever a filter on top of a customerId scope, so
|
||||
* someone else's id returns nothing. Recorded as SEC-16.
|
||||
*/
|
||||
@Controller('customer/me/report')
|
||||
export class CustomerReportController {
|
||||
constructor(private readonly report: CustomerReportService) {}
|
||||
|
||||
/** "کلیات حساب" — the running statement. */
|
||||
@CustomerAuth()
|
||||
@Get()
|
||||
summary(@CurrentCustomer() me: AuthenticatedCustomer, @Query('page') page?: string) {
|
||||
return this.report.summary(me.id, Number(page) || 1);
|
||||
}
|
||||
|
||||
/** "جزئیات حساب" — the whole detail list (the second tab). */
|
||||
@CustomerAuth()
|
||||
@Get('detail')
|
||||
detailList(@CurrentCustomer() me: AuthenticatedCustomer, @Query('page') page?: string) {
|
||||
return this.report.detail(me.id, undefined, Number(page) || 1);
|
||||
}
|
||||
|
||||
/** The current balances, for the statement header. */
|
||||
@CustomerAuth()
|
||||
@Get('balances')
|
||||
balances(@CurrentCustomer() me: AuthenticatedCustomer) {
|
||||
return this.report.balances(me.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* One document. Declared last so the literal routes above are matched first —
|
||||
* Nest resolves in declaration order.
|
||||
*/
|
||||
@CustomerAuth()
|
||||
@Get(':id')
|
||||
detail(@Param('id') id: string, @CurrentCustomer() me: AuthenticatedCustomer) {
|
||||
return this.report.detail(me.id, id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { CustomerReportController } from './customer-report.controller';
|
||||
import { CustomerReportService } from './customer-report.service';
|
||||
|
||||
/** "گزارش" — the customer's own account statement, from the internal ledger (D24). */
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [CustomerReportController],
|
||||
providers: [CustomerReportService],
|
||||
})
|
||||
export class CustomerReportModule {}
|
||||
@@ -0,0 +1,244 @@
|
||||
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: 'اصلاحیه',
|
||||
};
|
||||
Reference in New Issue
Block a user