Files
tehran_tala/apps/api/src/modules/accounting-sync/jalali.ts
T
s.esmaeiliandClaude Opus 4.6 19d490e790 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>
2026-10-06 16:48:36 +03:30

153 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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())}`;
}