Files
tehran_taj/apps/api/src/modules/accounting-sync/providers/tahesab.provider.ts
T

512 lines
18 KiB
TypeScript
Raw Normal View History

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,
};
}