- 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>
153 lines
4.8 KiB
TypeScript
153 lines
4.8 KiB
TypeScript
/**
|
||
* 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())}`;
|
||
}
|