311 lines
10 KiB
TypeScript
311 lines
10 KiB
TypeScript
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(' ') };
|
|||
|
|
}
|