2026-10-07 19:35:18 +03:30
/**
* 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)` ,
);
2026-10-08 16:29:51 +03:30
// 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.
2026-10-07 19:35:18 +03:30
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 );
}
2026-10-08 16:29:51 +03:30
// 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' );
}
2026-10-07 19:35:18 +03:30
}
/** 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 ]]);
2026-10-08 16:29:51 +03:30
/**
* 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' ],
]);
2026-10-07 19:35:18 +03:30
/** 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 ;
}
2026-10-08 16:29:51 +03:30
const legacyRids = [
... new Set ( perms . filter (( p ) => Number ( p . ut ) === legacyId ). map (( p ) => RENAMED_RIDS . get ( String ( p . rid )) ?? String ( p . rid ))),
];
2026-10-07 19:35:18 +03:30
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 ) {
2026-10-08 10:40:21 +03:30
// 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.
2026-10-07 19:35:18 +03:30
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 ),
2026-10-08 11:16:29 +03:30
// 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 ,
2026-10-07 19:35:18 +03:30
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)' );
2026-10-08 11:16:29 +03:30
if ( int ( r . sts ) === - 1 ) ctx . report . count ( S , 'legacy «عدم سفارش» (never enforced) — set to allowed (owner, option 2)' );
2026-10-07 19:35:18 +03:30
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' );
}
}
}
}