apps/api/scripts/legacy-import/ reads the legacy MySQL (read only) and merges into the target Postgres. Every rule is from docs/DATA-MIGRATION-MAP.md, and each step cites its section. It covers cities, roles and permissions, branches, admin users, sales experts, workshops, customers, brands, categories, products, variants, product images, orders, the ledger, favourites, custom orders and a reconciliation report. Key rules: - Debt is rebuilt from legacy's own formula (updateRemain): delivered orders minus confirmed type 1-4 documents. Type 9 «سفارش» documents mirror orders and are skipped, or every order's debt would count twice. A balance the records cannot explain is carried as one ADJUSTMENT entry. In the 2026-10-04 dump that is only customer #125 (owner ruling). - Merge, not import: rows already in the target are matched by mobile or product code, and only their empty fields are filled. Never written: accounting* columns, ProductVariant.imageId, Accounting* tables. No ProductImage is ever deleted. - Roles map by title, not number. Legacy 86 is «حسابدار» but target 86 is «کارشناس فروش». An expert sharing a mobile with an admin never demotes the admin. - Soft-deleted variants and products that live order lines still point at (473 of 610 lines) come over inactive, so order history stays whole. - Owner rulings 2026-10-07: birthdays filtered (541 of 915), typed city kept plus exact-match cityId, the 7 legacy rejections get the sentinel reason, OTP-only accounts get OTP_ONLY_PASSWORD_HASH. - Tehran wall-clock timestamps are converted with ICU rules, including Iran's pre-2022 DST. Jalali receipt dates are converted with the jalaali-js arithmetic (truncating division; verified on 8 known dates). Safety: --target is required and never read from .env. Nothing is written without --yes, and a target not named *import*/*staging* also needs --confirm-db. The id map lives in its own schema, legacy_import, so a re-run updates instead of duplicating. Writes bypass the API services, so no SMS is sent and nothing is queued to accounting. Tested against goldbank_import_test, a clone of the dev DB, in three runs: - 1,503/1,503 created customers match legacy gold and rial balances (±0.0001 g / ±1 rial) - accounting data is byte-identical before and after - 0 imported variants have an imageId - runs 2 and 3 created zero duplicate rows - 6,196 image files copied, 3 missing in the legacy storage New devDependency: mysql2 ^3.24.5. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
205 lines
7.6 KiB
TypeScript
205 lines
7.6 KiB
TypeScript
/**
|
||
* Value conversion from the henza 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));
|
||
}
|