Files
tehran_taj/apps/api/scripts/test-accounting-zarrin.ts
T

227 lines
9.8 KiB
TypeScript
Raw Normal View History

/**
* Accounting integration (D27) — the Zarrin driver against a REAL Zarrin
* installation. No database, no Nest: it calls the driver itself, so it can
* run against the owner's production install without touching the app.
*
* READ-ONLY by default (the install holds real customer data):
* 1. version + account groups (the token is checked by the group listing)
* 2. accounts: no «*» left in names (listhesabha prints «first * last»)
* 3. one account found again by its mobile (searchAccount)
* 4. balances: one and many
* 5. statement: its last running balance equals the account's real balance,
* and a range ending in the past agrees with one ending today
* 6. کارساخته: every page of listKarSakhte (100 per page), groups, stock
* 7. change feed (parLastTenRadif): first poll = cursor only
* 8. item movement (infogardeshsakhte, hand-printed JSON) — Zarrin itself
* sometimes answers 500 here; reported, not failed
*
* WRITE test only with ZARRIN_WRITE_TEST=1 and ZARRIN_TEST_ACCOUNT set — on
* the owner's install that is account 953 «تست اپ گلدبانک» (09990001111),
* created for this on 2026-10-12. Zarrin has no API to delete an account or
* a کارساخته, so this script never creates either:
* 9. edit the test account (its group must survive)
* 10. post a TEMPORARY 2-line document (1 g each, 10 %): the balance must move
* by exactly 2.2 g and the rows read as temporary
* 11. delete it: no rows left, balance and stock back where they were
*
* Usage:
* ZARRIN_URL=http://host:port/ZarrinService/accounting ZARRIN_TOKEN=… \
* [ZARRIN_ACCOUNT=499] [ZARRIN_WRITE_TEST=1 ZARRIN_TEST_ACCOUNT=953] \
* npx ts-node --transpile-only scripts/test-accounting-zarrin.ts
*
* Requests are paced: the owner's install refused connections once when hit
* back to back. The token is never printed.
*/
import { Prisma } from '@prisma/client';
import { toJalali } from '../src/modules/accounting-sync/jalali';
import { ProviderSettings } from '../src/modules/accounting-sync/providers/accounting-provider';
import { zarrinProvider as z } from '../src/modules/accounting-sync/providers/zarrin.provider';
const URL_ = process.env.ZARRIN_URL ?? '';
const TOKEN = process.env.ZARRIN_TOKEN ?? '';
const ACCOUNT = process.env.ZARRIN_ACCOUNT ?? '';
const WRITE = process.env.ZARRIN_WRITE_TEST === '1';
const TEST_ACCOUNT = process.env.ZARRIN_TEST_ACCOUNT ?? '';
if (!URL_ || !TOKEN) {
console.error('ZARRIN_URL and ZARRIN_TOKEN are required.');
process.exit(2);
}
if (WRITE && !TEST_ACCOUNT) {
console.error('ZARRIN_WRITE_TEST=1 needs ZARRIN_TEST_ACCOUNT (a test account the owner approved, e.g. 953).');
process.exit(2);
}
const s: ProviderSettings = { baseUrl: URL_, token: TOKEN, caCert: null, timeoutMs: 60_000, balanceOwesWhen: 'negative' };
const D = (v: string | number) => new Prisma.Decimal(v);
const pause = (ms = 1200) => new Promise((r) => setTimeout(r, ms));
const day = (n: number) => toJalali(new Date(Date.now() - n * 864e5));
let pass = 0;
let fail = 0;
const chk = (c: unknown, m: string) => {
if (c) pass += 1;
else fail += 1;
console.log(` ${c ? 'PASS' : 'FAIL'} ${m}`);
};
const note = (m: string) => console.log(` NOTE ${m}`);
const step = (t: string) => console.log(`\n${t}`);
async function main() {
step('1. connection');
const h = await z.health(s);
chk(h.version !== undefined && Number(h.customerGroups) > 0, `version ${h.version}, ${h.customerGroups} account groups`);
await pause();
step('2. accounts');
const all = (await z.listCustomers(s, null)).items;
chk(all.length > 0, `${all.length} accounts, ${all.filter((c) => c.mobile).length} with a mobile`);
chk(all.every((c) => !c.name.includes('*')), 'no «*» left in any name');
await pause();
const probe = all.find((c) => (ACCOUNT ? c.code === ACCOUNT : !!c.mobile)) ?? all[0];
const code = probe.code;
step(`3. search by mobile (account ${code})`);
if (probe.mobile) {
const found = await z.findCustomersByMobile(s, probe.mobile);
chk(found.some((c) => c.code === code), `found account ${code} by its mobile`);
} else note('account has no mobile; skipped');
await pause();
step('4. balances');
const [one] = await z.getBalances(s, [code]);
chk(!!one, `balance of ${code}: ${one?.goldGrams} g, ${one?.rial} rial (app sign: positive = owes)`);
await pause();
const many = await z.getBalances(s, all.slice(0, 3).map((c) => c.code));
chk(many.length === 3, `3 balances in one call (${many.length})`);
await pause();
step(`5. statement of ${code}`);
const toToday = await z.getStatement(s, { code, from: day(60), to: day(0) });
await pause();
const [now] = await z.getBalances(s, [code]);
const last = toToday[toToday.length - 1];
if (last) {
chk(String(last.goldBalance) === String(now?.goldGrams), `last running balance ${last.goldBalance} = real balance ${now?.goldGrams}`);
await pause();
const past = await z.getStatement(s, { code, from: day(60), to: day(5) });
const byKey = new Map(toToday.map((r) => [r.key, String(r.goldBalance)]));
const shared = past.filter((r) => byKey.has(r.key));
chk(shared.every((r) => byKey.get(r.key) === String(r.goldBalance)), `a range ending 5 days ago agrees on ${shared.length} shared rows`);
note(`${toToday.length} rows in 60 days, ${toToday.filter((r) => r.temporary).length} temporary`);
} else note('no rows in the last 60 days');
await pause();
step('6. کارساخته');
const items = await z.listKarSakhte(s);
chk(items.length > 0, `${items.length} items (pages of 100 read until the last)`);
await pause();
const groups = await z.listKarSakhteGroups(s);
chk(groups.length > 0, `${groups.length} item groups`);
await pause();
const stocked = items.find((i) => i.stockWeight?.gt(0));
if (stocked) {
const st = await z.getKarSakhteStock(s, stocked);
chk(st.weight !== null || st.count !== null, `stock of «${stocked.name}»: ${st.weight ?? st.count}`);
}
await pause();
step('7. change feed');
const first = await z.getChanges(s, null);
chk(!!first.cursor && first.accountCodes.length === 0, `first poll returns only the cursor (${first.cursor})`);
await pause();
step('8. item movement (slow; Zarrin sometimes fails here itself)');
try {
const mv = await z.getKarSakhteMovement(s, day(3), day(0));
chk(true, `${mv.length} rows`);
} catch (err) {
note(`not available right now: ${(err as Error).message.slice(0, 160)}`);
}
if (!WRITE) {
console.log('\nWrite test skipped (set ZARRIN_WRITE_TEST=1 and ZARRIN_TEST_ACCOUNT to run it).');
return;
}
step(`9. edit test account ${TEST_ACCOUNT}`);
const acct = all.find((c) => c.code === TEST_ACCOUNT);
if (!acct) throw new Error(`test account ${TEST_ACCOUNT} not found`);
const parts = acct.name.split(' ');
await z.updateCustomer(s, TEST_ACCOUNT, {
firstName: parts.slice(0, -1).join(' ') || acct.name,
lastName: parts.slice(-1)[0] ?? '',
fullName: acct.name,
mobile: acct.mobile ?? '',
phone: acct.phone,
address: `حساب آزمایشی اتصال اپ — آخرین تست ${new Date().toISOString().slice(0, 10)}`,
nationalCode: null,
group: { id: null, name: acct.groupName },
});
await pause();
const after = (await z.listCustomers(s, null)).items.find((c) => c.code === TEST_ACCOUNT);
chk(after?.groupName === acct.groupName, `group kept: ${after?.groupName}`);
await pause();
step('10–11. temporary document, then delete');
const [b0] = await z.getBalances(s, [TEST_ACCOUNT]);
const sell = items.filter((i) => i.typeName === 'گرمی' && i.stockWeight?.gte(2)).slice(0, 2);
if (sell.length < 2) throw new Error('need two gram items with stock ≥ 2 g');
const stock0: string[] = [];
for (const i of sell) {
await pause();
stock0.push(String((await z.getKarSakhteStock(s, i)).weight));
}
let ref = '';
try {
await pause();
const res = await z.postOrderDocument(
s,
{
factorCode: 'zarrinwritetest',
customerCode: TEST_ACCOUNT,
date: new Date(),
description: 'تست اتصال اپ',
temporary: true,
lines: sell.map((i) => ({ itemCode: i.code, itemName: i.name, weightGrams: D(1), count: 1, wagePercent: D(10), flatRial: D(0), karat: 750, description: `تست اتصال اپ — ${i.name}` })),
},
{},
async (p) => {
if (p.factorId) ref = p.factorId;
},
);
ref = res.ref;
await pause(2000);
const [b1] = await z.getBalances(s, [TEST_ACCOUNT]);
chk(b1 && b0 && b1.goldGrams.sub(b0.goldGrams).eq('2.2'), `invoice ${ref}: balance moved ${b1 && b0 ? b1.goldGrams.sub(b0.goldGrams) : '?'} g (expected 2.2)`);
await pause();
const rows = (await z.getStatement(s, { code: TEST_ACCOUNT, from: day(0), to: day(0) })).filter((r) => r.documentRef === ref);
chk(rows.length > 0 && rows.every((r) => r.temporary), `${rows.length} rows, all temporary`);
} finally {
if (ref) {
await pause(1500);
await z.deleteDocument(s, { factorCode: '', ref, customerCode: TEST_ACCOUNT });
await pause(2000);
const [b2] = await z.getBalances(s, [TEST_ACCOUNT]);
chk(b2 && b0 && b2.goldGrams.eq(b0.goldGrams), `deleted: balance back to ${b2?.goldGrams}`);
await pause();
const left = (await z.getStatement(s, { code: TEST_ACCOUNT, from: day(0), to: day(0) })).filter((r) => r.documentRef === ref);
chk(left.length === 0, `no rows of invoice ${ref} left`);
for (const [n, i] of sell.entries()) {
await pause();
const w = String((await z.getKarSakhteStock(s, i)).weight);
chk(w === stock0[n], `stock of «${i.name}» back to ${stock0[n]} (${w})`);
}
}
}
}
main()
.catch((err) => {
fail += 1;
console.error(`\nABORTED: ${(err as Error).message}`);
})
.finally(() => {
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
});