Zarrin v228 was tested on the real install (test account 953): negative balance = owes, and temporary documents move the balance at once. Update the admin copy and provider comments to match what was verified. پنل: متن راهنمای زرین بهروز شد: علامت مانده روی نصب واقعی بررسی شده است، و هشدار مربوط به ثبت سند موقت در «ثبت سفارش» اضافه شد. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
615 lines
24 KiB
TypeScript
615 lines
24 KiB
TypeScript
import { Prisma } from '@prisma/client';
|
||
import { AccountingHttpError, httpRequest, parseLooseJson } from '../accounting-http';
|
||
import { JalaliDate, jalaliToGregorian, jalaliToIsoDate, toIsoDateTehran, toJalali } from '../jalali';
|
||
|
||
const jalaliKey = (d: JalaliDate) => d.jy * 10000 + d.jm * 100 + d.jd;
|
||
function nextDay(d: JalaliDate): JalaliDate {
|
||
const g = jalaliToGregorian(d.jy, d.jm, d.jd);
|
||
return toJalali(new Date(Date.UTC(g.gy, g.gm - 1, g.gd + 1, 12)));
|
||
}
|
||
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.
|
||
*
|
||
* Reads verified on a real installation (v228, 870 accounts) on 2026-10-12,
|
||
* ACC-01: negative balance = owes; a «فاکتور موقت» moves the balance at once
|
||
* (unlike Tahesab); listKarSakhte pages by 100 (pageNum); parvande opens with
|
||
* a «مانده از قبل» row that is not a movement; goroohSakhte answers HTTP 400
|
||
* with a good body; infogardeshsakhte answers malformed JSON. Writes verified
|
||
* the same day on test account 953 (addAccount, edithesab, foroushSakhte,
|
||
* hazfRadifSanad); only addFelezsakhteModel is untested. Re-test with
|
||
* scripts/test-accounting-zarrin.ts; the full list is in plan §13.
|
||
*
|
||
* 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;
|
||
/** listKarSakhte returns this many per page (pageNum=1,2,…). */
|
||
const ITEM_PAGE = 100;
|
||
const MAX_ITEM_PAGES = 200;
|
||
/** infogardeshsakhte scans every item server-side; seen at 5–33 s. */
|
||
const MOVEMENT_TIMEOUT_MS = 90_000;
|
||
const OPENING_ROW = 'مانده از قبل';
|
||
const ALL_CODES = { az: 1, ta: 999999 };
|
||
const GROUP_SCAN_MAX = 300;
|
||
const GROUP_SCAN_CONCURRENCY = 10;
|
||
|
||
function endpoint(s: ProviderSettings) {
|
||
return s.baseUrl.trim();
|
||
}
|
||
|
||
class ZarrinHttpError extends AccountingProviderError {
|
||
constructor(
|
||
message: string,
|
||
body: string,
|
||
readonly status: number,
|
||
) {
|
||
super(message, false, body, status === 404);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Installations lack some documented kinds (v228 has no Get100Account,
|
||
* goroohSakhte or getinfoByPhone) and answer them with a bare 400.
|
||
*/
|
||
function isMissingKind(err: unknown) {
|
||
return err instanceof ZarrinHttpError && err.status === 400;
|
||
}
|
||
|
||
/**
|
||
* What the server said on a non-2xx reply, short enough for the admin screen.
|
||
* Error pages may echo the request URL, so the token is scrubbed.
|
||
*/
|
||
function errorBodyText(body: string, token: string) {
|
||
let text = body;
|
||
try {
|
||
const j = parseLooseJson(body) as any;
|
||
const o = typeof j === 'string' && /^\s*[{[]/.test(j) ? parseLooseJson(j) : j;
|
||
if (o && typeof o === 'object') text = str(o.msg) ?? str(o.detail) ?? str(o.message) ?? str(o.Message) ?? str(o.title) ?? JSON.stringify(o);
|
||
else if (typeof o === 'string') text = o;
|
||
} catch {
|
||
text = body.replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ').replace(/<[^>]+>/g, ' ');
|
||
}
|
||
if (token) for (const t of new Set([token, encodeURIComponent(token)])) text = text.split(t).join('***');
|
||
text = text.replace(/\s+/g, ' ').trim();
|
||
return text.length > 200 ? `${text.slice(0, 200)}…` : text;
|
||
}
|
||
|
||
/**
|
||
* infogardeshsakhte prints its JSON by hand: string values unquoted and a
|
||
* comma before every closing brace. Its layout is one "key": value per line,
|
||
* so each bare value is quoted and the dangling commas dropped.
|
||
*/
|
||
export function repairZarrinJson(body: string): string {
|
||
const lines = body.split(/\r?\n/).map((line) => {
|
||
const m = line.match(/^(\s*"[^"]+"\s*:\s*)(.*?)(,?)\s*$/);
|
||
if (!m) return line;
|
||
const value = m[2].trim();
|
||
if (value === '' || /^(-?\d+(\.\d+)?|true|false|null)$/.test(value) || /^["{[]/.test(value)) return line;
|
||
return `${m[1]}${JSON.stringify(value)}${m[3]}`;
|
||
});
|
||
return lines.join('\n').replace(/,(\s*[}\]])/g, '$1');
|
||
}
|
||
|
||
async function call(
|
||
s: ProviderSettings,
|
||
kind: string,
|
||
params: Record<string, string | number | null | undefined> = {},
|
||
opts: { timeoutMs?: number; repair?: (body: string) => string } = {},
|
||
): 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: opts.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) {
|
||
// v228 answers goroohSakhte with HTTP 400 and a perfectly good body.
|
||
const good = goodReply(res.body);
|
||
if (good) return good;
|
||
const reason = errorBodyText(res.body, s.token);
|
||
throw new ZarrinHttpError(`زرین (${kind}): HTTP ${res.status}${reason ? ` — ${reason}` : ''}`, res.body.slice(0, 500), res.status);
|
||
}
|
||
|
||
let json: unknown;
|
||
try {
|
||
json = parseLooseJson(opts.repair ? opts.repair(res.body) : 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[];
|
||
}
|
||
|
||
function goodReply(body: string): Record<string, unknown> | null {
|
||
try {
|
||
let j = parseLooseJson(body) as unknown;
|
||
if (typeof j === 'string' && /^\s*[{[]/.test(j)) j = parseLooseJson(j);
|
||
const o = j as Record<string, unknown> | null;
|
||
return o && typeof o === 'object' && (o.state === true || o.status === true) && Array.isArray(o.data) ? o : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** Zarrin wraps lists as `data`, sometimes as `date` (searchAccount), 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 accountName(v: unknown): string | null {
|
||
const n = (str(v) ?? '').replace(/\s*\*\s*/g, ' ').replace(/\s+/g, ' ').trim();
|
||
return n || null;
|
||
}
|
||
|
||
async function allAccounts(s: ProviderSettings): Promise<ExternalCustomer[]> {
|
||
return listOf(await call(s, 'listhesabha', ALL_CODES))
|
||
.filter((r) => r.id !== undefined)
|
||
.map((r) => ({
|
||
code: String(r.id),
|
||
// listhesabha prints «first * last» (and « * name» without a first name).
|
||
name: accountName(r.nam) ?? String(r.id),
|
||
mobile: normalizeMobile(r.mobile),
|
||
phone: str(r.tell),
|
||
nationalCode: str(r.codeMeli),
|
||
address: str(r.address),
|
||
groupId: null,
|
||
groupName: str(r.gorooh),
|
||
}));
|
||
}
|
||
|
||
/** Every کارساخته: listKarSakhte pages by 100 (pageNum), undocumented. */
|
||
async function allKarSakhte(s: ProviderSettings): Promise<Record<string, unknown>[]> {
|
||
const out: Record<string, unknown>[] = [];
|
||
const seen = new Set<string>();
|
||
for (let page = 1; page <= MAX_ITEM_PAGES; page += 1) {
|
||
const batch = listOf(await call(s, 'listKarSakhte', { pageNum: page }));
|
||
// An installation that ignores pageNum repeats page 1: stop there.
|
||
const fresh = batch.filter((r) => !seen.has(String(r.id)));
|
||
for (const r of fresh) seen.add(String(r.id));
|
||
out.push(...fresh);
|
||
if (batch.length < ITEM_PAGE || fresh.length === 0) break;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** An account's ledger rows (parvande) in a date range, every page, without the opening row. */
|
||
async function ledgerRows(s: ProviderSettings, code: string, from: string, to: string): Promise<Record<string, unknown>[]> {
|
||
const rows: Record<string, unknown>[] = [];
|
||
for (let page = 1; page <= MAX_STATEMENT_PAGES; page += 1) {
|
||
const batch = listOf(await call(s, 'parvande', { id: code, dateAz: from, dateTa: to, pageNum: page, detailCount: STATEMENT_PAGE }));
|
||
rows.push(...batch);
|
||
if (batch.length < STATEMENT_PAGE) break;
|
||
}
|
||
// «مانده از قبل» (idRadif 0) is a summary, not a movement.
|
||
return rows.filter((r) => String(r.idRadif ?? '') !== '0' && str(r.sharh) !== OPENING_ROW);
|
||
}
|
||
|
||
/** What a row adds to what the customer owes (app convention: positive = owes). */
|
||
function owesDelta(r: Record<string, unknown>) {
|
||
return {
|
||
gold: (dec(r.vaznTarazooPardakht) ?? ZERO).sub(dec(r.vaznTarazooDaryaft) ?? ZERO),
|
||
rial: (dec(r.poolPardakht) ?? ZERO).sub(dec(r.poolDaryaft) ?? ZERO),
|
||
};
|
||
}
|
||
|
||
function signed(s: ProviderSettings, v: Prisma.Decimal) {
|
||
// v228: negative = owes (the default). Kept a setting in case another install differs.
|
||
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) {
|
||
// listhesabha carries everything (group, mobile, address) and returns the
|
||
// whole code span in one reply (~865 accounts in under 2 s).
|
||
return { items: await allAccounts(s), next: null };
|
||
},
|
||
|
||
async findCustomersByMobile(s, mobile) {
|
||
const target = normalizeMobile(mobile);
|
||
if (!target) return [];
|
||
let rows: Record<string, unknown>[];
|
||
try {
|
||
// Matches on name or mobile, possibly partially; filtered exactly below.
|
||
rows = listOf(await call(s, 'searchAccount', { search: target }));
|
||
} catch (err) {
|
||
if (err instanceof AccountingProviderError && err.notFound) return [];
|
||
if (!isMissingKind(err)) throw err;
|
||
return (await allAccounts(s)).filter((c) => c.mobile === target);
|
||
}
|
||
return rows
|
||
.filter((r) => r.code !== undefined && normalizeMobile(r.mobile) === target)
|
||
.map((r) => ({
|
||
code: String(r.code),
|
||
name: joinName(r.name, r.namKhanevadegi),
|
||
mobile: target,
|
||
phone: null,
|
||
nationalCode: null,
|
||
address: null,
|
||
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.
|
||
// Seen working on v228 (plan §13).
|
||
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 = await ledgerRows(s, q.code, jalaliToIsoDate(q.from), jalaliToIsoDate(q.to));
|
||
|
||
// parvande has no running balance and its «مانده از قبل» row cannot be
|
||
// read as one, so the balance is anchored on the account's real balance
|
||
// now and walked back: closing(to) = now − movements after `to`.
|
||
const [now] = await zarrinProvider.getBalances(s, [q.code]);
|
||
let closingGold = now?.goldGrams ?? ZERO;
|
||
let closingRial = now?.rial ?? ZERO;
|
||
const today = toJalali(new Date());
|
||
if (jalaliKey(q.to) < jalaliKey(today)) {
|
||
for (const r of await ledgerRows(s, q.code, jalaliToIsoDate(nextDay(q.to)), jalaliToIsoDate(today))) {
|
||
const d = owesDelta(r);
|
||
closingGold = closingGold.sub(d.gold);
|
||
closingRial = closingRial.sub(d.rial);
|
||
}
|
||
}
|
||
let gold = closingGold;
|
||
let rial = closingRial;
|
||
for (const r of rows) {
|
||
const d = owesDelta(r);
|
||
gold = gold.sub(d.gold);
|
||
rial = rial.sub(d.rial);
|
||
}
|
||
|
||
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,
|
||
// A document posted with movaghat=1 says so in shomareSanad («موقت»);
|
||
// one left open in Zarrin's own UI is a «فاکتور موقت».
|
||
temporary: /موقت/.test(`${str(r.noeFactor) ?? ''} ${str(r.shomareSanad) ?? ''}`),
|
||
};
|
||
});
|
||
},
|
||
|
||
async listKarSakhteGroups(s) {
|
||
try {
|
||
return listOf(await call(s, 'goroohSakhte')).map((g) => ({ id: String(g.id), name: str(g.name) ?? String(g.id) }));
|
||
} catch (err) {
|
||
if (!isMissingKind(err)) throw err;
|
||
}
|
||
// Without goroohSakhte the only way is one infogorooh per id. Ids are
|
||
// sparse and unknown ids fail (some with a 500), so failures are skipped.
|
||
const found: { id: string; name: string }[] = [];
|
||
let next = 1;
|
||
const worker = async () => {
|
||
while (next <= GROUP_SCAN_MAX) {
|
||
const id = next++;
|
||
try {
|
||
for (const g of listOf(await call(s, 'infogorooh', { id }))) {
|
||
const name = str(g.nam);
|
||
if (name) found.push({ id: String(g.code ?? id), name });
|
||
}
|
||
} catch {
|
||
// not a group
|
||
}
|
||
}
|
||
};
|
||
await Promise.all(Array.from({ length: GROUP_SCAN_CONCURRENCY }, worker));
|
||
return found.sort((a, b) => Number(a.id) - Number(b.id));
|
||
},
|
||
|
||
async listKarSakhteTypes(s) {
|
||
const types = new Map<string, string>();
|
||
for (const r of await allKarSakhte(s)) {
|
||
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 (await allKarSakhte(s))
|
||
.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) {
|
||
const json = await call(
|
||
s,
|
||
'infogardeshsakhte',
|
||
{ az: jalaliToIsoDate(from), ta: jalaliToIsoDate(to) },
|
||
{ timeoutMs: Math.max(s.timeoutMs, MOVEMENT_TIMEOUT_MS), repair: repairZarrinJson },
|
||
);
|
||
return listOf(json).map(
|
||
(r) => ({
|
||
itemCode: str(r.code_kar),
|
||
itemName: str(r.nam_kar) ?? '—',
|
||
// One row per item: its LAST movement, which may predate the range.
|
||
date: fullJalali(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 = await ledgerRows(s, doc.customerCode, toIsoDateTehran(yearAgo), toIsoDateTehran(today));
|
||
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, repairZarrinJson, accountName };
|