Files
hedena_gold/apps/api/scripts/legacy-import/lib/normalize.ts
T
s.esmaeiliandCursor d613d16c82 fix: rename the henza brand to montzar
اپ: اصلاح نام برند به منتزر

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-10 17:17:14 +03:30

205 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
/**
* Value conversion from the legacy system's MySQL schema to the Prisma one.
*
* Every helper here is pure, so the rules can be read and tested in isolation.
* Each rule is the one recorded in docs/DATA-MIGRATION-MAP.md.
*/
import { Prisma } from '@prisma/client';
const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
const ARABIC_DIGITS = '٠١٢٣٤٥٦٧٨٩';
/** Persian/Arabic-Indic digits -> ASCII. Legacy did the same (Utility::faTOen). */
export function latinDigits(s: string): string {
return s.replace(/[۰-۹٠-٩]/g, (d) => {
const i = PERSIAN_DIGITS.indexOf(d);
return String(i >= 0 ? i : ARABIC_DIGITS.indexOf(d));
});
}
/**
* Persian text as stored: trimmed, single spaces, and Arabic ي/ك folded to
* Persian ی/ک. Empty -> null, so '' never reaches a nullable target column.
*/
export function text(v: unknown): string | null {
if (v === null || v === undefined) return null;
const s = String(v)
.replace(/ي/g, 'ی')
.replace(/ك/g, 'ک')
.replace(/\s+/g, ' ')
.trim();
return s === '' ? null : s;
}
/**
* Comparison key for matching names across systems. Also drops the zero-width
* non-joiner, which legacy typed inconsistently ("مدیر اصلی" vs "مدیراصلی").
* The ي/ك fold alone raised exact city matches from 32 to 72 (§10.4).
*/
export function matchKey(v: unknown): string | null {
const t = text(v);
return t === null ? null : t.replace(/‌/g, '').replace(/\s/g, '').toLowerCase();
}
/** Mobile as the login key: ASCII digits only, or null. */
export function mobile(v: unknown): string | null {
if (v === null || v === undefined) return null;
const d = latinDigits(String(v)).replace(/\D/g, '');
return d === '' ? null : d;
}
function num(v: unknown): number {
if (v === null || v === undefined || v === '') return 0;
const n = Number(v);
return Number.isFinite(n) ? n : 0;
}
/**
* Legacy stores money and weight as `double` (§4). Going through toFixed at the
* target scale drops the binary noise (12.3 is really 12.30000000000000071…)
* exactly as Decimal(14,4) would, so a reconciliation can compare like with like.
*/
export const grams = (v: unknown) => new Prisma.Decimal(num(v).toFixed(4));
export const rial = (v: unknown) => new Prisma.Decimal(num(v).toFixed(2));
export const percent = (v: unknown) => new Prisma.Decimal(num(v).toFixed(3));
export const isZero = (v: unknown) => Math.abs(num(v)) < 0.00005;
export const toNumber = num;
/** Integer or null — for tinyint flags and ids that may arrive as strings. */
export function int(v: unknown): number | null {
if (v === null || v === undefined || v === '') return null;
const n = Number(v);
return Number.isInteger(n) ? n : null;
}
/* ───────────── time ───────────── */
/**
* The Laravel app ran with timezone 'Asia/Tehran' (henza_server config/app.php:70),
* so every timestamp is Tehran wall-clock time. Read as a UTC instant it would
* be 3:30 (4:30 under the pre-2022 summer time) off, moving evening documents
* to the next day on a statement. The ICU zone rules know Iran's historical
* DST, so the offset is taken from them per instant rather than hard-coded.
*/
const tehranFormat = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Tehran',
hourCycle: 'h23',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
function tehranOffsetMs(utcMs: number): number {
const p: Record<string, string> = {};
for (const part of tehranFormat.formatToParts(new Date(utcMs))) p[part.type] = part.value;
const asUtc = Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second);
return asUtc - utcMs;
}
const WALL = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}):(\d{2}))?/;
/** 'YYYY-MM-DD HH:MM:SS' in Tehran wall-clock time -> the UTC instant. */
export function tehran(v: unknown): Date | null {
if (v === null || v === undefined || v === '') return null;
const m = WALL.exec(String(v));
if (!m || m[1] === '0000') return null;
const wall = Date.UTC(+m[1], +m[2] - 1, +m[3], +(m[4] ?? 0), +(m[5] ?? 0), +(m[6] ?? 0));
// Two passes: the offset at the guessed instant can differ across a DST edge.
let utc = wall - tehranOffsetMs(wall);
utc = wall - tehranOffsetMs(utc);
return new Date(utc);
}
/**
* Birthdays (owner ruling 2026-10-07, §10.7): of 915 set values only 541 are
* real. Dropped: the sign-up default (birth date == sign-up date, 190 rows) and
* the impossible ones (a time of day, or born after 2008; 184 rows). A
* birthday is a calendar date, so it is kept as UTC midnight of that date.
*/
export function birthday(birth: unknown, signup: unknown): Date | null {
const b = WALL.exec(String(birth ?? ''));
if (!b) return null;
if ((b[4] ?? '00') !== '00' || (b[5] ?? '00') !== '00' || (b[6] ?? '00') !== '00') return null;
if (+b[1] > 2008 || +b[1] < 1900) return null;
const s = WALL.exec(String(signup ?? ''));
if (s && s[1] === b[1] && s[2] === b[2] && s[3] === b[3]) return null;
return new Date(Date.UTC(+b[1], +b[2] - 1, +b[3]));
}
/* ───────────── Jalali dates ───────────── */
/**
* `cashes.recive_date` is a varchar holding a Jalali date ("1403/05/12"; 88 of
* 89 values start with 14). This is the standard jalaali-js arithmetic
* (Borkowski), inlined so the tool needs no extra dependency.
*
* Its integer division TRUNCATES toward zero. Math.floor is not a substitute:
* g2d's (gm - 8) / 6 is negative for March, and flooring it shifted every
* converted date by a year.
*/
const div = (a: number, b: number) => Math.trunc(a / b);
const mod = (a: number, b: number) => a - Math.trunc(a / b) * b;
function jalCal(jy: number) {
const breaks = [-61, 9, 38, 199, 426, 686, 756, 818, 1111, 1181, 1210, 1635, 2060, 2097, 2192, 2262, 2324, 2394, 2456, 3178];
const gy = jy + 621;
let leapJ = -14;
let jp = breaks[0];
let jump = 0;
for (let i = 1; i < breaks.length; i++) {
const jm = breaks[i];
jump = jm - jp;
if (jy < jm) break;
leapJ += div(jump, 33) * 8 + div(mod(jump, 33), 4);
jp = jm;
}
const n = jy - jp;
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;
return { gy, march: 20 + leapJ - leapG };
}
function g2d(gy: number, gm: number, gd: number) {
const d =
div((gy + div(gm - 8, 6) + 100100) * 1461, 4) +
div(153 * mod(gm + 9, 12) + 2, 5) +
gd - 34840408;
return d - div(div(gy + 100100 + div(gm - 8, 6), 100) * 3, 4) + 752;
}
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 };
}
/** Jalali y/m/d -> Gregorian y/m/d. */
export function jalaliToGregorian(jy: number, jm: number, jd: number) {
const r = jalCal(jy);
const jdn = g2d(r.gy, 3, r.march) + (jm - 1) * 31 - div(jm, 7) * (jm - 7) + jd - 1;
return d2g(jdn);
}
/**
* A free-text Jalali date ("1403/5/12", "۱۴۰۳-۰۵-۱۲") -> UTC midnight of the
* same day, or null when it does not parse. Callers keep the raw text too.
*/
export function jalaliDate(v: unknown): Date | null {
const s = latinDigits(String(v ?? '')).trim();
const m = /^(1[34]\d{2})\D(\d{1,2})\D(\d{1,2})$/.exec(s);
if (!m) return null;
const jm = +m[2];
const jd = +m[3];
if (jm < 1 || jm > 12 || jd < 1 || jd > 31) return null;
const g = jalaliToGregorian(+m[1], jm, jd);
return new Date(Date.UTC(g.gy, g.gm - 1, g.gd));
}