/** * 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'; import { agentSalesWhere, customerSalesWhere } from '../../../src/modules/sales-agent/agent-orders'; 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).`); } /** * «میزان فروش» for every migrated customer and expert, against legacy's own * formula (CustomerController::updateGramSell, soft-deleted orders excluded): * SUM(orders.input) WHERE customer_id = X OR reagent_id = X * computed live from legacy's rows — and, separately, legacy's STORED * `customers.grams_sell`, which the legacy screens print. The stored value is * a cache refreshed only on some order events, so where it disagrees with the * formula the legacy screen itself is out of date; each such row is listed. * * Legacy rows the import merged into one target row (duplicate mobiles) are * compared as one: their legacy figures are summed. */ export async function reconcileSales(ctx: Ctx) { const S = 'reconciliation — sales («میزان فروش»)'; const rows = await ctx.q( `SELECT c.id, c.grams_sell AS kept, (SELECT COALESCE(SUM(o.input), 0) FROM orders o WHERE o.deleted_at IS NULL AND (o.customer_id = c.id OR o.reagent_id = c.id)) AS live FROM customers c WHERE c.deleted_at IS NULL`, ); const groups = new Map(); let stale = 0; for (const r of rows) { const agentId = ctx.map.get('expert', r.id); const customerId = ctx.map.get('customer', r.id); if (!agentId && !customerId) continue; const key = agentId ? `expert:${agentId}` : `customer:${customerId!}`; const g = groups.get(key) ?? { where: agentId ? agentSalesWhere(agentId) : customerSalesWhere(customerId!), live: 0, ids: [], }; g.live += toNumber(r.live); g.ids.push(String(r.id)); groups.set(key, g); const live = new Prisma.Decimal(toNumber(r.live).toFixed(4)); const kept = new Prisma.Decimal(toNumber(r.kept).toFixed(4)); if (kept.sub(live).abs().gt(TOLERANCE)) { stale++; ctx.report.note(S, `legacy's stored figure is out of date for #${String(r.id)}: legacy screen shows ${kept.toString()}, legacy's own formula gives ${live.toString()}`); } } let ok = 0, bad = 0; for (const [key, g] of groups) { const agg = await ctx.db.order.aggregate({ where: g.where, _sum: { totalGoldWeightGrams: true } }); const target = agg._sum.totalGoldWeightGrams ?? new Prisma.Decimal(0); const live = new Prisma.Decimal(g.live.toFixed(4)); if (live.sub(target).abs().lte(TOLERANCE)) ok++; else { bad++; ctx.report.note(S, `⚠ ${key.split(':')[0]} legacy #${g.ids.join(' + #')}: legacy formula ${live.toString()} / target ${target.toString()}`); } } ctx.report.count(S, 'matches legacy formula (±0.0001 g)', ok); ctx.report.count(S, 'DIFFERS from legacy formula', bad); ctx.report.count(S, "legacy's stored screen value is stale (legacy disagrees with itself)", stale); }