Files

430 lines
19 KiB
TypeScript
Raw Permalink Normal View History

/**
* 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');
}
}