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
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user