fix(accounting): verify and fix Zarrin driver on real v228 install
Tested 20/20 on the owner's live Zarrin v228 (870 accounts, 656 items): Reads fixed: - listKarSakhte: now pages by 100 via pageNum (was returning only first 100 of 656) - goroohSakhte: HTTP 400 with a valid body is now accepted (was treated as missing) - parvande «مانده از قبل» row is not a balance; skip it and anchor the running balance on the live listMandeHesabha balance, walked back through movements - infogardeshsakhte: hand-printed JSON repaired, timeout raised to 90s - document delete: now searches all ledger pages, not just the first one - listhesabha names are «first * last»; the «*» is now stripped - keep-alive ECONNRESET: a socket hang-up on a reused connection is retried once on a fresh connection (server closes idle connections) Writes fixed (test account 953 «تست اپ گلدبانک»): - temporary flag: set from shomareSanad («موقت»), not noeFactor - noeFactor «فاکتور موقت» also flags a document left open in Zarrin's own UI New: test script apps/api/scripts/test-accounting-zarrin.ts (13 read + 7 write checks) Docs: §13 added to ACCOUNTING-INTEGRATION-PLAN.md; ACC-01 in ISSUES.md updated New: docs/FRESH-SERVER-DEPLOY-AUDIT.md (fresh-server deploy checklist) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
9fc0886a57
commit
8b74c85a2a
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
Reference in New Issue
Block a user