Cities (owner: as legacy) - City.parentId (self relation) replaces the flat `province` label; the migration turns each label into a parent row («استان …») first, so nothing is lost. - Admin pickers are legacy's tree (CityTreeSelect): the top level, the country, is a heading. The city form and grid get «والد». - Restored legacy _getP: a city without its own percent takes its ancestor's. Restored the omde-customer form's city → percent autofill. - Import copies legacy parents; delete refuses a city with children; a parent loop is refused. Permissions (owner approved the deletion) - dashboard / dashboard.omde / dashboard.tak rows become dashboard.view, then are deleted. - Import maps them the same way: legacy types 102, 104, 105, 108 and 135 had lost the dashboard. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
559 lines
26 KiB
TypeScript
559 lines
26 KiB
TypeScript
/**
|
|
* 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');
|
|
}
|
|
}
|
|
}
|
|
}
|