430 lines
19 KiB
TypeScript
430 lines
19 KiB
TypeScript
/**
|
||||
|
|
* 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');
|
|||
|
|
}
|
|||
|
|
}
|