- 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>
512 lines
18 KiB
TypeScript
512 lines
18 KiB
TypeScript
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,
|
||
};
|
||
}
|