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
+94
View File
@@ -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. |