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>
This commit is contained in:
s.esmaeili
2026-10-08 10:40:21 +03:30
co-authored by Claude Opus 5.5
parent 8c5d971d1f
commit 29084904e9
14 changed files with 267 additions and 26 deletions
+2 -1
View File
@@ -32,7 +32,7 @@ import { IdMap, makeContext, openSource, type Ctx, type Options } from './lib/co
import { admins, branches, cities, customers, experts, roles, workshops } from './steps/people';
import { brands, categories, products, variants } from './steps/catalog';
import { favorites, ledger, orders, preOrders } from './steps/commerce';
import { reconcile } from './steps/reconcile';
import { reconcile, reconcileSales } from './steps/reconcile';
/** Order matters: each step reads the ids the earlier ones mapped. */
const STEPS: [string, (ctx: Ctx) => Promise<void>][] = [
@@ -52,6 +52,7 @@ const STEPS: [string, (ctx: Ctx) => Promise<void>][] = [
['favorites', favorites],
['preorders', preOrders],
['reconcile', reconcile],
['reconcile-sales', reconcileSales],
];
const ENTITIES = [
@@ -50,7 +50,26 @@ export async function orders(ctx: Ctx) {
continue;
}
const orderNumber = `H-${String(r.id)}`;
// `orders.reagent_id` is the expert the order was credited to when it was
// placed (never moved afterwards) — carried as is, NOT re-derived from the
// customer's current referrer (owner ruling 2026-10-08). It names a
// Customer row in legacy: an expert maps to their AdminUser; anyone else
// (one customer, 17 orders from 2022) is kept as the referrer customer.
let salesAgentId: string | null = null;
let referrerCustomerId: string | null = null;
if (r.reagent_id !== null) {
salesAgentId = ctx.map.get('expert', r.reagent_id) ?? null;
if (!salesAgentId) referrerCustomerId = ctx.map.get('customer', r.reagent_id) ?? null;
if (!salesAgentId && !referrerCustomerId) {
ctx.report.count(S, 'placing expert not migrated — left empty');
ctx.report.note(S, `legacy order #${String(r.id)}: its reagent #${String(r.reagent_id)} was not migrated`);
} else {
ctx.report.count(S, salesAgentId ? 'credited to its legacy expert' : 'credited to its legacy referrer customer');
}
}
const data = {
salesAgentId,
referrerCustomerId,
customerId,
channel: OrderChannel.WHOLESALE,
status,
@@ -72,9 +91,7 @@ export async function orders(ctx: Ctx) {
if (how === 'created') await ctx.db.order.update({ where: { id }, data });
},
});
ctx.report.count(S, `${res.isNew ? 'created' : 'updated'} — ${status}`);
if (r.reagent_id !== null) ctx.report.count(S, 'placing expert (orders.reagent_id) not carried — no target field');
}
ctx.report.count(S, `${res.isNew ? 'created' : 'updated'} — ${status}`); }
await orderItems(ctx);
}
@@ -347,9 +347,12 @@ export async function experts(ctx: Ctx) {
include: { salesExpertProfile: true },
});
if (admin.role !== AdminRole.SALES_EXPERT) {
// Owner 2026-10-07: the admin keeps their role. Their expert profile is
// still kept, because it is what links their OWN legacy orders (their
// trading account) to them — without it their «میزان فروش» lost those
// orders. The profile grants nothing: the expert app checks the role.
ctx.report.count(S, `mobile already an admin with role ${admin.role} — kept, not demoted`);
ctx.report.note(S, `legacy expert #${String(r.id)} shares a mobile with admin role ${admin.role}; the admin was kept and referrals point to it.`);
continue;
}
if (!admin.salesExpertProfile) {
await ctx.db.salesExpertProfile.create({ data: { adminUserId: admin.id, ...profile } });
@@ -6,6 +6,7 @@
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');
@@ -89,3 +90,58 @@ export async function reconcile(ctx: Ctx) {
);
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);
}