feat: catalog read cache (api + user apps) and clearer Zarrin accounting errors
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -40,11 +40,51 @@ import {
|
||||
const STATEMENT_PAGE = 250;
|
||||
const CHANGE_WINDOW = 50;
|
||||
const MAX_STATEMENT_PAGES = 20;
|
||||
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;
|
||||
}
|
||||
|
||||
async function call(
|
||||
s: ProviderSettings,
|
||||
kind: string,
|
||||
@@ -73,7 +113,8 @@ async function call(
|
||||
}
|
||||
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
throw new AccountingProviderError(`زرین (${kind}): HTTP ${res.status}`, false, res.body.slice(0, 500), res.status === 404);
|
||||
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;
|
||||
@@ -96,7 +137,7 @@ async function call(
|
||||
return json as Record<string, unknown> | unknown[];
|
||||
}
|
||||
|
||||
/** Zarrin wraps lists as `data`, once as `date` (Get100Account), or not at all. */
|
||||
/** 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') {
|
||||
@@ -128,6 +169,21 @@ function fullJalali(v: unknown): string {
|
||||
return `14${m[1]}/${m[2].padStart(2, '0')}/${m[3].padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
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),
|
||||
name: str(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),
|
||||
}));
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -152,35 +208,10 @@ export const zarrinProvider: AccountingProvider = {
|
||||
}));
|
||||
},
|
||||
|
||||
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 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) {
|
||||
@@ -188,20 +219,22 @@ export const zarrinProvider: AccountingProvider = {
|
||||
if (!target) return [];
|
||||
let rows: Record<string, unknown>[];
|
||||
try {
|
||||
rows = listOf(await call(s, 'getinfoByPhone', { number: target }));
|
||||
// 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 [];
|
||||
throw err;
|
||||
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.nam, r.namKhanevadegi),
|
||||
name: joinName(r.name, r.namKhanevadegi),
|
||||
mobile: target,
|
||||
phone: str(r.tel),
|
||||
phone: null,
|
||||
nationalCode: null,
|
||||
address: str(r.address),
|
||||
address: null,
|
||||
groupId: null,
|
||||
groupName: null,
|
||||
}));
|
||||
@@ -333,7 +366,30 @@ export const zarrinProvider: AccountingProvider = {
|
||||
},
|
||||
|
||||
async listKarSakhteGroups(s) {
|
||||
return listOf(await call(s, 'goroohSakhte')).map((g) => ({ id: String(g.id), name: str(g.name) ?? String(g.id) }));
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user