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>
110 lines
4.2 KiB
JavaScript
110 lines
4.2 KiB
JavaScript
/**
|
|
* Integration test for the `customer_sync_full_name` database trigger
|
|
* (migration 20260922120000_customer_name_parts).
|
|
*
|
|
* The trigger is SQL, not Prisma, so jest's unit suite cannot cover it — it
|
|
* needs a live database. Run it against a development DB:
|
|
*
|
|
* node scripts/test-name-trigger.cjs
|
|
*
|
|
* Exits non-zero on any failure. It creates ONE temporary Customer row and
|
|
* deletes it again; the deletion is reported on stdout (CHANGELOG rule 2).
|
|
*
|
|
* The three directions required by the owner's ruling of 2026-09-22:
|
|
* parts only -> fullName is rebuilt
|
|
* fullName only -> the parts are re-derived
|
|
* both at once -> THE PARTS WIN
|
|
*/
|
|
const { PrismaClient } = require('@prisma/client');
|
|
|
|
const prisma = new PrismaClient();
|
|
let failures = 0;
|
|
|
|
function check(condition, message) {
|
|
if (!condition) failures++;
|
|
console.log(`${condition ? ' PASS' : ' FAIL'} ${message}`);
|
|
}
|
|
|
|
const read = async (id) =>
|
|
(
|
|
await prisma.$queryRawUnsafe(
|
|
`SELECT "fullName","firstName","lastName","makingChargeOffsetPercent" AS pct
|
|
FROM "Customer" WHERE id=$1`,
|
|
id,
|
|
)
|
|
)[0];
|
|
|
|
(async () => {
|
|
const id = 'trigger-test-' + Date.now();
|
|
const unique = Date.now();
|
|
|
|
await prisma.$executeRawUnsafe(
|
|
`INSERT INTO "Customer"
|
|
(id,"fullName","firstName","lastName",mobile,"nationalCode","passwordHash","updatedAt")
|
|
VALUES ($1,'',$2,$3,$4,$5,'x',NOW())`,
|
|
id,
|
|
'محمد علی',
|
|
'احمدی',
|
|
'09' + String(unique).slice(-9),
|
|
'NC' + unique,
|
|
);
|
|
|
|
// The owner's worked example: a two-token FIRST name must survive intact.
|
|
console.log('\nCASE 0 — INSERT with parts only («محمد علی» + «احمدی»)');
|
|
let r = await read(id);
|
|
console.log(' ', JSON.stringify(r));
|
|
check(r.fullName === 'محمد علی احمدی', 'fullName rebuilt from the parts');
|
|
check(r.firstName === 'محمد علی', 'a two-token first name is NOT re-split');
|
|
check(r.lastName === 'احمدی', 'last name kept separate');
|
|
|
|
console.log('\nCASE 1 — UPDATE the parts only');
|
|
await prisma.$executeRawUnsafe(
|
|
`UPDATE "Customer" SET "firstName"='رضا',"lastName"='کریمی' WHERE id=$1`,
|
|
id,
|
|
);
|
|
r = await read(id);
|
|
console.log(' ', JSON.stringify(r));
|
|
check(r.fullName === 'رضا کریمی', 'fullName rebuilt from the parts');
|
|
|
|
console.log('\nCASE 2 — UPDATE fullName only');
|
|
await prisma.$executeRawUnsafe(`UPDATE "Customer" SET "fullName"='سعید موسوی' WHERE id=$1`, id);
|
|
r = await read(id);
|
|
console.log(' ', JSON.stringify(r));
|
|
check(r.firstName === 'سعید' && r.lastName === 'موسوی', 'parts re-derived from fullName');
|
|
|
|
console.log('\nCASE 3 — UPDATE BOTH in one statement (the parts must win)');
|
|
await prisma.$executeRawUnsafe(
|
|
`UPDATE "Customer" SET "fullName"='IGNORED',"firstName"='نرگس',"lastName"='رحیمی' WHERE id=$1`,
|
|
id,
|
|
);
|
|
r = await read(id);
|
|
console.log(' ', JSON.stringify(r));
|
|
check(r.fullName === 'نرگس رحیمی', 'parts win; the submitted fullName is discarded');
|
|
|
|
console.log('\nCASE 4 — a multi-token fullName splits first-token / rest');
|
|
await prisma.$executeRawUnsafe(`UPDATE "Customer" SET "fullName"='سید محمد حسینی' WHERE id=$1`, id);
|
|
r = await read(id);
|
|
console.log(' ', JSON.stringify(r));
|
|
check(r.firstName === 'سید' && r.lastName === 'محمد حسینی', 'first token / the rest');
|
|
|
|
console.log('\nCASE 5 — an unrelated update leaves the name untouched');
|
|
const before = await read(id);
|
|
await prisma.$executeRawUnsafe(`UPDATE "Customer" SET mobile=mobile WHERE id=$1`, id);
|
|
const after = await read(id);
|
|
check(JSON.stringify(before) === JSON.stringify(after), 'no-op update changes nothing');
|
|
|
|
console.log('\nCASE 6 — makingChargeOffsetPercent');
|
|
check(Number(after.pct) === 0, 'defaults to 0, so no existing price moves');
|
|
|
|
await prisma.$executeRawUnsafe(`DELETE FROM "Customer" WHERE id=$1`, id);
|
|
console.log(`\nCleanup: deleted 1 temporary Customer row (${id}).`);
|
|
|
|
console.log(failures === 0 ? '\nALL PASSED' : `\n${failures} FAILED`);
|
|
await prisma.$disconnect();
|
|
process.exit(failures ? 1 : 0);
|
|
})().catch(async (e) => {
|
|
console.error(e);
|
|
await prisma.$disconnect();
|
|
process.exit(1);
|
|
});
|