اپ: اصلاح نام برند به منتزر Co-authored-by: Cursor <cursoragent@cursor.com>
205 lines
7.6 KiB
TypeScript
205 lines
7.6 KiB
TypeScript
/**
|
||
* 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));
|
||
}
|