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:
co-authored by
Claude Opus 5.5
parent
8c5d971d1f
commit
29084904e9
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user