227 lines
9.8 KiB
TypeScript
227 lines
9.8 KiB
TypeScript
/**
|
||||
|
|
* 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);
|
|||
|
|
});
|