- 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>
467 lines
17 KiB
TypeScript
467 lines
17 KiB
TypeScript
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 };
|