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