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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-09-22 17:50:47 +03:30
co-authored by Claude Opus 5
parent e3af31948e
commit 2194b3d4d9
13 changed files with 905 additions and 39 deletions
+107
View File
@@ -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`).