Files
hedena_gold/apps/api/scripts/legacy-import/steps/reconcile.ts
T
s.esmaeiliandClaude Opus 5.5 be5ca829d2 feat(import): henza → gold-bank data import tool
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>
2026-10-07 19:35:18 +03:30

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).`);
}