diff --git a/apps/admin-panel/src/views/apps/customer/ReagentCustomer.vue b/apps/admin-panel/src/views/apps/customer/ReagentCustomer.vue
index 5b6e14e..2f5b225 100644
--- a/apps/admin-panel/src/views/apps/customer/ReagentCustomer.vue
+++ b/apps/admin-panel/src/views/apps/customer/ReagentCustomer.vue
@@ -67,10 +67,41 @@
+
+
+
+
+
+
+
+
@@ -104,10 +135,19 @@ const form = reactive>({
pasazh_name: '',
address: '',
percent: undefined,
+ // D17 — the sales-expert fields the reference collected on this form and the
+ // port had dropped. They live on SalesExpertProfile now.
+ ncode: '',
+ phone: '',
+ city_id: undefined,
+ gram_level: undefined,
// legacy kept the FK in `image_id`; `image_url` only drives the thumbnail.
image_id: '',
image_url: null,
});
+
+/** Cities for the «شهر» select and the commission autofill. */
+const cities = ref>([]);
const err = reactive>({});
/** Snapshot of the server's record, captured after the edit form is populated. */
@@ -127,6 +167,38 @@ function diffFromInitial(omit: string[] = []): Record {
function onAvatar(file: Record) {
form.image_id = (file.id as string) ?? '';
}
+
+/**
+ * Legacy `getPercent(value)` — henza_admin ReagentCustomer.vue:215-228.
+ *
+ * Selecting a city fills the commission percentage:
+ * 1. use `percent` from the already-loaded city row, if it is set;
+ * 2. otherwise GET cities/get-percent-aux/{id} and use that;
+ * 3. write the result into the form.
+ *
+ * Ported verbatim, including the falsy test on step 1 — legacy used
+ * `if (!percent)`, so a city percent of 0 also falls through to the aux
+ * lookup. Replicated rather than "fixed" (D4).
+ */
+async function onCityChange(value: string) {
+ form.city_id = value;
+ if (!value) return;
+
+ let percent: number | null | undefined = cities.value.find((c) => c.id === value)?.percent;
+ if (!percent) {
+ try {
+ // The endpoint returns a BARE scalar, as the reference does
+ // (CitiesController::getPercentAux -> _getP, and the target's
+ // admin-misc.service.ts:125 returns `c.percentAux` directly).
+ const { data } = await api.get(`cities/get-percent-aux/${value}`);
+ percent = data as number | null;
+ } catch (e) {
+ console.log(e);
+ return;
+ }
+ }
+ form.percent = percent === null || percent === undefined ? undefined : Number(percent);
+}
const REQUIRED = 'لطفا مقدار را وارد کنید';
async function handleSubmit() {
@@ -145,11 +217,13 @@ async function handleSubmit() {
// (blank means "leave unchanged" on the admin-users endpoint).
const fields = id ? diffFromInitial(['image_url', 'password']) : allFields;
if (id && form.password) fields.password = form.password;
- const payload = {
- ...fields,
- name: `${form.fname} ${form.lname}`.trim(),
- role: 'SALES_EXPERT',
- };
+ const payload: Record = { ...fields, role: 'SALES_EXPERT' };
+ // D0 — fname/lname are real columns and already travel in `fields`. `name`
+ // goes only when a name part actually changed; sending it unconditionally
+ // would re-submit the whole name on every save and defeat the diff-send.
+ if (!id || 'fname' in fields || 'lname' in fields) {
+ payload.name = `${form.fname} ${form.lname}`.trim();
+ }
if (id) {
await api.put(`users/${id}`, payload);
} else {
@@ -166,6 +240,18 @@ async function handleSubmit() {
}
onMounted(async () => {
+ // D17 — the «شهر» select and its commission autofill need the city list.
+ try {
+ const { data } = await api.post('cities/list', { page: 1, limit: 500 });
+ cities.value = (data.rows ?? []).map((c: any) => ({
+ id: c.id,
+ title: c.title ?? c.name,
+ percent: c.percent === null || c.percent === undefined ? null : Number(c.percent),
+ }));
+ } catch (e) {
+ console.log(e);
+ }
+
if (id) {
try {
const { data } = await api.get(`admin/users/${id}`);
@@ -175,6 +261,15 @@ onMounted(async () => {
mobile: data.mobile ?? '',
address: data.address ?? '',
percent: data.percent === null ? undefined : Number(data.percent),
+ // D17 — from SalesExpertProfile. Numbers are coerced so diffFromInitial
+ // compares like with like and does not re-send an unchanged field.
+ ncode: data.ncode ?? '',
+ phone: data.phone ?? '',
+ city_id: data.city_id ?? undefined,
+ gram_level:
+ data.gram_level === null || data.gram_level === undefined
+ ? undefined
+ : Number(data.gram_level),
// guarded, unlike legacy — an agent with no avatar must not throw
image_id: data.image?.id ?? '',
image_url: data.image?.image ?? null,
diff --git a/apps/api/prisma/migrations/20260922180000_sales_expert_profile_and_admin_name_parts/migration.sql b/apps/api/prisma/migrations/20260922180000_sales_expert_profile_and_admin_name_parts/migration.sql
new file mode 100644
index 0000000..fe6a087
--- /dev/null
+++ b/apps/api/prisma/migrations/20260922180000_sales_expert_profile_and_admin_name_parts/migration.sql
@@ -0,0 +1,180 @@
+-- D17 — SalesExpertProfile, and firstName/lastName on AdminUser.
+--
+-- Two additive changes in one migration:
+--
+-- 1. SalesExpertProfile — the sales-expert fields that legacy kept on the
+-- `customers` row (type=1) and that were lost when a sales expert became
+-- an AdminUser. A separate table, not columns on AdminUser, because these
+-- are meaningful only for role = SALES_EXPERT and would be permanently
+-- null for every other admin.
+--
+-- 2. AdminUser.firstName / lastName — closes D0 requirement 7, applying the
+-- same split the Customer table already got. For ALL admins, not only
+-- sales experts.
+--
+-- NOTHING IS DROPPED. `fullName` stays and is kept in sync by a trigger.
+--
+-- IDEMPOTENT (D14/D15): this must run cleanly under BOTH paths — the Prisma CLI
+-- in development, and the app's own runner (src/prisma/migrate.ts) at container
+-- boot, which may replay it. Hence IF NOT EXISTS everywhere, CREATE OR REPLACE
+-- for the function, DROP ... IF EXISTS before CREATE TRIGGER, and a guarded
+-- backfill that only touches rows not already filled.
+--
+-- D13 / storefront impact: NONE. AdminUser is an admin-side table the retail
+-- storefront never reads, and SalesExpertProfile is new. `customers` is not
+-- touched.
+
+-- ───────────────────────────────────────────────────────────────────────────
+-- 1. SalesExpertProfile
+-- ───────────────────────────────────────────────────────────────────────────
+
+CREATE TABLE IF NOT EXISTS "SalesExpertProfile" (
+ "id" TEXT NOT NULL,
+ "adminUserId" TEXT NOT NULL,
+ -- legacy customers.city_id — a filterable column on the sales-expert grid
+ -- (ReagentListModel.js:14) AND the source of the commission autofill
+ -- (ReagentCustomer.vue:215-228).
+ "cityId" TEXT,
+ -- legacy customers.gram_level — the «سطح اعتبار» column, filterable
+ -- (ReagentListModel.js:17).
+ --
+ -- DECIMAL(14,4) deliberately matches Customer.goldCreditLimitGrams exactly
+ -- (schema.prisma:221, verified numeric_precision=14 numeric_scale=4), so the
+ -- same credit limit has identical precision and rendering on both tables.
+ "gramLevel" DECIMAL(14,4) NOT NULL DEFAULT 0,
+ -- legacy customers.phone / ncode — collected by the reference form
+ -- (ReagentCustomer.vue:95,31) and stored, though nothing reads them back.
+ -- Added on the owner's instruction so the form matches the reference.
+ --
+ -- Types and constraints copied from the reference, which is deliberately lax:
+ -- ncode : string(20), NULLABLE, **NOT UNIQUE**
+ -- (2020_10_01_120430_customers_table.php:22)
+ -- phone : string(15), NULLABLE, not unique
+ -- (2020_10_25_155247_customers_cols.php:17)
+ -- Neither has a server-side validation rule: createCustomer validates only
+ -- `mobile` (required|unique) and `percent` (required) — AdminController.php:354.
+ -- Client-side both are `required: false` (ReagentCustomer.vue:31,95).
+ -- No uniqueness is invented here; that would reject data the reference accepts.
+ "phone" VARCHAR(15),
+ "nationalCode" VARCHAR(20),
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "SalesExpertProfile_pkey" PRIMARY KEY ("id")
+);
+
+-- One profile per admin user.
+CREATE UNIQUE INDEX IF NOT EXISTS "SalesExpertProfile_adminUserId_key"
+ ON "SalesExpertProfile" ("adminUserId");
+
+-- The grid filters on city.
+CREATE INDEX IF NOT EXISTS "SalesExpertProfile_cityId_idx"
+ ON "SalesExpertProfile" ("cityId");
+
+DO $$
+BEGIN
+ ALTER TABLE "SalesExpertProfile"
+ ADD CONSTRAINT "SalesExpertProfile_adminUserId_fkey"
+ FOREIGN KEY ("adminUserId") REFERENCES "AdminUser"("id")
+ ON DELETE CASCADE ON UPDATE CASCADE;
+EXCEPTION
+ WHEN duplicate_object THEN NULL;
+END $$;
+
+DO $$
+BEGIN
+ ALTER TABLE "SalesExpertProfile"
+ ADD CONSTRAINT "SalesExpertProfile_cityId_fkey"
+ FOREIGN KEY ("cityId") REFERENCES "City"("id")
+ ON DELETE SET NULL ON UPDATE CASCADE;
+EXCEPTION
+ WHEN duplicate_object THEN NULL;
+END $$;
+
+-- Give every EXISTING sales expert a profile row, so the grid's city and
+-- credit-limit columns have something to left-join to from day one.
+--
+-- Idempotent by construction: `WHERE NOT EXISTS` means a replay inserts
+-- nothing, and it never touches a profile an operator has already filled in.
+--
+-- The id is derived from the AdminUser id rather than random, so a replay on a
+-- database where the row was deleted recreates the same key instead of
+-- accumulating duplicates. Values are the column defaults: gramLevel 0 and
+-- nulls elsewhere — deliberately neutral, since the reference has no data to
+-- migrate here and inventing a credit limit would change behaviour.
+INSERT INTO "SalesExpertProfile" ("id", "adminUserId", "gramLevel", "createdAt", "updatedAt")
+SELECT 'sep_' || a."id", a."id", 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
+FROM "AdminUser" a
+WHERE a."role" = 'SALES_EXPERT'
+ AND NOT EXISTS (
+ SELECT 1 FROM "SalesExpertProfile" s WHERE s."adminUserId" = a."id"
+ );
+
+-- ───────────────────────────────────────────────────────────────────────────
+-- 2. AdminUser.firstName / lastName (D0 requirement 7)
+-- ───────────────────────────────────────────────────────────────────────────
+
+ALTER TABLE "AdminUser" ADD COLUMN IF NOT EXISTS "firstName" TEXT;
+ALTER TABLE "AdminUser" ADD COLUMN IF NOT EXISTS "lastName" TEXT;
+
+-- Backfill: first whitespace-separated token -> firstName, the REST ->
+-- lastName. Identical rule to the Customer migration, so both tables split the
+-- same way. Rows with more than two tokens are a guess and are listed by
+-- scripts/report-name-split.cjs for a human to correct.
+--
+-- `WHERE "firstName" IS NULL` makes the replay a no-op and, just as important,
+-- prevents a re-run from overwriting a name a human has since corrected.
+UPDATE "AdminUser"
+SET
+ "firstName" = split_part(btrim(regexp_replace("fullName", '\s+', ' ', 'g')), ' ', 1),
+ "lastName" = NULLIF(
+ substr(
+ btrim(regexp_replace("fullName", '\s+', ' ', 'g')),
+ length(split_part(btrim(regexp_replace("fullName", '\s+', ' ', 'g')), ' ', 1)) + 2
+ ),
+ ''
+ )
+WHERE "firstName" IS NULL
+ AND "fullName" IS NOT NULL
+ AND btrim("fullName") <> '';
+
+-- Same two-way sync as Customer.
+--
+-- Precedence when BOTH the parts and fullName change in ONE statement:
+-- THE PARTS WIN — fullName is rebuilt from them and the submitted fullName is
+-- discarded. The parts are what the forms edit, so they carry the operator's
+-- intent; fullName is the derived, legacy-facing value. The first branch tests
+-- the parts, so it wins by construction.
+CREATE OR REPLACE FUNCTION admin_user_sync_full_name() RETURNS trigger AS $$
+BEGIN
+ IF (TG_OP = 'INSERT' AND (NEW."firstName" IS NOT NULL OR NEW."lastName" IS NOT NULL))
+ OR (TG_OP = 'UPDATE'
+ AND (NEW."firstName" IS DISTINCT FROM OLD."firstName"
+ OR NEW."lastName" IS DISTINCT FROM OLD."lastName")) THEN
+ NEW."fullName" := btrim(
+ coalesce(NEW."firstName", '') || ' ' || coalesce(NEW."lastName", '')
+ );
+
+ ELSIF TG_OP = 'UPDATE' AND NEW."fullName" IS DISTINCT FROM OLD."fullName"
+ AND NEW."fullName" IS NOT NULL AND btrim(NEW."fullName") <> '' THEN
+ NEW."firstName" := split_part(btrim(regexp_replace(NEW."fullName", '\s+', ' ', 'g')), ' ', 1);
+ NEW."lastName" := NULLIF(
+ substr(
+ btrim(regexp_replace(NEW."fullName", '\s+', ' ', 'g')),
+ length(split_part(btrim(regexp_replace(NEW."fullName", '\s+', ' ', 'g')), ' ', 1)) + 2
+ ),
+ ''
+ );
+ END IF;
+
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+DROP TRIGGER IF EXISTS admin_user_sync_full_name_trg ON "AdminUser";
+CREATE TRIGGER admin_user_sync_full_name_trg
+ BEFORE INSERT OR UPDATE ON "AdminUser"
+ FOR EACH ROW EXECUTE FUNCTION admin_user_sync_full_name();
+
+-- Name search and sort hit both parts.
+CREATE INDEX IF NOT EXISTS "AdminUser_firstName_idx" ON "AdminUser" ("firstName");
+CREATE INDEX IF NOT EXISTS "AdminUser_lastName_idx" ON "AdminUser" ("lastName");
diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma
index 1138bc6..82a0f25 100644
--- a/apps/api/prisma/schema.prisma
+++ b/apps/api/prisma/schema.prisma
@@ -123,11 +123,28 @@ model City {
addresses CustomerAddress[]
branches Branch[]
preOrders PreOrder[]
+ salesExpertProfiles SalesExpertProfile[]
}
model AdminUser {
- id String @id @default(cuid())
- fullName String
+ id String @id @default(cuid())
+
+ /// ⚠️ Kept in sync BY A DATABASE TRIGGER — `admin_user_sync_full_name`, from
+ /// migration 20260922180000. Prisma cannot see it, so a write here may store
+ /// something other than what you passed:
+ ///
+ /// * write the parts -> fullName is rebuilt as "first last"
+ /// * write fullName only -> the parts are re-derived from it
+ /// * write BOTH in one update -> THE PARTS WIN; the submitted fullName is
+ /// discarded
+ ///
+ /// Re-read after writing if you need the stored value. Same arrangement as
+ /// Customer (D0 requirement 7 — the split applies to ALL admins, not only
+ /// sales experts).
+ fullName String
+ firstName String?
+ lastName String?
+
mobile String @unique
passwordHash String
role AdminRole
@@ -164,9 +181,56 @@ model AdminUser {
sentNotifications Notification[] @relation("NotificationAuthor")
uploadedFiles UploadedFile[] @relation("FileUploader")
+ /// Only ever set for role = SALES_EXPERT (D17).
+ salesExpertProfile SalesExpertProfile?
+
@@index([role])
@@index([userTypeId])
@@index([avatarFileId])
+ @@index([firstName])
+ @@index([lastName])
+}
+
+/// The sales-expert fields legacy kept on the `customers` row (type=1) and that
+/// were lost when a sales expert became an AdminUser (D17).
+///
+/// A separate table rather than columns on AdminUser: these are meaningful only
+/// for role = SALES_EXPERT and would be permanently null for every other admin.
+/// A row is created for every SALES_EXPERT by the migration, and upserted by the
+/// service whenever one is created or a user's role becomes SALES_EXPERT — but
+/// readers still left-join, so a missing profile degrades to nulls rather than
+/// dropping the agent from the grid.
+///
+/// `reagent_id` is deliberately NOT here — see D17: in the reference it was a
+/// side effect of the shared customers form, and filling it removed that expert
+/// from the selectable-agent list (queryReagent, AdminController.php:420).
+model SalesExpertProfile {
+ id String @id @default(cuid())
+ adminUserId String @unique
+ adminUser AdminUser @relation(fields: [adminUserId], references: [id], onDelete: Cascade)
+
+ /// Legacy customers.city_id — a filterable grid column
+ /// (ReagentListModel.js:14) and the source of the commission autofill
+ /// (ReagentCustomer.vue:215-228).
+ cityId String?
+ city City? @relation(fields: [cityId], references: [id])
+
+ /// Legacy customers.gram_level — the «سطح اعتبار» column, filterable.
+ /// Decimal(14,4) matches Customer.goldCreditLimitGrams exactly so the same
+ /// credit limit has identical precision on both tables.
+ gramLevel Decimal @default(0) @db.Decimal(14, 4)
+
+ /// Legacy customers.phone (varchar 15) and ncode (varchar 20). The reference
+ /// stores both and reads neither back; kept so the form matches it. Neither
+ /// is unique and neither has a validation rule in the reference — inventing
+ /// one would reject data the reference accepts.
+ phone String? @db.VarChar(15)
+ nationalCode String? @db.VarChar(20)
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @default(now()) @updatedAt
+
+ @@index([cityId])
}
model Customer {
diff --git a/apps/api/scripts/test-sales-expert-roundtrip.cjs b/apps/api/scripts/test-sales-expert-roundtrip.cjs
new file mode 100644
index 0000000..13d4f2d
--- /dev/null
+++ b/apps/api/scripts/test-sales-expert-roundtrip.cjs
@@ -0,0 +1,169 @@
+/**
+ * D12 rule E0 round-trip test for the SALES EXPERT entity, plus the two grid
+ * filters D17 required.
+ *
+ * All Persian input is written here as literals and sent as UTF-8 JSON, never
+ * through a shell argument (D15).
+ */
+const { PrismaClient } = require('@prisma/client');
+const prisma = new PrismaClient();
+const API = 'http://localhost:3077/api';
+
+let pass = 0, fail = 0;
+const chk = (c, m) => { c ? pass++ : fail++; console.log((c ? ' PASS ' : ' FAIL ') + m); };
+
+const FIRST = 'محمد علی';
+const LAST = 'احمدی';
+
+async function login() {
+ const r = await fetch(API + '/admin/auth/login', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ phone: '09128081392', password: '09128081392' }),
+ });
+ return (await r.json()).api_token;
+}
+
+const snap = async (id) => {
+ const u = await prisma.adminUser.findUnique({
+ where: { id }, include: { salesExpertProfile: true },
+ });
+ const { updatedAt, ...profile } = u.salesExpertProfile ?? {};
+ return JSON.stringify({
+ fullName: u.fullName, firstName: u.firstName, lastName: u.lastName,
+ mobile: u.mobile, role: u.role, wagePercent: String(u.wagePercent),
+ address: u.address, storeName: u.storeName,
+ profile: { ...profile, gramLevel: String(profile.gramLevel) },
+ });
+};
+
+(async () => {
+ const token = await login();
+ const H = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token };
+ const city = await prisma.city.findFirst({ select: { id: true, name: true } });
+ const city2 = await prisma.city.findFirst({ where: { id: { not: city.id } }, select: { id: true, name: true } });
+ const mobile = '0912' + String(Date.now()).slice(-7);
+
+ console.log('STEP 1 — create a sales expert with EVERY field populated');
+ const createBody = {
+ fname: FIRST, lname: LAST, mobile, password: 'Test12345',
+ role: 'SALES_EXPERT', percent: 4.5,
+ city_id: city.id, gram_level: 250.5, phone: '02188887777', ncode: '1234567890',
+ address: 'تهران، خیابان ولیعصر', store_name: 'طلای محمد', pasazh_name: 'پاساژ بزرگ', mcode: 'SE-001',
+ };
+ const cr = await fetch(API + '/admin/users', { method: 'POST', headers: H, body: JSON.stringify(createBody) });
+ const created = await cr.json();
+ if (!created.id) { console.log(' create failed:', JSON.stringify(created).slice(0, 300)); process.exit(1); }
+ const id = created.id;
+ console.log(' id:', id);
+
+ const row = await prisma.adminUser.findUnique({ where: { id }, include: { salesExpertProfile: true } });
+ chk(row.firstName === FIRST, `firstName stored as «${FIRST}» — a two-token first name is NOT re-split`);
+ chk(row.lastName === LAST, `lastName stored as «${LAST}»`);
+ chk(row.fullName === `${FIRST} ${LAST}`, 'fullName rebuilt by the trigger');
+ chk(!!row.salesExpertProfile, 'SalesExpertProfile auto-created on create');
+ chk(row.salesExpertProfile?.cityId === city.id, 'profile.cityId stored');
+ chk(String(row.salesExpertProfile?.gramLevel) === '250.5', 'profile.gramLevel stored (250.5)');
+ chk(row.salesExpertProfile?.phone === '02188887777', 'profile.phone stored');
+ chk(row.salesExpertProfile?.nationalCode === '1234567890', 'profile.nationalCode stored');
+
+ console.log('\nSTEP 2 — open the edit form: every field populated, separately');
+ const g = await (await fetch(API + `/admin/users/${id}`, { headers: H })).json();
+ chk(g.fname === FIRST, `fname === «${FIRST}» (NOT the whole name)`);
+ chk(g.lname === LAST, `lname === «${LAST}» (NOT empty)`);
+ chk(Number(g.percent) === 4.5, 'percent === 4.5');
+
+ const listed = await (await fetch(API + '/admin/customers-omde/reagent/list', {
+ method: 'POST', headers: H, body: JSON.stringify({ page: 1, limit: 200 }),
+ })).json();
+ const mine = (listed.rows ?? []).find((x) => x.id === id);
+ chk(!!mine, 'the new expert appears in the reagent grid');
+ chk(mine?.fname === FIRST && mine?.lname === LAST, 'grid shows the real name parts');
+ chk(mine?.city_id === city.id, 'grid returns a REAL city_id (was hardcoded null)');
+ chk(mine?.city?.title === city.name, 'grid returns the city object for the column');
+ chk(String(mine?.gram_level) === '250.5', 'grid returns a REAL gram_level (was hardcoded null)');
+ chk(mine?.phone === '02188887777', 'grid returns phone');
+ chk(mine?.ncode === '1234567890', 'grid returns ncode');
+
+ console.log('\nSTEP 3 — save with NO changes: the row must be byte-identical');
+ const before = await snap(id);
+ await fetch(API + `/admin/users/${id}`, {
+ method: 'PUT', headers: H,
+ body: JSON.stringify({
+ fname: g.fname, lname: g.lname, mobile: g.mobile, role: 'SALES_EXPERT',
+ percent: Number(g.percent), city_id: mine.city_id, gram_level: Number(mine.gram_level),
+ phone: mine.phone, ncode: mine.ncode, address: g.address,
+ store_name: g.store_name, pasazh_name: g.pasazh_name, mcode: g.mcode,
+ }),
+ });
+ const after = await snap(id);
+ chk(before === after, 'no-op save changed nothing');
+ if (before !== after) console.log(' before: ' + before + '\n after : ' + after);
+
+ console.log('\nSTEP 4a — change ONLY lastName');
+ await fetch(API + `/admin/users/${id}`, {
+ method: 'PUT', headers: H, body: JSON.stringify({ lname: 'محمدی' }),
+ });
+ const s4 = JSON.parse(await snap(id));
+ const b4 = JSON.parse(after);
+ chk(s4.lastName === 'محمدی' && s4.firstName === FIRST, `only lastName changed; firstName still «${FIRST}»`);
+ chk(s4.fullName === `${FIRST} محمدی`, 'fullName rebuilt by the trigger');
+ chk(JSON.stringify(s4.profile) === JSON.stringify(b4.profile), 'the profile was untouched');
+
+ console.log('\nSTEP 4b — change ONLY gramLevel');
+ const b5 = JSON.parse(await snap(id));
+ await fetch(API + `/admin/users/${id}`, {
+ method: 'PUT', headers: H, body: JSON.stringify({ gram_level: 999.25 }),
+ });
+ const s5 = JSON.parse(await snap(id));
+ const diff = Object.keys(s5.profile).filter((k) => String(s5.profile[k]) !== String(b5.profile[k]));
+ chk(diff.length === 1 && diff[0] === 'gramLevel', 'only gramLevel changed: ' + diff.join(','));
+ chk(s5.firstName === b5.firstName && s5.lastName === b5.lastName, 'the name was untouched');
+
+ console.log('\nSTEP 5 — the two grid FILTERS');
+ const byCity = await (await fetch(API + '/admin/customers-omde/reagent/list', {
+ method: 'POST', headers: H,
+ body: JSON.stringify({ page: 1, limit: 200, filters: [{ field: 'city_id', op: '=', value: city.id }] }),
+ })).json();
+ chk((byCity.rows ?? []).some((x) => x.id === id), `city filter (${city.name}) returns the expert`);
+ chk((byCity.rows ?? []).every((x) => x.city_id === city.id), 'city filter returns ONLY that city');
+
+ if (city2) {
+ const otherCity = await (await fetch(API + '/admin/customers-omde/reagent/list', {
+ method: 'POST', headers: H,
+ body: JSON.stringify({ page: 1, limit: 200, filters: [{ field: 'city_id', op: '=', value: city2.id }] }),
+ })).json();
+ chk(!(otherCity.rows ?? []).some((x) => x.id === id), `filtering by a DIFFERENT city (${city2.name}) excludes it`);
+ }
+
+ const byGram = await (await fetch(API + '/admin/customers-omde/reagent/list', {
+ method: 'POST', headers: H,
+ body: JSON.stringify({ page: 1, limit: 200, filters: [{ field: 'gram_level', op: '=', value: 999.25 }] }),
+ })).json();
+ chk((byGram.rows ?? []).some((x) => x.id === id), 'gram_level filter returns the expert');
+ chk((byGram.rows ?? []).every((x) => String(x.gram_level) === '999.25'), 'gram_level filter returns ONLY matches');
+
+ console.log('\nSTEP 6 — an expert with NO profile still lists (left join)');
+ const orphan = await prisma.adminUser.create({
+ data: { fullName: 'بدون پروفایل', firstName: 'بدون', lastName: 'پروفایل',
+ mobile: '0913' + String(Date.now()).slice(-7), passwordHash: 'x', role: 'SALES_EXPERT' },
+ });
+ const l2 = await (await fetch(API + '/admin/customers-omde/reagent/list', {
+ method: 'POST', headers: H, body: JSON.stringify({ page: 1, limit: 200 }),
+ })).json();
+ const o = (l2.rows ?? []).find((x) => x.id === orphan.id);
+ chk(!!o, 'an expert without a profile still appears in the grid');
+ chk(o?.city_id === null && o?.gram_level === null, 'its city/gram cells are null, not an error');
+
+ console.log('\nSTEP 7 — the referrer dropdown lists every active expert');
+ const opts = await (await fetch(API + '/admin/customers-omde/query/reagent', { headers: H })).json();
+ chk(opts.some((x) => x.id === id), 'the new expert is selectable as a referrer');
+ chk(opts.some((x) => x.id === orphan.id), 'the profile-less expert is selectable too');
+
+ await prisma.salesExpertProfile.deleteMany({ where: { adminUserId: { in: [id, orphan.id] } } });
+ await prisma.adminUser.deleteMany({ where: { id: { in: [id, orphan.id] } } });
+ console.log(`\nCleanup: deleted 2 test AdminUser rows (${id}, ${orphan.id}) and their profiles.`);
+
+ console.log(`\npassed: ${pass} failed: ${fail}`);
+ await prisma.$disconnect();
+ process.exit(fail ? 1 : 0);
+})().catch(async (e) => { console.error(e); await prisma.$disconnect(); process.exit(1); });
diff --git a/apps/api/src/modules/admin-comments/admin-comments.service.ts b/apps/api/src/modules/admin-comments/admin-comments.service.ts
index b2e289d..c141355 100644
--- a/apps/api/src/modules/admin-comments/admin-comments.service.ts
+++ b/apps/api/src/modules/admin-comments/admin-comments.service.ts
@@ -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,
diff --git a/apps/api/src/modules/admin-customers/admin-customers.service.ts b/apps/api/src/modules/admin-customers/admin-customers.service.ts
index 710ff5d..6714053 100644
--- a/apps/api/src/modules/admin-customers/admin-customers.service.ts
+++ b/apps/api/src/modules/admin-customers/admin-customers.service.ts
@@ -322,10 +322,16 @@ export class AdminCustomersService {
const aliases: Record = {
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,
};
}),
);
diff --git a/apps/api/src/modules/admin-documents/admin-documents.service.ts b/apps/api/src/modules/admin-documents/admin-documents.service.ts
index 1f08ed3..2bd7911 100644
--- a/apps/api/src/modules/admin-documents/admin-documents.service.ts
+++ b/apps/api/src/modules/admin-documents/admin-documents.service.ts
@@ -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,
+ }));
}
}
diff --git a/apps/api/src/modules/admin-orders/admin-orders.service.ts b/apps/api/src/modules/admin-orders/admin-orders.service.ts
index 9c55524..da4d427 100644
--- a/apps/api/src/modules/admin-orders/admin-orders.service.ts
+++ b/apps/api/src/modules/admin-orders/admin-orders.service.ts
@@ -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,
},
diff --git a/apps/api/src/modules/admin-preorders/admin-preorders.service.ts b/apps/api/src/modules/admin-preorders/admin-preorders.service.ts
index e0229b2..d9e6b64 100644
--- a/apps/api/src/modules/admin-preorders/admin-preorders.service.ts
+++ b/apps/api/src/modules/admin-preorders/admin-preorders.service.ts
@@ -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,
diff --git a/apps/api/src/modules/admin-users/admin-users.service.ts b/apps/api/src/modules/admin-users/admin-users.service.ts
index abaca8d..3f724d1 100644
--- a/apps/api/src/modules/admin-users/admin-users.service.ts
+++ b/apps/api/src/modules/admin-users/admin-users.service.ts
@@ -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) {
- 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) {
+ 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) {
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 } },
+ },
});
}
diff --git a/docs/parity/CHANGELOG.md b/docs/parity/CHANGELOG.md
index f7d8848..a52c267 100644
--- a/docs/parity/CHANGELOG.md
+++ b/docs/parity/CHANGELOG.md
@@ -446,3 +446,97 @@ the old process. Kill the listener (`netstat -ano | grep :3077`) before
concluding anything from a log. `smsConfigured()` returns false with no
credentials, so the dev log fires exactly as intended — confirmed once the port
was free.
+
+---
+
+## 2026-09-22 (evening) — D17: SalesExpertProfile + AdminUser name parts
+
+### Migration `20260922180000_sales_expert_profile_and_admin_name_parts`
+
+Shown, dry-run and approved before running. Applied with `migrate deploy`
+(D14); drift afterwards is **zero**.
+
+- **`SalesExpertProfile`** — `adminUserId` (unique FK, cascade), `cityId`
+ (FK → City, SET NULL), `gramLevel`, `phone`, `nationalCode`.
+- **`AdminUser.firstName` / `lastName`** — additive, backfilled first-token/rest,
+ with the `admin_user_sync_full_name` trigger (**parts win** when both change).
+ Applies to **all** admins, closing D0 requirement 7.
+- **Profile backfill** — `INSERT … WHERE NOT EXISTS` gave all 3 existing sales
+ experts a profile; the id is derived from the admin id, so a replay cannot
+ duplicate.
+
+Owner's two corrections, both applied:
+
+1. **Profiles for existing experts** + an `upsert` in the service on create and
+ on a role change to `SALES_EXPERT`, and `listReagents` **left-joins** so an
+ expert without a profile still lists (verified by test).
+2. **`gramLevel` type matched** to `Customer.goldCreditLimitGrams` —
+ `DECIMAL(14,4)`, asserted identical in the dry-run.
+
+**Validation research (as instructed).** The reference is deliberately lax:
+`ncode` is `string(20)` nullable and **NOT unique**
+(`2020_10_01_120430_customers_table.php:22`), `phone` is `string(15)` nullable
+(`2020_10_25_155247_customers_cols.php:17`). Neither has a server rule —
+`createCustomer` validates only `mobile` (required|unique) and `percent`
+(required) (`AdminController.php:354`) — and both are `required: false` on the
+client (`ReagentCustomer.vue:31,95`). Copied exactly; **no uniqueness invented**,
+since that would reject data the reference accepts.
+
+### Dry-run before applying
+
+Ran the whole file inside a transaction, **twice**, then rolled back: 14
+statements, table created, 3 profiles inserted, backfill correct, replay
+produced no duplicates, and the rollback left the database untouched (verified
+by re-querying `information_schema`).
+
+### API
+
+- `listReagents` returns **real** `city_id`, `city`, `gram_level`, `phone`,
+ `ncode`; the grid's «شهر» and «سطح اعتبار» filters map through the relation.
+ `remain` / `rial_remain` stay null — a sales expert is an AdminUser and has no
+ ledger of its own (D10).
+- `findOne` includes the profile so the edit form populates.
+- `create` stores name parts and creates the profile for a sales expert;
+ `update` writes the parts and upserts the profile.
+- **Every remaining `fname: fullName, lname: ''` was fixed** — 6 more sites in
+ `admin-users`, `admin-comments`, `admin-documents` (×2), `admin-orders`,
+ `admin-preorders`.
+
+### Admin panel
+
+`ReagentCustomer.vue` gains «شهر», «سقف اعتبار», «کد ملی», «تلفن», and the
+**city → commission autofill** ported verbatim from
+`henza_admin/src/views/apps/customer/ReagentCustomer.vue:215-228` — including
+the reference's falsy test, so a city percent of 0 also falls through to
+`cities/get-percent-aux/{id}` (replicated, not "fixed" — D4). `name` is now sent
+only when a name part changed, so diff-send is not defeated.
+
+### Tests
+
+| Suite | Result |
+|---|---|
+| `scripts/test-sales-expert-roundtrip.cjs` (new) | **33/33** |
+| `npx jest` | 32/32 |
+| `scripts/auth-smoke.sh` | 53/53 |
+| `scripts/test-name-trigger.cjs` | 9/9 |
+| Typecheck | api ✅ · admin-panel ✅ |
+
+The round trip covers create-with-every-field → edit form populated separately →
+byte-identical no-op save → single-field edits, with **«محمد علی» + «احمدی»**
+surviving intact, plus both grid filters (positive **and** negative), the
+left-join case, and the referrer dropdown.
+
+**A test bug worth recording:** the filter assertions first failed because the
+test sent `op: 'eq'`. The grid actually sends `op: '='`
+(`XTable.vue:197-200`), and `toCondition` returns null for an unknown operator,
+so the filter was silently dropped and the "returns ONLY matches" assertions
+passed vacuously against an empty list. The product was correct; the test was
+not. **A filter test needs a negative case, or a dropped filter looks like a
+pass.**
+
+### Data deletions (standing rule 2)
+
+| What | How many | Why |
+|---|---|---|
+| `AdminUser` rows created by the round-trip test (one full expert, one profile-less) + their `SalesExpertProfile` rows | 2 per run | Test fixtures, removed by the test itself. |
+| 1 temporary `Customer` row per `test-name-trigger.cjs` run | 1 per run | Test fixture. |
diff --git a/docs/parity/DECISIONS.md b/docs/parity/DECISIONS.md
index 77847d6..677bdf6 100644
--- a/docs/parity/DECISIONS.md
+++ b/docs/parity/DECISIONS.md
@@ -958,3 +958,110 @@ fixing any section, **show the owner a short summary of the gaps found** and wai
This supersedes the "audit everything, then fix everything" reading of D12: the
field-by-field requirement is unchanged, but it is applied **per section**, with
a checkpoint before each one.
+
+---
+
+# D17 — Sales expert: `SalesExpertProfile`, and `reagent_id` deliberately not ported (2026-09-22)
+
+## `reagent_id` — deliberately NOT replicated
+
+**Owner's ruling, verbatim:**
+
+> «`reagent_id` در فرم کارشناس مرجع، اثر جانبی فرم مشترک `customers` بوده و پر
+> کردنش کارشناس را از لیست قابلانتخاب حذف میکرد (`queryReagent:420`). عمداً
+> پورت نشد. منطق معرف روی `Customer.referredByAgentId` کامل پیاده شده است.»
+
+### The evidence behind it
+
+`reagent_id` carries real logic in the reference — but **every single use reads a
+CUSTOMER's `reagent_id`** (which agent introduced them), never a sales expert's
+own:
+
+| Use | Reference | What it does |
+|---|---|---|
+| Scope "my customers" | `AdminController.php:271` | `->where('reagent_id', $auth->id)` |
+| Set on customer create | `AdminController.php:326` | records the introducing agent |
+| Commission / gram-sold rollup | `MajorOrderController.php:267-269,316-318` | `updateGramSell($customer->reagent_id)` |
+| SMS to the customer's agent | `ApiMobileController.php:727,750` | notify the agent |
+| Analytics rollup | `AdminController.php:550-552` | `whereNull('reagent_id')` roots + their children |
+
+A `grep` for any code reading `reagent_id` on a `type=1` row returns **nothing**.
+
+**The decisive line is `queryReagent` (`AdminController.php:415-424`):**
+
+```php
+Customer::select(['id', 'name as title'])->whereNull('reagent_id')
+```
+
+Only rows **without** a `reagent_id` may be chosen as an agent. So filling the
+`reagent_id` field on a sales expert's own form (`ReagentCustomer.vue:139`)
+**removes that expert from the selectable-agent list** — an unwanted side effect
+of the shared `customers` form, not a feature. The same `whereNull` filter shapes
+the analytics rollup.
+
+**Therefore:** the field is not added to `SalesExpertProfile`. Adding it would
+either do nothing or reproduce that defect. No new hierarchy logic is invented.
+
+Matrix annotation: **«عمداً تکرار نشد — D17»**.
+
+### Confirmed: no agent can be dropped from the referrer dropdown
+
+The owner asked for explicit confirmation. The target's agent list is
+`queryReagents()` (`apps/api/src/modules/admin-customers/admin-customers.service.ts:779-786`):
+
+```ts
+this.prisma.adminUser.findMany({
+ where: { role: 'SALES_EXPERT', isActive: true },
+ orderBy: { fullName: 'asc' },
+ select: { id: true, fullName: true },
+})
+```
+
+It filters on **role and active only** — there is no `whereNull` equivalent, so
+**every active sales expert appears and none can be accidentally excluded.**
+This is strictly better than the reference, whose `whereNull('reagent_id')`
+silently hid any agent whose own `reagent_id` had been filled in.
+
+Wiring: `admin-customers.controller.ts:82` → `GET admin/customers-omde/query/reagent`,
+mapped at `apps/admin-panel/src/api/endpoints.ts:77`, consumed by
+`bulkCustomer.vue:436`.
+
+## `SalesExpertProfile` — approved shape
+
+A separate table rather than columns on `AdminUser`, because these fields are
+meaningful only for `role = SALES_EXPERT` and would be permanently null for every
+other admin.
+
+| Column | Type | Reference origin | Why |
+|---|---|---|---|
+| `adminUserId` | unique FK → `AdminUser` | — | one profile per expert |
+| `cityId` | FK → `City`, nullable | `ReagentCustomer.vue:111` | a filterable grid column (`ReagentListModel.js:14`) **and** it drives the commission autofill |
+| `gramLevel` | `Decimal(14,4)`, default 0 | `ReagentCustomer.vue:158` | the «سطح اعتبار» column, filterable (`ReagentListModel.js:17`) |
+| `phone` | text, nullable | `ReagentCustomer.vue:95` | stored in the reference; no reader — added on the owner's instruction |
+| `nationalCode` | text, nullable | `ReagentCustomer.vue:31` | same |
+
+`phone` and `nationalCode` keep the reference's validation exactly:
+`required: false`, message `لطفا مقدار را وارد کنید`.
+
+## City-driven commission autofill
+
+Ported verbatim from `henza_admin/src/views/apps/customer/ReagentCustomer.vue:215-228`:
+
+1. On city selection, look for `percent` on the already-loaded city row.
+2. If it is absent or falsy, `GET cities/get-percent-aux/{id}` and use the response.
+3. Write the result into the form's `percent` field.
+
+Backend: `AdminController.php` behind `admin.php:225-226`
+(`cities/get-percent/{id}` and `cities/get-percent-aux/{id}`), reading
+`cities.percent` / `cities.percent_aux` — both already present on the target's
+`City` model as `percent` / `percentAux` (`schema.prisma` `model City`).
+
+## `firstName` / `lastName` on `AdminUser`
+
+Closes D0 requirement 7. Same pattern as `Customer`, applied to **all**
+`AdminUser` rows, not only sales experts: additive columns, backfill
+(first token → `firstName`, the rest → `lastName`), a sync trigger where **the
+parts win** when both change in one statement, and a multi-token review report.
+
+Every `fname: fullName, lname: ''` site is corrected — starting with
+`listReagents` (`admin-customers.service.ts:375-376`).
diff --git a/docs/parity/PROGRESS.md b/docs/parity/PROGRESS.md
index 739bca4..4404fd9 100644
--- a/docs/parity/PROGRESS.md
+++ b/docs/parity/PROGRESS.md
@@ -10,7 +10,7 @@
| **Phase** | **Phase 1 — admin parity, matrix in progress** |
| **Branch** | `parity/phase-1-admin` (cut from `parity/security-hotfix`) |
| **Status** | Both stages done and verified live: **46/46** smoke assertions, **7/7** jest unit tests, typecheck + build clean on all three projects. |
-| **Next step** | Phase 1 (a) Persons — report where the reference uses the 5 sales-expert fields for `type=1`, then implement per D16 |
+| **Next step** | Phase 1(a) continued: `singleCustomer` diff-send, then the field-by-field audit of all Persons forms and grids |
| **Blocked on** | nothing |
| **Last updated** | 2026-09-22 |
@@ -51,7 +51,7 @@ while the id and the id-addressed routes remain.**
## ⚠️ Read `DECISIONS.md` before doing anything
-All user rulings live there (D0–D16). Highlights that change how work is done:
+All user rulings live there (D0–D17). Highlights that change how work is done:
- **D12 — the matrix is FIELD-BY-FIELD, not page-by-page**, and a 4-step
round-trip test per entity is mandatory. Both rules were added to the
@@ -106,6 +106,7 @@ All user rulings live there (D0–D16). Highlights that change how work is done:
| D14 | **`migrate dev` in development, `migrate deploy` in production and nowhere else.** Never `migrate reset`; never edit `_prisma_migrations`. On drift + a reset offer: **stop and report**. |
| D15 | **`_goldbank_migrations` is KEPT** — it is the tracking table of the app's own migration runner (`src/prisma/migrate.ts`), needed because the Docker image has no Prisma CLI. Declared `@@ignore`d; drift is zero. **Persian test input must come from a FILE, never a shell argument.** |
| D16 | **PRICE-01 STOPPED** — price display is retail-storefront (⏸️ D13); the B2B app works in making charge only. `variant-price.ts` + tests kept but unused. **Making charge stays in scope.** New work order: Persons → Products/variants/order-detail → the rest of the admin → gate → apps. **Per section: implement gaps, then field-by-field test, and show a gap summary BEFORE fixing.** |
+| D17 | **SalesExpertProfile** (cityId, gramLevel, phone, nationalCode) + **firstName/lastName on all AdminUsers**. **`reagent_id` deliberately NOT ported** — in the reference it was a side effect of the shared customers form and filling it removed the expert from the selectable-agent list (queryReagent:420); the referrer logic lives on `Customer.referredByAgentId`. |
## Open — awaiting user approval