Files
tehran_taj/docs/parity/CHANGELOG.md
T
s.esmaeiliandClaude Opus 5 2194b3d4d9 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>
2026-09-22 17:50:47 +03:30

543 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CHANGELOG — parity changes
Every change made to `gold-banking-monorepo` during the parity project, with the
reason and the reference path that justifies it.
Format:
```
## <date> — <phase> — <group title>
- **What:** <the change>
- **Why:** <the parity gap it closes>
- **Reference:** <henza_* path:line>
- **Target:** <gold-banking-monorepo path:line>
- **Matrix row:** <id in 01-ADMIN-MATRIX.md / 02-APPS-MATRIX.md>
- **Commit:** <sha>
```
---
## 2026-09-21 — Phase 0 — Setup
- **What:** Created branch `parity/phase-0` and `docs/parity/` scaffolding
(`PROGRESS.md`, `ISSUES.md`, `CHANGELOG.md`).
- **Why:** Persistent state required by `CLAUDE.md` §6.
- **Reference:** n/a
- **Target:** `docs/parity/`
- **Matrix row:** n/a
_No source code changed in Phase 0 (read-only phase)._
---
## 2026-09-22 — Security hotfix, stage 1 — close the unauthenticated write paths
Branch `parity/security-hotfix`. Authorised by the user's rulings D7, T1–T3.
### 1. Ledger routes are admin-only (SEC-09)
- **What:** removed the controller-level `@Public()` from `AccountingController`
and gave each route a rid: `wallet.list` (balance), `wallet.charge`
(reconcile), `document.create` (both repayments), `document.list.confirm`
(confirm entry).
- **Why:** four of the five routes WRITE to the ledger and were reachable with no
token at all. Faithful to the reference, whose entire admin route file is
unauthenticated, but contrary to confirmed decision D1.
- **Reference:** `henza_server/routes/admin.php:12,272` (the `auth:api` wrapper is
commented out at both ends)
- **Target:** `apps/api/src/modules/accounting/accounting.controller.ts`
- **Breakage check:** `api.getBalance()` is the only app-side consumer and it has
**zero call sites** in either user app (dead code, already logged as DEAD-01/02).
### 2. Order status change is admin-only (SEC-10)
- **What:** moved `@Public()` from the class to the two customer-facing handlers,
leaving `PATCH orders/:id/status` behind `order.list.sts.submit`.
- **Why:** it let an anonymous caller move any order's status **and** attribute
the change to any admin through the DTO's `actorAdminUserId`.
- **Reference:** an admin operation; the admin panel posts to `admin/orders/status`
(`apps/admin-panel/src/api/endpoints.ts:42,48`), so this route had no caller.
- **Target:** `apps/api/src/modules/orders/orders.controller.ts`
- **Still public:** `POST /orders` and `GET /orders/:id`, because the target has
no customer session yet — closed in stage 2.
### 3. `GET /customers` no longer leaks the customer book (SEC-08)
- **What:** added `LOGIN_LOOKUP_SELECT` (`id`, `mobile`, `isActive`,
`isArchived`) and pointed `list()` at it. Removed `fullName`, `nationalCode`,
`goldDebtGrams`, `fiatDebtRial`, `goldCreditLimitGrams`, `verificationStep`,
`cityId`, `referredByAgentId`, `nationalIdImageUrl`, `businessLicenseUrl`,
`shopImageUrl`, `createdAt`, `updatedAt` from that response.
- **Why:** the route is unauthenticated because both apps match the phone number
in the browser, so it shipped every customer's national ID number, debts,
credit limit and identity-document URLs to any visitor.
- **Reference:** login was `POST userAuth` → SMS → `POST userVerfiy`, returning
exactly one customer after the code matched
(`henza_server/app/Http/Controllers/Api/ApiMobileController.php:39-78`).
- **Target:** `apps/api/src/modules/customers/customers.service.ts`,
plus `CustomerSummary` narrowed in **both** apps' `src/api/client.ts`
(parity rule: the same logic change in both).
- **Route survives** until stage 2 replaces the client-side phone match with OTP.
### 4. Test script
- **What:** added `apps/api/scripts/auth-smoke.sh` — asserts 401 without a token,
403 with a low-privilege token, and pass-through for a super admin, on every
closed route; plus that the public routes still answer 200 and that
`GET /customers` exposes only the four login fields.
- **Result:** **23 passed, 0 failed** against a live server and the dev seed.
### Not changed
`/uploads` — per ruling T2, uploaded files including identity documents stay
public. The filename check requested alongside it passed with no changes needed
(md5 of time + name + UUID; no directory listing).
---
## 2026-09-22 — Security hotfix, stage 2 — real customer sessions
Branch `parity/security-hotfix`. Authorised by D6, D7, T1 and T3.
Stage 1 narrowed `GET /customers` but, as the user pointed out, did not close
the leak: the response still carried every customer **id**, and
`/customers/:id`, `/:id/orders` and `/:id/ledger-entries` were still public — so
two requests still reconstructed the whole book. Stage 2 removes the id-bearing
surface entirely.
### 1. Customer JWT, separate from the admin one (T1)
- **New:** `src/modules/customer-auth/` — `CustomerJwtStrategy` (passport
strategy `'customer-jwt'`), `CustomerJwtGuard`, `@CustomerAuth()`,
`@CurrentCustomer()`.
- Signed with **`JWT_CUSTOMER_SECRET`**, separate from `JWT_SECRET`, and every
token carries **`typ: 'customer'`** which the strategy verifies. Either check
alone would be enough; both are present deliberately.
- Customer state is re-read from the DB on every request, so deactivating or
archiving a customer takes effect at once rather than at token expiry.
- **Reference:** the same split existed there — `auth:api` for admins,
`auth('api-customer')` for the app.
### 2. Real OTP (D6)
- **New table `CustomerOtp`** (migration `20260922000000_customer_otp`,
additive: one new table, nothing existing touched).
- `POST /api/customer/auth/request-otp` ← `ApiMobileController::userAuth` (:39-49)
- `POST /api/customer/auth/verify-otp` ← `ApiMobileController::userVerfiy` (:51-64)
- **Parity behaviour:** the 4-digit `rand(1000,9999)` range, the
active-customer filter, `Utility::faTOen()` digit normalisation on both the
number and the code, and rejection on mismatch.
- **Deliberate improvements over the reference** (user-approved, recorded in
DECISIONS.md so the matrix does not flag them later):
- the code is stored as a **bcrypt hash**, never plain text;
- it **expires** after 2 minutes (the reference's never did);
- **5 attempts** per challenge, then lockout (the reference had no limit);
- **single use** — a consumed code cannot be replayed;
- **send throttle**: 60s cooldown per number, 5 per number and 20 per IP per
10 minutes, counted off the table so it survives restarts;
- **no enumeration** — an unregistered number returns the same `{sent:true}`.
The reference's `firstOrFail()` leaked a 404 for unknown numbers.
- No SMS gateway exists in this project (STUB-02), so the code is logged in
non-production only and the message is persisted as an `SmsMessage` row. The
reference's message text is kept verbatim for when a gateway lands.
### 3. The id-bearing customer routes are gone
- **Deleted** `customers.controller.ts` — with it `GET /customers`,
`/customers/:id`, `/:id/orders`, `/:id/ledger-entries`.
- **Replaced by** `/api/customer/me`, `/me/orders`, `/me/ledger-entries`, which
take the id **from the token**. There is no longer any route that accepts a
customer id, so the IDOR is closed structurally rather than by a check.
- `CustomersService` stays and is reached only through those routes.
- The admin panel was unaffected — it uses `admin/customers-omde/*`.
### 4. Both apps migrated together (parity rule)
- `stores/session.ts`: `token` is now the credential and `isLoggedIn` is
token-based. `customerId` is kept for display only and is no longer an
identity claim.
- `api/client.ts`: sends `Authorization: Bearer`, throws `UnauthorizedError` on
401 and calls the handler registered in `main.ts`, which clears the session
and routes to login so stale personal data cannot stay on screen.
- `LoginView.vue`: real two-step OTP. Each app keeps its own markup and styling;
only the logic changed, and it is identical in both.
- The four account screens now call `getMe()` / `getMyOrders()` /
`getMyLedgerEntries()`; router guards test `isLoggedIn`.
### Tests
- `apps/api/scripts/auth-smoke.sh` extended with the stage-2 assertions:
**46 passed, 0 failed** against a live server — old routes 404, customer
routes 401 without a token, wrong/reused code 401, correct code 200, throttle
429, no enumeration, customer token rejected on admin routes and admin token
rejected on customer routes, no id-bearing surface, and `/customer/me`
returning the token's own customer.
- **jest installed** (dev-only, user-approved) with `jest.config.cjs`, so
`pnpm test` works for the first time: **7 passed** covering `faToEn`.
`faToEn` moved to its own `digits.ts` so it can be tested without booting Nest.
- Typecheck clean on api, user-app and user-app-v2; both apps build.
---
## 2026-09-22 — Security hotfix, stage 3 — SEC-02 (orders) + OTP audit
Branch `parity/security-hotfix`. The user declined to defer SEC-02 to Phase 2:
a public `POST /orders` taking `customerId` from the body let anyone place an
order on any customer's account, and an order lands on that customer's **gold
debt and ledger**. That is a money hole, so it is closed here.
### 1. `POST /orders` is customer-authenticated
- **What:** the route is behind `@CustomerAuth()`. `OrdersService.createOrder`
now takes `customerId` as a **separate argument**, supplied by the controller
from the token. The body's `customerId` is ignored entirely; a mismatch is
logged (`OrdersService` warn) and the token always wins.
- `CreateOrderDto.customerId` and the shared `CreateOrderDto` are **optional**,
so both apps keep validating while they still send it. Removed in Phase 2.
- **Reference:** `ApiMobileController::setOrder` read `auth()->id()`
(henza_server:533) behind the `api-customer` guard — this restores that.
- **Admin path unaffected:** `AdminOrdersService` places orders *for* a selected
customer and passes that id explicitly; its route is admin-guarded.
### 2. `GET /orders/:id` is scoped to the owner
- `getOwnedById(orderId, customerId)` matches on both, and a miss is **404, not
403** — a 403 would confirm the id exists, the same enumeration leak the old
`/customers/:id` routes had. A nonexistent id and someone else's id are
indistinguishable.
- Operators use the new `GET /orders/admin/:id` behind rid `order.list`.
Declared **before** `:id` because Nest matches in declaration order.
### 3. Both apps
- `CartView.vue` in **both** apps now gates on `session.isLoggedIn` (the token)
rather than on a remembered `customerId`, which is no longer a credential.
### 4. OTP audit (user request 3)
- **No master code, no bypass, no hardcoded code** anywhere in the API or either
app — verified by grep for `master.?code|bypass|backdoor|1111|0000|1234|skipOtp|DEV_OTP|TEST_OTP`.
Exactly one code path issues a token, and only after `bcrypt.compare`.
- **SMS provider: none.** No gateway is wired up (STUB-02). In development the
code is logged once via `Logger`, guarded by `NODE_ENV !== 'production'`.
- **Fixed while auditing:** `deliver()` persisted the real message text — with
the code in it — into `SmsMessage.body`, which the admin SMS grid both lists
and full-text searches (`admin-misc.service.ts:135-136`). Any operator with
the `sms` rid could read live OTP codes, defeating the point of hashing
`codeHash`. The persisted row is now a redacted audit line; the real text is
built for the future gateway and not stored. The 9 test rows created during
this session were deleted.
### Tests
`apps/api/scripts/auth-smoke.sh` extended again — **53 passed, 0 failed**:
order routes 401 without a token, a spoofed body `customerId` ignored in favour
of the token, own order readable, unknown/foreign order id 404, and the admin
order route rejecting a customer token while admitting an admin one.
Typecheck clean on api, user-app, user-app-v2 and shared-types.
---
## 2026-09-22 — migrate resolve, D13 scope, and the D0/percent migration set
### Standing rule adopted (owner, 2026-09-22)
1. **Migrations run only through the standard Prisma workflow** — `migrate dev`
in development. **`prisma db execute` is no longer used to apply schema
changes.** (It was used once, in the security hotfix, to create the
`CustomerOtp` table; that is what the resolve below repairs.)
2. **Every data deletion is recorded here** — including throwaway rows this
session creates itself.
### Data deletions (rule 2, applied retroactively to this session)
| When | What | How many | Why |
|---|---|---|---|
| Security hotfix stage 3 | `SmsMessage` rows written by the OTP flow during testing, whose `body` still held the live code before the SEC-11 fix | 9 | They contained real OTP codes in a grid any `sms`-rid operator can read. Test data only; no customer-facing message was ever sent (no gateway is wired). |
No other data has been deleted in this project.
### Ledger repair — approved and executed
```
npx prisma migrate resolve --applied 20260922000000_customer_otp
→ Migration 20260922000000_customer_otp marked as applied.
npx prisma migrate status
→ 9 migrations found. Database schema is up to date!
```
The `CustomerOtp` table already existed (created with `db execute` in stage 2);
this wrote the missing `_prisma_migrations` row **without running any SQL**, so
`migrate deploy` will no longer try to re-create the table. No schema change.
### Documentation
- **D13 recorded** — the retail storefront (wallet, cashback,
`ConfigStatic['percent']`) is preserved but out of scope. `DECISIONS.md` §D13,
`PROTECTED.md` §H, and status `⏸️` plus RULE C1 added to the audit skill.
- **Endpoint count corrected: 412, not 369.** `routes/admin.php` has 151 live
explicit routes, not the 117 the Phase 0 inventory recorded (DOC-01). Of the
412, **109 are retail storefront**, 15 are `web.php` dev/ops, and **288 stay
in scope** (218 admin + 70 B2B app).
- **SMS provider report** — the reference uses the Trez panel
(`smspanel.trez.ir`, `Sms.php:69,97`) with **credentials committed to git**
(SEC-12, must be rotated). All 12 message templates catalogued, env var names
proposed. **Nothing implemented; awaiting approval.**
- **MON-08 answered** — `henza_pwa` **computes** `percent_pay`, `discount` and
`rial_pay`; the user types none of them (`cart.vue:170-179`,
`Mixins/product/index.ts:69-75`). So Phase 2 item #1 is to derive them
server-side and ignore the client's values, with a comparison test proving the
total is unchanged for an ordinary customer. `rash_weight` stays read from the
DB, recorded as a deliberate deviation.
### New, not yet applied — awaiting approval
| File | Purpose |
|---|---|
| `prisma/migrations/20260922120000_customer_name_parts/migration.sql` | D0 — adds `firstName`/`lastName`, backfills first-token/rest, adds a `customer_sync_full_name` trigger keeping `fullName` in sync in both directions, indexes both parts. Additive; nothing dropped. |
| `prisma/migrations/20260922120100_customer_making_charge_offset/migration.sql` | D9 — adds `makingChargeOffsetPercent DECIMAL(6,3) NOT NULL DEFAULT 0`. |
| `scripts/report-name-split.cjs` | Read-only review report for names with more than two tokens (D0 requirement 3), with `--csv` output. |
### Added and already passing
| File | Purpose |
|---|---|
| `src/modules/pricing/variant-price.ts` | The reference price formula ported verbatim from `ProductVariant.php:183-190`, with the customer offset as an explicit input defaulting to 0. |
| `src/modules/pricing/variant-price.spec.ts` | 11 tests against **hand calculations** derived from the PHP source, covering with/without the offset, the VAT-on-non-metal-portion-only rule, floor-to-1000, and the equivalence between the app folding the offset into `percent_pay` and the server deriving it. |
`npx jest` → **18 passed** (7 existing + 11 new), 2 suites.
---
## 2026-09-22 (later) — D0 + percent applied, SMS provider implemented
### Migrations applied
`migrate dev` could not run (interactive-only, and this shell is not a TTY).
Drift was checked read-only first: the only difference is the pre-existing
`_goldbank_migrations` table, which Prisma would DROP — **it was not dropped**
(DEAD-07). Applied with `migrate deploy`, which touches only the pending files:
```
Applying migration `20260922120000_customer_name_parts`
Applying migration `20260922120100_customer_making_charge_offset`
All migrations have been successfully applied.
→ 11 migrations found. Database schema is up to date!
```
Backfill result (`node scripts/report-name-split.cjs`): 8 customers, 7 split
reliably, 0 single-token, 0 empty, **1 needs review** — `customer-dev-test`
(«کاربر تست توسعه»), a row this session created itself.
### Data deletions (standing rule 2)
| When | What | How many | Why |
|---|---|---|---|
| Round-trip test | `Customer` rows created by the test: 2 successful + 1 whose Persian text the Windows shell had mangled into `?` | 3 | Test fixtures. Customer count back to its original 8. |
| Trigger test | 1 temporary `Customer` row per run, deleted by the script itself and reported on stdout | 1/run | Test fixture. |
### API
- `toRow()` returns the **real** `fname`/`lname` (D0) and `percent` now reads
`makingChargeOffsetPercent` **from the customer** instead of the referring
agent's commission rate (D9 / CUST-02).
- `create()` stores the name parts, and **requires `percent`** with the
reference's own message, `وارد کردن percent الزامی است` — Laravel's stock
`required` text (`resources/lang/fa/validation.php:99`) with no attribute
translation for `percent`, so it is verbatim.
- `update()` writes the parts (letting the trigger rebuild `fullName`) and
**persists `percent`**, which it previously ignored entirely.
- `updateCredit()` accepted `percent` in its signature and never wrote it —
fixed.
- Sort/filter aliases `fname`/`lname` pointed at `fullName`; they now point at
the real columns, and `percent` is aliased too. Grid search covers both parts.
- `createSingle()` (retail form, D13) stores the parts too. No storefront
behaviour changed — only the shared columns are kept consistent.
### SMS — implemented (owner-approved)
- New `SmsModule` / `SmsService` (`@Global`), a port of `Sms.php:57-82`: POST,
form-urlencoded, 20s timeout, `PhoneNumber`/`Username`/`Password`/`Mobile`/
`Message`, body trimmed, every message persisted as `Sms::set()` did.
- **Failure handling matches the reference**: `send()` never throws. The
reference discarded `Sms::Send`'s result, so a gateway outage never aborted
the order, the document approval or the customer creation that triggered it.
Failures are logged and returned in `SmsSendResult`.
- **OTP text is byte-identical** to `ApiMobileController.php:47`, including the
`<#> code is …` first line and the trailing `aMo6iFyUQaY` — the Android SMS
Retriever hash. A unit test asserts the exact string.
- `assertSmsConfigured()` runs in `main.ts` **before the server listens**: in
production a missing key throws, naming it; outside production it warns and
codes are logged locally, so today's dev flow is unchanged.
- Credentials come from the environment only. `.env.example` lists the six names
with **empty values**; `.env` is gitignored (verified).
- The 12 B2B message templates are implemented per flow in their own phase —
admin in Phase 1, app in Phase 2 — not pre-emptively.
### Admin panel
- `singleCustomer.vue` — the `split(' ')` fallback is **removed** (D0 item 6).
- `bulkCustomer.vue` — `percent` defaults to **3** on create and is required
with the legacy message; the edit path coerces the Decimal string to a number
so diff-send does not re-send an unchanged field; `name` is only sent when a
name part actually changed, so the whole name is no longer re-submitted on
every save.
- `ReagentCustomer.vue` — **unchanged**. Its «درصد کمیسیون» is
`AdminUser.wagePercent`, a genuinely different field from `customers.percent`.
### Tests
- `npx jest` — **32 passed** (7 existing + 11 price formula + 14 SMS config/template).
- `scripts/test-name-trigger.cjs` — **9 assertions**, all three sync directions.
- `scripts/auth-smoke.sh` — **53 passed, 0 failed**.
- **Round-trip test (D12 rule E0)** on the customer entity, live:
create «محمد علی» + «احمدی» + percent 3 → edit form shows each field
separately and correctly → no-op save leaves the row **byte-identical** →
changing only `lname` changes `lastName` and `fullName` (trigger) and nothing
else → changing only `percent` changes exactly that column. The two-token
first name «محمد علی» survives intact, which is the defect the owner
originally reported.
- Typecheck: api ✅ · admin-panel ✅.
---
## 2026-09-22 (later still) — D16 priority change, SEC-02 re-verified
### PRICE-01 stopped (D16)
Price display is retail-storefront work, so it falls under D13 and is now ⏸️.
- `apps/api/src/modules/pricing/variant-price.ts` and its 11 tests are **kept**
and still run; they are simply not used.
- **Nothing needed reverting.** `computeVariantPrice` was never called from
anywhere outside its own file and spec — verified by grep over `apps/api/src`
and `apps/admin-panel/src`. No code had been wired to display a price.
- Making-charge work (`percentage_pay`, `makingChargeOffsetPercent`, wage display
on variants and order lines) **stays in scope**.
- `ISSUES.md` PRICE-01 re-marked ⏸️ rather than deleted, so the evidence survives.
### SEC-02 re-verified live
Re-tested end to end rather than quoting the earlier run:
| Check | Result |
|---|---|
| `POST /orders` with no token | 401 |
| Body `customerId: "customer-2"` + `customer-dev-test` token | landed on **customer-dev-test**; warn-logged |
| `GET /orders/:id` own | 200 |
| `GET /orders/:id` another customer's | **404** (not 403) |
| `GET /orders/:id` unknown | 404, identical |
| `GET /orders/admin/:id` with a customer token | 401 |
**9/9 passed**, plus **32/32** in `auth-smoke.sh`.
### Data deletions (standing rule 2)
| What | How many | Why |
|---|---|---|
| 1 `Order` created by the SEC-02 test, with its `OrderItem`, `OrderStatusEvent` and `LedgerEntry` rows | 1 order + its children | Test fixture, removed by the test itself. |
### A testing note worth keeping
The dev OTP line briefly appeared to be missing after the SMS change. It was not
a regression: a **stale API process from an earlier test still held port 3077**,
so the new instance died with `EADDRINUSE` and the log being tailed belonged to
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. |