/** * 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; 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('SELECT id, remain, rial_remain FROM customers WHERE deleted_at IS NULL'); const want = new Map(); 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).`); }