/** * 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; 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 = { 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('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( `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 = { 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('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('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('SELECT id, remain, rial_remain FROM customers WHERE deleted_at IS NULL'); const stored = new Map(); 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(); 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( '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(); 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 = { 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('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'); } }