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>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
a72fa607a5
commit
19d490e790
@@ -0,0 +1,310 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { JalaliDate } from '../jalali';
|
||||
|
||||
/**
|
||||
* One accounting software (D27). AccountingSyncService picks a driver by the
|
||||
* admin's switch and owns everything around the call — links, outbox, retries,
|
||||
* logging — so a driver only translates between the app's shapes and its
|
||||
* software's API.
|
||||
*
|
||||
* Every figure a driver returns is ALREADY in the app's convention:
|
||||
* * gold in grams of 750, positive balance = the customer owes;
|
||||
* * rial in rial, positive balance = the customer owes.
|
||||
* Each software's own sign and unit quirks stay inside its driver.
|
||||
*/
|
||||
|
||||
export type ProviderName = 'tahesab' | 'zarrin';
|
||||
|
||||
/** Decrypted connection settings, handed to every call. */
|
||||
export interface ProviderSettings {
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
caCert: string | null;
|
||||
timeoutMs: number;
|
||||
/** Tahesab only: the `DBname` header (`TahesabDB` for SQL Server, `DB` for Access). */
|
||||
dbName?: string;
|
||||
/** Zarrin only, until verified on a real installation (plan §9). */
|
||||
balanceOwesWhen?: 'negative' | 'positive';
|
||||
}
|
||||
|
||||
export interface ExternalGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ExternalCustomer {
|
||||
code: string;
|
||||
name: string;
|
||||
/** Normalised to 09xxxxxxxxx, or null when the account has no usable mobile. */
|
||||
mobile: string | null;
|
||||
phone: string | null;
|
||||
nationalCode: string | null;
|
||||
address: string | null;
|
||||
groupId: string | null;
|
||||
groupName: string | null;
|
||||
}
|
||||
|
||||
export interface CustomerPayload {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
fullName: string;
|
||||
mobile: string;
|
||||
phone: string | null;
|
||||
address: string | null;
|
||||
nationalCode: string | null;
|
||||
group: { id: string | null; name: string | null };
|
||||
}
|
||||
|
||||
export interface ExternalBalance {
|
||||
code: string;
|
||||
goldGrams: Prisma.Decimal;
|
||||
rial: Prisma.Decimal;
|
||||
}
|
||||
|
||||
export interface StatementQuery {
|
||||
code: string;
|
||||
from: JalaliDate;
|
||||
to: JalaliDate;
|
||||
}
|
||||
|
||||
/** One line of a customer's statement, normalised across providers. */
|
||||
export interface StatementRow {
|
||||
/** Stable within the provider (Tahesab ID + Radif, Zarrin idRadif). */
|
||||
key: string;
|
||||
/** The document the line belongs to — what a detail lookup is keyed on. */
|
||||
documentRef: string;
|
||||
/** The account the line belongs to, for the SEC-16 ownership check. */
|
||||
accountCode: string;
|
||||
documentNo: string;
|
||||
/** Jalali `1405/07/12`. */
|
||||
date: string;
|
||||
time: string | null;
|
||||
title: string;
|
||||
description: string | null;
|
||||
itemName: string | null;
|
||||
weight: Prisma.Decimal | null;
|
||||
karat: number | null;
|
||||
count: Prisma.Decimal | null;
|
||||
unitPrice: Prisma.Decimal | null;
|
||||
wagePercent: Prisma.Decimal | null;
|
||||
/** 750-equivalent gold received from the customer (reduces what they owe). */
|
||||
goldIn: Prisma.Decimal;
|
||||
goldOut: Prisma.Decimal;
|
||||
rialIn: Prisma.Decimal;
|
||||
rialOut: Prisma.Decimal;
|
||||
/** Running balance after this line, when the software reports one. */
|
||||
goldBalance: Prisma.Decimal | null;
|
||||
rialBalance: Prisma.Decimal | null;
|
||||
metal: string | null;
|
||||
temporary: boolean;
|
||||
}
|
||||
|
||||
export interface ExternalKarSakhte {
|
||||
/** Zarrin idKar; for Tahesab, which has no item ids, the item name itself. */
|
||||
code: string;
|
||||
name: string;
|
||||
groupId: string | null;
|
||||
groupName: string | null;
|
||||
typeId: string | null;
|
||||
typeName: string | null;
|
||||
saleWagePercent: Prisma.Decimal | null;
|
||||
stockWeight: Prisma.Decimal | null;
|
||||
stockCount: Prisma.Decimal | null;
|
||||
}
|
||||
|
||||
export interface KarSakhteStock {
|
||||
weight: Prisma.Decimal | null;
|
||||
count: Prisma.Decimal | null;
|
||||
}
|
||||
|
||||
export interface MovementRow {
|
||||
itemCode: string | null;
|
||||
itemName: string;
|
||||
date: string;
|
||||
description: string | null;
|
||||
weight: Prisma.Decimal | null;
|
||||
amount: Prisma.Decimal | null;
|
||||
count: Prisma.Decimal | null;
|
||||
}
|
||||
|
||||
export interface DocumentLine {
|
||||
/** The linked کارساخته: Zarrin idKar / Tahesab Name_Kar. */
|
||||
itemCode: string;
|
||||
itemName: string;
|
||||
/** Physical (raw) weight of the line, all pieces. */
|
||||
weightGrams: Prisma.Decimal;
|
||||
count: number;
|
||||
/** Effective making charge: variant wage minus discount, in percent. */
|
||||
wagePercent: Prisma.Decimal;
|
||||
/** Flat rial charge of the line (stones), all pieces. */
|
||||
flatRial: Prisma.Decimal;
|
||||
karat: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface OrderDocumentInput {
|
||||
/** Deterministic per order; Tahesab dedupes on it (≥ 20 chars, lowercase). */
|
||||
factorCode: string;
|
||||
customerCode: string;
|
||||
date: Date;
|
||||
description: string;
|
||||
temporary: boolean;
|
||||
lines: DocumentLine[];
|
||||
}
|
||||
|
||||
/** Resumable state of a multi-call document (Zarrin posts one call per line). */
|
||||
export interface DocumentProgress {
|
||||
factorId?: string;
|
||||
doneLines?: number;
|
||||
}
|
||||
|
||||
export interface DocumentPostResult {
|
||||
ref: string;
|
||||
progress: DocumentProgress;
|
||||
response: unknown;
|
||||
}
|
||||
|
||||
/** What changed in the accounting software since the last poll (real-time sync). */
|
||||
export interface ChangeBatch {
|
||||
/** Opaque; hand it back on the next poll. */
|
||||
cursor: string;
|
||||
/** Accounts that got a new or edited document. */
|
||||
accountCodes: string[];
|
||||
/** کارساخته names that got a new or edited document. */
|
||||
itemNames: string[];
|
||||
/** More changed than one poll can see: the caller must do a full refresh. */
|
||||
overflow: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderCapabilities {
|
||||
/** Can a کارساخته be created through the API? (Zarrin yes, Tahesab no.) */
|
||||
createKarSakhte: boolean;
|
||||
/** Can a posted document be looked up by our own key before a retry? */
|
||||
documentInquiry: boolean;
|
||||
/** Does the movement report cover every account, or only recent documents? */
|
||||
movementScope: 'all' | 'recent-documents';
|
||||
/** Can recent changes be polled cheaply (see getChanges)? */
|
||||
changeFeed: boolean;
|
||||
}
|
||||
|
||||
export interface AccountingProvider {
|
||||
readonly name: ProviderName;
|
||||
readonly label: string;
|
||||
readonly capabilities: ProviderCapabilities;
|
||||
|
||||
health(s: ProviderSettings): Promise<Record<string, unknown>>;
|
||||
|
||||
listCustomerGroups(s: ProviderSettings): Promise<ExternalGroup[]>;
|
||||
/** One page of accounts; `next` is null after the last page. */
|
||||
listCustomers(s: ProviderSettings, cursor: string | null): Promise<{ items: ExternalCustomer[]; next: string | null }>;
|
||||
findCustomersByMobile(s: ProviderSettings, mobile: string): Promise<ExternalCustomer[]>;
|
||||
createCustomer(s: ProviderSettings, p: CustomerPayload): Promise<{ code: string; response: unknown }>;
|
||||
updateCustomer(s: ProviderSettings, code: string, p: CustomerPayload): Promise<{ response: unknown }>;
|
||||
|
||||
getBalances(s: ProviderSettings, codes: string[]): Promise<ExternalBalance[]>;
|
||||
/**
|
||||
* Documents added or edited since `cursor` (null = first poll: only returns
|
||||
* the starting cursor). Cheap enough to call every few seconds.
|
||||
*/
|
||||
getChanges(s: ProviderSettings, cursor: string | null): Promise<ChangeBatch>;
|
||||
getStatement(s: ProviderSettings, q: StatementQuery): Promise<StatementRow[]>;
|
||||
|
||||
listKarSakhteGroups(s: ProviderSettings): Promise<ExternalGroup[]>;
|
||||
listKarSakhteTypes(s: ProviderSettings): Promise<ExternalGroup[]>;
|
||||
listKarSakhte(s: ProviderSettings): Promise<ExternalKarSakhte[]>;
|
||||
getKarSakhteStock(s: ProviderSettings, item: { code: string; name: string }): Promise<KarSakhteStock>;
|
||||
getKarSakhteMovement(s: ProviderSettings, from: JalaliDate, to: JalaliDate): Promise<MovementRow[]>;
|
||||
createKarSakhte(
|
||||
s: ProviderSettings,
|
||||
item: { name: string; groupId: string | null; typeId: string | null },
|
||||
): Promise<{ code: string; response: unknown }>;
|
||||
|
||||
postOrderDocument(
|
||||
s: ProviderSettings,
|
||||
doc: OrderDocumentInput,
|
||||
progress: DocumentProgress,
|
||||
onProgress: (p: DocumentProgress) => Promise<void>,
|
||||
): Promise<DocumentPostResult>;
|
||||
/** True when a document with this key exists. */
|
||||
documentExists(s: ProviderSettings, doc: { factorCode: string; ref: string | null }): Promise<boolean>;
|
||||
deleteDocument(
|
||||
s: ProviderSettings,
|
||||
doc: { factorCode: string; ref: string | null; customerCode: string },
|
||||
): Promise<{ response: unknown }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A call the software answered with an error, or could not be made.
|
||||
* `ambiguous` = the software may have acted on it anyway (timeout after send).
|
||||
*/
|
||||
export class AccountingProviderError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly ambiguous = false,
|
||||
readonly response?: unknown,
|
||||
readonly notFound = false,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AccountingProviderError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Not offered by this software's API. */
|
||||
export class AccountingNotSupportedError extends AccountingProviderError {
|
||||
constructor(what: string) {
|
||||
super(`${what} در API این نرمافزار وجود ندارد`);
|
||||
this.name = 'AccountingNotSupportedError';
|
||||
}
|
||||
}
|
||||
|
||||
// ── shared helpers ─────────────────────────────────────────────────────
|
||||
|
||||
const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
|
||||
const ARABIC_DIGITS = '٠١٢٣٤٥٦٧٨٩';
|
||||
|
||||
export function asciiDigits(v: string) {
|
||||
return v
|
||||
.replace(/[۰-۹]/g, (d) => String(PERSIAN_DIGITS.indexOf(d)))
|
||||
.replace(/[٠-٩]/g, (d) => String(ARABIC_DIGITS.indexOf(d)));
|
||||
}
|
||||
|
||||
/**
|
||||
* `9121234567`, `09121234567`, `+989121234567`, `00989121234567`, Persian
|
||||
* digits → `09121234567`. Anything that is not an Iranian mobile → null.
|
||||
*/
|
||||
export function normalizeMobile(v: unknown): string | null {
|
||||
if (v === null || v === undefined) return null;
|
||||
let d = asciiDigits(String(v)).replace(/\D/g, '');
|
||||
if (d.startsWith('0098')) d = d.slice(4);
|
||||
else if (d.startsWith('98') && d.length === 12) d = d.slice(2);
|
||||
if (d.length === 10 && d.startsWith('9')) d = `0${d}`;
|
||||
return /^09\d{9}$/.test(d) ? d : null;
|
||||
}
|
||||
|
||||
/** A Prisma.Decimal from a value the software sent, or null for "null"/blank/NaN. */
|
||||
export function dec(v: unknown): Prisma.Decimal | null {
|
||||
if (v === null || v === undefined) return null;
|
||||
const s = asciiDigits(String(v)).replace(/,/g, '').trim();
|
||||
if (s === '' || s.toLowerCase() === 'null' || s.toLowerCase() === 'nan') return null;
|
||||
try {
|
||||
const d = new Prisma.Decimal(s);
|
||||
return d.isFinite() ? d : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const ZERO = new Prisma.Decimal(0);
|
||||
|
||||
export function str(v: unknown): string | null {
|
||||
if (v === null || v === undefined) return null;
|
||||
const s = String(v).trim();
|
||||
return s === '' || s.toLowerCase() === 'null' ? null : s;
|
||||
}
|
||||
|
||||
/** Split a full name into first/last the way the app's DB trigger does. */
|
||||
export function splitName(full: string): { firstName: string; lastName: string } {
|
||||
const parts = full.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length <= 1) return { firstName: parts[0] ?? '', lastName: '' };
|
||||
return { firstName: parts[0], lastName: parts.slice(1).join(' ') };
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { AccountingProvider, ProviderName } from './accounting-provider';
|
||||
import { tahesabProvider } from './tahesab.provider';
|
||||
import { zarrinProvider } from './zarrin.provider';
|
||||
|
||||
/**
|
||||
* Every accounting software, keyed by the admin switch's value. A new one is a
|
||||
* driver file plus one line here — the same shape as the SMS drivers (D26).
|
||||
*/
|
||||
export const ACCOUNTING_PROVIDERS: Readonly<Record<ProviderName, AccountingProvider>> = {
|
||||
tahesab: tahesabProvider,
|
||||
zarrin: zarrinProvider,
|
||||
};
|
||||
|
||||
export function isProviderName(v: unknown): v is ProviderName {
|
||||
return typeof v === 'string' && Object.prototype.hasOwnProperty.call(ACCOUNTING_PROVIDERS, v);
|
||||
}
|
||||
|
||||
export * from './accounting-provider';
|
||||
@@ -0,0 +1,298 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,511 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { AccountingHttpError, httpRequest, parseLooseJson } from '../accounting-http';
|
||||
import { formatJalali, toJalali } from '../jalali';
|
||||
import {
|
||||
AccountingNotSupportedError,
|
||||
AccountingProvider,
|
||||
AccountingProviderError,
|
||||
ExternalCustomer,
|
||||
ProviderSettings,
|
||||
StatementRow,
|
||||
ZERO,
|
||||
dec,
|
||||
normalizeMobile,
|
||||
str,
|
||||
} from './accounting-provider';
|
||||
|
||||
/**
|
||||
* Tahesab (تهحساب) — https://tahesab.ir/APiDoc/ (OpenAPI 3.1, API 10.1405.x).
|
||||
*
|
||||
* Wire format, confirmed against the public test server on 2026-10-05:
|
||||
* POST <base>/ body {"<Method>": [args by position]}
|
||||
* headers Authorization: Bearer <token>, DBname: TahesabDB (SQL Server) | DB (Access)
|
||||
* The server dispatches on the body key; the path in the docs is cosmetic.
|
||||
* Errors come back as {"ERROR": "..."} with HTTP 400/404.
|
||||
*
|
||||
* Behaviour measured on the test server (plan §9), which this driver relies on:
|
||||
* * Balance sign: an exit (خروج) LOWERS MandeyeVazni — negative = the
|
||||
* customer owes, as henza_server's `* -1` assumed. Flipped here.
|
||||
* * A temporary (موقت) document does not move the balance until the
|
||||
* accountant finalises it.
|
||||
* * The gold a document moves is Vazn × Ayar / 750. Darsad_Talayee and
|
||||
* Ojrat are stored but do NOT move the balance, so the making charge is sent
|
||||
* as a raised Ayar (750 at 10% → 825); Vazn stays the physical weight.
|
||||
* * PoolSang does not move the rial balance; MablaghKol does.
|
||||
* * Factor_Code is stored lowercased; re-sending a row with the same
|
||||
* Factor_Code + Radif_Number is rejected as a duplicate key — which is what
|
||||
* makes a retry safe.
|
||||
*/
|
||||
|
||||
const CUSTOMER_PAGE = 500;
|
||||
/** Consecutive empty code windows before the account list is considered done. */
|
||||
const EMPTY_WINDOWS_TO_STOP = 4;
|
||||
const MAX_CUSTOMER_CODE = 2_000_000;
|
||||
const BALANCE_CHUNK = 100;
|
||||
const RECENT_DOCUMENTS = 1000;
|
||||
const CHANGE_WINDOW = 200;
|
||||
|
||||
function baseUrl(s: ProviderSettings) {
|
||||
return s.baseUrl.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function headers(s: ProviderSettings) {
|
||||
return {
|
||||
Authorization: `Bearer ${s.token}`,
|
||||
DBname: s.dbName || 'TahesabDB',
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
function isNotFoundMessage(msg: string) {
|
||||
return /یافت نشد|Not_Found|not found/i.test(msg);
|
||||
}
|
||||
|
||||
async function call(s: ProviderSettings, method: string, args: unknown[]): Promise<unknown> {
|
||||
let res;
|
||||
try {
|
||||
res = await httpRequest({
|
||||
method: 'POST',
|
||||
url: `${baseUrl(s)}/`,
|
||||
headers: headers(s),
|
||||
body: JSON.stringify({ [method]: args }),
|
||||
timeoutMs: s.timeoutMs,
|
||||
ca: s.caCert,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof AccountingHttpError) {
|
||||
throw new AccountingProviderError(`تهحساب (${method}): ${err.message}`, err.ambiguous);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
let json: unknown;
|
||||
try {
|
||||
json = parseLooseJson(res.body);
|
||||
} catch {
|
||||
throw new AccountingProviderError(
|
||||
`تهحساب (${method}): پاسخ نامعتبر (HTTP ${res.status})`,
|
||||
res.status >= 200 && res.status < 300,
|
||||
res.body.slice(0, 500),
|
||||
);
|
||||
}
|
||||
|
||||
const error = json && typeof json === 'object' && !Array.isArray(json) ? (json as { ERROR?: unknown }).ERROR : undefined;
|
||||
if (error !== undefined || res.status < 200 || res.status >= 300) {
|
||||
const msg = typeof error === 'string' ? error : `HTTP ${res.status}`;
|
||||
throw new AccountingProviderError(
|
||||
`تهحساب (${method}): ${msg.split('\r\n')[0]}`,
|
||||
false,
|
||||
trimDiagnostics(json),
|
||||
res.status === 404 || isNotFoundMessage(msg),
|
||||
);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/** Tahesab error bodies carry ~30 diagnostic fields; keep only the message. */
|
||||
function trimDiagnostics(json: unknown) {
|
||||
if (json && typeof json === 'object' && 'ERROR' in (json as object)) {
|
||||
return { ERROR: (json as { ERROR: unknown }).ERROR };
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/** A method that answers "nothing found" with an ERROR, as an empty result. */
|
||||
async function callList(s: ProviderSettings, method: string, args: unknown[]) {
|
||||
try {
|
||||
return await call(s, method, args);
|
||||
} catch (err) {
|
||||
if (err instanceof AccountingProviderError && err.notFound) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Tahesab keys row objects by id: {"1": {...}, "2": {...}}. */
|
||||
function rowsOf(json: unknown): Record<string, unknown>[] {
|
||||
if (!json) return [];
|
||||
if (Array.isArray(json)) return json as Record<string, unknown>[];
|
||||
if (typeof json === 'object') {
|
||||
return Object.values(json as Record<string, unknown>).filter(
|
||||
(v): v is Record<string, unknown> => !!v && typeof v === 'object',
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function toCustomer(r: Record<string, unknown>): ExternalCustomer {
|
||||
return {
|
||||
code: String(r.Code),
|
||||
name: str(r.Name) ?? '',
|
||||
mobile: normalizeMobile(r.Mobile),
|
||||
phone: null,
|
||||
nationalCode: str(r.CodeMelli),
|
||||
address: str(r.Address),
|
||||
groupId: str(r.GID),
|
||||
groupName: str(r.GoroupName),
|
||||
};
|
||||
}
|
||||
|
||||
const neg = (d: Prisma.Decimal | null) => (d ? d.negated() : null);
|
||||
|
||||
const METALS: Record<string, string> = { '0': 'طلا', '1': 'پلاتین', '2': 'نقره' };
|
||||
|
||||
/**
|
||||
* Rounds the raised karat to 3 decimals, which is what the desktop app keeps.
|
||||
* 750 × (1 + 10/100) = 825.
|
||||
*/
|
||||
export function karatWithWage(karat: number, wagePercent: Prisma.Decimal) {
|
||||
return new Prisma.Decimal(karat).mul(new Prisma.Decimal(1).add(wagePercent.div(100))).toDecimalPlaces(3);
|
||||
}
|
||||
|
||||
export const tahesabProvider: AccountingProvider = {
|
||||
name: 'tahesab',
|
||||
label: 'تهحساب',
|
||||
capabilities: { createKarSakhte: false, documentInquiry: true, movementScope: 'recent-documents', changeFeed: true },
|
||||
|
||||
async health(s) {
|
||||
const path = baseUrl(s).startsWith('https:') ? 'CheckHealth-SSL' : 'CheckHealth-No-SSL';
|
||||
let info: Record<string, unknown> = {};
|
||||
try {
|
||||
const res = await httpRequest({
|
||||
method: 'GET',
|
||||
url: `${baseUrl(s)}/${path}`,
|
||||
headers: headers(s),
|
||||
timeoutMs: s.timeoutMs,
|
||||
ca: s.caCert,
|
||||
});
|
||||
const json = parseLooseJson(res.body) as Record<string, unknown>;
|
||||
info = {
|
||||
status: json.Status,
|
||||
api: json.Api_Status,
|
||||
db: json.DB_Status,
|
||||
version: json.TahesabVersion,
|
||||
dbType: json.DBType,
|
||||
dbName: json.DBName,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof AccountingHttpError) throw new AccountingProviderError(`تهحساب: ${err.message}`);
|
||||
throw new AccountingProviderError('تهحساب: پاسخ CheckHealth نامعتبر است');
|
||||
}
|
||||
// CheckHealth ignores the token and DBname; a real call proves both.
|
||||
const groups = rowsOf(await call(s, 'DoListGorooh', []));
|
||||
return { ...info, customerGroups: groups.length };
|
||||
},
|
||||
|
||||
async listCustomerGroups(s) {
|
||||
const groups = rowsOf(await call(s, 'DoListGorooh', [])).map((g) => ({
|
||||
id: String(g.GID),
|
||||
name: str(g.Name) ?? String(g.GID),
|
||||
}));
|
||||
// Accounts outside every defined group carry GID 0 (seen on the test
|
||||
// server as «مشتري»); offer it so it can be picked for import.
|
||||
if (!groups.some((g) => g.id === '0')) groups.push({ id: '0', name: 'بدون گروه (GID 0)' });
|
||||
return groups;
|
||||
},
|
||||
|
||||
async listCustomers(s, cursor) {
|
||||
let [start, empty] = (cursor ?? '1:0').split(':').map(Number);
|
||||
if (!Number.isFinite(start) || start < 1) start = 1;
|
||||
if (!Number.isFinite(empty)) empty = 0;
|
||||
|
||||
const json = await callList(s, 'Dolistmoshtari', [start, start + CUSTOMER_PAGE - 1]);
|
||||
const items = rowsOf(json).map(toCustomer);
|
||||
empty = items.length === 0 ? empty + 1 : 0;
|
||||
const nextStart = start + CUSTOMER_PAGE;
|
||||
const done = empty >= EMPTY_WINDOWS_TO_STOP || nextStart > MAX_CUSTOMER_CODE;
|
||||
return { items, next: done ? null : `${nextStart}:${empty}` };
|
||||
},
|
||||
|
||||
async findCustomersByMobile(s, mobile) {
|
||||
const target = normalizeMobile(mobile);
|
||||
if (!target) return [];
|
||||
// The account's Mobile is free text: some rows hold 9121234567, others
|
||||
// 09121234567, and the lookup matches the stored form. Ask both ways.
|
||||
const found = new Map<string, ExternalCustomer>();
|
||||
for (const form of [Number(target.slice(1)), target]) {
|
||||
for (const r of rowsOf(await callList(s, 'DoListMoshtari', [form]))) {
|
||||
const c = toCustomer(r);
|
||||
if (c.mobile === target) found.set(c.code, c);
|
||||
}
|
||||
}
|
||||
return [...found.values()];
|
||||
},
|
||||
|
||||
async createCustomer(s, p) {
|
||||
const json = (await call(s, 'DoNewMoshtari', [
|
||||
p.fullName,
|
||||
p.group.name ?? '',
|
||||
p.mobile,
|
||||
p.address ?? '',
|
||||
p.nationalCode ?? '',
|
||||
'',
|
||||
'',
|
||||
'-1',
|
||||
-1, // Moshtari_Code -1: Tahesab assigns the code
|
||||
0, // Jens_Felez: gold
|
||||
])) as { OK?: unknown };
|
||||
if (json?.OK === undefined || json.OK === null) {
|
||||
throw new AccountingProviderError('تهحساب: کد حساب جدید برنگشت', false, json);
|
||||
}
|
||||
return { code: String(json.OK), response: json };
|
||||
},
|
||||
|
||||
async updateCustomer(s, code, p) {
|
||||
// "-1" leaves a field unchanged (DoEditMoshtari example in the docs).
|
||||
const json = await call(s, 'DoEditMoshtari', [
|
||||
p.fullName,
|
||||
p.group.name ?? '-1',
|
||||
p.mobile,
|
||||
p.address ?? '-1',
|
||||
p.nationalCode ?? '-1',
|
||||
'-1',
|
||||
'-1',
|
||||
'-1',
|
||||
Number(code),
|
||||
]);
|
||||
return { response: json };
|
||||
},
|
||||
|
||||
async getBalances(s, codes) {
|
||||
const out = [];
|
||||
for (let i = 0; i < codes.length; i += BALANCE_CHUNK) {
|
||||
const chunk = codes.slice(i, i + BALANCE_CHUNK);
|
||||
// Unknown codes are simply left out of MandeHesab.
|
||||
const json = (await callList(s, 'getmandehesabbycode', chunk)) as { MandeHesab?: unknown } | null;
|
||||
for (const r of rowsOf(json?.MandeHesab)) {
|
||||
out.push({
|
||||
code: String(r.Code),
|
||||
goldGrams: (dec(r.MandeyeVazni) ?? ZERO).negated(),
|
||||
rial: (dec(r.MandeyeMali) ?? ZERO).negated(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
async getChanges(s, cursor) {
|
||||
// DoListUpdatedAsnad lists the newest documents by registration/edit time.
|
||||
// Cursor "maxId|latestEdit": a row is news when its ID is higher, or it
|
||||
// carries a later ZamanEdit. Nothing older than the window is visible, so
|
||||
// a window with no already-seen row means "too many changes: refresh all".
|
||||
const rows = rowsOf(await callList(s, 'DoListUpdatedAsnad', [CHANGE_WINDOW]));
|
||||
let maxId = 0;
|
||||
let latestEdit = '';
|
||||
for (const r of rows) {
|
||||
maxId = Math.max(maxId, Number(r.ID) || 0);
|
||||
const edited = String(r.ZamanEdit ?? '');
|
||||
if (edited && edited !== 'null' && edited > latestEdit) latestEdit = edited;
|
||||
}
|
||||
const next = `${maxId}|${latestEdit}`;
|
||||
if (cursor === null) return { cursor: next, accountCodes: [], itemNames: [], overflow: false };
|
||||
|
||||
const [seenId, seenEdit] = [Number(cursor.split('|')[0]) || 0, cursor.split('|')[1] ?? ''];
|
||||
const fresh = rows.filter((r) => {
|
||||
const edited = String(r.ZamanEdit ?? '');
|
||||
return (Number(r.ID) || 0) > seenId || (!!edited && edited !== 'null' && edited > seenEdit);
|
||||
});
|
||||
const accounts = new Set<string>();
|
||||
const items = new Set<string>();
|
||||
for (const r of fresh) {
|
||||
const code = str(r.MCode);
|
||||
if (code) accounts.add(code);
|
||||
const kar = str(r.No_Kar);
|
||||
if (kar && /کار/.test(String(r.NO ?? ''))) items.add(kar);
|
||||
}
|
||||
return {
|
||||
cursor: next,
|
||||
accountCodes: [...accounts],
|
||||
itemNames: [...items],
|
||||
overflow: rows.length >= CHANGE_WINDOW && fresh.length === rows.length,
|
||||
};
|
||||
},
|
||||
|
||||
async getStatement(s, q) {
|
||||
// Count_Last = -1 asks for the running balance on every row.
|
||||
const json = await callList(s, 'DoListAsnad', [
|
||||
-1,
|
||||
Number(q.code),
|
||||
formatJalali(q.from),
|
||||
formatJalali(q.to),
|
||||
'',
|
||||
0,
|
||||
'IRR',
|
||||
]);
|
||||
return rowsOf(json).map((r) => toStatementRow(r));
|
||||
},
|
||||
|
||||
async listKarSakhteGroups() {
|
||||
return [];
|
||||
},
|
||||
|
||||
async listKarSakhteTypes() {
|
||||
return [];
|
||||
},
|
||||
|
||||
async listKarSakhte(s) {
|
||||
// Only items that already have a document appear here (Tahesab has no
|
||||
// item master in its API). Names that arrive as "????" lost their
|
||||
// encoding upstream and cannot be matched back; they are skipped.
|
||||
return rowsOf(await callList(s, 'DoListNameKarsakhte', []))
|
||||
.map((r) => str(r.Name))
|
||||
.filter((name): name is string => !!name && !name.includes('??'))
|
||||
.map((name) => ({
|
||||
code: name,
|
||||
name,
|
||||
groupId: null,
|
||||
groupName: null,
|
||||
typeId: null,
|
||||
typeName: null,
|
||||
saleWagePercent: null,
|
||||
stockWeight: null,
|
||||
stockCount: null,
|
||||
}));
|
||||
},
|
||||
|
||||
async getKarSakhteStock(s, item) {
|
||||
const json = (await callList(s, 'GetMojoodiKarSakhte', [item.name, 0])) as Record<string, unknown> | null;
|
||||
return { weight: dec(json?.VaznMojood), count: dec(json?.TedadMojood) };
|
||||
},
|
||||
|
||||
async getKarSakhteMovement(s, from, to) {
|
||||
// Tahesab cannot list documents across all accounts by date; the closest
|
||||
// is the most recent N documents, filtered here.
|
||||
const lo = formatJalali(from, '/');
|
||||
const hi = formatJalali(to, '/');
|
||||
return rowsOf(await callList(s, 'DoListUpdatedAsnad', [RECENT_DOCUMENTS]))
|
||||
.filter((r) => /کار/.test(String(r.NO ?? '')))
|
||||
.filter((r) => {
|
||||
const d = String(r.Tarikh ?? '');
|
||||
return d >= lo && d <= hi;
|
||||
})
|
||||
.map((r) => ({
|
||||
itemCode: str(r.No_Kar),
|
||||
itemName: str(r.No_Kar) ?? '—',
|
||||
date: String(r.Tarikh ?? ''),
|
||||
description: [str(r.NO), str(r.Sharh1)].filter(Boolean).join(' — ') || null,
|
||||
weight: dec(r.Vazn),
|
||||
amount: dec(r.Mali),
|
||||
count: dec(r.Count),
|
||||
}));
|
||||
},
|
||||
|
||||
async createKarSakhte() {
|
||||
throw new AccountingNotSupportedError('ساخت کارساخته');
|
||||
},
|
||||
|
||||
async postOrderDocument(s, doc, progress, onProgress) {
|
||||
const d = toJalali(doc.date);
|
||||
const multi = doc.lines.length > 1 ? 1 : -1;
|
||||
let done = progress.doneLines ?? 0;
|
||||
let last: unknown = null;
|
||||
|
||||
for (let i = done; i < doc.lines.length; i += 1) {
|
||||
const line = doc.lines[i];
|
||||
const args = [
|
||||
doc.temporary ? 0 : 1, // Sabte_Kol_Or_Movaghat: 0 = temporary
|
||||
Number(doc.customerCode),
|
||||
-1, // Factor_Number: assigned by Tahesab
|
||||
i + 1, // Radif_Number
|
||||
d.jy,
|
||||
d.jm,
|
||||
d.jd,
|
||||
Number(line.weightGrams.toFixed(3)),
|
||||
Number(karatWithWage(line.karat, line.wagePercent).toString()),
|
||||
line.count,
|
||||
line.itemName,
|
||||
1, // IsVoroodOrKhorooj: 1 = exit (goods to the customer)
|
||||
1, // OjratTedadiOrGerami: per gram
|
||||
Number(line.flatRial.toFixed(0)), // MablaghKol — the only rial field that moves the balance
|
||||
0,
|
||||
0,
|
||||
0, // Darsad_Talayee: the charge is already in Ayar; never count it twice
|
||||
0,
|
||||
'',
|
||||
0,
|
||||
0, // PoolSang: does not move the balance; the stones are in MablaghKol
|
||||
line.description,
|
||||
doc.factorCode,
|
||||
-1,
|
||||
multi,
|
||||
0,
|
||||
0,
|
||||
];
|
||||
try {
|
||||
last = await call(s, 'DoNewSanadVKHKar', args);
|
||||
} catch (err) {
|
||||
// The row is already there (an earlier attempt landed but its reply
|
||||
// was lost): Factor_Code + Radif_Number is a unique key in Tahesab.
|
||||
if (!(err instanceof AccountingProviderError && /duplicate key/i.test(err.message + JSON.stringify(err.response ?? '')))) {
|
||||
throw err;
|
||||
}
|
||||
last = { duplicate: true };
|
||||
}
|
||||
done = i + 1;
|
||||
await onProgress({ doneLines: done });
|
||||
}
|
||||
|
||||
const ref = String((last as { Factor_Code?: unknown })?.Factor_Code ?? doc.factorCode).toLowerCase();
|
||||
return { ref, progress: { doneLines: done }, response: last };
|
||||
},
|
||||
|
||||
async documentExists(s, doc) {
|
||||
try {
|
||||
await call(s, 'DoNewSanadInquiry', [doc.factorCode]);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err instanceof AccountingProviderError && err.notFound) return false;
|
||||
// A multi-row document answers {"ERROR":"Duplicate"}: found, more than
|
||||
// one row under the code (seen on the test server).
|
||||
if (err instanceof AccountingProviderError && /:\s*Duplicate$/i.test(err.message)) return true;
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteDocument(s, doc) {
|
||||
try {
|
||||
return { response: await call(s, 'DoDeleteSanad', [doc.factorCode]) };
|
||||
} catch (err) {
|
||||
// Already gone — the goal is reached.
|
||||
if (err instanceof AccountingProviderError && err.notFound) return { response: { alreadyDeleted: true } };
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
function toStatementRow(r: Record<string, unknown>): StatementRow {
|
||||
const metal = String(r.Jens_Felez ?? '0');
|
||||
const temporary = r.isSanadMovaghat === true;
|
||||
const isGold = metal === '0';
|
||||
const isRial = !r.Arz || String(r.Arz).toUpperCase() === 'IRR';
|
||||
// Positive TabdilVazn / Mali = in the customer's favour (they gave us gold
|
||||
// or money); negative = they took goods or money from us.
|
||||
const tabdil = isGold ? dec(r.TabdilVazn) ?? ZERO : ZERO;
|
||||
const mali = isRial ? dec(r.Mali) ?? ZERO : ZERO;
|
||||
return {
|
||||
key: `${r.ID}-${r.Radif ?? 1}`,
|
||||
documentRef: String(r.Factor_Code ?? r.ID).toLowerCase(),
|
||||
accountCode: String(r.MCode ?? ''),
|
||||
documentNo: String(r.Sh_Factor ?? ''),
|
||||
date: String(r.Tarikh ?? ''),
|
||||
time: str(r.Tarikh_Time),
|
||||
title: str(r.NO) ?? '',
|
||||
description: [str(r.Sharh1), str(r.Sharh2), str(r.Baabat)].filter(Boolean).join(' — ') || null,
|
||||
itemName: str(r.No_Kar),
|
||||
weight: dec(r.Vazn),
|
||||
karat: dec(r.Ayar)?.toNumber() ?? null,
|
||||
count: dec(r.Count),
|
||||
unitPrice: dec(r.Fi) ?? dec(r.Mazaneh),
|
||||
wagePercent: dec(r.DarsadSood),
|
||||
goldIn: tabdil.gt(0) ? tabdil : ZERO,
|
||||
goldOut: tabdil.lt(0) ? tabdil.negated() : ZERO,
|
||||
rialIn: mali.gt(0) ? mali : ZERO,
|
||||
rialOut: mali.lt(0) ? mali.negated() : ZERO,
|
||||
// A temporary row reports TahesabVazni/Mali = 0 because it moves nothing
|
||||
// yet; null lets the report carry the previous balance forward instead.
|
||||
goldBalance: temporary ? null : neg(dec(r.TahesabVazni)),
|
||||
rialBalance: temporary ? null : neg(dec(r.TahesabMali)),
|
||||
metal: METALS[metal] ?? metal,
|
||||
temporary,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { httpRequest } from '../accounting-http';
|
||||
import { OrderDocumentInput, ProviderSettings } from './accounting-provider';
|
||||
import { __zarrinInternals, zarrinProvider as z } from './zarrin.provider';
|
||||
|
||||
jest.mock('../accounting-http', () => ({
|
||||
...jest.requireActual('../accounting-http'),
|
||||
httpRequest: jest.fn(),
|
||||
}));
|
||||
const http = httpRequest as jest.MockedFunction<typeof httpRequest>;
|
||||
|
||||
const s: ProviderSettings = {
|
||||
baseUrl: 'http://zarrin.local:8000/ZarrinService/accounting',
|
||||
token: 'SECRET',
|
||||
caCert: null,
|
||||
timeoutMs: 5000,
|
||||
balanceOwesWhen: 'negative',
|
||||
};
|
||||
|
||||
const reply = (body: unknown, status = 200) =>
|
||||
Promise.resolve({ status, body: typeof body === 'string' ? body : JSON.stringify(body) });
|
||||
|
||||
const url = (n = 0) => new URL(http.mock.calls[n][0].url);
|
||||
|
||||
beforeEach(() => http.mockReset());
|
||||
|
||||
describe('wire format', () => {
|
||||
it('sends GET ?kind=…&token=… with the parameters, and nothing blank', async () => {
|
||||
http.mockReturnValueOnce(reply({ state: true, msg: 'OK', data: [{ id: '4', name: 'مشتری (خریدار)' }] }));
|
||||
const groups = await z.listCustomerGroups(s);
|
||||
const u = url();
|
||||
expect(http.mock.calls[0][0].method).toBe('GET');
|
||||
expect(u.pathname).toBe('/ZarrinService/accounting');
|
||||
expect(u.searchParams.get('kind')).toBe('listGorooheHesabha');
|
||||
expect(u.searchParams.get('token')).toBe('SECRET');
|
||||
expect(groups).toEqual([{ id: '4', name: 'مشتری (خریدار)' }]);
|
||||
});
|
||||
|
||||
it('unwraps the JSON-in-a-string replies the documentation shows', async () => {
|
||||
const inner = JSON.stringify({ state: true, msg: 'حساب با موفقیت ایجاد شد', id: 132 });
|
||||
http.mockReturnValueOnce(reply(JSON.stringify(inner)));
|
||||
const res = await z.createCustomer(s, {
|
||||
firstName: 'تست',
|
||||
lastName: 'تست',
|
||||
fullName: 'تست تست',
|
||||
mobile: '09121112233',
|
||||
phone: null,
|
||||
address: null,
|
||||
nationalCode: null,
|
||||
group: { id: '4', name: 'مشتری' },
|
||||
});
|
||||
expect(res.code).toBe('132');
|
||||
expect(url().searchParams.get('fkNoe')).toBe('4');
|
||||
expect(url().searchParams.has('telephone')).toBe(false);
|
||||
});
|
||||
|
||||
it('turns state:false into an error carrying the message', async () => {
|
||||
http.mockReturnValueOnce(reply({ state: false, msg: 'دسترسی مجاز نیست' }));
|
||||
await expect(z.listCustomerGroups(s)).rejects.toThrow('دسترسی مجاز نیست');
|
||||
});
|
||||
|
||||
it('lists the different envelopes Zarrin uses', () => {
|
||||
const { listOf, fullJalali } = __zarrinInternals;
|
||||
expect(listOf({ data: [1] })).toEqual([1]);
|
||||
expect(listOf({ date: [2] })).toEqual([2]); // Get100Account's typo
|
||||
expect(listOf([3])).toEqual([3]);
|
||||
expect(listOf({ id: '84', nam: 'x' })).toEqual([{ id: '84', nam: 'x' }]);
|
||||
expect(fullJalali('04/05/29')).toBe('1404/05/29');
|
||||
});
|
||||
});
|
||||
|
||||
describe('customers', () => {
|
||||
it('pages with Get100Account and fills groups from listhesabha', async () => {
|
||||
const page = Array.from({ length: 100 }, (_, i) => ({ code: 81 + i, name: 'n', namKhanevadegi: `f${i}`, mobile: i === 0 ? '09121112233' : '' }));
|
||||
http
|
||||
.mockReturnValueOnce(reply({ state: true, msg: '', date: page }))
|
||||
.mockReturnValueOnce(reply({ data: [{ id: '81', nam: 'حسین اصغری', gorooh: 'مشتری (خریدار)', tell: '021', mobile: '09121112233', codeMeli: '' }] }));
|
||||
const res = await z.listCustomers(s, null);
|
||||
expect(url(0).searchParams.get('id')).toBe('0');
|
||||
expect(url(1).searchParams.get('az')).toBe('81');
|
||||
expect(url(1).searchParams.get('ta')).toBe('180');
|
||||
expect(res.items[0]).toMatchObject({ code: '81', name: 'حسین اصغری', mobile: '09121112233', groupName: 'مشتری (خریدار)', phone: '021' });
|
||||
expect(res.next).toBe('181');
|
||||
});
|
||||
|
||||
it('keeps the account in its group on edit', async () => {
|
||||
http.mockReturnValueOnce(reply({ state: true, msg: 'ویرایش اطلاعات با موفقیت انجام شد' }));
|
||||
await z.updateCustomer(s, '101', {
|
||||
firstName: 'حسین',
|
||||
lastName: 'اصغری',
|
||||
fullName: 'حسین اصغری',
|
||||
mobile: '09121427233',
|
||||
phone: null,
|
||||
address: 'خیابان مولوی',
|
||||
nationalCode: null,
|
||||
group: { id: null, name: 'واسطه' },
|
||||
});
|
||||
expect(url().searchParams.get('nameG')).toBe('واسطه');
|
||||
expect(url().searchParams.get('id')).toBe('101');
|
||||
});
|
||||
});
|
||||
|
||||
describe('balances and statement', () => {
|
||||
it('applies the configured sign', async () => {
|
||||
http.mockReturnValue(reply({ state: true, msg: 'OK', data: [{ id: '108', name: 'x', geram: '-75.409', pool: '-3.211960271E9' }, { id: '9', geram: '1', pool: '1' }] }));
|
||||
const neg = await z.getBalances(s, ['108', '5']);
|
||||
expect(neg).toHaveLength(1);
|
||||
expect(neg[0].goldGrams.toString()).toBe('75.409');
|
||||
expect(neg[0].rial.toString()).toBe('3211960271');
|
||||
const pos = await z.getBalances({ ...s, balanceOwesWhen: 'positive' }, ['108', '9']);
|
||||
expect(pos[0].goldGrams.toString()).toBe('-75.409');
|
||||
});
|
||||
|
||||
it('folds a running balance from «مانده از قبل»', async () => {
|
||||
http.mockReturnValueOnce(
|
||||
reply({
|
||||
state: true,
|
||||
detail: 'OK',
|
||||
data: [
|
||||
{ idRadif: '0', idFactor: '0', idHesab: '81', sharh: 'مانده از قبل', vaznTarazooPardakht: '10.0', vaznTarazooDaryaft: '0.0', poolPardakht: '0', poolDaryaft: '0' },
|
||||
{ idRadif: '470', idFactor: '221', idHesab: '81', noeRadif: 'چک', tarikh: '04/05/29', vaznTarazooPardakht: '0.0', vaznTarazooDaryaft: '2.5', poolPardakht: '810000000', poolDaryaft: '0', ayar: '0' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const rows = await z.getStatement(s, { code: '81', from: { jy: 1404, jm: 5, jd: 1 }, to: { jy: 1404, jm: 5, jd: 31 } });
|
||||
expect(url().searchParams.get('dateAz')).toBe('2025-07-23');
|
||||
expect(rows[0].goldBalance?.toString()).toBe('10');
|
||||
expect(rows[1]).toMatchObject({ documentRef: '221', date: '1404/05/29', title: 'چک' });
|
||||
expect(rows[1].goldBalance?.toString()).toBe('7.5');
|
||||
expect(rows[1].rialBalance?.toString()).toBe('810000000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('order documents', () => {
|
||||
const line = (code: string, wage: number) => ({
|
||||
itemCode: code,
|
||||
itemName: code,
|
||||
weightGrams: new Prisma.Decimal('10'),
|
||||
count: 1,
|
||||
wagePercent: new Prisma.Decimal(wage),
|
||||
flatRial: new Prisma.Decimal(0),
|
||||
karat: 750,
|
||||
description: 'd',
|
||||
});
|
||||
const doc: OrderDocumentInput = {
|
||||
factorCode: 'gbordx00000000000000000',
|
||||
customerCode: '81',
|
||||
date: new Date(),
|
||||
description: 'سفارش',
|
||||
temporary: true,
|
||||
lines: [line('19', 12), line('20', 8)],
|
||||
};
|
||||
|
||||
it('opens the invoice with the account, appends with the invoice id, in bonak mode', async () => {
|
||||
http.mockReturnValueOnce(reply({ state: true, msg: 'OK', data: { idFactor: 117764 } })).mockReturnValueOnce(reply({ state: true, msg: 'OK', data: { idFactor: 117764 } }));
|
||||
const progress: unknown[] = [];
|
||||
const res = await z.postOrderDocument(s, doc, {}, async (p) => {
|
||||
progress.push(p);
|
||||
});
|
||||
const a = url(0).searchParams;
|
||||
const b = url(1).searchParams;
|
||||
expect(a.get('kind')).toBe('foroushSakhte');
|
||||
expect(a.get('idHesab')).toBe('81');
|
||||
expect(a.has('idFactor')).toBe(false);
|
||||
expect(a.get('idSakhte')).toBe('19');
|
||||
expect(a.get('darsad')).toBe('12');
|
||||
expect(a.get('vazn')).toBe('10.000');
|
||||
expect(a.get('pd')).toBe('-1');
|
||||
expect(a.get('bonak')).toBe('1');
|
||||
expect(a.get('movaghat')).toBe('1');
|
||||
expect(b.get('idFactor')).toBe('117764');
|
||||
expect(b.has('idHesab')).toBe(false);
|
||||
expect(b.get('movaghat')).toBe('1'); // temporary, as the owner ruled
|
||||
expect(progress).toEqual([
|
||||
{ factorId: '117764', doneLines: 1 },
|
||||
{ factorId: '117764', doneLines: 2 },
|
||||
]);
|
||||
expect(res.ref).toBe('117764');
|
||||
});
|
||||
|
||||
it('resumes into the same invoice', async () => {
|
||||
http.mockReturnValueOnce(reply({ state: true, data: { idFactor: 5 } }));
|
||||
await z.postOrderDocument(s, doc, { factorId: '5', doneLines: 1 }, async () => undefined);
|
||||
expect(http).toHaveBeenCalledTimes(1);
|
||||
expect(url().searchParams.get('idFactor')).toBe('5');
|
||||
expect(url().searchParams.get('idSakhte')).toBe('20');
|
||||
});
|
||||
|
||||
it('finds the row to delete in the ledger, since foroushSakhte never returned it', async () => {
|
||||
http
|
||||
.mockReturnValueOnce(reply({ state: true, data: [{ idRadif: '0', idFactor: '0' }, { idRadif: '731', idFactor: '390' }] }))
|
||||
.mockReturnValueOnce(reply({ state: true, msg: 'حذف با موفقیت انجام شد :)' }));
|
||||
await z.deleteDocument(s, { factorCode: 'x', ref: '390', customerCode: '81' });
|
||||
expect(url(1).searchParams.get('kind')).toBe('hazfRadifSanad');
|
||||
expect(url(1).searchParams.get('pkRadifSanad')).toBe('731');
|
||||
expect(url(1).searchParams.get('kol')).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('change feed (real-time sync)', () => {
|
||||
it('reads the newest journal rows and reports accounts above the cursor', async () => {
|
||||
http.mockReturnValueOnce(
|
||||
reply({ state: true, data: [{ idRadif: '731', idHesab: '118' }, { idRadif: '733', idHesab: '40' }, { idRadif: '734', idHesab: '40' }] }),
|
||||
);
|
||||
const r = await z.getChanges(s, '731');
|
||||
expect(url().searchParams.get('kind')).toBe('parLastTenRadif');
|
||||
expect(url().searchParams.has('idHesab')).toBe(false);
|
||||
expect(r).toMatchObject({ cursor: '734', accountCodes: ['40'], overflow: false });
|
||||
});
|
||||
|
||||
it('first poll only returns the cursor', async () => {
|
||||
http.mockReturnValueOnce(reply({ state: true, data: [{ idRadif: '731', idHesab: '118' }] }));
|
||||
expect(await z.getChanges(s, null)).toEqual({ cursor: '731', accountCodes: [], itemNames: [], overflow: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,466 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { AccountingHttpError, httpRequest, parseLooseJson } from '../accounting-http';
|
||||
import { jalaliToIsoDate, toIsoDateTehran } from '../jalali';
|
||||
import {
|
||||
AccountingProvider,
|
||||
AccountingProviderError,
|
||||
ExternalCustomer,
|
||||
ProviderSettings,
|
||||
StatementRow,
|
||||
ZERO,
|
||||
dec,
|
||||
normalizeMobile,
|
||||
splitName,
|
||||
str,
|
||||
} from './accounting-provider';
|
||||
|
||||
/**
|
||||
* Zarrin (زرین) — https://zarrin.ir/api-docs/ (the page renders
|
||||
* /wp-content/themes/Hirad/data/api.json, 82 entries).
|
||||
*
|
||||
* Wire format: GET <base>?kind=<api>&token=<token>&<params>, where <base> is
|
||||
* the installation's `http://host:8000/ZarrinService/accounting`. The token
|
||||
* travels in the query string, so a URL built here must never be logged.
|
||||
*
|
||||
* NOT verified against a live installation — the owner has none to test on
|
||||
* (plan, decision 11). Everything below follows the documentation and its
|
||||
* sample outputs, and is covered by unit tests built from those samples. The
|
||||
* open points (balance sign, which rial field the stones belong in) are
|
||||
* configurable or flagged, and the integration is to be switched on for real
|
||||
* only after a trial period on temporary documents.
|
||||
*
|
||||
* Differences from Tahesab that shape the sync:
|
||||
* * No idempotency key on foroushSakhte. A timed-out document call cannot be
|
||||
* retried blindly — the worker parks it as NEEDS_REVIEW.
|
||||
* * foroushSakhte does not return the row id needed to delete a document;
|
||||
* deleteDocument looks it up from the account's ledger (parvande).
|
||||
* * کارساخته can be created (addFelezsakhteModel) but never edited.
|
||||
*/
|
||||
|
||||
const STATEMENT_PAGE = 250;
|
||||
const CHANGE_WINDOW = 50;
|
||||
const MAX_STATEMENT_PAGES = 20;
|
||||
|
||||
function endpoint(s: ProviderSettings) {
|
||||
return s.baseUrl.trim();
|
||||
}
|
||||
|
||||
async function call(
|
||||
s: ProviderSettings,
|
||||
kind: string,
|
||||
params: Record<string, string | number | null | undefined> = {},
|
||||
): Promise<Record<string, unknown> | unknown[]> {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(endpoint(s));
|
||||
} catch {
|
||||
throw new AccountingProviderError('زرین: آدرس سرور نامعتبر است');
|
||||
}
|
||||
url.searchParams.set('kind', kind);
|
||||
url.searchParams.set('token', s.token);
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== null && v !== undefined && v !== '') url.searchParams.set(k, String(v));
|
||||
}
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await httpRequest({ method: 'GET', url: url.toString(), timeoutMs: s.timeoutMs, ca: s.caCert });
|
||||
} catch (err) {
|
||||
if (err instanceof AccountingHttpError) {
|
||||
throw new AccountingProviderError(`زرین (${kind}): ${err.message}`, err.ambiguous);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
throw new AccountingProviderError(`زرین (${kind}): HTTP ${res.status}`, false, res.body.slice(0, 500), res.status === 404);
|
||||
}
|
||||
|
||||
let json: unknown;
|
||||
try {
|
||||
json = parseLooseJson(res.body);
|
||||
// The documented outputs are JSON-encoded strings of JSON ("{\"state\":…}").
|
||||
if (typeof json === 'string' && /^\s*[{[]/.test(json)) json = parseLooseJson(json);
|
||||
} catch {
|
||||
throw new AccountingProviderError(`زرین (${kind}): پاسخ نامعتبر`, true, res.body.slice(0, 500));
|
||||
}
|
||||
if (!json || typeof json !== 'object') {
|
||||
throw new AccountingProviderError(`زرین (${kind}): پاسخ نامعتبر`, true, json);
|
||||
}
|
||||
|
||||
const o = json as Record<string, unknown>;
|
||||
if (o.state === false || o.status === false) {
|
||||
const msg = str(o.msg) ?? str(o.detail) ?? str(o.message) ?? 'خطای نامشخص';
|
||||
throw new AccountingProviderError(`زرین (${kind}): ${msg}`, false, json, /یافت نشد|موجود نیست/.test(msg));
|
||||
}
|
||||
return json as Record<string, unknown> | unknown[];
|
||||
}
|
||||
|
||||
/** Zarrin wraps lists as `data`, once as `date` (Get100Account), or not at all. */
|
||||
function listOf(json: unknown): Record<string, unknown>[] {
|
||||
if (Array.isArray(json)) return json as Record<string, unknown>[];
|
||||
if (json && typeof json === 'object') {
|
||||
const o = json as Record<string, unknown>;
|
||||
for (const key of ['data', 'date', 'mande']) {
|
||||
if (Array.isArray(o[key])) return o[key] as Record<string, unknown>[];
|
||||
}
|
||||
if ('id' in o || 'code' in o) return [o];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function dataOf(json: unknown): Record<string, unknown> {
|
||||
const o = (json ?? {}) as Record<string, unknown>;
|
||||
const d = o.data;
|
||||
if (d && typeof d === 'object' && !Array.isArray(d)) return d as Record<string, unknown>;
|
||||
return o;
|
||||
}
|
||||
|
||||
function joinName(first: unknown, last: unknown) {
|
||||
return [str(first), str(last)].filter(Boolean).join(' ').trim();
|
||||
}
|
||||
|
||||
/** Zarrin prints dates as yy/mm/dd of the 1400s: "04/05/29" → "1404/05/29". */
|
||||
function fullJalali(v: unknown): string {
|
||||
const s = str(v) ?? '';
|
||||
const m = s.match(/^(\d{2})\/(\d{1,2})\/(\d{1,2})$/);
|
||||
if (!m) return s;
|
||||
return `14${m[1]}/${m[2].padStart(2, '0')}/${m[3].padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function signed(s: ProviderSettings, v: Prisma.Decimal) {
|
||||
// Until checked on a real installation (plan §9) the sign is a setting.
|
||||
return (s.balanceOwesWhen ?? 'negative') === 'negative' ? v.negated() : v;
|
||||
}
|
||||
|
||||
export const zarrinProvider: AccountingProvider = {
|
||||
name: 'zarrin',
|
||||
label: 'زرین',
|
||||
capabilities: { createKarSakhte: true, documentInquiry: false, movementScope: 'all', changeFeed: true },
|
||||
|
||||
async health(s) {
|
||||
const v = (await call(s, 'version')) as Record<string, unknown>;
|
||||
// `version` may not check the token; a group listing does.
|
||||
const groups = listOf(await call(s, 'listGorooheHesabha'));
|
||||
return { version: v.version, message: v.message, customerGroups: groups.length };
|
||||
},
|
||||
|
||||
async listCustomerGroups(s) {
|
||||
return listOf(await call(s, 'listGorooheHesabha')).map((g) => ({
|
||||
id: String(g.id),
|
||||
name: str(g.name) ?? String(g.id),
|
||||
}));
|
||||
},
|
||||
|
||||
async listCustomers(s, cursor) {
|
||||
const from = Number(cursor ?? 0) || 0;
|
||||
// Get100Account pages by code (100 accounts from `id` on) but carries no
|
||||
// group or address; listhesabha over the same code span fills those in.
|
||||
const page = listOf(await call(s, 'Get100Account', { id: from }));
|
||||
if (page.length === 0) return { items: [], next: null };
|
||||
|
||||
const codes = page.map((r) => Number(r.code)).filter(Number.isFinite);
|
||||
const lo = Math.min(...codes);
|
||||
const hi = Math.max(...codes);
|
||||
const details = new Map<string, Record<string, unknown>>();
|
||||
for (const r of listOf(await call(s, 'listhesabha', { az: lo, ta: hi }))) details.set(String(r.id), r);
|
||||
|
||||
const items: ExternalCustomer[] = page.map((r) => {
|
||||
const code = String(r.code);
|
||||
const d = details.get(code) ?? {};
|
||||
return {
|
||||
code,
|
||||
name: str(d.nam) ?? joinName(r.name, r.namKhanevadegi),
|
||||
mobile: normalizeMobile(d.mobile ?? r.mobile),
|
||||
phone: str(d.tell),
|
||||
nationalCode: str(d.codeMeli),
|
||||
address: str(d.address),
|
||||
groupId: null,
|
||||
groupName: str(d.gorooh),
|
||||
};
|
||||
});
|
||||
const next = page.length < 100 ? null : String(hi + 1);
|
||||
return { items, next };
|
||||
},
|
||||
|
||||
async findCustomersByMobile(s, mobile) {
|
||||
const target = normalizeMobile(mobile);
|
||||
if (!target) return [];
|
||||
let rows: Record<string, unknown>[];
|
||||
try {
|
||||
rows = listOf(await call(s, 'getinfoByPhone', { number: target }));
|
||||
} catch (err) {
|
||||
if (err instanceof AccountingProviderError && err.notFound) return [];
|
||||
throw err;
|
||||
}
|
||||
return rows
|
||||
.filter((r) => r.code !== undefined && normalizeMobile(r.mobile) === target)
|
||||
.map((r) => ({
|
||||
code: String(r.code),
|
||||
name: joinName(r.nam, r.namKhanevadegi),
|
||||
mobile: target,
|
||||
phone: str(r.tel),
|
||||
nationalCode: null,
|
||||
address: str(r.address),
|
||||
groupId: null,
|
||||
groupName: null,
|
||||
}));
|
||||
},
|
||||
|
||||
async createCustomer(s, p) {
|
||||
const json = (await call(s, 'addAccount', {
|
||||
firstName: p.firstName || p.fullName,
|
||||
lastName: p.lastName || '-',
|
||||
fkNoe: p.group.id,
|
||||
mobile: p.mobile,
|
||||
telephone: p.phone,
|
||||
address: p.address,
|
||||
codeMelli: p.nationalCode,
|
||||
})) as Record<string, unknown>;
|
||||
const id = json.id ?? dataOf(json).id;
|
||||
if (id === undefined || id === null) throw new AccountingProviderError('زرین: کد حساب جدید برنگشت', false, json);
|
||||
return { code: String(id), response: json };
|
||||
},
|
||||
|
||||
async updateCustomer(s, code, p) {
|
||||
const json = await call(s, 'edithesab', {
|
||||
id: code,
|
||||
name: p.firstName || p.fullName,
|
||||
family: p.lastName,
|
||||
mobile: p.mobile,
|
||||
tel: p.phone,
|
||||
address: p.address,
|
||||
codeMeli: p.nationalCode,
|
||||
// Without nameG Zarrin moves the account into its customers group, so the
|
||||
// current group is always sent back.
|
||||
nameG: p.group.name,
|
||||
});
|
||||
return { response: json };
|
||||
},
|
||||
|
||||
async getBalances(s, codes) {
|
||||
if (codes.length === 0) return [];
|
||||
const wanted = new Set(codes);
|
||||
const rows =
|
||||
codes.length === 1
|
||||
? listOf(await call(s, 'listMandeHesabha', { id: codes[0], isRial: 0 }))
|
||||
: listOf(await call(s, 'listMandeHesabha', { isRial: 0 }));
|
||||
return rows
|
||||
.filter((r) => wanted.has(String(r.id)))
|
||||
.map((r) => ({
|
||||
code: String(r.id),
|
||||
goldGrams: signed(s, dec(r.geram) ?? ZERO),
|
||||
rial: signed(s, dec(r.pool) ?? ZERO),
|
||||
}));
|
||||
},
|
||||
|
||||
async getChanges(s, cursor) {
|
||||
// parLastTenRadif without an account is the newest rows of the whole
|
||||
// journal; each row names its account (idHesab) and has an ascending idRadif.
|
||||
// Not yet seen on a real install (ACC-01).
|
||||
const rows = listOf(await call(s, 'parLastTenRadif', { limit: CHANGE_WINDOW }));
|
||||
let maxId = 0;
|
||||
for (const r of rows) maxId = Math.max(maxId, Number(r.idRadif) || 0);
|
||||
if (cursor === null) return { cursor: String(maxId), accountCodes: [], itemNames: [], overflow: false };
|
||||
const seen = Number(cursor) || 0;
|
||||
const fresh = rows.filter((r) => (Number(r.idRadif) || 0) > seen);
|
||||
const accounts = new Set<string>();
|
||||
for (const r of fresh) {
|
||||
const code = str(r.idHesab);
|
||||
if (code) accounts.add(code);
|
||||
}
|
||||
return {
|
||||
cursor: String(Math.max(maxId, seen)),
|
||||
accountCodes: [...accounts],
|
||||
itemNames: [],
|
||||
overflow: rows.length >= CHANGE_WINDOW && fresh.length === rows.length,
|
||||
};
|
||||
},
|
||||
|
||||
async getStatement(s, q) {
|
||||
const rows: Record<string, unknown>[] = [];
|
||||
for (let page = 1; page <= MAX_STATEMENT_PAGES; page += 1) {
|
||||
const batch = listOf(
|
||||
await call(s, 'parvande', {
|
||||
id: q.code,
|
||||
dateAz: jalaliToIsoDate(q.from),
|
||||
dateTa: jalaliToIsoDate(q.to),
|
||||
pageNum: page,
|
||||
detailCount: STATEMENT_PAGE,
|
||||
}),
|
||||
);
|
||||
rows.push(...batch);
|
||||
if (batch.length < STATEMENT_PAGE) break;
|
||||
}
|
||||
|
||||
// parvande has no running balance; it opens with «مانده از قبل» and the
|
||||
// balance is folded from there. پرداخت = we gave = the customer owes more.
|
||||
let gold = ZERO;
|
||||
let rial = ZERO;
|
||||
return rows.map((r): StatementRow => {
|
||||
const goldIn = dec(r.vaznTarazooDaryaft) ?? ZERO;
|
||||
const goldOut = dec(r.vaznTarazooPardakht) ?? ZERO;
|
||||
const rialIn = dec(r.poolDaryaft) ?? ZERO;
|
||||
const rialOut = dec(r.poolPardakht) ?? ZERO;
|
||||
gold = gold.add(goldOut).sub(goldIn);
|
||||
rial = rial.add(rialOut).sub(rialIn);
|
||||
const factor = str(r.idFactor);
|
||||
return {
|
||||
key: String(r.idRadif ?? ''),
|
||||
documentRef: factor && factor !== '0' ? factor : '',
|
||||
accountCode: String(r.idHesab ?? q.code),
|
||||
documentNo: factor && factor !== '0' ? factor : '',
|
||||
date: fullJalali(r.tarikh),
|
||||
time: null,
|
||||
title: str(r.noeRadif) ?? str(r.noeFactor) ?? str(r.sharh) ?? '',
|
||||
description: str(r.sharh),
|
||||
itemName: str(r.namFelez),
|
||||
weight: dec(r.vaznDaryaft)?.gt(0) ? dec(r.vaznDaryaft) : dec(r.vaznPardakht),
|
||||
karat: dec(r.ayar)?.toNumber() || null,
|
||||
count: null,
|
||||
unitPrice: null,
|
||||
wagePercent: null,
|
||||
goldIn,
|
||||
goldOut,
|
||||
rialIn,
|
||||
rialOut,
|
||||
goldBalance: gold,
|
||||
rialBalance: rial,
|
||||
metal: null,
|
||||
temporary: false,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
async listKarSakhteGroups(s) {
|
||||
return listOf(await call(s, 'goroohSakhte')).map((g) => ({ id: String(g.id), name: str(g.name) ?? String(g.id) }));
|
||||
},
|
||||
|
||||
async listKarSakhteTypes(s) {
|
||||
const types = new Map<string, string>();
|
||||
for (const r of listOf(await call(s, 'listKarSakhte'))) {
|
||||
if (r.fkNoe !== undefined) types.set(String(r.fkNoe), str(r.NoeName) ?? String(r.fkNoe));
|
||||
}
|
||||
return [...types].map(([id, name]) => ({ id, name }));
|
||||
},
|
||||
|
||||
async listKarSakhte(s) {
|
||||
return listOf(await call(s, 'listKarSakhte'))
|
||||
.filter((r) => r.id !== undefined && str(r.namFelezsakhthe))
|
||||
.map((r) => ({
|
||||
code: String(r.id),
|
||||
name: str(r.namFelezsakhthe) as string,
|
||||
// listKarSakhte carries the item's type (گرمی/تعدادی…) but not its
|
||||
// group (النگو/انگشتر…); category stays the app's call (D27).
|
||||
groupId: null,
|
||||
groupName: null,
|
||||
typeId: str(r.fkNoe),
|
||||
typeName: str(r.NoeName),
|
||||
saleWagePercent: dec(r.darsadForoosh),
|
||||
stockWeight: dec(r.vazn),
|
||||
stockCount: dec(r.tedad),
|
||||
}));
|
||||
},
|
||||
|
||||
async getKarSakhteStock(s, item) {
|
||||
const d = dataOf(await call(s, 'mojoodiKala', { id: item.code, dateTa: toIsoDateTehran(new Date()) }));
|
||||
const qty = dec(d.mojoodi);
|
||||
const byWeight = /گرم/.test(String(d.noeName ?? 'گرمی'));
|
||||
return { weight: byWeight ? qty : null, count: byWeight ? null : qty };
|
||||
},
|
||||
|
||||
async getKarSakhteMovement(s, from, to) {
|
||||
return listOf(await call(s, 'infogardeshsakhte', { az: jalaliToIsoDate(from), ta: jalaliToIsoDate(to) })).map(
|
||||
(r) => ({
|
||||
itemCode: str(r.code_kar),
|
||||
itemName: str(r.nam_kar) ?? '—',
|
||||
date: str(r.tarikh_akharin_gardesh) ?? '',
|
||||
description: str(r.sharh_akharin_gardesh),
|
||||
weight: dec(r.vazn_akharin_gardesh),
|
||||
amount: dec(r.mablagh_akharin_gardesh),
|
||||
count: null,
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
async createKarSakhte(s, item) {
|
||||
const json = (await call(s, 'addFelezsakhteModel', {
|
||||
nameKar: item.name,
|
||||
noeKar: item.typeId,
|
||||
gorooheKar: item.groupId,
|
||||
})) as Record<string, unknown>;
|
||||
const id = json.idKar ?? dataOf(json).idKar;
|
||||
if (id === undefined || id === null) throw new AccountingProviderError('زرین: کد کارساختهی جدید برنگشت', false, json);
|
||||
return { code: String(id), response: json };
|
||||
},
|
||||
|
||||
async postOrderDocument(s, doc, progress, onProgress) {
|
||||
let factorId = progress.factorId;
|
||||
let done = progress.doneLines ?? 0;
|
||||
let last: unknown = null;
|
||||
|
||||
for (let i = done; i < doc.lines.length; i += 1) {
|
||||
const line = doc.lines[i];
|
||||
const isLast = i === doc.lines.length - 1;
|
||||
last = await call(s, 'foroushSakhte', {
|
||||
// The first call opens the invoice for the account; the rest append.
|
||||
...(factorId ? { idFactor: factorId } : { idHesab: doc.customerCode }),
|
||||
idSakhte: line.itemCode,
|
||||
// Bonak mode: mablagh is the rial charge, darsad the per-gram making charge.
|
||||
mablagh: line.flatRial.toFixed(0),
|
||||
sharh: line.description,
|
||||
vazn: line.weightGrams.toFixed(3),
|
||||
vaznSang: 0,
|
||||
mablaghSang: 0,
|
||||
pd: -1, // پرداخت: goods leave to the customer
|
||||
darsad: line.wagePercent.toString(),
|
||||
// The invoice has to stay open while lines are still to come; the last
|
||||
// line closes it unless the document is to stay temporary (D27: it is).
|
||||
movaghat: doc.temporary || !isLast ? 1 : 0,
|
||||
bonak: 1,
|
||||
});
|
||||
const id = (dataOf(last).idFactor ?? (last as Record<string, unknown>).idFactor) as unknown;
|
||||
if (!factorId) {
|
||||
if (id === undefined || id === null) throw new AccountingProviderError('زرین: شماره فاکتور برنگشت', true, last);
|
||||
factorId = String(id);
|
||||
}
|
||||
done = i + 1;
|
||||
await onProgress({ factorId, doneLines: done });
|
||||
}
|
||||
|
||||
return { ref: String(factorId ?? ''), progress: { factorId, doneLines: done }, response: last };
|
||||
},
|
||||
|
||||
async documentExists() {
|
||||
// No lookup by our own key exists in Zarrin's API.
|
||||
return false;
|
||||
},
|
||||
|
||||
async deleteDocument(s, doc) {
|
||||
if (!doc.ref) throw new AccountingProviderError('زرین: شماره فاکتور سند معلوم نیست');
|
||||
// hazfRadifSanad needs a row id, which foroushSakhte never returned; find
|
||||
// one in the account's ledger for the last year.
|
||||
const today = new Date();
|
||||
const yearAgo = new Date(today.getTime() - 366 * 24 * 3600 * 1000);
|
||||
const rows = listOf(
|
||||
await call(s, 'parvande', {
|
||||
id: doc.customerCode,
|
||||
dateAz: toIsoDateTehran(yearAgo),
|
||||
dateTa: toIsoDateTehran(today),
|
||||
pageNum: 1,
|
||||
detailCount: STATEMENT_PAGE,
|
||||
}),
|
||||
);
|
||||
const row = rows.find((r) => String(r.idFactor) === doc.ref && str(r.idRadif) && String(r.idRadif) !== '0');
|
||||
if (!row) return { response: { alreadyDeleted: true } };
|
||||
const json = await call(s, 'hazfRadifSanad', {
|
||||
pkRadifSanad: String(row.idRadif),
|
||||
kol: 1,
|
||||
commentUser: 'لغو سفارش در اپ',
|
||||
});
|
||||
return { response: json };
|
||||
},
|
||||
};
|
||||
|
||||
/** Exposed for tests. */
|
||||
export const __zarrinInternals = { listOf, fullJalali };
|
||||
Reference in New Issue
Block a user