Initial commit: Hedena Gold monorepo.

First commit of the Hedena Gold product (separate from gold-bank): branding, package names, and deploy defaults aligned to the new brand; legacy Henza trees stay local-only via .gitignore.
This commit is contained in:
s.esmaeili
2026-10-15 17:19:10 +03:30
commit ea86636027
1121 changed files with 401065 additions and 0 deletions
@@ -0,0 +1,241 @@
/**
* Catalogue: brands, categories, products, variants and product images.
* Rules: docs/DATA-MIGRATION-MAP.md §9, §10.5.
*
* Which rows: every live product/variant, PLUS every soft-deleted variant (and
* its product) that a live order line still points at. 473 of the 610 live
* order lines reference a deleted variant; OrderItem.productVariantId is
* required, so without those the order history would lose most of its lines.
* They come over with isActive = false, so the shop never shows them.
*
* ProductVariant.imageId is never written (PROTECTED.md §B), and no
* ProductImage is ever deleted: the variant FK is onDelete SetNull, so a
* delete-and-recreate would silently clear every admin-picked variant image.
*/
import { ProductImageRole, Prisma } from '@prisma/client';
import { fillEmpty, upsert, type Ctx } from '../lib/context';
import { grams, int, isZero, matchKey, percent, tehran, text } from '../lib/normalize';
type Row = Record<string, unknown>;
const LIVE_ORDER_LINES = `
SELECT oi.variant_id 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 AND oi.variant_id IS NOT NULL`;
export async function brands(ctx: Ctx) {
const S = 'brands';
const rows = await ctx.q<Row>(
`SELECT * FROM product_brand WHERE deleted_at IS NULL
OR id IN (SELECT brand_id FROM product WHERE brand_id IS NOT NULL)`,
);
const existing = await ctx.db.brand.findMany();
for (const r of rows) {
const title = text(r.title);
if (!title) continue;
const res = await upsert(ctx, 'brand', r.id, {
exists: async (id) => !!(await ctx.db.brand.findUnique({ where: { id } })),
find: async () => existing.find((b) => matchKey(b.title) === matchKey(title)) ?? null,
create: () => ctx.db.brand.create({ data: { title } }),
update: async (id, how) => {
if (how === 'created') await ctx.db.brand.update({ where: { id }, data: { title } });
},
});
ctx.report.count(S, res.isNew ? 'created' : 'matched/updated');
}
}
export async function categories(ctx: Ctx) {
const S = 'categories';
const rows = await ctx.q<Row>('SELECT * FROM category_collection WHERE deleted_at IS NULL');
const existing = await ctx.db.category.findMany();
for (const r of rows) {
const title = text(r.title);
if (!title) continue;
const type = int(r.type) ?? 1;
// The port's mapping (admin-categories.service.ts:75-80): image, icon,
// meta -> metaTag, body_html -> description.
const data = {
title,
type,
imageUrl: await ctx.imageUrl(r.image_id ?? r.image_id_id, S),
iconUrl: await ctx.imageUrl(r.icon_id, S),
metaTag: text(r.meta),
description: text(r.body_html),
};
const res = await upsert(ctx, 'category', r.id, {
exists: async (id) => !!(await ctx.db.category.findUnique({ where: { id } })),
// Same title in the same tree (site / app) is the same category.
find: async () => existing.find((c) => matchKey(c.title) === matchKey(title) && c.type === type) ?? null,
create: () => ctx.db.category.create({ data }),
update: async (id, how) => {
if (how === 'created') {
await ctx.db.category.update({ where: { id }, data });
} else {
const cur = await ctx.db.category.findUniqueOrThrow({ where: { id } });
const patch = fillEmpty(cur, data);
if (Object.keys(patch).length) await ctx.db.category.update({ where: { id }, data: patch });
}
},
});
ctx.report.count(S, res.isNew ? 'created' : res.how === 'matched' ? 'matched existing' : 'updated');
}
// Second pass, once every category has an id: the parent links.
for (const r of rows) {
const id = ctx.map.get('category', r.id);
if (!id || r.parent_id_id === null) continue;
const parentId = ctx.map.get('category', r.parent_id_id);
if (!parentId) {
ctx.report.count(S, 'parent not migrated (deleted in legacy) — left at top level');
continue;
}
if (ctx.map.how('category', r.id) !== 'matched') {
await ctx.db.category.update({ where: { id }, data: { parentId } });
}
}
}
export async function products(ctx: Ctx) {
const S = 'products';
const rows = await ctx.q<Row>(
`SELECT * FROM product
WHERE deleted_at IS NULL
OR id IN (SELECT v.product_id_id FROM products_productvarient v WHERE v.id IN (${LIVE_ORDER_LINES}))`,
);
// Legacy links products to categories many-to-many (category_collect) and
// keeps TWO trees: type 1 = site, type 2 = app (admin-categories.service.ts:12).
// Every product with more than one category (1,250) is simply in both trees.
// The target keeps one categoryId, which the app shows and links to
// (user-app SingleProduct.vue), so the APP category is the one kept;
// the site one only when the product has no app category.
const links = await ctx.q<Row>(
`SELECT cc.product_id_id AS pid, cc.collection_id_id AS cid, c.type AS ctype FROM category_collect cc
JOIN category_collection c ON c.id = cc.collection_id_id AND c.deleted_at IS NULL
WHERE cc.deleted_at IS NULL ORDER BY cc.position, cc.id`,
);
const chosenCategory = new Map<string, { cid: string; app: boolean }>();
const categoryCount = new Map<string, number>();
for (const l of links) {
const pid = String(l.pid);
const app = int(l.ctype) === 2;
categoryCount.set(pid, (categoryCount.get(pid) ?? 0) + 1);
const cur = chosenCategory.get(pid);
if (!cur || (app && !cur.app)) chosenCategory.set(pid, { cid: String(l.cid), app });
}
for (const r of rows) {
const title = text(r.title) ?? `محصول ${String(r.id)}`;
const deleted = r.deleted_at !== null;
const code = text(r.product_code);
const data = {
title,
description: text(r.body_html),
categoryId: ctx.map.get('category', chosenCategory.get(String(r.id))?.cid) ?? null,
brandId: ctx.map.get('brand', r.brand_id) ?? null,
productCode: code,
showType: int(r.show_type) ?? 3,
isActive: !deleted && int(r.is_active) === 1,
};
const chosen = chosenCategory.get(String(r.id));
if (!chosen) ctx.report.count(S, 'no live category');
else if ((categoryCount.get(String(r.id)) ?? 0) > 1) ctx.report.count(S, `in several legacy categories — ${chosen.app ? 'app' : 'site'} category kept`);
if (int(r.disabled) === 1) ctx.report.count(S, 'legacy `disabled` = 1 (not represented; port ignores it too)');
const res = await upsert(ctx, 'product', r.id, {
exists: async (id) => !!(await ctx.db.product.findUnique({ where: { id } })),
find: async () => (code ? ctx.db.product.findUnique({ where: { productCode: code }, select: { id: true } }) : null),
create: () => ctx.db.product.create({ data: { ...data, createdAt: tehran(r.created_at) ?? undefined } }),
update: async (id, how) => {
// Never accountingStock* (D27): not in `data`.
if (how === 'created') {
await ctx.db.product.update({ where: { id }, data });
} else {
const cur = await ctx.db.product.findUniqueOrThrow({ where: { id } });
const patch = fillEmpty(cur, data);
if (Object.keys(patch).length) await ctx.db.product.update({ where: { id }, data: patch });
}
},
});
ctx.report.count(S, `${res.isNew ? 'created' : res.how === 'matched' ? 'matched existing' : 'updated'}${deleted ? ' (deleted in legacy, kept inactive for order history)' : ''}`);
if (res.how !== 'matched') await productImages(ctx, r, res.id);
}
}
/**
* The port's image roles (admin-products.service.ts:185-188):
* indicator_id -> FEATURED, mobile_pic -> FEATURED_MOBILE; the gallery is
* products_productimage. Only for products the import created — a product the
* target already had keeps exactly the images it has.
*/
async function productImages(ctx: Ctx, p: Row, productId: string) {
const S = 'product images';
const add = async (key: string, imageId: unknown, role: ProductImageRole, sortOrder: number) => {
// Resolved even when the row exists, because resolving is what copies the
// file: a first run without --images followed by one with it must still
// bring the files over.
const url = await ctx.imageUrl(imageId, S);
if (!url || ctx.map.get('pimage', key)) return;
const img = await ctx.db.productImage.create({ data: { productId, url, role, sortOrder } });
await ctx.map.set('pimage', key, img.id, 'created');
ctx.report.count(S, role);
};
await add(`featured:${String(p.id)}`, p.indicator_id_id, ProductImageRole.FEATURED, 0);
await add(`mobile:${String(p.id)}`, p.mobile_pic_id, ProductImageRole.FEATURED_MOBILE, 0);
const gallery = await ctx.q<Row>(
'SELECT id, image_id FROM products_productimage WHERE deleted_at IS NULL AND product_id = ? ORDER BY id',
[p.id],
);
let i = 1;
for (const g of gallery) await add(`gallery:${String(g.id)}`, g.image_id, ProductImageRole.GALLERY, i++);
}
export async function variants(ctx: Ctx) {
const S = 'variants';
const rows = await ctx.q<Row>(
`SELECT * FROM products_productvarient
WHERE deleted_at IS NULL OR id IN (${LIVE_ORDER_LINES})`,
);
for (const r of rows) {
const productId = ctx.map.get('product', r.product_id_id);
if (!productId) {
ctx.report.count(S, 'skipped: product not migrated');
continue;
}
const deleted = r.deleted_at !== null;
let sku = text(r.barcode);
if (sku) {
const clash = await ctx.db.productVariant.findUnique({ where: { sku }, select: { id: true } });
if (clash && clash.id !== ctx.map.get('variant', r.id)) {
ctx.report.count(S, 'barcode already used by another variant — sku left empty');
sku = null;
}
}
// `Inventory` (capital I in the source column, §9) is legacy's stock count;
// `qty` is what the port reads, so that is what is carried.
const data: Prisma.ProductVariantUncheckedUpdateInput = {
productId,
title: text(r.title) ?? '-',
sku,
rashWeightGrams: grams(r.grams),
defaultWagePercent: percent(r.percentage_pay),
qty: int(r.qty) ?? 0,
isPreOrder: int(r.is_pre_order) === 1,
isActive: !deleted && int(r.is_active) === 1,
// imageId: deliberately absent — PROTECTED.md §B.
};
if (!isZero(r.rial_pay)) ctx.report.count(S, 'rial charge (rial_pay) not carried — no target field');
const res = await upsert(ctx, 'variant', r.id, {
exists: async (id) => !!(await ctx.db.productVariant.findUnique({ where: { id } })),
find: async () => (sku ? ctx.db.productVariant.findUnique({ where: { sku }, select: { id: true } }) : null),
create: () => ctx.db.productVariant.create({ data: data as Prisma.ProductVariantUncheckedCreateInput }),
update: async (id, how) => {
if (how === 'created') await ctx.db.productVariant.update({ where: { id }, data });
},
});
ctx.report.count(S, `${res.isNew ? 'created' : res.how === 'matched' ? 'matched existing (left as is)' : 'updated'}${deleted ? ' (deleted in legacy, inactive)' : ''}`);
}
}
@@ -0,0 +1,429 @@
/**
* 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');
}
}
@@ -0,0 +1,558 @@
/**
* People and places: cities, roles, branches, admin users, sales experts,
* workshops and customers. Rules: docs/DATA-MIGRATION-MAP.md §2–§6, §10.4–§10.7.
*/
import { AdminRole, CustomerKind, Prisma, VerificationStep } from '@prisma/client';
import { ALL_RIDS } from '../../../src/modules/auth/acl.constants';
import { isPasswordHash, OTP_ONLY_PASSWORD_HASH } from '../../../src/modules/auth/password-hash';
import { fillEmpty, upsert, type Ctx } from '../lib/context';
import { birthday, grams, int, isZero, matchKey, mobile, percent, tehran, text } from '../lib/normalize';
type Row = Record<string, unknown>;
/** Owner ruling 2026-10-07 for the 7 legacy rejections, which recorded no reason. */
export const LEGACY_REJECTION_REASON = 'رد شده در سیستم قدیم — فاقد شرح علت ثبت‌شده';
const fullNameOf = (r: Row) =>
text(r.name) ?? text([text(r.fname), text(r.lname)].filter(Boolean).join(' ')) ?? String(r.mobile ?? '');
/** A legacy bcrypt hash keeps working; anything else becomes OTP-only (§10.4). */
const passwordOf = (r: Row) => (isPasswordHash(r.password) ? String(r.password) : OTP_ONLY_PASSWORD_HASH);
/* ───────────── cities ───────────── */
export async function cities(ctx: Ctx) {
const S = 'cities';
// Live cities plus any deleted one a live customer still points at.
const rows = await ctx.q<Row>(
`SELECT id, title, parent, level, percent, percent_aux FROM cities
WHERE deleted_at IS NULL
OR id IN (SELECT city_id FROM customers WHERE deleted_at IS NULL AND city_id IS NOT NULL)`,
);
// Legacy is a tree (country > province > city > district) and so is the
// target (PERS-02): rows first, then each one's parent once all are mapped.
const existing = await ctx.db.city.findMany();
const byKey = new Map(existing.map((c) => [matchKey(c.name), c.id]));
for (const r of rows) {
const name = text(r.title);
if (!name) {
ctx.report.count(S, 'skipped: no title');
continue;
}
const data = {
name,
percent: r.percent === null ? null : percent(r.percent),
percentAux: r.percent_aux === null ? null : percent(r.percent_aux),
};
const res = await upsert(ctx, 'city', r.id, {
exists: async (id) => !!(await ctx.db.city.findUnique({ where: { id } })),
find: async () => {
const id = byKey.get(matchKey(name));
return id ? { id } : null;
},
create: () => ctx.db.city.create({ data }),
update: async (id, how) => {
if (how === 'created') {
await ctx.db.city.update({ where: { id }, data });
} else {
const cur = await ctx.db.city.findUniqueOrThrow({ where: { id } });
const patch = fillEmpty(cur, data);
if (Object.keys(patch).length) await ctx.db.city.update({ where: { id }, data: patch });
}
},
});
ctx.report.count(S, res.isNew ? 'created' : res.how === 'matched' ? 'matched existing' : 'updated');
byKey.set(matchKey(name), res.id);
}
// Parents. A row the import created takes legacy's parent; a matched row
// keeps the parent it already has (fill-empty, as every other field). A
// parent that was not imported (deleted in legacy) leaves the row on top.
const parentOf = new Map((await ctx.db.city.findMany({ select: { id: true, parentId: true } })).map((c) => [c.id, c.parentId]));
for (const r of rows) {
const id = ctx.map.get('city', r.id);
const parentId = r.parent === null ? null : ctx.map.get('city', r.parent);
if (!id || !parentId || parentId === id) continue;
if (ctx.map.how('city', r.id) !== 'created' && parentOf.get(id)) continue;
if (parentOf.get(id) === parentId) continue;
// Never close a loop: `id` must not be an ancestor of its new parent.
let cur: string | null | undefined = parentId;
let loop = false;
for (let guard = 0; cur && guard < 50; guard++) {
if (cur === id) loop = true;
cur = parentOf.get(cur);
}
if (loop) {
ctx.report.note(S, `legacy city ${r.id}: parent ${r.parent} would make a loop — left as is`);
continue;
}
await ctx.db.city.update({ where: { id }, data: { parentId } });
parentOf.set(id, parentId);
ctx.report.count(S, 'parent set');
}
}
/** Target City id for an exact (normalised) name match, or null. */
async function cityByName(ctx: Ctx, name: string | null): Promise<string | null> {
if (!name) return null;
const all = await ctx.db.city.findMany({ select: { id: true, name: true } });
const key = matchKey(name);
return all.find((c) => matchKey(c.name) === key)?.id ?? null;
}
/* ───────────── roles & permissions ───────────── */
/**
* Legacy 85 «مدیر» and target 85 «مدیر ارشد» are the same full-access role:
* legacy 85 holds 173 permissions, more than any other type, and target 85 holds
* every one of the 144 rids. Every other type is matched by TITLE, never by id —
* legacy 86 is «حسابدار» but target 86 is «کارشناس فروش» (seed), so an id copy
* would hand the accountant's 71 permissions to every sales expert.
*/
const SAME_ROLE: ReadonlyMap<number, number> = new Map([[85, 85]]);
/**
* Legacy rids the target renamed (PERM-01): the wholesale and retail dashboards
* («آمار عمده فروشی» dashboard.omde, «آمار تک فروشی» dashboard.tak) and their
* tree parent became the one dashboard behind "dashboard.view". Without this a
* role that saw a dashboard in legacy would lose it.
*/
const RENAMED_RIDS: ReadonlyMap<string, string> = new Map([
['dashboard', 'dashboard.view'],
['dashboard.omde', 'dashboard.view'],
['dashboard.tak', 'dashboard.view'],
]);
/** AdminRole is a label only — every permission comes from the user type. */
function adminRoleFor(legacyUt: number | null): AdminRole {
if (legacyUt === 85 || legacyUt === 104 || legacyUt === 105) return AdminRole.SUPER_ADMIN; // مدیر · مدیر اصلی · مدیران
if (legacyUt === 86) return AdminRole.ACCOUNTANT; // حسابدار
return AdminRole.SYSTEM_APPROVER;
}
export async function roles(ctx: Ctx) {
const S = 'roles & permissions';
// Legacy user types are config_statics rows (UserController.php:26:
// `exists:config_statics,id`), titled by `val`.
const types = await ctx.q<Row>(
"SELECT id, val FROM config_statics WHERE `key` = '__list__ut' AND deleted_at IS NULL ORDER BY id",
);
const perms = await ctx.q<{ rid: string; ut: number }>('SELECT DISTINCT rid, ut FROM permission');
const known = new Set<string>(ALL_RIDS as readonly string[]);
const target = await ctx.db.userType.findMany();
const byTitle = new Map(target.map((t) => [matchKey(t.title), t.id]));
const takenIds = new Set(target.map((t) => t.id));
for (const t of types) {
const legacyId = Number(t.id);
const title = text(t.val) ?? `نقش ${legacyId}`;
const mapped = ctx.map.get('ut', legacyId);
let targetId: number;
let created = false;
if (mapped) {
targetId = Number(mapped);
} else if (SAME_ROLE.has(legacyId) && takenIds.has(SAME_ROLE.get(legacyId)!)) {
targetId = SAME_ROLE.get(legacyId)!;
await ctx.map.set('ut', legacyId, String(targetId), 'matched');
} else if (byTitle.has(matchKey(title))) {
targetId = byTitle.get(matchKey(title))!;
await ctx.map.set('ut', legacyId, String(targetId), 'matched');
if (targetId !== legacyId) ctx.report.note(S, `legacy type ${legacyId} «${title}» → existing target type ${targetId} (same title, different number)`);
} else {
targetId = legacyId;
if (takenIds.has(targetId)) {
targetId = Math.max(...takenIds, ...types.map((x) => Number(x.id))) + 1;
ctx.report.note(S, `legacy type ${legacyId} «${title}»: number taken by a different target role, created as ${targetId}`);
}
await ctx.db.userType.create({ data: { id: targetId, title } });
takenIds.add(targetId);
await ctx.map.set('ut', legacyId, String(targetId), 'created');
created = true;
}
const legacyRids = [
...new Set(perms.filter((p) => Number(p.ut) === legacyId).map((p) => RENAMED_RIDS.get(String(p.rid)) ?? String(p.rid))),
];
if (created || ctx.map.how('ut', legacyId) === 'created') {
// A role the import created gets legacy's permissions, limited to rids the
// target ACL knows (the storefront's are out of scope, D13).
const usable = legacyRids.filter((r) => known.has(r));
const res = await ctx.db.permission.createMany({
data: usable.map((rid) => ({ rid, userTypeId: targetId })),
skipDuplicates: true,
});
ctx.report.count(S, 'permissions copied', res.count);
ctx.report.count(S, 'permissions dropped (rid unknown to the target ACL)', legacyRids.length - usable.length);
ctx.report.count(S, 'types created');
} else {
// An existing target role keeps the target's permission set (D1: the
// target's stricter auth stands). Nothing is added to it.
ctx.report.count(S, 'types matched (permissions left as the target has them)');
ctx.report.count(S, 'legacy permissions not applied to matched types', legacyRids.length);
}
}
const orphanUt = [...new Set(perms.map((p) => Number(p.ut)))].filter((u) => !types.some((t) => Number(t.id) === u));
if (orphanUt.length) ctx.report.note(S, `permission rows for types with no config_statics row were skipped: ${orphanUt.join(', ')}`);
}
/* ───────────── branches & workshops ───────────── */
export async function branches(ctx: Ctx) {
const S = 'branches';
const rows = await ctx.q<Row>('SELECT * FROM branches WHERE deleted_at IS NULL');
const existing = await ctx.db.branch.findMany();
for (const r of rows) {
const name = text(r.name);
if (!name) continue;
const data = { name, address: text(r.address), phone: text(r.phone), isActive: int(r.is_active) !== 0 };
const res = await upsert(ctx, 'branch', r.id, {
exists: async (id) => !!(await ctx.db.branch.findUnique({ where: { id } })),
find: async () => existing.find((b) => matchKey(b.name) === matchKey(name)) ?? null,
create: () => ctx.db.branch.create({ data }),
update: async (id, how) => {
if (how === 'created') await ctx.db.branch.update({ where: { id }, data });
},
});
ctx.report.count(S, res.isNew ? 'created' : 'matched/updated');
}
// branches.user_id names the branch's manager; the target Branch has no such field.
ctx.report.note(S, 'branches.user_id (branch manager) has no target field and was not carried over.');
}
export async function workshops(ctx: Ctx) {
const S = 'workshops';
const rows = await ctx.q<Row>('SELECT * FROM workshops WHERE deleted_at IS NULL');
const existing = await ctx.db.workshop.findMany();
for (const r of rows) {
const name = text(r.name);
if (!name) continue;
const data = { name, address: text(r.address), phone: text(r.phone), isActive: int(r.is_active) !== 0 };
const res = await upsert(ctx, 'workshop', r.id, {
exists: async (id) => !!(await ctx.db.workshop.findUnique({ where: { id } })),
find: async () => existing.find((w) => matchKey(w.name) === matchKey(name)) ?? null,
create: () => ctx.db.workshop.create({ data }),
update: async (id, how) => {
if (how === 'created') await ctx.db.workshop.update({ where: { id }, data });
},
});
ctx.report.count(S, res.isNew ? 'created' : 'matched/updated');
}
// Legacy Workshop::user() is belongsTo(Customer) (a type-5 customer); the
// target supervisor is an AdminUser. No safe link exists, so none is guessed.
ctx.report.note(S, 'supervisor not linked: legacy points at a customer (Workshop::user → Customer), the target expects an admin user.');
}
/* ───────────── admin users (legacy `users`) ───────────── */
export async function admins(ctx: Ctx) {
const S = 'admin users';
const rows = await ctx.q<Row>('SELECT * FROM users WHERE deleted_at IS NULL');
for (const r of rows) {
const m = mobile(r.mobile);
if (!m) {
ctx.report.count(S, 'skipped: no mobile');
continue;
}
const ut = int(r.ut);
const userTypeId = ctx.map.get('ut', ut);
const branchId = ctx.map.get('branch', r.branch_id) ?? null;
const base = {
fullName: fullNameOf(r),
firstName: text(r.fname),
lastName: text(r.lname),
branchId,
userTypeId: userTypeId ? Number(userTypeId) : null,
};
const res = await upsert(ctx, 'admin', r.id, {
exists: async (id) => !!(await ctx.db.adminUser.findUnique({ where: { id } })),
find: () => ctx.db.adminUser.findUnique({ where: { mobile: m }, select: { id: true } }),
create: () =>
ctx.db.adminUser.create({
data: {
...base,
mobile: m,
passwordHash: passwordOf(r),
role: adminRoleFor(ut),
createdAt: tehran(r.created_at) ?? undefined,
},
}),
update: async (id, how) => {
if (how === 'created') {
await ctx.db.adminUser.update({ where: { id }, data: base });
} else {
// An admin the target already has keeps its role, type, password and
// branch; only empty name parts are filled.
const cur = await ctx.db.adminUser.findUniqueOrThrow({ where: { id } });
const patch = fillEmpty(cur, { firstName: base.firstName, lastName: base.lastName });
if (Object.keys(patch).length) await ctx.db.adminUser.update({ where: { id }, data: patch });
}
},
});
ctx.report.count(S, res.isNew ? 'created' : res.how === 'matched' ? 'matched existing (left as is)' : 'updated');
if (res.isNew && !isPasswordHash(r.password)) ctx.report.count(S, 'created without a usable password');
}
}
/* ───────────── sales experts (legacy customers.type = 1) ───────────── */
/**
* D17: an expert is an AdminUser (role SALES_EXPERT) with a SalesExpertProfile.
* Their own orders and debt live on a separate trading Customer, created by the
* customers step for the experts who have any (13 with orders, 14 with
* documents in the 2026-10-04 dump).
*
* An expert whose mobile already belongs to an admin with another role (7 in
* the dump, three of them full admins) is NOT demoted: the admin keeps its
* role, and the expert's referrals point at that admin.
*/
export async function experts(ctx: Ctx) {
const S = 'sales experts';
const rows = await ctx.q<Row>('SELECT * FROM customers WHERE deleted_at IS NULL AND type = 1');
const existingExpertType = await ctx.db.adminUser.groupBy({
by: ['userTypeId'],
where: { role: AdminRole.SALES_EXPERT, userTypeId: { not: null } },
_count: true,
orderBy: { _count: { userTypeId: 'desc' } },
});
const expertType =
existingExpertType[0]?.userTypeId ??
(await ctx.db.userType.findMany()).find((t) => matchKey(t.title) === matchKey('کارشناس فروش'))?.id ??
null;
if (expertType === null) ctx.report.note(S, 'no target user type for sales experts found; created experts have no user type');
for (const r of rows) {
const m = mobile(r.mobile);
if (!m) {
ctx.report.count(S, 'skipped: no mobile');
continue;
}
const profile = {
cityId: ctx.map.get('city', r.city_id) ?? null,
gramLevel: grams(r.gram_level),
phone: text(r.phone),
nationalCode: text(r.ncode),
};
const owned = {
fullName: fullNameOf(r),
firstName: text(r.fname),
lastName: text(r.lname),
// AdminUser.wagePercent is "legacy Customer.percent" (schema).
wagePercent: r.percent === null ? null : percent(r.percent),
isActive: int(r.active) !== -1,
avatarFileId: await ctx.uploadedFile(r.image_id, 'legacy-avatar', S),
address: text(r.address),
storeName: text(r.store_name),
pasazhName: text(r.pasazh_name),
memberCode: text(r.mcode),
};
const res = await upsert(ctx, 'expert', r.id, {
exists: async (id) => !!(await ctx.db.adminUser.findUnique({ where: { id } })),
find: () => ctx.db.adminUser.findUnique({ where: { mobile: m }, select: { id: true } }),
create: () =>
ctx.db.adminUser.create({
data: {
...owned,
mobile: m,
passwordHash: passwordOf(r),
role: AdminRole.SALES_EXPERT,
userTypeId: expertType,
createdAt: tehran(r.created_at) ?? undefined,
},
}),
update: async (id, how) => {
// A matched admin (the target's own, or a legacy `users` admin) is not
// rewritten here; the role check below decides what else happens.
if (how === 'created') await ctx.db.adminUser.update({ where: { id }, data: owned });
},
});
const admin = await ctx.db.adminUser.findUniqueOrThrow({
where: { id: res.id },
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.`);
}
if (!admin.salesExpertProfile) {
await ctx.db.salesExpertProfile.create({ data: { adminUserId: admin.id, ...profile } });
} else if (res.how === 'created') {
await ctx.db.salesExpertProfile.update({ where: { id: admin.salesExpertProfile.id }, data: profile });
} else {
const patch = fillEmpty(admin.salesExpertProfile, profile);
if (Object.keys(patch).length) await ctx.db.salesExpertProfile.update({ where: { id: admin.salesExpertProfile.id }, data: patch });
}
ctx.report.count(S, res.isNew ? 'created' : res.how === 'matched' ? 'matched existing expert' : 'updated');
}
}
/* ───────────── customers ───────────── */
/** Legacy Customer::$TYPES (Customer.php:85-92); NULL predates sign-up's `type = 2`. */
function kindFor(type: number | null): CustomerKind {
switch (type) {
case 3: return CustomerKind.WALK_IN_SHOPKEEPER;
case 4: return CustomerKind.BANKER;
case 5: return CustomerKind.WORKSHOP_SUPERVISOR;
default: return CustomerKind.APP_SHOPKEEPER; // 2, 1 (expert's trading account), NULL
}
}
/**
* Two 3-state legacy columns (1 pending, 2 accepted, 3 rejected; §5.5) to one
* enum. Step one governs: a rejected step one closed the case in legacy
* (`active = -1`), and a pending step one means review never got past it.
*/
function verificationFor(one: number | null, two: number | null): VerificationStep {
if (one === 3 || (one !== 1 && two === 3)) return VerificationStep.REJECTED;
if (one === 1) return VerificationStep.STEP_ONE_SUBMITTED;
if (one === 2) return two === 2 ? VerificationStep.VERIFIED : VerificationStep.STEP_TWO_SUBMITTED;
return VerificationStep.UNVERIFIED;
}
export async function customers(ctx: Ctx) {
const S = 'customers';
const rows = await ctx.q<Row>('SELECT * FROM customers WHERE deleted_at IS NULL');
const withOrders = new Set((await ctx.q<Row>('SELECT DISTINCT customer_id AS id FROM orders WHERE deleted_at IS NULL')).map((r) => String(r.id)));
const withCash = new Set((await ctx.q<Row>('SELECT DISTINCT customer_id AS id FROM cashes WHERE deleted_at IS NULL')).map((r) => String(r.id)));
const hasMoney = (r: Row) =>
withOrders.has(String(r.id)) || withCash.has(String(r.id)) || !isZero(r.remain) || !isZero(r.rial_remain);
// An expert becomes a Customer only as a trading account, i.e. when they
// have money of their own; otherwise they exist only as an AdminUser.
const candidates = rows.filter((r) => int(r.type) !== 1 || hasMoney(r));
ctx.report.count(S, 'experts without money (admin user only)', rows.length - candidates.length);
// Duplicate mobiles (27 groups, §10.4). The winner is the row with money
// (never more than one in the dump), else the most recently updated.
const groups = new Map<string, Row[]>();
for (const r of candidates) {
const m = mobile(r.mobile);
if (!m) {
// In the 2026-10-04 dump this is one test account whose "mobile" is the
// word «تست»: it cannot log in under any system, so it is not invented a number.
ctx.report.count(S, 'skipped: mobile has no digits');
const own = withOrders.has(String(r.id)) || withCash.has(String(r.id));
ctx.report.note(S, `legacy customer #${String(r.id)} skipped: its mobile contains no digits${own ? ' — its orders/documents are skipped with it' : ''}`);
continue;
}
groups.set(m, [...(groups.get(m) ?? []), r]);
}
for (const [m, group] of groups) {
const ordered = [...group].sort(
(a, b) =>
Number(hasMoney(b)) - Number(hasMoney(a)) ||
String(b.updated_at ?? '').localeCompare(String(a.updated_at ?? '')) ||
Number(b.id) - Number(a.id),
);
const [r, ...losers] = ordered;
const moneyLosers = losers.filter(hasMoney);
if (moneyLosers.length) {
// Not seen in the 2026-10-04 dump; if a fresh dump has it, the money of
// two legacy rows lands on one customer and must be reviewed by hand.
ctx.report.note(S, `⚠ duplicate mobile with money on several rows: legacy #${[r, ...moneyLosers].map((x) => String(x.id)).join(', #')} merged onto one customer — review`);
}
const type = int(r.type);
const step = verificationFor(int(r.sts_verify_step_one), int(r.sts_verify_step_two));
const typedCity = text(r.new_city);
// Sign-up forced city_id = 5 (تهران) on everyone (Major/CustomerController.php:21),
// so when the customer typed a city, that text — not city_id 5 — is the truth.
const cityId = typedCity
? int(r.city_id) !== 5 && r.city_id !== null
? ctx.map.get('city', r.city_id) ?? null
: await cityByName(ctx, typedCity)
: ctx.map.get('city', r.city_id) ?? null;
const owned = {
firstName: text(r.fname),
lastName: text(r.lname),
nationalCode: text(r.ncode), // empty -> NULL; '' would collide under @unique (§10.4)
cityId,
cityNameRaw: typedCity,
kind: kindFor(type),
phone: text(r.phone),
address: text(r.address),
storeName: text(r.store_name),
pasazhName: text(r.pasazh_name),
zone: text(r.zone),
memberCode: text(r.mcode),
referredByAgentId: ctx.map.get('expert', r.reagent_id) ?? null,
goldCreditLimitGrams: grams(r.gram_level),
makingChargeOffsetPercent: percent(r.percent),
verificationStep: step,
verificationRejectionReason: step === VerificationStep.REJECTED ? LEGACY_REJECTION_REASON : null,
nationalIdImageUrl: await ctx.imageUrl(r.ncode_image_id, S),
businessLicenseUrl: await ctx.imageUrl(r.business_license_image_id, S),
shopImageUrl: await ctx.imageUrl(r.shop_image_id, S),
avatarFileId: await ctx.uploadedFile(r.image_id, 'legacy-avatar', S),
birthDate: birthday(r.birth_date, r.created_at),
// Owner ruling 2026-10-08 (MON-05, option 2): everyone may order after the
// move. Legacy customers.sts was never read by any code — most of the 313
// «عدم سفارش» rows are just the sign-up default (ApiMobileController.php:704)
// — so carrying it over would suddenly block real customers once the new
// system enforces the switch. Only a deliberate suspension after go-live counts.
canPlaceOrders: true,
isActive: int(r.active) !== -1,
isArchived: int(r.is_archive) === 1,
};
if (r.reagent_id !== null && !owned.referredByAgentId) ctx.report.count(S, 'referrer not mapped (not a migrated expert)');
if (int(r.sts) === -1) ctx.report.count(S, 'legacy «عدم سفارش» (never enforced) — set to allowed (owner, option 2)');
if (r.birth_date !== null && !owned.birthDate) ctx.report.count(S, 'birth date dropped (sign-up default or impossible)');
const res = await upsert(ctx, 'customer', r.id, {
exists: async (id) => !!(await ctx.db.customer.findUnique({ where: { id } })),
find: () => ctx.db.customer.findUnique({ where: { mobile: m }, select: { id: true } }),
create: () =>
ctx.db.customer.create({
data: {
...owned,
fullName: fullNameOf(r),
mobile: m,
passwordHash: passwordOf(r),
createdAt: tehran(r.created_at) ?? undefined,
} satisfies Prisma.CustomerUncheckedCreateInput,
}),
update: async (id, how) => {
if (how === 'created') {
// Never the accounting* columns (D27, §10.5): not in `owned`.
await ctx.db.customer.update({ where: { id }, data: owned });
} else {
const cur = await ctx.db.customer.findUniqueOrThrow({ where: { id } });
const patch = fillEmpty(cur, owned);
// The target's own verification state wins; a legacy rejection reason
// on a customer the target shows as anything but REJECTED would contradict it.
if (cur.verificationStep !== VerificationStep.REJECTED) delete patch.verificationRejectionReason;
if (Object.keys(patch).length) await ctx.db.customer.update({ where: { id }, data: patch });
}
},
});
ctx.report.count(S, res.isNew ? 'created' : res.how === 'matched' ? 'matched existing (empty fields filled)' : 'updated');
if (step === VerificationStep.REJECTED) ctx.report.count(S, 'rejected (legacy reason sentinel written)');
for (const l of losers) {
await ctx.map.set('customer', l.id, res.id, 'merged');
ctx.report.count(S, 'duplicate-mobile rows merged into the winner');
}
// An expert's trading account (D17): link it from their profile.
if (type === 1) {
const adminId = ctx.map.get('expert', r.id);
const profile = adminId ? await ctx.db.salesExpertProfile.findUnique({ where: { adminUserId: adminId } }) : null;
if (profile && !profile.tradingCustomerId) {
await ctx.db.salesExpertProfile.update({ where: { id: profile.id }, data: { tradingCustomerId: res.id } });
ctx.report.count(S, 'expert trading accounts linked');
}
}
}
}
@@ -0,0 +1,147 @@
/**
* Reconciliation (GO-LIVE-PLAN §7): what legacy has vs what the target now
* holds, with the tolerance of DATA-MIGRATION-MAP §4 instead of "zero
* difference", which double → Decimal rounding makes impossible.
*/
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');
export async function reconcile(ctx: Ctx) {
const S = 'reconciliation';
/* counts: legacy rows in scope vs ids mapped */
const scope: [string, string, string][] = [
['cities', 'city', `SELECT COUNT(*) n FROM cities WHERE deleted_at IS NULL`],
['branches', 'branch', `SELECT COUNT(*) n FROM branches WHERE deleted_at IS NULL`],
['workshops', 'workshop', `SELECT COUNT(*) n FROM workshops WHERE deleted_at IS NULL`],
['admin users', 'admin', `SELECT COUNT(*) n FROM users WHERE deleted_at IS NULL`],
['sales experts', 'expert', `SELECT COUNT(*) n FROM customers WHERE deleted_at IS NULL AND type = 1`],
['live products', 'product', `SELECT COUNT(*) n FROM product WHERE deleted_at IS NULL`],
['live variants', 'variant', `SELECT COUNT(*) n FROM products_productvarient WHERE deleted_at IS NULL`],
['live orders', 'order', `SELECT COUNT(*) n FROM orders WHERE deleted_at IS NULL`],
['custom orders', 'preorder', `SELECT COUNT(*) n FROM custom_orders WHERE deleted_at IS NULL`],
];
const rows: string[] = ['| | legacy | mapped |', '|---|---:|---:|'];
for (const [label, entity, sql] of scope) {
const [{ n }] = await ctx.q<{ n: number }>(sql);
const [{ m }] = await ctx.db.$queryRawUnsafe<{ m: bigint }[]>(
`SELECT COUNT(*) AS m FROM legacy_import.id_map WHERE entity = $1`, entity,
);
rows.push(`| ${label} | ${String(n)} | ${String(m)} |`);
}
ctx.report.note(S, `Counts (mapped may exceed legacy live rows where deleted rows were kept for order history):\n\n${rows.join('\n')}\n`);
/* balances: every migrated customer, legacy stored vs target cached */
const legacy = await ctx.q<Row>('SELECT id, remain, rial_remain FROM customers WHERE deleted_at IS NULL');
const want = new Map<string, { gold: number; rial: number }>();
for (const r of legacy) {
const id = ctx.map.get('customer', r.id);
if (!id) continue;
const w = want.get(id) ?? { gold: 0, rial: 0 };
w.gold += toNumber(r.remain);
w.rial += toNumber(r.rial_remain);
want.set(id, w);
}
const targets = await ctx.db.customer.findMany({
where: { id: { in: [...want.keys()] } },
select: { id: true, goldDebtGrams: true, fiatDebtRial: true },
});
// Matched customers may carry target-native entries too, so only the
// customers the import created are held to legacy's number.
const created = new Set(
(await ctx.db.$queryRawUnsafe<{ target_id: string }[]>(
`SELECT DISTINCT target_id FROM legacy_import.id_map WHERE entity = 'customer' AND how = 'created'`,
)).map((r) => r.target_id),
);
let goldOk = 0, goldBad = 0, rialOk = 0, rialBad = 0;
let legacyGold = new Prisma.Decimal(0), targetGold = new Prisma.Decimal(0);
let legacyRial = new Prisma.Decimal(0), targetRial = new Prisma.Decimal(0);
for (const t of targets) {
if (!created.has(t.id)) continue;
const w = want.get(t.id)!;
const g = new Prisma.Decimal(w.gold.toFixed(4));
const r = new Prisma.Decimal(w.rial.toFixed(2));
legacyGold = legacyGold.add(g); targetGold = targetGold.add(t.goldDebtGrams);
legacyRial = legacyRial.add(r); targetRial = targetRial.add(t.fiatDebtRial);
if (g.sub(t.goldDebtGrams).abs().lte(TOLERANCE)) goldOk++;
else { goldBad++; ctx.report.note(S, `⚠ gold balance differs for customer ${t.id}: legacy ${g.toString()} / target ${t.goldDebtGrams.toString()}`); }
if (r.sub(t.fiatDebtRial).abs().lt(1)) rialOk++;
else { rialBad++; ctx.report.note(S, `⚠ rial balance differs for customer ${t.id}: legacy ${r.toString()} / target ${t.fiatDebtRial.toString()}`); }
}
ctx.report.count(S, 'created customers — gold balance matches legacy (±0.0001 g)', goldOk);
ctx.report.count(S, 'created customers — gold balance DIFFERS', goldBad);
ctx.report.count(S, 'created customers — rial balance matches legacy (±1 rial)', rialOk);
ctx.report.count(S, 'created customers — rial balance DIFFERS', rialBad);
ctx.report.note(S, `Total gold debt (created customers): legacy ${legacyGold.toFixed(4)} g · target ${targetGold.toFixed(4)} g`);
ctx.report.note(S, `Total rial debt (created customers): legacy ${legacyRial.toFixed(2)} · target ${targetRial.toFixed(2)}`);
ctx.report.note(S, `Matched (pre-existing) customers are not held to legacy's number: they may carry target-native ledger entries. ${targets.length - created.size} of them; see the "balances" section for whose cached balance changed.`);
/* protected columns: the import must never have written them */
const [{ n: imageIds }] = await ctx.db.$queryRawUnsafe<{ n: bigint }[]>(
`SELECT COUNT(*) AS n FROM "ProductVariant" v
JOIN legacy_import.id_map m ON m.entity = 'variant' AND m.target_id = v.id AND m.how = 'created'
WHERE v."imageId" IS NOT NULL`,
);
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);
}