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>
92 lines
5.2 KiB
TypeScript
92 lines
5.2 KiB
TypeScript
/**
|
|
* Reconciliation (GO-LIVE-PLAN §7): what legacy has vs what the target now
|
|
* holds, with the tolerance of DATA-MIGRATION-MAP §4 instead of "zero
|
|
* difference", which double → Decimal rounding makes impossible.
|
|
*/
|
|
import { Prisma } from '@prisma/client';
|
|
import type { Ctx } from '../lib/context';
|
|
import { toNumber } from '../lib/normalize';
|
|
|
|
type Row = Record<string, unknown>;
|
|
const TOLERANCE = new Prisma.Decimal('0.0001');
|
|
|
|
export async function reconcile(ctx: Ctx) {
|
|
const S = 'reconciliation';
|
|
|
|
/* counts: legacy rows in scope vs ids mapped */
|
|
const scope: [string, string, string][] = [
|
|
['cities', 'city', `SELECT COUNT(*) n FROM cities WHERE deleted_at IS NULL`],
|
|
['branches', 'branch', `SELECT COUNT(*) n FROM branches WHERE deleted_at IS NULL`],
|
|
['workshops', 'workshop', `SELECT COUNT(*) n FROM workshops WHERE deleted_at IS NULL`],
|
|
['admin users', 'admin', `SELECT COUNT(*) n FROM users WHERE deleted_at IS NULL`],
|
|
['sales experts', 'expert', `SELECT COUNT(*) n FROM customers WHERE deleted_at IS NULL AND type = 1`],
|
|
['live products', 'product', `SELECT COUNT(*) n FROM product WHERE deleted_at IS NULL`],
|
|
['live variants', 'variant', `SELECT COUNT(*) n FROM products_productvarient WHERE deleted_at IS NULL`],
|
|
['live orders', 'order', `SELECT COUNT(*) n FROM orders WHERE deleted_at IS NULL`],
|
|
['custom orders', 'preorder', `SELECT COUNT(*) n FROM custom_orders WHERE deleted_at IS NULL`],
|
|
];
|
|
const rows: string[] = ['| | legacy | mapped |', '|---|---:|---:|'];
|
|
for (const [label, entity, sql] of scope) {
|
|
const [{ n }] = await ctx.q<{ n: number }>(sql);
|
|
const [{ m }] = await ctx.db.$queryRawUnsafe<{ m: bigint }[]>(
|
|
`SELECT COUNT(*) AS m FROM legacy_import.id_map WHERE entity = $1`, entity,
|
|
);
|
|
rows.push(`| ${label} | ${String(n)} | ${String(m)} |`);
|
|
}
|
|
ctx.report.note(S, `Counts (mapped may exceed legacy live rows where deleted rows were kept for order history):\n\n${rows.join('\n')}\n`);
|
|
|
|
/* balances: every migrated customer, legacy stored vs target cached */
|
|
const legacy = await ctx.q<Row>('SELECT id, remain, rial_remain FROM customers WHERE deleted_at IS NULL');
|
|
const want = new Map<string, { gold: number; rial: number }>();
|
|
for (const r of legacy) {
|
|
const id = ctx.map.get('customer', r.id);
|
|
if (!id) continue;
|
|
const w = want.get(id) ?? { gold: 0, rial: 0 };
|
|
w.gold += toNumber(r.remain);
|
|
w.rial += toNumber(r.rial_remain);
|
|
want.set(id, w);
|
|
}
|
|
const targets = await ctx.db.customer.findMany({
|
|
where: { id: { in: [...want.keys()] } },
|
|
select: { id: true, goldDebtGrams: true, fiatDebtRial: true },
|
|
});
|
|
// Matched customers may carry target-native entries too, so only the
|
|
// customers the import created are held to legacy's number.
|
|
const created = new Set(
|
|
(await ctx.db.$queryRawUnsafe<{ target_id: string }[]>(
|
|
`SELECT DISTINCT target_id FROM legacy_import.id_map WHERE entity = 'customer' AND how = 'created'`,
|
|
)).map((r) => r.target_id),
|
|
);
|
|
|
|
let goldOk = 0, goldBad = 0, rialOk = 0, rialBad = 0;
|
|
let legacyGold = new Prisma.Decimal(0), targetGold = new Prisma.Decimal(0);
|
|
let legacyRial = new Prisma.Decimal(0), targetRial = new Prisma.Decimal(0);
|
|
for (const t of targets) {
|
|
if (!created.has(t.id)) continue;
|
|
const w = want.get(t.id)!;
|
|
const g = new Prisma.Decimal(w.gold.toFixed(4));
|
|
const r = new Prisma.Decimal(w.rial.toFixed(2));
|
|
legacyGold = legacyGold.add(g); targetGold = targetGold.add(t.goldDebtGrams);
|
|
legacyRial = legacyRial.add(r); targetRial = targetRial.add(t.fiatDebtRial);
|
|
if (g.sub(t.goldDebtGrams).abs().lte(TOLERANCE)) goldOk++;
|
|
else { goldBad++; ctx.report.note(S, `⚠ gold balance differs for customer ${t.id}: legacy ${g.toString()} / target ${t.goldDebtGrams.toString()}`); }
|
|
if (r.sub(t.fiatDebtRial).abs().lt(1)) rialOk++;
|
|
else { rialBad++; ctx.report.note(S, `⚠ rial balance differs for customer ${t.id}: legacy ${r.toString()} / target ${t.fiatDebtRial.toString()}`); }
|
|
}
|
|
ctx.report.count(S, 'created customers — gold balance matches legacy (±0.0001 g)', goldOk);
|
|
ctx.report.count(S, 'created customers — gold balance DIFFERS', goldBad);
|
|
ctx.report.count(S, 'created customers — rial balance matches legacy (±1 rial)', rialOk);
|
|
ctx.report.count(S, 'created customers — rial balance DIFFERS', rialBad);
|
|
ctx.report.note(S, `Total gold debt (created customers): legacy ${legacyGold.toFixed(4)} g · target ${targetGold.toFixed(4)} g`);
|
|
ctx.report.note(S, `Total rial debt (created customers): legacy ${legacyRial.toFixed(2)} · target ${targetRial.toFixed(2)}`);
|
|
ctx.report.note(S, `Matched (pre-existing) customers are not held to legacy's number: they may carry target-native ledger entries. ${targets.length - created.size} of them; see the "balances" section for whose cached balance changed.`);
|
|
|
|
/* protected columns: the import must never have written them */
|
|
const [{ n: imageIds }] = await ctx.db.$queryRawUnsafe<{ n: bigint }[]>(
|
|
`SELECT COUNT(*) AS n FROM "ProductVariant" v
|
|
JOIN legacy_import.id_map m ON m.entity = 'variant' AND m.target_id = v.id AND m.how = 'created'
|
|
WHERE v."imageId" IS NOT NULL`,
|
|
);
|
|
ctx.report.note(S, `Imported variants with an imageId: ${String(imageIds)} (must be 0 — PROTECTED.md §B).`);
|
|
}
|