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:
co-authored by
Claude Opus 5
parent
7b598e9e8e
commit
8133a2bdf4
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user