parity(api,admin): split customer name, add making-charge offset, wire SMS

Applies the two approved additive migrations and fixes every consumer.

D0 — Customer.firstName / lastName
The reference has had two columns since 2020
(2020_10_01_120430_customers_table.php:18-19); the port collapsed them, so
the edit form showed the whole name in the "first name" box and saved an
empty last name. Adds both columns, backfills first-token/rest, and keeps
fullName in sync with a customer_sync_full_name trigger. Nothing dropped.

The trigger is SQL, so Prisma cannot see it: writing the parts rebuilds
fullName, writing fullName re-derives the parts, and writing both in one
statement lets THE PARTS WIN. Documented in the migration header, the
Customer model doc comment and DECISIONS.md §D14, because a write can now
store something other than what was passed.

D9 — Customer.makingChargeOffsetPercent
Legacy customers.percent, the per-customer making-charge offset that the
app adds to the variant's making charge (henza_pwa
Mixins/product/index.ts:69-75). toRow() was returning the referring
AGENT's commission rate instead (CUST-02). DB default 0 reproduces
today's behaviour exactly, so no existing customer's price moves; the
reference's application-layer default of 3 for NEW customers
(AdminController.php:338) lives in the create form, and the field is
required on create with the reference's own message (:354).

update() and updateCredit() both accepted percent and silently discarded
it; both now persist it. Sort/filter aliases fname/lname pointed at
fullName and now point at the real columns.

SMS — Trez provider (Sms.php:57-82)
New global SmsModule/SmsService: form-urlencoded POST, 20s timeout, body
trimmed, every message persisted. send() never throws, matching the
reference, which discarded Sms::Send's result so a gateway outage never
aborted the order or approval that triggered it. The OTP text is
byte-identical, including the Android SMS Retriever markers. Credentials
come from env only; production refuses to boot without them rather than
silently locking every customer out. .env.example carries names, no values.

Admin panel: the split(' ') fallback is removed from singleCustomer.vue,
and bulkCustomer.vue defaults percent to 3, requires it, coerces the
Decimal on load so diff-send works, and only re-sends `name` when a name
part changed. ReagentCustomer.vue is untouched — its «درصد کمیسیون» is
AdminUser.wagePercent, a different field.

Tests: 32 jest (incl. 14 new SMS config/template), 9 trigger assertions,
53 auth smoke, and the full D12 round-trip on the customer entity —
«محمد علی» + «احمدی» survives create, edit, a byte-identical no-op save,
and single-field edits. Typecheck clean on api and admin-panel.

Note: migrate dev is interactive-only and could not run here, so the
migrations were applied with migrate deploy after a read-only drift check.
The only drift is the pre-existing _goldbank_migrations table, which
Prisma would DROP; it was NOT dropped (DEAD-07).

[ref: henza_server/app/Models/Sms.php, app/Http/Controllers/Adm/AdminController.php]

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-09-22 08:08:50 +03:30
co-authored by Claude Opus 5
parent 7b598e9e8e
commit 8133a2bdf4
18 changed files with 1013 additions and 51 deletions
+92
View File
@@ -306,3 +306,95 @@ this wrote the missing `_prisma_migrations` row **without running any SQL**, so
| `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 ✅.