Files

311 lines
10 KiB
TypeScript
Raw Permalink Normal View History

import { Prisma } from '@prisma/client';
import { JalaliDate } from '../jalali';
/**
* One accounting software (D27). AccountingSyncService picks a driver by the
* admin's switch and owns everything around the call — links, outbox, retries,
* logging — so a driver only translates between the app's shapes and its
* software's API.
*
* Every figure a driver returns is ALREADY in the app's convention:
* * gold in grams of 750, positive balance = the customer owes;
* * rial in rial, positive balance = the customer owes.
* Each software's own sign and unit quirks stay inside its driver.
*/
export type ProviderName = 'tahesab' | 'zarrin';
/** Decrypted connection settings, handed to every call. */
export interface ProviderSettings {
baseUrl: string;
token: string;
caCert: string | null;
timeoutMs: number;
/** Tahesab only: the `DBname` header (`TahesabDB` for SQL Server, `DB` for Access). */
dbName?: string;
/** Zarrin only, until verified on a real installation (plan §9). */
balanceOwesWhen?: 'negative' | 'positive';
}
export interface ExternalGroup {
id: string;
name: string;
}
export interface ExternalCustomer {
code: string;
name: string;
/** Normalised to 09xxxxxxxxx, or null when the account has no usable mobile. */
mobile: string | null;
phone: string | null;
nationalCode: string | null;
address: string | null;
groupId: string | null;
groupName: string | null;
}
export interface CustomerPayload {
firstName: string;
lastName: string;
fullName: string;
mobile: string;
phone: string | null;
address: string | null;
nationalCode: string | null;
group: { id: string | null; name: string | null };
}
export interface ExternalBalance {
code: string;
goldGrams: Prisma.Decimal;
rial: Prisma.Decimal;
}
export interface StatementQuery {
code: string;
from: JalaliDate;
to: JalaliDate;
}
/** One line of a customer's statement, normalised across providers. */
export interface StatementRow {
/** Stable within the provider (Tahesab ID + Radif, Zarrin idRadif). */
key: string;
/** The document the line belongs to — what a detail lookup is keyed on. */
documentRef: string;
/** The account the line belongs to, for the SEC-16 ownership check. */
accountCode: string;
documentNo: string;
/** Jalali `1405/07/12`. */
date: string;
time: string | null;
title: string;
description: string | null;
itemName: string | null;
weight: Prisma.Decimal | null;
karat: number | null;
count: Prisma.Decimal | null;
unitPrice: Prisma.Decimal | null;
wagePercent: Prisma.Decimal | null;
/** 750-equivalent gold received from the customer (reduces what they owe). */
goldIn: Prisma.Decimal;
goldOut: Prisma.Decimal;
rialIn: Prisma.Decimal;
rialOut: Prisma.Decimal;
/** Running balance after this line, when the software reports one. */
goldBalance: Prisma.Decimal | null;
rialBalance: Prisma.Decimal | null;
metal: string | null;
temporary: boolean;
}
export interface ExternalKarSakhte {
/** Zarrin idKar; for Tahesab, which has no item ids, the item name itself. */
code: string;
name: string;
groupId: string | null;
groupName: string | null;
typeId: string | null;
typeName: string | null;
saleWagePercent: Prisma.Decimal | null;
stockWeight: Prisma.Decimal | null;
stockCount: Prisma.Decimal | null;
}
export interface KarSakhteStock {
weight: Prisma.Decimal | null;
count: Prisma.Decimal | null;
}
export interface MovementRow {
itemCode: string | null;
itemName: string;
date: string;
description: string | null;
weight: Prisma.Decimal | null;
amount: Prisma.Decimal | null;
count: Prisma.Decimal | null;
}
export interface DocumentLine {
/** The linked کارساخته: Zarrin idKar / Tahesab Name_Kar. */
itemCode: string;
itemName: string;
/** Physical (raw) weight of the line, all pieces. */
weightGrams: Prisma.Decimal;
count: number;
/** Effective making charge: variant wage minus discount, in percent. */
wagePercent: Prisma.Decimal;
/** Flat rial charge of the line (stones), all pieces. */
flatRial: Prisma.Decimal;
karat: number;
description: string;
}
export interface OrderDocumentInput {
/** Deterministic per order; Tahesab dedupes on it (≥ 20 chars, lowercase). */
factorCode: string;
customerCode: string;
date: Date;
description: string;
temporary: boolean;
lines: DocumentLine[];
}
/** Resumable state of a multi-call document (Zarrin posts one call per line). */
export interface DocumentProgress {
factorId?: string;
doneLines?: number;
}
export interface DocumentPostResult {
ref: string;
progress: DocumentProgress;
response: unknown;
}
/** What changed in the accounting software since the last poll (real-time sync). */
export interface ChangeBatch {
/** Opaque; hand it back on the next poll. */
cursor: string;
/** Accounts that got a new or edited document. */
accountCodes: string[];
/** کارساخته names that got a new or edited document. */
itemNames: string[];
/** More changed than one poll can see: the caller must do a full refresh. */
overflow: boolean;
}
export interface ProviderCapabilities {
/** Can a کارساخته be created through the API? (Zarrin yes, Tahesab no.) */
createKarSakhte: boolean;
/** Can a posted document be looked up by our own key before a retry? */
documentInquiry: boolean;
/** Does the movement report cover every account, or only recent documents? */
movementScope: 'all' | 'recent-documents';
/** Can recent changes be polled cheaply (see getChanges)? */
changeFeed: boolean;
}
export interface AccountingProvider {
readonly name: ProviderName;
readonly label: string;
readonly capabilities: ProviderCapabilities;
health(s: ProviderSettings): Promise<Record<string, unknown>>;
listCustomerGroups(s: ProviderSettings): Promise<ExternalGroup[]>;
/** One page of accounts; `next` is null after the last page. */
listCustomers(s: ProviderSettings, cursor: string | null): Promise<{ items: ExternalCustomer[]; next: string | null }>;
findCustomersByMobile(s: ProviderSettings, mobile: string): Promise<ExternalCustomer[]>;
createCustomer(s: ProviderSettings, p: CustomerPayload): Promise<{ code: string; response: unknown }>;
updateCustomer(s: ProviderSettings, code: string, p: CustomerPayload): Promise<{ response: unknown }>;
getBalances(s: ProviderSettings, codes: string[]): Promise<ExternalBalance[]>;
/**
* Documents added or edited since `cursor` (null = first poll: only returns
* the starting cursor). Cheap enough to call every few seconds.
*/
getChanges(s: ProviderSettings, cursor: string | null): Promise<ChangeBatch>;
getStatement(s: ProviderSettings, q: StatementQuery): Promise<StatementRow[]>;
listKarSakhteGroups(s: ProviderSettings): Promise<ExternalGroup[]>;
listKarSakhteTypes(s: ProviderSettings): Promise<ExternalGroup[]>;
listKarSakhte(s: ProviderSettings): Promise<ExternalKarSakhte[]>;
getKarSakhteStock(s: ProviderSettings, item: { code: string; name: string }): Promise<KarSakhteStock>;
getKarSakhteMovement(s: ProviderSettings, from: JalaliDate, to: JalaliDate): Promise<MovementRow[]>;
createKarSakhte(
s: ProviderSettings,
item: { name: string; groupId: string | null; typeId: string | null },
): Promise<{ code: string; response: unknown }>;
postOrderDocument(
s: ProviderSettings,
doc: OrderDocumentInput,
progress: DocumentProgress,
onProgress: (p: DocumentProgress) => Promise<void>,
): Promise<DocumentPostResult>;
/** True when a document with this key exists. */
documentExists(s: ProviderSettings, doc: { factorCode: string; ref: string | null }): Promise<boolean>;
deleteDocument(
s: ProviderSettings,
doc: { factorCode: string; ref: string | null; customerCode: string },
): Promise<{ response: unknown }>;
}
/**
* A call the software answered with an error, or could not be made.
* `ambiguous` = the software may have acted on it anyway (timeout after send).
*/
export class AccountingProviderError extends Error {
constructor(
message: string,
readonly ambiguous = false,
readonly response?: unknown,
readonly notFound = false,
) {
super(message);
this.name = 'AccountingProviderError';
}
}
/** Not offered by this software's API. */
export class AccountingNotSupportedError extends AccountingProviderError {
constructor(what: string) {
super(`${what} در API این نرم‌افزار وجود ندارد`);
this.name = 'AccountingNotSupportedError';
}
}
// ── shared helpers ─────────────────────────────────────────────────────
const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
const ARABIC_DIGITS = '٠١٢٣٤٥٦٧٨٩';
export function asciiDigits(v: string) {
return v
.replace(/[۰-۹]/g, (d) => String(PERSIAN_DIGITS.indexOf(d)))
.replace(/[٠-٩]/g, (d) => String(ARABIC_DIGITS.indexOf(d)));
}
/**
* `9121234567`, `09121234567`, `+989121234567`, `00989121234567`, Persian
* digits → `09121234567`. Anything that is not an Iranian mobile → null.
*/
export function normalizeMobile(v: unknown): string | null {
if (v === null || v === undefined) return null;
let d = asciiDigits(String(v)).replace(/\D/g, '');
if (d.startsWith('0098')) d = d.slice(4);
else if (d.startsWith('98') && d.length === 12) d = d.slice(2);
if (d.length === 10 && d.startsWith('9')) d = `0${d}`;
return /^09\d{9}$/.test(d) ? d : null;
}
/** A Prisma.Decimal from a value the software sent, or null for "null"/blank/NaN. */
export function dec(v: unknown): Prisma.Decimal | null {
if (v === null || v === undefined) return null;
const s = asciiDigits(String(v)).replace(/,/g, '').trim();
if (s === '' || s.toLowerCase() === 'null' || s.toLowerCase() === 'nan') return null;
try {
const d = new Prisma.Decimal(s);
return d.isFinite() ? d : null;
} catch {
return null;
}
}
export const ZERO = new Prisma.Decimal(0);
export function str(v: unknown): string | null {
if (v === null || v === undefined) return null;
const s = String(v).trim();
return s === '' || s.toLowerCase() === 'null' ? null : s;
}
/** Split a full name into first/last the way the app's DB trigger does. */
export function splitName(full: string): { firstName: string; lastName: string } {
const parts = full.trim().split(/\s+/).filter(Boolean);
if (parts.length <= 1) return { firstName: parts[0] ?? '', lastName: '' };
return { firstName: parts[0], lastName: parts.slice(1).join(' ') };
}