feat: accounting integration (D27), SMS relay driver (D26), rebuild user apps

- 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>
This commit is contained in:
s.esmaeili
2026-10-06 16:48:36 +03:30
co-authored by Claude Opus 4.6
parent a72fa607a5
commit 19d490e790
179 changed files with 12538 additions and 370 deletions
@@ -0,0 +1,152 @@
/**
* Jalali (Shamsi) ⇄ Gregorian conversion.
*
* Tahesab wants a document date as separate Shamsi year/month/day integers and
* its list filters as `1405-04-24`; the customer app sends report ranges as
* `1405/04/24`; Zarrin wants Gregorian `2024-10-12` on most calls. One small
* converter covers all three without a dependency.
*
* The arithmetic is the well-known jalaali-js algorithm (Borkowski's leap-year
* break table), valid for Jalali years -61 … 3177.
*/
export interface JalaliDate {
jy: number;
jm: number;
jd: number;
}
const BREAKS = [
-61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, 1635, 2060, 2097, 2192, 2262, 2324, 2394, 2456,
3178,
];
function div(a: number, b: number) {
return ~~(a / b);
}
function mod(a: number, b: number) {
return a - ~~(a / b) * b;
}
function jalCal(jy: number) {
const gy = jy + 621;
let leapJ = -14;
let jp = BREAKS[0];
let jump = 0;
if (jy < jp || jy >= BREAKS[BREAKS.length - 1]) throw new RangeError(`Invalid Jalali year ${jy}`);
for (let i = 1; i < BREAKS.length; i += 1) {
const jm = BREAKS[i];
jump = jm - jp;
if (jy < jm) break;
leapJ = leapJ + div(jump, 33) * 8 + div(mod(jump, 33), 4);
jp = jm;
}
let n = jy - jp;
leapJ = leapJ + div(n, 33) * 8 + div(mod(n, 33) + 3, 4);
if (mod(jump, 33) === 4 && jump - n === 4) leapJ += 1;
const leapG = div(gy, 4) - div((div(gy, 100) + 1) * 3, 4) - 150;
const march = 20 + leapJ - leapG;
if (jump - n < 6) n = n - jump + div(jump + 4, 33) * 33;
let leap = mod(mod(n + 1, 33) - 1, 4);
if (leap === -1) leap = 4;
return { leap, gy, march };
}
function g2d(gy: number, gm: number, gd: number) {
let d =
div((gy + div(gm - 8, 6) + 100100) * 1461, 4) + div(153 * mod(gm + 9, 12) + 2, 5) + gd - 34840408;
d = d - div(div(gy + 100100 + div(gm - 8, 6), 100) * 3, 4) + 752;
return d;
}
function d2g(jdn: number) {
let j = 4 * jdn + 139361631;
j = j + div(div(4 * jdn + 183187720, 146097) * 3, 4) * 4 - 3908;
const i = div(mod(j, 1461), 4) * 5 + 308;
const gd = div(mod(i, 153), 5) + 1;
const gm = mod(div(i, 153), 12) + 1;
const gy = div(j, 1461) - 100100 + div(8 - gm, 6);
return { gy, gm, gd };
}
function j2d(jy: number, jm: number, jd: number) {
const r = jalCal(jy);
return g2d(r.gy, 3, r.march) + (jm - 1) * 31 - div(jm, 7) * (jm - 7) + jd - 1;
}
function d2j(jdn: number): JalaliDate {
const gy = d2g(jdn).gy;
let jy = gy - 621;
const r = jalCal(jy);
const jdn1f = g2d(gy, 3, r.march);
let k = jdn - jdn1f;
if (k >= 0) {
if (k <= 185) return { jy, jm: 1 + div(k, 31), jd: mod(k, 31) + 1 };
k -= 186;
} else {
jy -= 1;
k += 179;
if (r.leap === 1) k += 1;
}
return { jy, jm: 7 + div(k, 30), jd: mod(k, 30) + 1 };
}
/** Iran has had no DST since 2022, so a fixed +03:30 gives the local calendar day. */
const TEHRAN_OFFSET_MS = (3 * 60 + 30) * 60 * 1000;
/** The Jalali calendar day of an instant, as seen in Tehran. */
export function toJalali(date: Date): JalaliDate {
const local = new Date(date.getTime() + TEHRAN_OFFSET_MS);
return d2j(g2d(local.getUTCFullYear(), local.getUTCMonth() + 1, local.getUTCDate()));
}
/** Gregorian calendar day for a Jalali day: `{ gy, gm, gd }`. */
export function jalaliToGregorian(jy: number, jm: number, jd: number) {
return d2g(j2d(jy, jm, jd));
}
/**
* Parses `1405/04/24`, `1405-4-24` or Persian digits. Returns null for anything
* that is not a real Jalali date.
*/
export function parseJalali(input: string | null | undefined): JalaliDate | null {
if (!input) return null;
const ascii = String(input)
.trim()
.replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d)))
.replace(/[٠-٩]/g, (d) => String('٠١٢٣٤٥٦٧٨٩'.indexOf(d)));
const m = ascii.match(/^(\d{4})[/-](\d{1,2})[/-](\d{1,2})$/);
if (!m) return null;
const jy = Number(m[1]);
const jm = Number(m[2]);
const jd = Number(m[3]);
if (jm < 1 || jm > 12 || jd < 1 || jd > 31 || (jm > 6 && jd > 30)) return null;
try {
// Round-trip rejects 30 Esfand in a common year.
const back = d2j(j2d(jy, jm, jd));
return back.jy === jy && back.jm === jm && back.jd === jd ? { jy, jm, jd } : null;
} catch {
return null;
}
}
const pad = (n: number) => String(n).padStart(2, '0');
/** `1405-04-24` (or with another separator). */
export function formatJalali(d: JalaliDate, sep = '-') {
return `${d.jy}${sep}${pad(d.jm)}${sep}${pad(d.jd)}`;
}
/** `2026-07-15` for a Jalali day. */
export function jalaliToIsoDate(d: JalaliDate) {
const g = jalaliToGregorian(d.jy, d.jm, d.jd);
return `${g.gy}-${pad(g.gm)}-${pad(g.gd)}`;
}
/** `2026-07-15` for an instant, Tehran calendar day. */
export function toIsoDateTehran(date: Date) {
const local = new Date(date.getTime() + TEHRAN_OFFSET_MS);
return `${local.getUTCFullYear()}-${pad(local.getUTCMonth() + 1)}-${pad(local.getUTCDate())}`;
}