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:
co-authored by
Claude Opus 5
parent
e3af31948e
commit
2194b3d4d9
@@ -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. |
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user