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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user