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
+64
View File
@@ -713,3 +713,67 @@ owner's ruling the target's behaviour stays.** Recorded here as a deliberate
security deviation from the reference. The visible result is identical whenever
the client sends the true variant weight, which the reference app always does
(`cart.vue:178`); it differs only for a forged request, which is the point.
---
# D14 — Migration workflow, and the `customer_sync_full_name` trigger (2026-09-22)
## Workflow
| Environment | Command | Rule |
|---|---|---|
| Development | `npx prisma migrate dev` | the only way schema changes are applied |
| Production | `npx prisma migrate deploy` | **only** this — never `migrate dev`, never `migrate reset` |
| Anywhere | `prisma db execute` | **no longer used for schema changes** |
If `migrate dev` reports drift and offers a reset: **stop, do not accept, report
it.** `_prisma_migrations` is never edited by hand.
### What actually happened on 2026-09-22
`migrate dev` **refused to run**: it is interactive by design and this shell is
not a TTY. That is a safety property, not a problem — a reset prompt could never
be auto-accepted here.
Before falling back, drift was checked read-only with `prisma migrate diff`. It
reported one pre-existing difference that has nothing to do with this work:
```
-- DropTable
DROP TABLE "_goldbank_migrations";
```
`_goldbank_migrations` is a **bespoke ledger from the earlier port**, holding 12
historical rows (`0001_init.sql`, `seed:initial`, …). It is not in
`schema.prisma`, so Prisma considers it foreign and would drop it. **It was not
dropped.** Dropping it is a destructive operation, it is unapproved, and the
rows are the only record of how the pre-Prisma port was applied. Logged as
**DEAD-07**; leave it until the owner decides.
The two new migrations were therefore applied with **`migrate deploy`**, which
applies exactly the pending files and touches nothing else — so the drift was
neither "fixed" nor acted upon. Result: `11 migrations found. Database schema is
up to date!`
## The `customer_sync_full_name` trigger
Created by `20260922120000_customer_name_parts`. **Prisma does not know it
exists**, so a write through Prisma Client can produce a row different from what
was written. Documented in three places so the next developer cannot miss it:
the migration header, the `Customer` model doc comment in `schema.prisma`, and
here.
| Write | Result |
|---|---|
| the parts only | `fullName` is rebuilt as `first + ' ' + last` |
| `fullName` only | the parts are re-derived (first token / the rest) |
| **both in one statement** | **THE PARTS WIN**; the submitted `fullName` is discarded |
**Why the parts win.** After D0 the parts are what the three forms edit, so they
carry the operator's intent; `fullName` is the derived, legacy-facing value.
The opposite precedence would silently undo an edit made in the two boxes the
operator actually typed into.
Verified live by `apps/api/scripts/test-name-trigger.cjs` — 9 assertions across
all three directions plus insert, a multi-token name, and a no-op update. It
creates one temporary row and deletes it, reporting the deletion.