Files
hedena_gold/apps/api/scripts/legacy-import/steps/reconcile.ts
T
s.esmaeiliandClaude Opus 5.5 29084904e9 feat(orders): credit each order to the expert who placed it, as legacy did
Legacy fixed `orders.reagent_id` once, when the order was placed, and its
«میزان فروش» summed `customer_id = X OR reagent_id = X`. The app credited
orders to the customer's CURRENT referrer and left out an expert's own
orders, so 9 of 49 imported experts showed a different figure.

- Order.salesAgentId (+ referrerCustomerId for 17 legacy orders whose
  referrer was not an expert); additive migration, recent app orders
  backfilled from the customer's referrer
- createOrder records the expert at placement time
- one shared filter (agent-orders.ts) for the experts list, expert
  detail, dashboard and the expert app; child orders follow salesAgentId
- customer page gets its «میزان فروش» back
- import carries orders.reagent_id as is; ex-experts kept as admins keep
  their profile so their own orders stay linked
- reconcile-sales: 1,557 of 1,558 match legacy's own formula; balances
  unchanged

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:40:21 +03:30

148 lines
8.1 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';
import { agentSalesWhere, customerSalesWhere } from '../../../src/modules/sales-agent/agent-orders';
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).`);
}
/**
* «میزان فروش» 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<Row>(
`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<string, { where: Prisma.OrderWhereInput; live: number; ids: string[] }>();
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);
}