apps/api/scripts/legacy-import/ reads the legacy MySQL (read only) and merges into the target Postgres. Every rule is from docs/DATA-MIGRATION-MAP.md, and each step cites its section. It covers cities, roles and permissions, branches, admin users, sales experts, workshops, customers, brands, categories, products, variants, product images, orders, the ledger, favourites, custom orders and a reconciliation report. Key rules: - Debt is rebuilt from legacy's own formula (updateRemain): delivered orders minus confirmed type 1-4 documents. Type 9 «سفارش» documents mirror orders and are skipped, or every order's debt would count twice. A balance the records cannot explain is carried as one ADJUSTMENT entry. In the 2026-10-04 dump that is only customer #125 (owner ruling). - Merge, not import: rows already in the target are matched by mobile or product code, and only their empty fields are filled. Never written: accounting* columns, ProductVariant.imageId, Accounting* tables. No ProductImage is ever deleted. - Roles map by title, not number. Legacy 86 is «حسابدار» but target 86 is «کارشناس فروش». An expert sharing a mobile with an admin never demotes the admin. - Soft-deleted variants and products that live order lines still point at (473 of 610 lines) come over inactive, so order history stays whole. - Owner rulings 2026-10-07: birthdays filtered (541 of 915), typed city kept plus exact-match cityId, the 7 legacy rejections get the sentinel reason, OTP-only accounts get OTP_ONLY_PASSWORD_HASH. - Tehran wall-clock timestamps are converted with ICU rules, including Iran's pre-2022 DST. Jalali receipt dates are converted with the jalaali-js arithmetic (truncating division; verified on 8 known dates). Safety: --target is required and never read from .env. Nothing is written without --yes, and a target not named *import*/*staging* also needs --confirm-db. The id map lives in its own schema, legacy_import, so a re-run updates instead of duplicating. Writes bypass the API services, so no SMS is sent and nothing is queued to accounting. Tested against goldbank_import_test, a clone of the dev DB, in three runs: - 1,503/1,503 created customers match legacy gold and rial balances (±0.0001 g / ±1 rial) - accounting data is byte-identical before and after - 0 imported variants have an imageId - runs 2 and 3 created zero duplicate rows - 6,196 image files copied, 3 missing in the legacy storage New devDependency: mysql2 ^3.24.5. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
521 lines
24 KiB
TypeScript
521 lines
24 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)`,
|
|
);
|
|
const all = new Map((await ctx.q<Row>('SELECT id, title, parent, level FROM cities')).map((r) => [String(r.id), r]));
|
|
|
|
// Legacy is a tree (country > province > city > district); the target City is
|
|
// flat with a `province` label, taken from the level-1 ancestor.
|
|
const provinceOf = (r: Row): string | null => {
|
|
let cur: Row | undefined = r;
|
|
for (let guard = 0; cur && guard < 10; guard++) {
|
|
if (int(cur.level) === 1) return cur === r ? null : text(cur.title);
|
|
cur = all.get(String(cur.parent));
|
|
}
|
|
return null;
|
|
};
|
|
|
|
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,
|
|
province: provinceOf(r),
|
|
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);
|
|
}
|
|
}
|
|
|
|
/** 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]]);
|
|
|
|
/** 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 = perms.filter((p) => Number(p.ut) === legacyId).map((p) => 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) {
|
|
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.`);
|
|
continue;
|
|
}
|
|
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),
|
|
canPlaceOrders: int(r.sts) !== -1,
|
|
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 (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');
|
|
}
|
|
}
|
|
}
|
|
}
|