parity(admin): D17 — SalesExpertProfile, AdminUser name parts, agent grid fields

Restores the sales-expert fields that were lost when a sales expert became
an AdminUser instead of a type=1 Customer row.

New SalesExpertProfile holds cityId, gramLevel, phone and nationalCode. A
separate table rather than columns on AdminUser, because these mean
something only for role = SALES_EXPERT. gramLevel is DECIMAL(14,4), matched
to Customer.goldCreditLimitGrams so the same credit limit has identical
precision on both tables. phone and nationalCode copy the reference exactly:
nullable, NOT unique, no format rule — createCustomer validates only mobile
and percent, and both fields are required:false on the reference form.
Inventing a uniqueness constraint would reject data the reference accepts.

reagent_id is deliberately NOT ported (D17). Every use of it in the
reference reads a CUSTOMER's reagent_id, which the target already models as
Customer.referredByAgentId. On a sales expert's own form it was a side
effect of the shared customers form: filling it removed that expert from
the selectable-agent list, because queryReagent filters on
whereNull('reagent_id'). The target's equivalent filters on role and active
only, so no agent can be dropped — strictly better than the reference.

AdminUser gains firstName/lastName with the same additive migration,
backfill and sync trigger as Customer, for all admins rather than only
sales experts. This closes D0 requirement 7, and every remaining
`fname: fullName, lname: ''` was fixed — six further sites across
admin-users, admin-comments, admin-documents, admin-orders and
admin-preorders.

listReagents now returns real city_id, city, gram_level, phone and ncode,
and left-joins so an expert without a profile still lists with empty cells
rather than vanishing. The two grid filters map through the relation. The
service upserts a profile on create and on a role change to SALES_EXPERT,
and the migration backfilled one for every existing expert.

ReagentCustomer.vue gains the four fields plus the city -> commission
autofill, ported verbatim including the reference's falsy test so a city
percent of 0 also falls through to the aux lookup (replicated, not fixed).

Tests: new sales-expert round trip 33/33, covering create with every field,
an edit form with each field populated separately, a byte-identical no-op
save, single-field edits, both grid filters with negative cases, the
left-join case and the referrer dropdown. «محمد علی» + «احمدی» survives
intact. Plus jest 32/32, auth-smoke 53/53, customer trigger 9/9, typecheck
clean on api and admin-panel. Migration dry-run twice in a rolled-back
transaction before applying; drift is zero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-09-22 17:50:47 +03:30
co-authored by Claude Opus 5
parent e3af31948e
commit 2194b3d4d9
13 changed files with 905 additions and 39 deletions
@@ -125,8 +125,9 @@ export class AdminCommentsService {
customer: {
id: c.customer.id,
name: c.customer.fullName,
fname: c.customer.fullName,
lname: '',
// D0 — real name parts
fname: c.customer.firstName ?? c.customer.fullName,
lname: c.customer.lastName ?? '',
mobile: c.customer.mobile,
},
caj: c.createdAt,
@@ -322,10 +322,16 @@ export class AdminCustomersService {
const aliases: Record<string, string> = {
name: 'fullName',
lname: 'fullName',
// D0/D17 — real columns on AdminUser now, as on Customer.
fname: 'firstName',
lname: 'lastName',
caj: 'createdAt',
active: 'isActive',
percent: 'wagePercent',
// D17 — these live on the related SalesExpertProfile, so the grid's two
// filters have to reach through the relation.
city_id: 'salesExpertProfile.cityId',
gram_level: 'salesExpertProfile.gramLevel',
};
const mapField = (f?: string) => (f ? (aliases[f] ?? f) : f);
@@ -344,8 +350,21 @@ export class AdminCustomersService {
};
const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, {
searchable: ['fullName', 'mobile'],
allowed: ['id', 'fullName', 'mobile', 'isActive', 'createdAt', 'wagePercent', 'role'],
searchable: ['fullName', 'firstName', 'lastName', 'mobile'],
allowed: [
'id',
'fullName',
'firstName',
'lastName',
'mobile',
'isActive',
'createdAt',
'wagePercent',
'role',
// D17 — the grid's «شهر» and «سطح اعتبار» filters.
'salesExpertProfile.cityId',
'salesExpertProfile.gramLevel',
],
defaultOrderBy: { createdAt: 'desc' },
});
@@ -359,7 +378,14 @@ export class AdminCustomersService {
orderBy: orderBy as Prisma.AdminUserOrderByWithRelationInput,
skip,
take,
include: { branch: true, _count: { select: { referredCustomers: true } } },
// D17 — a LEFT JOIN by nature in Prisma: an expert without a profile
// still comes back, with `salesExpertProfile` null, so a missing row
// degrades to empty cells rather than dropping the agent from the grid.
include: {
branch: true,
salesExpertProfile: { include: { city: true } },
_count: { select: { referredCustomers: true } },
},
}),
this.prisma.adminUser.count({ where: scoped }),
]);
@@ -374,11 +400,17 @@ export class AdminCustomersService {
return {
id: r.id,
name: r.fullName,
fname: r.fullName,
lname: '',
// D0 requirement 7 — the real parts, as on Customer. This used to be
// `fname: r.fullName, lname: ''`, the same defect the customer forms had.
fname: r.firstName ?? r.fullName,
lname: r.lastName ?? '',
mobile: r.mobile,
city_id: null,
city: null,
// D17 — real values now, so the grid's city column and its filter work.
// Null-safe: an expert with no profile row simply shows empty.
city_id: r.salesExpertProfile?.cityId ?? null,
city: r.salesExpertProfile?.city
? { id: r.salesExpertProfile.city.id, title: r.salesExpertProfile.city.name }
: null,
percent: r.wagePercent,
grams_sell: agg._sum.totalGoldWeightGrams ?? 0,
child: r._count.referredCustomers,
@@ -388,12 +420,15 @@ export class AdminCustomersService {
caj: r.createdAt,
uaj: r.createdAt,
branch: r.branch ? { id: r.branch.id, name: r.branch.name } : null,
// Legacy columns an AdminUser has no counterpart for.
gram_level: null,
// D17 — from SalesExpertProfile. Null-safe for an expert with no row.
gram_level: r.salesExpertProfile?.gramLevel ?? null,
phone: r.salesExpertProfile?.phone ?? null,
ncode: r.salesExpertProfile?.nationalCode ?? null,
// Still genuinely absent: a sales expert is an AdminUser and has no
// ledger of its own, so it carries no gram or rial debt (D10).
remain: null,
rial_remain: null,
phone: null,
address: null,
address: r.address ?? null,
};
}),
);
@@ -182,8 +182,9 @@ export class AdminDocumentsService {
customer: {
id: e.customer.id,
name: e.customer.fullName,
fname: e.customer.fullName,
lname: '',
// D0 — real name parts
fname: e.customer.firstName ?? e.customer.fullName,
lname: e.customer.lastName ?? '',
mobile: e.customer.mobile,
},
posted_by: e.postedByAdminUser ? e.postedByAdminUser.fullName : null,
@@ -432,8 +433,15 @@ export class AdminDocumentsService {
const rows = await this.prisma.customer.findMany({
where: { isActive: true },
orderBy: { fullName: 'asc' },
select: { id: true, fullName: true, mobile: true },
select: { id: true, fullName: true, firstName: true, lastName: true, mobile: true },
});
return rows.map((c) => ({ id: c.id, name: c.fullName, fname: c.fullName, lname: '', mobile: c.mobile }));
// D0 — real name parts.
return rows.map((c) => ({
id: c.id,
name: c.fullName,
fname: c.firstName ?? c.fullName,
lname: c.lastName ?? '',
mobile: c.mobile,
}));
}
}
@@ -129,8 +129,8 @@ export class AdminOrdersService {
id: o.customer.id,
// Legacy split names into fname/lname; the new schema has one fullName.
// Both are exposed so ported templates binding either keep working.
fname: o.customer.fullName,
lname: '',
fname: o.customer.firstName ?? o.customer.fullName,
lname: o.customer.lastName ?? '',
fullName: o.customer.fullName,
phone: o.customer.mobile,
},
@@ -92,8 +92,9 @@ export class AdminPreOrdersService {
workshop: p.workshop ? { id: p.workshop.id, name: p.workshop.name } : null,
customer: {
id: p.customer.id,
fname: p.customer.fullName,
lname: '',
// D0 — real name parts
fname: p.customer.firstName ?? p.customer.fullName,
lname: p.customer.lastName ?? '',
name: p.customer.fullName,
fullName: p.customer.fullName,
mobile: p.customer.mobile,
@@ -93,13 +93,24 @@ export class AdminUsersService {
private toRow(
u: Prisma.AdminUserGetPayload<{
include: { userType: true; branch: true; avatarFile: true };
}>,
}> & {
// D17 — present on the detail read; absent from the list query, hence optional.
salesExpertProfile?: {
cityId: string | null;
gramLevel: Prisma.Decimal;
phone: string | null;
nationalCode: string | null;
city?: { id: string; name: string } | null;
} | null;
},
) {
return {
id: u.id,
name: u.fullName,
fname: u.fullName,
lname: '',
// D0 requirement 7 — the real parts. The fallback only matters for a row
// written before the 20260922180000 migration backfilled them.
fname: u.firstName ?? u.fullName,
lname: u.lastName ?? '',
mobile: u.mobile,
role: u.role,
ut: u.userTypeId,
@@ -119,13 +130,28 @@ export class AdminUsersService {
mcode: u.memberCode,
image_id: u.avatarFileId,
image: u.avatarFile ? { id: u.avatarFile.id, image: u.avatarFile.url } : null,
// D17 — from SalesExpertProfile. Null for a non-expert, and null-safe for
// an expert whose profile row is somehow missing.
city_id: u.salesExpertProfile?.cityId ?? null,
city: u.salesExpertProfile?.city
? { id: u.salesExpertProfile.city.id, title: u.salesExpertProfile.city.name }
: null,
gram_level: u.salesExpertProfile?.gramLevel ?? null,
phone: u.salesExpertProfile?.phone ?? null,
ncode: u.salesExpertProfile?.nationalCode ?? null,
};
}
async findOne(id: string) {
const u = await this.prisma.adminUser.findUnique({
where: { id },
include: { userType: true, branch: true, avatarFile: true },
// D17 — the edit form needs the sales-expert fields too.
include: {
userType: true,
branch: true,
avatarFile: true,
salesExpertProfile: { include: { city: true } },
},
});
if (!u) throw new NotFoundException('کاربر یافت نشد');
return this.toRow(u);
@@ -140,7 +166,11 @@ export class AdminUsersService {
* کاربری") actually chosen. UserCreate.vue now always sends one.
*/
async create(data: Record<string, unknown>) {
const fullName = String(data.name ?? data.fname ?? '').trim();
// D0 requirement 7 — store the real name parts, as Customer does. `name`
// is still accepted for callers that only send a whole name.
const firstName = String(data.fname ?? data.name ?? '').trim();
const lastName = String(data.lname ?? '').trim();
const fullName = [firstName, lastName].filter(Boolean).join(' ');
const mobile = String(data.mobile ?? '').trim();
const password = String(data.password ?? '');
const role = data.role as AdminRole | undefined;
@@ -156,6 +186,8 @@ export class AdminUsersService {
return this.prisma.adminUser.create({
data: {
fullName,
firstName: firstName || null,
lastName: lastName || null,
mobile,
passwordHash: await bcrypt.hash(password, 10),
role,
@@ -173,18 +205,60 @@ export class AdminUsersService {
pasazhName: (data.pasazh_name as string) || null,
memberCode: (data.mcode as string) || null,
avatarFileId: (data.image_id as string) || null,
// D17 — a sales expert always gets its profile row, so the grid's city
// and credit-limit columns have somewhere to read from immediately.
...(role === 'SALES_EXPERT'
? { salesExpertProfile: { create: this.salesExpertProfileData(data) } }
: {}),
},
include: {
userType: true,
branch: true,
avatarFile: true,
salesExpertProfile: { include: { city: true } },
},
include: { userType: true, branch: true, avatarFile: true },
});
}
/**
* The SalesExpertProfile columns as posted by ReagentCustomer.vue (D17).
*
* `phone` and `nationalCode` are stored exactly as the reference does:
* optional, not unique, no format rule — the reference validates only
* `mobile` and `percent` on this form (AdminController.php:354).
*/
private salesExpertProfileData(data: Record<string, unknown>) {
return {
cityId: (data.city_id as string) || null,
gramLevel:
data.gram_level !== undefined && data.gram_level !== null && data.gram_level !== ''
? new Prisma.Decimal(String(data.gram_level))
: new Prisma.Decimal(0),
phone: (data.phone as string) || null,
nationalCode: (data.ncode as string) || null,
};
}
/** Legacy `PUT users/{id}`. */
async update(id: string, data: Record<string, unknown>) {
const existing = await this.prisma.adminUser.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('کاربر یافت نشد');
const patch: Prisma.AdminUserUpdateInput = {};
if (data.name !== undefined) patch.fullName = String(data.name);
// D0 requirement 7 — write the parts and let the
// admin_user_sync_full_name trigger rebuild `fullName`. When a caller sends
// both, the parts win (see the migration header).
if (data.fname !== undefined || data.lname !== undefined) {
const firstName = String(data.fname ?? existing.firstName ?? '').trim();
const lastName = String(data.lname ?? existing.lastName ?? '').trim();
patch.firstName = firstName || null;
patch.lastName = lastName || null;
} else if (data.name !== undefined) {
// Whole-name-only callers: the trigger derives the parts.
patch.fullName = String(data.name);
}
if (data.mobile !== undefined) {
const mobile = String(data.mobile);
if (mobile !== existing.mobile) {
@@ -220,10 +294,47 @@ export class AdminUsersService {
: { disconnect: true };
}
// D17 — the profile follows the role.
//
// `upsert` covers all three cases in one statement: a user becoming a sales
// expert gets a profile; an existing expert's profile is updated; and an
// expert whose profile went missing gets one back rather than erroring.
// A profile is never deleted when the role changes away — that would
// silently discard the operator's city and credit limit on a mistaken role
// switch, and it costs nothing to keep.
const effectiveRole = (patch.role as AdminRole | undefined) ?? existing.role;
const touchesProfile =
data.city_id !== undefined ||
data.gram_level !== undefined ||
data.phone !== undefined ||
data.ncode !== undefined;
if (effectiveRole === 'SALES_EXPERT' && (touchesProfile || patch.role !== undefined)) {
const profile = this.salesExpertProfileData(data);
patch.salesExpertProfile = {
upsert: {
create: profile,
// Only the keys the caller actually sent, so an untouched field is
// not overwritten with a default on a partial (diff-send) save.
update: {
...(data.city_id !== undefined ? { cityId: profile.cityId } : {}),
...(data.gram_level !== undefined ? { gramLevel: profile.gramLevel } : {}),
...(data.phone !== undefined ? { phone: profile.phone } : {}),
...(data.ncode !== undefined ? { nationalCode: profile.nationalCode } : {}),
},
},
};
}
return this.prisma.adminUser.update({
where: { id },
data: patch,
include: { userType: true, branch: true, avatarFile: true },
include: {
userType: true,
branch: true,
avatarFile: true,
salesExpertProfile: { include: { city: true } },
},
});
}