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
@@ -97,6 +97,9 @@ export class AdminCustomersService {
private static readonly ALLOWED = [
'id',
'fullName',
'firstName',
'lastName',
'makingChargeOffsetPercent',
'mobile',
'nationalCode',
'cityId',
@@ -120,8 +123,11 @@ export class AdminCustomersService {
/** Translates legacy filter field names onto real schema paths. */
private static readonly FIELD_ALIASES: Record<string, string> = {
name: 'fullName',
fname: 'fullName',
lname: 'fullName',
// D0 — these now have real columns to point at. Sorting or filtering on
// the last name used to silently act on the whole name.
fname: 'firstName',
lname: 'lastName',
percent: 'makingChargeOffsetPercent',
city_id: 'cityId',
active: 'isActive',
gram_level: 'goldCreditLimitGrams',
@@ -196,7 +202,10 @@ export class AdminCustomersService {
const aliased = this.applyAliases(query);
const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, {
searchable: ['fullName', 'mobile', 'nationalCode'],
// D0 — `fullName` is kept in sync by the trigger so it still matches, but
// the parts are searched too: a surname-only query is the common case and
// it must not depend on how the whole name happens to be spaced.
searchable: ['fullName', 'firstName', 'lastName', 'mobile', 'nationalCode'],
allowed: AdminCustomersService.ALLOWED,
defaultOrderBy: { createdAt: 'desc' },
});
@@ -227,10 +236,15 @@ export class AdminCustomersService {
) {
return {
id: c.id,
// Legacy stored fname/lname separately; the new schema has one fullName.
// D0 — the schema now has the real parts, as the reference always did
// (2020_10_01_120430_customers_table.php:18-19). Before this, the edit
// form showed the WHOLE name in the "first name" box and saved an empty
// last name. `firstName`/`lastName` are backfilled and kept in sync with
// `fullName` by the customer_sync_full_name trigger, so the fallbacks
// below only matter for a row written before the migration ran.
name: c.fullName,
fname: c.fullName,
lname: '',
fname: c.firstName ?? c.fullName,
lname: c.lastName ?? '',
mobile: c.mobile,
national_code: c.nationalCode,
city_id: c.cityId,
@@ -241,8 +255,12 @@ export class AdminCustomersService {
gram_level: c.goldCreditLimitGrams,
remain: c.goldDebtGrams,
rial_remain: c.fiatDebtRial,
// legacy `percent` came from the referring agent's commission rate
percent: c.referredByAgent?.wagePercent ?? null,
// D9 — legacy `percent` is a column ON THE CUSTOMER
// (customer_add.php:32), the per-customer making-charge offset. The port
// read the referring AGENT's commission rate instead, which is a
// different number entirely (CUST-02). The legacy key is kept so the
// ported table models and forms bind unchanged.
percent: c.makingChargeOffsetPercent,
// legacy kept these as two INDEPENDENT switches — see toggleSts/toggleActive
active: c.isActive ? 1 : -1,
sts: c.canPlaceOrders ? 1 : -1,
@@ -516,7 +534,13 @@ export class AdminCustomersService {
* is generic enough to patch any Customer row, agent or otherwise.
*/
async createSingle(data: Record<string, unknown>) {
const fullName = String(data.name ?? data.fname ?? '').trim();
// D0 — store the parts when the caller sends them. This is the retail
// (storefront) form, which D13 puts out of scope for parity work; the name
// handling is corrected only so the shared Customer columns stay
// consistent, and no storefront behaviour changes.
const firstName = String(data.fname ?? data.name ?? '').trim();
const lastName = String(data.lname ?? '').trim();
const fullName = [firstName, lastName].filter(Boolean).join(' ');
const mobile = String(data.mobile ?? data.phone ?? '').trim();
if (!fullName || !mobile) {
@@ -526,6 +550,8 @@ export class AdminCustomersService {
return this.prisma.customer.create({
data: {
fullName,
firstName: firstName || null,
lastName: lastName || null,
mobile,
nationalCode: mobile, // schema requires it and it is unique; singleCustomer.vue collects no ncode
passwordHash: '',
@@ -545,7 +571,11 @@ export class AdminCustomersService {
* (e.g. reg.vue, or a stale client) still sending type=1.
*/
async create(data: Record<string, unknown>) {
const fullName = String(data.name ?? data.fname ?? '').trim();
// D0 — the form posts fname and lname separately; store them separately.
// `name` is still accepted for the callers that only send a whole name.
const firstName = String(data.fname ?? data.name ?? '').trim();
const lastName = String(data.lname ?? '').trim();
const fullName = [firstName, lastName].filter(Boolean).join(' ');
const mobile = String(data.mobile ?? '').trim();
const nationalCode = String(data.national_code ?? data.nationalCode ?? '').trim();
@@ -553,6 +583,14 @@ export class AdminCustomersService {
throw new BadRequestException('نام و موبایل الزامی است');
}
// D9 — the reference makes `percent` required on create
// (AdminController.php:354) with the stock Laravel message, and its
// attribute has no Persian translation, so the text is verbatim what the
// reference produces.
if (data.percent === undefined || data.percent === null || data.percent === '') {
throw new BadRequestException('وارد کردن percent الزامی است');
}
const kind = legacyTypeToKind(data.type) ?? CustomerKind.APP_SHOPKEEPER;
if (kind === CustomerKind.SALES_AGENT) {
throw new BadRequestException(
@@ -563,6 +601,12 @@ export class AdminCustomersService {
return this.prisma.customer.create({
data: {
fullName,
firstName: firstName || null,
lastName: lastName || null,
// D9 — per-customer making-charge offset. Required above, so this is
// always a real value; the reference's own form default is 3
// (AdminController.php:338) and is applied client-side.
makingChargeOffsetPercent: new Prisma.Decimal(String(data.percent)),
mobile,
nationalCode: nationalCode || mobile, // schema requires it and it is unique
// The form collects a password (bulkCustomer.vue / ReagentCustomer.vue).
@@ -603,7 +647,25 @@ export class AdminCustomersService {
if (!existing) throw new NotFoundException('مشتری یافت نشد');
const patch: Prisma.CustomerUpdateInput = {};
if (data.name !== undefined) patch.fullName = String(data.name);
// D0 — write the parts, not a collapsed whole name. The
// customer_sync_full_name trigger rebuilds `fullName` from them, and when
// a caller sends both the parts win (see the migration's header).
if (data.fname !== undefined || data.lname !== undefined) {
const firstName = String(data.fname ?? existing.firstName ?? '').trim();
const lastName = String(data.lname ?? existing.lastName ?? '').trim();
patch.firstName = firstName || null;
patch.lastName = lastName || null;
} else if (data.name !== undefined) {
// Whole-name-only callers: the trigger derives the parts.
patch.fullName = String(data.name);
}
// D9 — this was simply not handled, so editing it silently did nothing.
if (data.percent !== undefined && data.percent !== null && data.percent !== '') {
patch.makingChargeOffsetPercent = new Prisma.Decimal(String(data.percent));
}
if (data.mobile !== undefined) patch.mobile = String(data.mobile);
if (data.national_code !== undefined) patch.nationalCode = String(data.national_code);
if (data.city_id !== undefined) {
@@ -857,9 +919,15 @@ export class AdminCustomersService {
throw new BadRequestException('مقدار سطح اعتبار ارسال نشده است');
}
return this.prisma.customer.update({
where: { id },
data: { goldCreditLimitGrams: new Prisma.Decimal(data.gram_level) },
});
// D9 — `percent` was in this method's signature but was never written, so
// editing it from the row menu silently did nothing.
const patch: Prisma.CustomerUpdateInput = {
goldCreditLimitGrams: new Prisma.Decimal(data.gram_level),
};
if (data.percent !== undefined && data.percent !== null && String(data.percent) !== '') {
patch.makingChargeOffsetPercent = new Prisma.Decimal(String(data.percent));
}
return this.prisma.customer.update({ where: { id }, data: patch });
}
}