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