- 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>
299 lines
14 KiB
TypeScript
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 });
|
|
});
|
|
});
|