Files
s.esmaeiliandClaude Opus 5.5 fbe4f38e06 feat(cities): cities are a tree again, as legacy's; old dashboard permissions merged (PERS-02, PERM-01)
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>
2026-10-08 16:31:12 +03:30

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');
}
}
}
}