/** * 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; /** 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( `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 { 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 = 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 = 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( "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(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('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('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('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('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('SELECT * FROM customers WHERE deleted_at IS NULL'); const withOrders = new Set((await ctx.q('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('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(); 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'); } } } }