Files
tehran_taj/apps/api/src/modules/accounting-sync/providers/tahesab.provider.spec.ts
T
s.esmaeiliandClaude Opus 4.6 19d490e790 feat: accounting integration (D27), SMS relay driver (D26), rebuild user apps
- Add Tahesab/Zarrin accounting sync module with admin panel UI
- Add SMS relay driver as default provider, refactor SMS into driver pattern
- Update customer services, orders, products, and catalog queries
- Rebuild user-app and user-app-v2 dist assets
- Add Prisma migration for accounting tables
- Update docker-compose and env examples
- Update parity docs (DECISIONS, ISSUES, PROGRESS)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-06 16:48:36 +03:30

299 lines
14 KiB
TypeScript

import { Prisma } from '@prisma/client';
import { httpRequest } from '../accounting-http';
import { AccountingProviderError, OrderDocumentInput, ProviderSettings } from './accounting-provider';
import { karatWithWage, tahesabProvider as t } from './tahesab.provider';
jest.mock('../accounting-http', () => ({
...jest.requireActual('../accounting-http'),
httpRequest: jest.fn(),
}));
const http = httpRequest as jest.MockedFunction<typeof httpRequest>;
const s: ProviderSettings = { baseUrl: 'https://tahesab.local:8081/', token: 'TOK', caCert: null, timeoutMs: 5000, dbName: 'TahesabDB' };
const reply = (status: number, body: unknown) =>
Promise.resolve({ status, body: typeof body === 'string' ? body : JSON.stringify(body) });
/** The body of the n-th request, parsed. */
const sent = (n = 0) => JSON.parse(http.mock.calls[n][0].body as string);
beforeEach(() => http.mockReset());
describe('wire format', () => {
it('posts {"Method": [args]} to the root with the bearer token and DBname', async () => {
http.mockReturnValueOnce(reply(200, [{ Name: 'بنکدار', GID: 2 }]));
const groups = await t.listCustomerGroups(s);
const req = http.mock.calls[0][0];
expect(req.method).toBe('POST');
expect(req.url).toBe('https://tahesab.local:8081/');
expect(req.headers).toMatchObject({ Authorization: 'Bearer TOK', DBname: 'TahesabDB' });
expect(sent()).toEqual({ DoListGorooh: [] });
// GID 0 (accounts in no group) is offered too.
expect(groups).toEqual([
{ id: '2', name: 'بنکدار' },
{ id: '0', name: 'بدون گروه (GID 0)' },
]);
});
it('turns {"ERROR": …} into a provider error, flagging "not found"', async () => {
http.mockReturnValueOnce(reply(404, { ERROR: 'هیچ رکوردی یافت نشد.', Command: 'POST', SSL: '1' }));
await expect(t.updateCustomer(s, '5', { firstName: 'a', lastName: 'b', fullName: 'a b', mobile: '09120000000', phone: null, address: null, nationalCode: null, group: { id: null, name: null } })).rejects.toMatchObject({
notFound: true,
response: { ERROR: 'هیچ رکوردی یافت نشد.' },
});
});
it('reports a timeout after the request went out as ambiguous', async () => {
const { AccountingHttpError } = jest.requireActual('../accounting-http');
http.mockRejectedValueOnce(new AccountingHttpError('timeout', true, 'TIMEOUT'));
await expect(t.listCustomerGroups(s)).rejects.toMatchObject({ ambiguous: true });
});
});
describe('customers', () => {
it('pages through code windows and stops after empty ones', async () => {
http.mockReturnValueOnce(
reply(200, {
'1': { Code: 1, Name: 'masoud', GID: '2', GoroupName: 'بنکدار', Mobile: '9121234567', CodeMelli: '', Address: 'آدرس' },
}),
);
const page1 = await t.listCustomers(s, null);
expect(sent()).toEqual({ Dolistmoshtari: [1, 500] });
expect(page1.items[0]).toMatchObject({ code: '1', mobile: '09121234567', groupId: '2', nationalCode: null });
expect(page1.next).toBe('501:0');
http.mockReturnValue(reply(404, { ERROR: 'هیچ رکوردی یافت نشد.' }));
let cursor: string | null = page1.next;
let calls = 0;
while (cursor) {
cursor = (await t.listCustomers(s, cursor)).next;
calls += 1;
}
expect(calls).toBe(4);
});
it('looks a mobile up in both stored forms and keeps exact matches only', async () => {
http
.mockReturnValueOnce(reply(200, { '1': { Code: 1, Name: 'a', Mobile: '9121234567' }, '790': { Code: 790, Name: 'b', Mobile: '9121234567' } }))
.mockReturnValueOnce(reply(404, { ERROR: 'هیچ رکوردی یافت نشد.' }));
const found = await t.findCustomersByMobile(s, '09121234567');
expect(sent(0)).toEqual({ DoListMoshtari: [9121234567] });
expect(sent(1)).toEqual({ DoListMoshtari: ['09121234567'] });
expect(found.map((c) => c.code)).toEqual(['1', '790']);
});
it('creates with an auto code and returns it', async () => {
http.mockReturnValueOnce(reply(200, { OK: 35 }));
const res = await t.createCustomer(s, {
firstName: 'علی',
lastName: 'محمدی',
fullName: 'علی محمدی',
mobile: '09121112233',
phone: null,
address: null,
nationalCode: null,
group: { id: '13', name: 'مشتري' },
});
expect(res.code).toBe('35');
const args = sent().DoNewMoshtari;
expect(args[0]).toBe('علی محمدی');
expect(args[1]).toBe('مشتري');
expect(args[2]).toBe('09121112233');
expect(args[8]).toBe(-1);
});
});
describe('balances and statement', () => {
it('flips the sign: in Tahesab negative means the customer owes', async () => {
http.mockReturnValueOnce(
reply(200, { MandeHesab: [{ Code: '1', MandeyeMali: '-238999230', MandeyeVazni: '-177.431' }, { Code: '2', MandeyeMali: '5', MandeyeVazni: '1.5' }] }),
);
const b = await t.getBalances(s, ['1', '2', '999']);
expect(sent()).toEqual({ getmandehesabbycode: ['1', '2', '999'] });
expect(b).toEqual([
{ code: '1', goldGrams: new Prisma.Decimal('177.431'), rial: new Prisma.Decimal('238999230') },
{ code: '2', goldGrams: new Prisma.Decimal('-1.5'), rial: new Prisma.Decimal('-5') },
]);
});
it('maps DoListAsnad rows (real test-server rows)', async () => {
http.mockReturnValueOnce(
reply(200, {
'11558': {
ID: 11558, Radif: 1, Factor_Code: '123456789123421578191111985', MCode: 1, Tarikh: '1405/07/11', Tarikh_Time: '19:47:44',
NO: 'ورود کارساخته', Sh_Factor: 228, Vazn: 1.25, No_Kar: 'testmm', Ayar: 750, Fi: 10000, Mazaneh: 'null', Count: 1,
TabdilVazn: 1.25, Mali: 100000, Sharh1: 'sharhtest', TahesabVazni: 11526.174, TahesabMali: -209358396276,
Arz: 'IRR', Jens_Felez: 0, isSanadMovaghat: false, DarsadSood: 5.5,
},
'12261': {
ID: 12261, Radif: 1, Factor_Code: 'GBORDX', MCode: 1, Tarikh: '1405/07/12', NO: 'خروج کارساخته', Vazn: 2,
TabdilVazn: -2, Mali: 'null', TahesabVazni: 0, TahesabMali: 0, Arz: 'IRR', Jens_Felez: 0, isSanadMovaghat: true,
},
}),
);
const rows = await t.getStatement(s, { code: '1', from: { jy: 1405, jm: 6, jd: 1 }, to: { jy: 1405, jm: 7, jd: 13 } });
expect(sent()).toEqual({ DoListAsnad: [-1, 1, '1405-06-01', '1405-07-13', '', 0, 'IRR'] });
expect(rows[0]).toMatchObject({
documentRef: '123456789123421578191111985',
accountCode: '1',
title: 'ورود کارساخته',
itemName: 'testmm',
metal: 'طلا',
temporary: false,
});
expect(rows[0].goldIn.toString()).toBe('1.25');
expect(rows[0].goldOut.toString()).toBe('0');
expect(rows[0].rialIn.toString()).toBe('100000');
expect(rows[0].goldBalance?.toString()).toBe('-11526.174');
expect(rows[0].rialBalance?.toString()).toBe('209358396276');
// A temporary row moves nothing yet: no balance of its own.
expect(rows[1]).toMatchObject({ documentRef: 'gbordx', temporary: true, goldBalance: null });
expect(rows[1].goldOut.toString()).toBe('2');
});
});
describe('order documents', () => {
const doc: OrderDocumentInput = {
factorCode: 'gbordcm1abcdefghijklmnopqrstuv',
customerCode: '2',
date: new Date('2026-10-05T08:00:00Z'),
description: 'سفارش 1001 از اپ',
temporary: true,
lines: [
{ itemCode: 'انگشتر', itemName: 'انگشتر', weightGrams: new Prisma.Decimal('4.200'), count: 2, wagePercent: new Prisma.Decimal(10), flatRial: new Prisma.Decimal(0), karat: 750, description: 'line 1' },
{ itemCode: 'النگو', itemName: 'النگو', weightGrams: new Prisma.Decimal('10'), count: 1, wagePercent: new Prisma.Decimal('18.5'), flatRial: new Prisma.Decimal(500000), karat: 750, description: 'line 2' },
],
};
it('raises the karat by the making charge — the only field that moves the gold balance', () => {
expect(karatWithWage(750, new Prisma.Decimal(10)).toString()).toBe('825');
expect(karatWithWage(750, new Prisma.Decimal('18.5')).toString()).toBe('888.75');
});
it('posts one temporary exit row per line under one Factor_Code', async () => {
http.mockReturnValue(reply(200, { OK: doc.factorCode, Factor_Code: doc.factorCode }));
const progress: unknown[] = [];
const res = await t.postOrderDocument(s, doc, {}, async (p) => {
progress.push(p);
});
const r1 = sent(0).DoNewSanadVKHKar;
const r2 = sent(1).DoNewSanadVKHKar;
expect(r1).toHaveLength(27);
expect(r1[0]).toBe(0); // temporary
expect(r1[1]).toBe(2); // Moshtari_Code
expect([r1[3], r2[3]]).toEqual([1, 2]); // Radif_Number
expect([r1[4], r1[5], r1[6]]).toEqual([1405, 7, 13]);
expect(r1[7]).toBe(4.2); // physical weight
expect(r1[8]).toBe(825); // karat with the charge
expect(r1[9]).toBe(2);
expect(r1[10]).toBe('انگشتر');
expect(r1[11]).toBe(1); // exit
expect(r1[16]).toBe(0); // Darsad_Talayee never double-counts
expect(r2[13]).toBe(500000); // stones in MablaghKol
expect(r2[20]).toBe(0); // not PoolSang
expect(r1[22]).toBe(doc.factorCode);
expect(r1[24]).toBe(1); // MultiRadif
expect(progress).toEqual([{ doneLines: 1 }, { doneLines: 2 }]);
expect(res.ref).toBe(doc.factorCode);
});
it('resumes after the last confirmed line', async () => {
http.mockReturnValue(reply(200, { OK: doc.factorCode, Factor_Code: doc.factorCode }));
await t.postOrderDocument(s, doc, { doneLines: 1 }, async () => undefined);
expect(http).toHaveBeenCalledTimes(1);
expect(sent(0).DoNewSanadVKHKar[3]).toBe(2);
});
it('treats a duplicate-row rejection as "already there", which makes a retry safe', async () => {
http
.mockReturnValueOnce(
reply(400, {
ERROR:
"[EUniError] The statement has been terminated.\r\nCannot insert duplicate key row in object 'dbo.hesab_detail' with unique index 'UX_hesab_detail_UniqRowID'.",
}),
)
.mockReturnValueOnce(reply(200, { OK: doc.factorCode, Factor_Code: doc.factorCode }));
const res = await t.postOrderDocument(s, doc, {}, async () => undefined);
expect(res.progress).toEqual({ doneLines: 2 });
});
it('does not swallow other errors', async () => {
http.mockReturnValueOnce(reply(400, { ERROR: 'کد حساب نامعتبر است' }));
await expect(t.postOrderDocument(s, doc, {}, async () => undefined)).rejects.toBeInstanceOf(AccountingProviderError);
});
it('inquires and deletes by Factor_Code', async () => {
http.mockReturnValueOnce(reply(200, { Found: doc.factorCode })).mockReturnValueOnce(reply(404, { ERROR: 'Not_Found' }));
expect(await t.documentExists(s, { factorCode: doc.factorCode, ref: null })).toBe(true);
expect(await t.documentExists(s, { factorCode: 'x', ref: null })).toBe(false);
http.mockReturnValueOnce(reply(404, { ERROR: 'سند یافت نشد.' }));
expect((await t.deleteDocument(s, { factorCode: 'x', ref: null, customerCode: '2' })).response).toEqual({ alreadyDeleted: true });
});
});
describe('items', () => {
it('skips item names that lost their encoding and reads stock per name', async () => {
http.mockReturnValueOnce(reply(200, [{ Name: 'انگشتر', Ayar: 750 }, { Name: '???????0.400????' }]));
const items = await t.listKarSakhte(s);
expect(items.map((i) => i.name)).toEqual(['انگشتر']);
http.mockReturnValueOnce(reply(200, { Name: 'انگشتر', VaznMojood: 12.5, TedadMojood: 3, MaxAyar: 750 }));
const stock = await t.getKarSakhteStock(s, { code: 'انگشتر', name: 'انگشتر' });
expect(sent(1)).toEqual({ GetMojoodiKarSakhte: ['انگشتر', 0] });
expect(stock.weight?.toString()).toBe('12.5');
expect(stock.count?.toString()).toBe('3');
});
it('cannot create items through the API', async () => {
await expect(t.createKarSakhte(s, { name: 'x', groupId: null, typeId: null })).rejects.toThrow('وجود ندارد');
});
});
describe('change feed (real-time sync)', () => {
const row = (ID: number, MCode: number, extra: Record<string, unknown> = {}) => ({
ID,
MCode,
NO: 'فروش طلا',
No_Kar: 'null',
ZamanEdit: null,
...extra,
});
const feed = (rows: ReturnType<typeof row>[]) => reply(200, Object.fromEntries(rows.map((r) => [String(r.ID), r])));
it('the first poll only returns the starting cursor', async () => {
http.mockReturnValueOnce(feed([row(10, 1), row(11, 2)]));
expect(await t.getChanges(s, null)).toEqual({ cursor: '11|', accountCodes: [], itemNames: [], overflow: false });
expect(sent()).toEqual({ DoListUpdatedAsnad: [200] });
});
it('later polls report the accounts and کارساخته of new documents only', async () => {
http.mockReturnValueOnce(
feed([row(11, 2), row(12, 7), row(13, 7), row(14, 9, { NO: 'خروج کارساخته', No_Kar: 'انگشتر' })]),
);
const r = await t.getChanges(s, '11|');
expect(r.accountCodes.sort()).toEqual(['7', '9']);
expect(r.itemNames).toEqual(['انگشتر']);
expect(r.cursor).toBe('14|');
expect(r.overflow).toBe(false);
});
it('sees an edit of an old document through ZamanEdit', async () => {
http.mockReturnValueOnce(feed([row(11, 2, { ZamanEdit: '1405/07/12 18:00:00' }), row(12, 3)]));
const r = await t.getChanges(s, '12|1405/07/12 17:00:00');
expect(r.accountCodes).toEqual(['2']);
});
it('says overflow when every row of the window is new', async () => {
const rows = Array.from({ length: 200 }, (_, i) => row(100 + i, i));
http.mockReturnValueOnce(feed(rows));
expect((await t.getChanges(s, '50|')).overflow).toBe(true);
});
it('reports nothing when nothing changed', async () => {
http.mockReturnValueOnce(feed([row(11, 2), row(12, 3)]));
expect(await t.getChanges(s, '12|')).toMatchObject({ accountCodes: [], itemNames: [], overflow: false });
});
});