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
@@ -0,0 +1,97 @@
-- D0 — split Customer.fullName into firstName / lastName (ADDITIVE).
--
-- Reference: henza_server has two separate columns from the start
-- database/migrations/2020_10_01_120430_customers_table.php:18-19
-- $table->string('fname')->nullable();
-- $table->string('lname')->nullable();
-- The target collapsed them into one `fullName`, so the admin edit form shows
-- the whole name in the "first name" box and saves an empty last name
-- (admin-customers.service.ts:231-233 returns `fname: c.fullName, lname: ''`).
--
-- NOTHING IS DROPPED. `fullName` stays and is kept in sync as
-- `firstName || ' ' || lastName`, so every existing reader keeps working
-- (D0 requirement 4).
--
-- D13 / storefront impact: `Customer` is a SHARED table — both the B2B app and
-- the retail storefront read it. This migration only ADDS two nullable columns
-- and leaves `fullName` populated and authoritative, so no storefront query,
-- view or report changes behaviour. No storefront column is touched.
-- 1. The two new columns. Nullable: a customer may legitimately have no
-- surname, and the reference's own columns are nullable.
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "firstName" TEXT;
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "lastName" TEXT;
-- 2. Backfill: first whitespace-separated token -> firstName, the REST ->
-- lastName (D0 requirement 2). Multi-space and leading/trailing whitespace
-- are normalised first so " علی محمدی " splits correctly.
--
-- Rows with a single token get firstName = that token and lastName = NULL.
-- Rows with more than two tokens put every remaining token in lastName;
-- they are ALSO listed in the review report (see the companion script) so a
-- human can correct them — the split cannot be guessed safely
-- (D0 requirement 3).
UPDATE "Customer"
SET
"firstName" = split_part(btrim(regexp_replace("fullName", '\s+', ' ', 'g')), ' ', 1),
"lastName" = NULLIF(
substr(
btrim(regexp_replace("fullName", '\s+', ' ', 'g')),
length(split_part(btrim(regexp_replace("fullName", '\s+', ' ', 'g')), ' ', 1)) + 2
),
''
)
WHERE "firstName" IS NULL
AND "fullName" IS NOT NULL
AND btrim("fullName") <> '';
-- 3. Keep `fullName` in sync from now on.
--
-- Writers may update either the parts or the whole; this trigger makes the
-- parts authoritative whenever they are the thing that changed, and
-- otherwise leaves an explicit `fullName` write alone. That way old code
-- that still writes only `fullName` is not broken (D0 requirement 4).
-- Precedence when BOTH the parts and fullName change in the SAME statement:
-- THE PARTS WIN. fullName is rebuilt from them and the submitted fullName is
-- discarded. Rationale: after D0 the parts are the field the forms edit, so
-- they carry the operator's intent; fullName is the derived, legacy-facing
-- value. Choosing the opposite would silently undo an edit made in the two
-- boxes the operator actually typed into.
-- The first branch below tests the parts, so it wins by construction.
CREATE OR REPLACE FUNCTION customer_sync_full_name() RETURNS trigger AS $$
BEGIN
-- The name parts changed (or are being set on insert): rebuild fullName.
-- This is checked FIRST, so when both change in one statement the parts win.
IF (TG_OP = 'INSERT' AND (NEW."firstName" IS NOT NULL OR NEW."lastName" IS NOT NULL))
OR (TG_OP = 'UPDATE'
AND (NEW."firstName" IS DISTINCT FROM OLD."firstName"
OR NEW."lastName" IS DISTINCT FROM OLD."lastName")) THEN
NEW."fullName" := btrim(
coalesce(NEW."firstName", '') || ' ' || coalesce(NEW."lastName", '')
);
-- Only fullName changed: derive the parts from it, so the two never drift.
ELSIF TG_OP = 'UPDATE' AND NEW."fullName" IS DISTINCT FROM OLD."fullName"
AND NEW."fullName" IS NOT NULL AND btrim(NEW."fullName") <> '' THEN
NEW."firstName" := split_part(btrim(regexp_replace(NEW."fullName", '\s+', ' ', 'g')), ' ', 1);
NEW."lastName" := NULLIF(
substr(
btrim(regexp_replace(NEW."fullName", '\s+', ' ', 'g')),
length(split_part(btrim(regexp_replace(NEW."fullName", '\s+', ' ', 'g')), ' ', 1)) + 2
),
''
);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS customer_sync_full_name_trg ON "Customer";
CREATE TRIGGER customer_sync_full_name_trg
BEFORE INSERT OR UPDATE ON "Customer"
FOR EACH ROW EXECUTE FUNCTION customer_sync_full_name();
-- 4. Name search hits both parts in the admin grid.
CREATE INDEX IF NOT EXISTS "Customer_firstName_idx" ON "Customer" ("firstName");
CREATE INDEX IF NOT EXISTS "Customer_lastName_idx" ON "Customer" ("lastName");
@@ -0,0 +1,49 @@
-- D9 — add Customer.makingChargeOffsetPercent (ADDITIVE, behaviour-neutral).
--
-- Reference: `customers.percent`, a real per-customer column.
-- database/migrations/2020_10_19_153228_customer_add.php:32
-- $table->double('percent')->default(0);
--
-- What it does in the reference:
-- * it is ADDED to the variant's making charge when the app builds an order
-- line: henza_pwa/src/Mixins/product/index.ts:69-75
-- getWage(v) = myStore.state.percent + v.percentage_pay
-- and `state.percent` is this column, delivered by POST /me-data
-- (henza_pwa/src/store/myDataStore.ts:29,59,122-127).
-- * it shifts the catalogue making-charge filter bounds:
-- ApiMobileController.php:391-396 and :76-77.
-- * it is NOT part of the price formula itself — the extra percent there
-- comes from ConfigStatic['product_price_extra_percent']
-- (ProductVariant.php:50,183).
--
-- Why this name and not `productPercent`: the reference has three unrelated
-- things called `percent` (see docs/parity/MIGRATION-STATE.md). This is the
-- per-customer making-charge offset. It is mapped back to the legacy key
-- `percent` in toRow(), so the admin panel's field name does not change.
-- No duplicate field is created.
--
-- The target currently reads `referredByAgent.wagePercent` instead, which is
-- the wrong field entirely (CUST-02) — that is corrected in the service layer,
-- not here.
--
-- D13 / storefront impact: NONE. `customers.percent` is a B2B wholesale field;
-- it is consumed only by the B2B app's cart and catalogue filter. The
-- storefront's own percent is ConfigStatic['percent'] (the wallet cashback
-- rate), a different key in a different table, which this migration does not
-- touch. `Customer` is a shared table, but adding a column with a default that
-- reproduces today's behaviour changes no storefront read.
-- DEFAULT 0 is deliberate and behaviour-neutral:
-- * it matches the reference's own column default (customer_add.php:32);
-- * with 0 the computed wage is `0 + variant.percentage_pay`, i.e. exactly
-- the making charge alone — which is what the target does today, since it
-- has no such column. So no existing customer's price moves.
-- NOTE: the reference's createCustomer() writes 3, not 0, for NEW customers
-- (AdminController.php:338) and makes the field required on create (:354).
-- That is an application-layer default for new rows, NOT a backfill value,
-- and it is handled in the service, not in this migration.
ALTER TABLE "Customer"
ADD COLUMN IF NOT EXISTS "makingChargeOffsetPercent" DECIMAL(6,3) NOT NULL DEFAULT 0;
COMMENT ON COLUMN "Customer"."makingChargeOffsetPercent" IS
'Legacy customers.percent — per-customer making-charge offset, ADDED to ProductVariant.defaultWagePercent when pricing an order line, and used to shift the catalogue making-charge filter bounds. Not the wallet cashback rate (that is the storefront ConfigStatic[percent]) and not the wholesale per-line charge (that is OrderItem.wagePercentSnapshot).';
+41 -2
View File
@@ -170,8 +170,29 @@ model AdminUser {
}
model Customer {
id String @id @default(cuid())
fullName String
id String @id @default(cuid())
/// ⚠️ `fullName`, `firstName` and `lastName` are kept in sync BY A DATABASE
/// TRIGGER — `customer_sync_full_name`, created in migration
/// 20260922120000_customer_name_parts. Prisma does not know about it, so a
/// write here is NOT the whole story:
///
/// * write the parts -> fullName is rebuilt as "first last"
/// * write fullName only -> the parts are re-derived from it
/// * write BOTH in one update -> THE PARTS WIN; fullName is overwritten
/// with "first last" and whatever you passed for fullName is discarded
///
/// So a value you wrote can differ from the value that lands in the row.
/// Re-read after writing if you need the stored value. Prefer writing the
/// parts; `fullName` exists for the legacy readers that predate D0.
///
/// Reference has these as two columns from the start
/// (henza_server 2020_10_01_120430_customers_table.php:18-19 fname/lname);
/// the port collapsed them, which is the D0 defect.
fullName String
firstName String?
lastName String?
mobile String @unique
nationalCode String @unique
passwordHash String
@@ -201,6 +222,22 @@ model Customer {
goldDebtGrams Decimal @default(0) @db.Decimal(14, 4) // legacy remain
fiatDebtRial Decimal @default(0) @db.Decimal(20, 2) // legacy rial_remain
/// Legacy `customers.percent` — the per-customer making-charge offset. It is
/// ADDED to the variant's making charge when pricing an order line
/// (henza_pwa src/Mixins/product/index.ts:69-75:
/// getWage(v) = state.percent + v.percentage_pay)
/// and it shifts the catalogue making-charge filter bounds
/// (ApiMobileController.php:391-396,76-77). It is NOT part of the price
/// formula itself — that extra percent is ConfigStatic['product_price_extra_percent'].
///
/// NOT the wallet cashback rate (storefront ConfigStatic['percent'], D13) and
/// NOT the wholesale per-line charge (OrderItem.wagePercentSnapshot).
///
/// DB default 0 keeps every existing customer's price unchanged. The
/// reference's application-layer default for NEW customers is 3
/// (AdminController.php:338) and the field is required on create (:354).
makingChargeOffsetPercent Decimal @default(0) @db.Decimal(6, 3)
verificationStep VerificationStep @default(UNVERIFIED)
nationalIdImageUrl String?
businessLicenseUrl String?
@@ -236,6 +273,8 @@ model Customer {
otpChallenges CustomerOtp[]
@@index([referredByAgentId])
@@index([firstName])
@@index([lastName])
}
model CustomerAddress {