Files
tehran_taj/apps/api/scripts/legacy-import/steps/reconcile.ts
T

92 lines
5.2 KiB
TypeScript
Raw Normal View History

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