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
@@ -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).';
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user