Files
tehran_taj/apps/api/scripts/test-accounting-zarrin.ts
s.esmaeiliandClaude Sonnet 4.6 8b74c85a2a 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>
2026-10-12 15:23:24 +03:30

227 lines
9.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
});