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
@@ -86,7 +86,7 @@
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="درصد">
<a-form-item label="درصد" :validate-status="err.percent ? 'error' : ''" :help="err.percent">
<a-input-number v-model:value="form.percent" class="w-full" />
</a-form-item>
<a-form-item label="سقف اعتبار (برحسب گرم)">
@@ -199,7 +199,7 @@
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="درصد">
<a-form-item label="درصد" :validate-status="err.percent ? 'error' : ''" :help="err.percent">
<a-input-number v-model:value="form.percent" class="w-full" />
</a-form-item>
<a-form-item label="سقف اعتبار (برحسب گرم)">
@@ -318,7 +318,9 @@ const form = reactive<Record<string, any>>({
// backend falls back to when the field is absent.
type: undefined,
reagent_id: undefined,
percent: undefined,
// D9 — the reference defaults a new customer's making-charge offset to 3
// (AdminController.php:338) and makes the field required (:354).
percent: 3,
gram_level: undefined,
// The three document photos legacy collected via MyUpload. Stored as plain
// URLs (schema: nationalIdImageUrl / businessLicenseUrl / shopImageUrl),
@@ -377,6 +379,12 @@ function validate(): boolean {
if (!form.fname) err.fname = 'لطفا مقدار را وارد کنید';
if (!form.lname) err.lname = 'لطفا مقدار را وارد کنید';
if (!form.mobile) err.mobile = 'لطفا مقدار را وارد کنید';
// D9 — `percent` is required on create in the reference
// (AdminController.php:354). 0 is a legitimate value, so test for
// "not supplied", not for falsiness.
if (form.percent === undefined || form.percent === null || (form.percent as unknown) === '') {
err.percent = 'لطفا مقدار را وارد کنید';
}
return Object.keys(err).length === 0;
}
@@ -390,12 +398,18 @@ async function handleSubmitOmde() {
// whole form means any field the edit path failed to repopulate is sent as
// '' and overwrites good data — see the note on Object.assign below.
const changed = id ? diffFromInitial() : { ...form };
const payload = {
const payload: Record<string, unknown> = {
...changed,
id: id ?? null,
name: `${form.fname} ${form.lname}`.trim(),
national_code: form.ncode,
};
// D0 — fname/lname are real columns and travel in `changed` on their own.
// `name` is only sent when a name part actually changed; sending it
// unconditionally would re-submit the whole name on every save and defeat
// the diff-send the edit path relies on.
if (!id || 'fname' in changed || 'lname' in changed) {
payload.name = `${form.fname} ${form.lname}`.trim();
}
if (id) {
await api.put(`customer/${id}`, payload);
} else {
@@ -440,7 +454,10 @@ onMounted(async () => {
mobile: data.mobile ?? '',
city_id: data.city_id ?? undefined,
address: data.address ?? '',
percent: data.percent ?? undefined,
// D9 — the API sends a Decimal, which serialises as a string. Coerce
// it, or a-input-number shows it fine but diffFromInitial() compares
// "3" with 3 and re-sends an unchanged field on every save.
percent: data.percent === null || data.percent === undefined ? undefined : Number(data.percent),
gram_level: data.gram_level ?? undefined,
reagent_id: data.reagent?.id ?? undefined,
// These five were previously OMITTED here. Because handleSubmitOmde
@@ -89,7 +89,9 @@ async function handleSubmit() {
loading.value = true;
const payload = {
...form,
// one fullName in the schema; the two inputs are joined on submit
// D0 — fname and lname are real columns now, so they go as-is (they are
// already in `form`). `name` is still sent for the legacy readers that
// expect a whole name; the server derives it from the parts anyway.
name: `${form.fname} ${form.lname}`.trim(),
mobile: form.phone,
};
@@ -118,10 +120,12 @@ onMounted(async () => {
if (id) {
try {
const { data } = await api.get(`customer/${id}`);
const parts = String(data.name ?? '').trim().split(' ');
// D0 — the API returns the real parts now. The old split(' ') fallback
// is gone: it guessed at the boundary and put two-token first names in
// the wrong box.
Object.assign(form, {
fname: data.fname || parts[0] || '',
lname: data.lname || parts.slice(1).join(' '),
fname: data.fname ?? '',
lname: data.lname ?? '',
phone: data.mobile ?? '',
city_id: data.city_id ?? undefined,
});