Files
tehran_tala/apps/api/scripts/legacy-import/steps/commerce.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

430 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Orders, the ledger, favourites and custom orders.
* Rules: docs/DATA-MIGRATION-MAP.md §7, §8, §10, §10.7.
*/
import {
LedgerCurrency,
LedgerDirection,
LedgerSourceType,
OrderChannel,
OrderStatus,
PreOrderStatus,
Prisma,
} from '@prisma/client';
import { upsert, type Ctx } from '../lib/context';
import { grams, int, jalaliDate, percent, rial, tehran, text, toNumber } from '../lib/normalize';
type Row = Record<string, unknown>;
const D = Prisma.Decimal;
/** Order::$STS (Order.php:66-74). 7 «کنسل شده» is commented out in legacy and absent from the data. */
const ORDER_STATUS: Record<number, OrderStatus> = {
0: OrderStatus.SUSPENDED,
1: OrderStatus.PLACED,
2: OrderStatus.ADMIN_APPROVED,
3: OrderStatus.SOURCING,
4: OrderStatus.PREPARING,
5: OrderStatus.DELIVERED_TO_AGENT,
6: OrderStatus.DELIVERED_TO_CUSTOMER,
};
/* ───────────── orders ───────────── */
export async function orders(ctx: Ctx) {
const S = 'orders';
// Wholesale only. Retail `orders_orders` is deferred by the owner (§7.2) and
// must not be mapped with this status table: its 7 means «تحویل مشتری».
const rows = await ctx.q<Row>('SELECT * FROM orders WHERE deleted_at IS NULL');
for (const r of rows) {
const customerId = ctx.map.get('customer', r.customer_id);
if (!customerId) {
ctx.report.count(S, 'skipped: customer not migrated');
ctx.report.note(S, `legacy order #${String(r.id)} skipped: its customer #${String(r.customer_id)} was not migrated (status ${String(r.sts)})`);
continue;
}
const sts = int(r.sts);
const status = sts !== null ? ORDER_STATUS[sts] : undefined;
if (!status) {
ctx.report.count(S, 'skipped: unknown status');
ctx.report.note(S, `legacy order #${String(r.id)} has status ${String(r.sts)}, which has no mapping`);
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,
// `input` is what legacy debits (CustomerController::updateRemain) and
// equals `w` on all 300 live orders; `wage` is the making-charge part of it.
totalGoldWeightGrams: grams(r.input),
wageWeightGrams: grams(r.wage),
flatRialCharge: rial(r.rial_input),
// Legacy 0 «معلق» is the over-credit-limit hold.
isSuspendedOverCreditLimit: sts === 0,
// Legacy records no delivery time; the last change of a delivered order is the best available.
deliveredAt: sts === 6 ? tehran(r.updated_at) : null,
};
const res = await upsert(ctx, 'order', r.id, {
exists: async (id) => !!(await ctx.db.order.findUnique({ where: { id } })),
find: () => ctx.db.order.findUnique({ where: { orderNumber }, select: { id: true } }),
create: () => ctx.db.order.create({ data: { ...data, orderNumber, createdAt: tehran(r.created_at) ?? undefined } }),
update: async (id, how) => {
if (how === 'created') await ctx.db.order.update({ where: { id }, data });
},
});
ctx.report.count(S, `${res.isNew ? 'created' : 'updated'} — ${status}`); }
await orderItems(ctx);
}
async function orderItems(ctx: Ctx) {
const S = 'order lines';
const rows = await ctx.q<Row>(
`SELECT oi.* FROM order_items oi JOIN orders o ON o.id = oi.order_id AND o.deleted_at IS NULL
WHERE oi.deleted_at IS NULL`,
);
for (const r of rows) {
const orderId = ctx.map.get('order', r.order_id);
if (!orderId) continue;
const productVariantId = ctx.map.get('variant', r.variant_id);
if (!productVariantId) {
ctx.report.count(S, 'skipped: variant not migrated');
ctx.report.note(S, `legacy order line #${String(r.id)} (order #${String(r.order_id)}) lost: variant #${String(r.variant_id)} not found`);
continue;
}
const data = {
orderId,
productVariantId,
quantity: int(r.qty) ?? 1,
rashWeightGramsSnapshot: grams(r.rash_weight),
wagePercentSnapshot: percent(r.percent),
discountPercentSnapshot: percent(r.discount),
computedWeightGrams: grams(r.w),
flatRialCharge: rial(r.rial_pay),
};
const res = await upsert(ctx, 'orderitem', r.id, {
exists: async (id) => !!(await ctx.db.orderItem.findUnique({ where: { id } })),
find: async () => null,
create: () => ctx.db.orderItem.create({ data }),
update: async (id) => {
await ctx.db.orderItem.update({ where: { id }, data });
},
});
ctx.report.count(S, res.isNew ? 'created' : 'updated');
// §7.4: the target keeps one snapshot; legacy's audit pair is lost.
if (toNumber(r.original_rash_weight) !== toNumber(r.rash_weight) || toNumber(r.original_percent) !== toNumber(r.percent)) {
ctx.report.count(S, 'weight or wage edited after ordering (original_* not carried)');
}
}
}
/* ───────────── ledger ───────────── */
/** Cash::$TYPES (Major/Cash.php:56-62). Type 9 «سفارش» is handled from `orders`. */
const CASH_SOURCE: Record<number, LedgerSourceType> = {
1: LedgerSourceType.REPAYMENT_MELTED_GOLD,
2: LedgerSourceType.REPAYMENT_TRANSFER,
3: LedgerSourceType.REPAYMENT_CHEQUE,
4: LedgerSourceType.REPAYMENT_CASH,
};
/** Grams per mesghal. Legacy's price_per_gram is per mesghal (CashController.php:68). */
const MESGHAL = 4.331802;
/**
* Legacy debt is COMPUTED, never posted (CustomerController::updateRemain):
*
* remain = Σ orders.input (sts > 4) − Σ cashes.value (confirmed, type 1–4)
* rial_remain = Σ orders.rial_input (sts > 4)
*
* so the ledger is rebuilt from exactly those rows. Recomputing that formula on
* the 2026-10-04 dump reproduces the stored balance for 1,590 of 1,591
* customers to 0.0001 g (§10.7). Consequences:
* - `cashes` type 9 («سفارش») is NOT carried: it mirrors an order, and the
* order already posts that debt. Both would count it twice.
* - Rials paid (types 3/4 `price`) never reduce rial debt; a cash payment is
* converted to gold (`value`) and reduces the gold debt. Kept as is.
* - A balance the records cannot explain (customer #125: −9.8951 g, set by
* hand after its only document was deleted) is carried as an explicit
* ADJUSTMENT entry, so the customer sees the same number (owner, 2026-10-07).
*/
export async function ledger(ctx: Ctx) {
const S = 'ledger';
const entry = async (key: string, data: Prisma.LedgerEntryUncheckedCreateInput) => {
const res = await upsert(ctx, 'ledger', key, {
exists: async (id) => !!(await ctx.db.ledgerEntry.findUnique({ where: { id } })),
find: async () => null,
create: () => ctx.db.ledgerEntry.create({ data }),
update: async (id) => {
await ctx.db.ledgerEntry.update({ where: { id }, data });
},
});
return res.id;
};
const signed = (amount: number) => ({
direction: amount >= 0 ? LedgerDirection.DEBIT : LedgerDirection.CREDIT,
amount: grams(Math.abs(amount)),
});
// Orders delivered to the agent or the customer carry the debt.
const delivered = await ctx.q<Row>('SELECT * FROM orders WHERE deleted_at IS NULL AND sts > 4');
for (const o of delivered) {
const customerId = ctx.map.get('customer', o.customer_id);
const orderId = ctx.map.get('order', o.id);
if (!customerId || !orderId) continue;
const at = tehran(o.updated_at) ?? undefined;
if (toNumber(o.input) !== 0) {
await entry(`order:${String(o.id)}:gold`, {
customerId, orderId, ...signed(toNumber(o.input)),
currency: LedgerCurrency.GOLD_GRAM, sourceType: LedgerSourceType.ORDER_STOCK,
confirmed: true, confirmedAt: at, createdAt: at,
note: 'بدهی وزنی سفارش — انتقال از سیستم قدیم',
});
ctx.report.count(S, 'order debt (gold)');
}
if (toNumber(o.rial_input) !== 0) {
const amt = toNumber(o.rial_input);
await entry(`order:${String(o.id)}:rial`, {
customerId, orderId,
direction: amt >= 0 ? LedgerDirection.DEBIT : LedgerDirection.CREDIT,
amount: rial(Math.abs(amt)),
currency: LedgerCurrency.RIAL, sourceType: LedgerSourceType.ORDER_STOCK,
confirmed: true, confirmedAt: at, createdAt: at,
note: 'بدهی ریالی سفارش — انتقال از سیستم قدیم',
});
ctx.report.count(S, 'order debt (rial)');
}
}
const cashes = await ctx.q<Row>('SELECT * FROM cashes WHERE deleted_at IS NULL');
for (const c of cashes) {
const type = int(c.type);
if (type === 9) {
ctx.report.count(S, 'cash type 9 (order mirror) skipped — debt comes from the order');
continue;
}
const sourceType = type !== null ? CASH_SOURCE[type] : undefined;
if (!sourceType) {
ctx.report.count(S, 'skipped: unknown document type');
ctx.report.note(S, `legacy document #${String(c.id)} has type ${String(c.type)}`);
continue;
}
const customerId = ctx.map.get('customer', c.customer_id);
if (!customerId) {
ctx.report.count(S, 'skipped: customer not migrated');
continue;
}
// A repayment is a CREDIT; a negative `value` (4 transfers) is a debit.
const value = toNumber(c.value);
const received = jalaliDate(c.recive_date);
const confirmed = int(c.confirmed) === 1;
const ppg = toNumber(c.price_per_gram);
await entry(`cash:${String(c.id)}`, {
customerId,
currency: LedgerCurrency.GOLD_GRAM,
direction: value >= 0 ? LedgerDirection.CREDIT : LedgerDirection.DEBIT,
amount: grams(Math.abs(value)),
sourceType,
confirmed,
confirmedAt: confirmed ? tehran(c.updated_at) : null,
postedByAdminUserId: ctx.map.get('admin', c.user_id) ?? null,
karatPerMille: int(c.ayar),
labName: text(c.lab),
hallmark: text(c.ang),
accountName: text(c.account_name),
bankName: text(c.bank_name),
transferTo: text(c.to),
originalValue: c.original_value === null ? null : grams(c.original_value),
priceRial: c.price === null ? null : rial(c.price),
pricePerGramRialAtEntry: ppg ? rial(ppg / MESGHAL) : null,
receivedAt: received,
// The raw Jalali text is kept in the note when it did not parse.
note: [text(c.title), text(c.note), !received && text(c.recive_date) ? `تاریخ دریافت: ${text(c.recive_date)}` : null]
.filter(Boolean).join(' — ') || null,
createdAt: tehran(c.created_at) ?? undefined,
});
ctx.report.count(S, `document ${sourceType}${confirmed ? '' : ' (unconfirmed — not in the balance)'}`);
}
await balances(ctx);
}
/**
* Per migrated customer: compare what the import posted with the balance legacy
* stored, carry any real difference as one ADJUSTMENT entry, then set the
* cached balance to the sum of ALL confirmed entries (the rule every other
* ledger path keeps: confirmed DEBIT adds, confirmed CREDIT subtracts).
*/
async function balances(ctx: Ctx) {
const S = 'balances';
const legacy = await ctx.q<Row>('SELECT id, remain, rial_remain FROM customers WHERE deleted_at IS NULL');
const stored = new Map<string, { gold: number; rial: number }>();
for (const r of legacy) {
const target = ctx.map.get('customer', r.id);
if (!target) continue;
const cur = stored.get(target) ?? { gold: 0, rial: 0 };
cur.gold += toNumber(r.remain);
cur.rial += toNumber(r.rial_remain);
stored.set(target, cur);
}
const importedIds = new Set<string>();
for (const row of await ctx.db.$queryRawUnsafe<{ target_id: string }[]>(
`SELECT target_id FROM legacy_import.id_map WHERE entity = 'ledger' AND legacy_id NOT LIKE 'adjust:%'`,
)) importedIds.add(row.target_id);
// Up to this a difference is the double → Decimal(14,4) rounding of §4, not a
// real gap — inclusive: the first run posted two −0.0001 "adjustments" that
// were nothing but rounding, and would have shown on those statements.
const TOLERANCE = 0.0001;
for (const [customerId, want] of stored) {
const entries = await ctx.db.ledgerEntry.findMany({ where: { customerId, confirmed: true } });
const sum = (currency: LedgerCurrency, onlyImported: boolean) =>
entries
.filter((e) => e.currency === currency && (!onlyImported || importedIds.has(e.id)))
.reduce((acc, e) => (e.direction === LedgerDirection.DEBIT ? acc.add(e.amount) : acc.sub(e.amount)), new D(0));
for (const [currency, wanted, label] of [
[LedgerCurrency.GOLD_GRAM, want.gold, 'gold'],
[LedgerCurrency.RIAL, want.rial, 'rial'],
] as const) {
const key = `adjust:${customerId}:${label}`;
const diff = new D(wanted.toFixed(4)).sub(sum(currency, true));
const existing = ctx.map.get('ledger', key);
if (diff.abs().lte(TOLERANCE)) {
if (!diff.isZero()) ctx.report.count(S, `rounding-only difference (${label}, ≤ ${TOLERANCE}) — no adjustment`);
if (existing) {
await ctx.db.ledgerEntry.deleteMany({ where: { id: existing } });
await ctx.map.drop('ledger', key);
}
continue;
}
const data: Prisma.LedgerEntryUncheckedCreateInput = {
customerId,
currency,
direction: diff.gt(0) ? LedgerDirection.DEBIT : LedgerDirection.CREDIT,
amount: currency === LedgerCurrency.RIAL ? new D(diff.abs().toFixed(2)) : new D(diff.abs().toFixed(4)),
sourceType: LedgerSourceType.ADJUSTMENT,
confirmed: true,
confirmedAt: new Date(),
note: 'تراز انتقالی از سیستم قدیم — مانده‌ی ذخیره‌شده در سیستم قدیم با اسناد قابل بازسازی نبود',
};
if (existing) await ctx.db.ledgerEntry.update({ where: { id: existing }, data });
else {
const created = await ctx.db.ledgerEntry.create({ data });
await ctx.map.set('ledger', key, created.id, 'created');
}
ctx.report.count(S, `opening-balance adjustment (${label})`);
ctx.report.note(S, `customer ${customerId}: ${label} adjustment ${diff.toString()} so the balance equals legacy's stored ${wanted.toFixed(4)}`);
}
// The cached balance = Σ every confirmed entry, the target's own and the imported ones.
const fresh = await ctx.db.ledgerEntry.findMany({ where: { customerId, confirmed: true } });
const total = (currency: LedgerCurrency) =>
fresh
.filter((e) => e.currency === currency)
.reduce((acc, e) => (e.direction === LedgerDirection.DEBIT ? acc.add(e.amount) : acc.sub(e.amount)), new D(0));
const before = await ctx.db.customer.findUniqueOrThrow({ where: { id: customerId }, select: { goldDebtGrams: true, fiatDebtRial: true } });
const gold = total(LedgerCurrency.GOLD_GRAM);
const rialSum = total(LedgerCurrency.RIAL);
if (!before.goldDebtGrams.eq(gold) || !before.fiatDebtRial.eq(rialSum)) {
await ctx.db.customer.update({ where: { id: customerId }, data: { goldDebtGrams: gold, fiatDebtRial: rialSum } });
ctx.report.count(S, 'cached balance set from the ledger');
}
}
}
/* ───────────── favourites ───────────── */
/**
* The wholesale app's favourites are `major_favorites` (ApiMobileController
* :73-465, model Major\Favorite) — 3,305 rows. `fav` (251) belongs to the
* retail storefront (Customer\FavController, guard api-singleCustomer); its
* customer_id points at retail logins, so it is deliberately NOT read here.
*/
export async function favorites(ctx: Ctx) {
const S = 'favorites';
const rows = await ctx.q<Row>(
'SELECT customer_id, product_id, MIN(created_at) AS created_at FROM major_favorites WHERE deleted_at IS NULL GROUP BY customer_id, product_id',
);
const data: Prisma.CustomerFavoriteCreateManyInput[] = [];
const seen = new Set<string>();
for (const r of rows) {
const customerId = ctx.map.get('customer', r.customer_id);
const productId = ctx.map.get('product', r.product_id);
if (!customerId) { ctx.report.count(S, 'skipped: customer not migrated'); continue; }
if (!productId) { ctx.report.count(S, 'skipped: product deleted in legacy'); continue; }
const k = `${customerId}|${productId}`;
if (seen.has(k)) { ctx.report.count(S, 'duplicate collapsed (merged customers)'); continue; }
seen.add(k);
data.push({ customerId, productId, createdAt: tehran(r.created_at) ?? undefined });
}
const res = await ctx.db.customerFavorite.createMany({ data, skipDuplicates: true });
ctx.report.count(S, 'created', res.count);
ctx.report.count(S, 'already present', data.length - res.count);
}
/* ───────────── custom orders ───────────── */
/** CustomOrder::$STS_EN (CustomOrder.php:41-47) maps one-to-one. */
const PREORDER_STATUS: Record<number, PreOrderStatus> = {
1: PreOrderStatus.PLACED,
2: PreOrderStatus.ADMIN_APPROVED,
3: PreOrderStatus.WORKSHOP_APPROVED,
4: PreOrderStatus.READY_TO_SHIP,
5: PreOrderStatus.DELIVERED,
6: PreOrderStatus.CANCELLED,
};
export async function preOrders(ctx: Ctx) {
const S = 'custom orders';
const rows = await ctx.q<Row>('SELECT * FROM custom_orders WHERE deleted_at IS NULL');
for (const r of rows) {
const customerId = ctx.map.get('customer', r.customer_id);
if (!customerId) { ctx.report.count(S, 'skipped: customer not migrated'); continue; }
const sumText = text(r.sum);
const data = {
customerId,
cityId: ctx.map.get('city', r.city_id) ?? null,
workshopId: ctx.map.get('workshop', r.workshop_id) ?? null,
status: PREORDER_STATUS[int(r.sts) ?? 1] ?? PreOrderStatus.PLACED,
designImageUrl: await ctx.imageUrl(r.file_id, S),
colorName: text(r.color),
sizeSpec: text(r.size),
stoneColor: text(r.color_stone),
minaColor: text(r.color_mina),
estimatedWeightGrams: r.weight === null ? null : grams(r.weight),
estimatedPriceRial: sumText && /^\d+(\.\d+)?$/.test(sumText) ? rial(sumText) : null,
description: text(r.description),
};
const res = await upsert(ctx, 'preorder', r.id, {
exists: async (id) => !!(await ctx.db.preOrder.findUnique({ where: { id } })),
find: async () => null,
create: () => ctx.db.preOrder.create({ data: { ...data, createdAt: tehran(r.created_at) ?? undefined } }),
update: async (id) => {
await ctx.db.preOrder.update({ where: { id }, data });
},
});
ctx.report.count(S, res.isNew ? 'created' : 'updated');
}
}