parity(admin): D17 — SalesExpertProfile, AdminUser name parts, agent grid fields

Restores the sales-expert fields that were lost when a sales expert became
an AdminUser instead of a type=1 Customer row.

New SalesExpertProfile holds cityId, gramLevel, phone and nationalCode. A
separate table rather than columns on AdminUser, because these mean
something only for role = SALES_EXPERT. gramLevel is DECIMAL(14,4), matched
to Customer.goldCreditLimitGrams so the same credit limit has identical
precision on both tables. phone and nationalCode copy the reference exactly:
nullable, NOT unique, no format rule — createCustomer validates only mobile
and percent, and both fields are required:false on the reference form.
Inventing a uniqueness constraint would reject data the reference accepts.

reagent_id is deliberately NOT ported (D17). Every use of it in the
reference reads a CUSTOMER's reagent_id, which the target already models as
Customer.referredByAgentId. On a sales expert's own form it was a side
effect of the shared customers form: filling it removed that expert from
the selectable-agent list, because queryReagent filters on
whereNull('reagent_id'). The target's equivalent filters on role and active
only, so no agent can be dropped — strictly better than the reference.

AdminUser gains firstName/lastName with the same additive migration,
backfill and sync trigger as Customer, for all admins rather than only
sales experts. This closes D0 requirement 7, and every remaining
`fname: fullName, lname: ''` was fixed — six further sites across
admin-users, admin-comments, admin-documents, admin-orders and
admin-preorders.

listReagents now returns real city_id, city, gram_level, phone and ncode,
and left-joins so an expert without a profile still lists with empty cells
rather than vanishing. The two grid filters map through the relation. The
service upserts a profile on create and on a role change to SALES_EXPERT,
and the migration backfilled one for every existing expert.

ReagentCustomer.vue gains the four fields plus the city -> commission
autofill, ported verbatim including the reference's falsy test so a city
percent of 0 also falls through to the aux lookup (replicated, not fixed).

Tests: new sales-expert round trip 33/33, covering create with every field,
an edit form with each field populated separately, a byte-identical no-op
save, single-field edits, both grid filters with negative cases, the
left-join case and the referrer dropdown. «محمد علی» + «احمدی» survives
intact. Plus jest 32/32, auth-smoke 53/53, customer trigger 9/9, typecheck
clean on api and admin-panel. Migration dry-run twice in a rolled-back
transaction before applying; drift is zero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-09-22 17:50:47 +03:30
co-authored by Claude Opus 5
parent e3af31948e
commit 2194b3d4d9
13 changed files with 905 additions and 39 deletions
@@ -0,0 +1,180 @@
-- D17 — SalesExpertProfile, and firstName/lastName on AdminUser.
--
-- Two additive changes in one migration:
--
-- 1. SalesExpertProfile — the sales-expert fields that legacy kept on the
-- `customers` row (type=1) and that were lost when a sales expert became
-- an AdminUser. A separate table, not columns on AdminUser, because these
-- are meaningful only for role = SALES_EXPERT and would be permanently
-- null for every other admin.
--
-- 2. AdminUser.firstName / lastName — closes D0 requirement 7, applying the
-- same split the Customer table already got. For ALL admins, not only
-- sales experts.
--
-- NOTHING IS DROPPED. `fullName` stays and is kept in sync by a trigger.
--
-- IDEMPOTENT (D14/D15): this must run cleanly under BOTH paths — the Prisma CLI
-- in development, and the app's own runner (src/prisma/migrate.ts) at container
-- boot, which may replay it. Hence IF NOT EXISTS everywhere, CREATE OR REPLACE
-- for the function, DROP ... IF EXISTS before CREATE TRIGGER, and a guarded
-- backfill that only touches rows not already filled.
--
-- D13 / storefront impact: NONE. AdminUser is an admin-side table the retail
-- storefront never reads, and SalesExpertProfile is new. `customers` is not
-- touched.
-- ───────────────────────────────────────────────────────────────────────────
-- 1. SalesExpertProfile
-- ───────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS "SalesExpertProfile" (
"id" TEXT NOT NULL,
"adminUserId" TEXT NOT NULL,
-- legacy customers.city_id — a filterable column on the sales-expert grid
-- (ReagentListModel.js:14) AND the source of the commission autofill
-- (ReagentCustomer.vue:215-228).
"cityId" TEXT,
-- legacy customers.gram_level — the «سطح اعتبار» column, filterable
-- (ReagentListModel.js:17).
--
-- DECIMAL(14,4) deliberately matches Customer.goldCreditLimitGrams exactly
-- (schema.prisma:221, verified numeric_precision=14 numeric_scale=4), so the
-- same credit limit has identical precision and rendering on both tables.
"gramLevel" DECIMAL(14,4) NOT NULL DEFAULT 0,
-- legacy customers.phone / ncode — collected by the reference form
-- (ReagentCustomer.vue:95,31) and stored, though nothing reads them back.
-- Added on the owner's instruction so the form matches the reference.
--
-- Types and constraints copied from the reference, which is deliberately lax:
-- ncode : string(20), NULLABLE, **NOT UNIQUE**
-- (2020_10_01_120430_customers_table.php:22)
-- phone : string(15), NULLABLE, not unique
-- (2020_10_25_155247_customers_cols.php:17)
-- Neither has a server-side validation rule: createCustomer validates only
-- `mobile` (required|unique) and `percent` (required) — AdminController.php:354.
-- Client-side both are `required: false` (ReagentCustomer.vue:31,95).
-- No uniqueness is invented here; that would reject data the reference accepts.
"phone" VARCHAR(15),
"nationalCode" VARCHAR(20),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SalesExpertProfile_pkey" PRIMARY KEY ("id")
);
-- One profile per admin user.
CREATE UNIQUE INDEX IF NOT EXISTS "SalesExpertProfile_adminUserId_key"
ON "SalesExpertProfile" ("adminUserId");
-- The grid filters on city.
CREATE INDEX IF NOT EXISTS "SalesExpertProfile_cityId_idx"
ON "SalesExpertProfile" ("cityId");
DO $$
BEGIN
ALTER TABLE "SalesExpertProfile"
ADD CONSTRAINT "SalesExpertProfile_adminUserId_fkey"
FOREIGN KEY ("adminUserId") REFERENCES "AdminUser"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
DO $$
BEGIN
ALTER TABLE "SalesExpertProfile"
ADD CONSTRAINT "SalesExpertProfile_cityId_fkey"
FOREIGN KEY ("cityId") REFERENCES "City"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
-- Give every EXISTING sales expert a profile row, so the grid's city and
-- credit-limit columns have something to left-join to from day one.
--
-- Idempotent by construction: `WHERE NOT EXISTS` means a replay inserts
-- nothing, and it never touches a profile an operator has already filled in.
--
-- The id is derived from the AdminUser id rather than random, so a replay on a
-- database where the row was deleted recreates the same key instead of
-- accumulating duplicates. Values are the column defaults: gramLevel 0 and
-- nulls elsewhere — deliberately neutral, since the reference has no data to
-- migrate here and inventing a credit limit would change behaviour.
INSERT INTO "SalesExpertProfile" ("id", "adminUserId", "gramLevel", "createdAt", "updatedAt")
SELECT 'sep_' || a."id", a."id", 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
FROM "AdminUser" a
WHERE a."role" = 'SALES_EXPERT'
AND NOT EXISTS (
SELECT 1 FROM "SalesExpertProfile" s WHERE s."adminUserId" = a."id"
);
-- ───────────────────────────────────────────────────────────────────────────
-- 2. AdminUser.firstName / lastName (D0 requirement 7)
-- ───────────────────────────────────────────────────────────────────────────
ALTER TABLE "AdminUser" ADD COLUMN IF NOT EXISTS "firstName" TEXT;
ALTER TABLE "AdminUser" ADD COLUMN IF NOT EXISTS "lastName" TEXT;
-- Backfill: first whitespace-separated token -> firstName, the REST ->
-- lastName. Identical rule to the Customer migration, so both tables split the
-- same way. Rows with more than two tokens are a guess and are listed by
-- scripts/report-name-split.cjs for a human to correct.
--
-- `WHERE "firstName" IS NULL` makes the replay a no-op and, just as important,
-- prevents a re-run from overwriting a name a human has since corrected.
UPDATE "AdminUser"
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") <> '';
-- Same two-way sync as Customer.
--
-- Precedence when BOTH the parts and fullName change in ONE statement:
-- THE PARTS WIN — fullName is rebuilt from them and the submitted fullName is
-- discarded. The parts are what the forms edit, so they carry the operator's
-- intent; fullName is the derived, legacy-facing value. The first branch tests
-- the parts, so it wins by construction.
CREATE OR REPLACE FUNCTION admin_user_sync_full_name() RETURNS trigger AS $$
BEGIN
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", '')
);
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 admin_user_sync_full_name_trg ON "AdminUser";
CREATE TRIGGER admin_user_sync_full_name_trg
BEFORE INSERT OR UPDATE ON "AdminUser"
FOR EACH ROW EXECUTE FUNCTION admin_user_sync_full_name();
-- Name search and sort hit both parts.
CREATE INDEX IF NOT EXISTS "AdminUser_firstName_idx" ON "AdminUser" ("firstName");
CREATE INDEX IF NOT EXISTS "AdminUser_lastName_idx" ON "AdminUser" ("lastName");
+66 -2
View File
@@ -123,11 +123,28 @@ model City {
addresses CustomerAddress[]
branches Branch[]
preOrders PreOrder[]
salesExpertProfiles SalesExpertProfile[]
}
model AdminUser {
id String @id @default(cuid())
fullName String
id String @id @default(cuid())
/// ⚠️ Kept in sync BY A DATABASE TRIGGER — `admin_user_sync_full_name`, from
/// migration 20260922180000. Prisma cannot see it, so a write here may store
/// something other than what you passed:
///
/// * 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; the submitted fullName is
/// discarded
///
/// Re-read after writing if you need the stored value. Same arrangement as
/// Customer (D0 requirement 7 — the split applies to ALL admins, not only
/// sales experts).
fullName String
firstName String?
lastName String?
mobile String @unique
passwordHash String
role AdminRole
@@ -164,9 +181,56 @@ model AdminUser {
sentNotifications Notification[] @relation("NotificationAuthor")
uploadedFiles UploadedFile[] @relation("FileUploader")
/// Only ever set for role = SALES_EXPERT (D17).
salesExpertProfile SalesExpertProfile?
@@index([role])
@@index([userTypeId])
@@index([avatarFileId])
@@index([firstName])
@@index([lastName])
}
/// The sales-expert fields legacy kept on the `customers` row (type=1) and that
/// were lost when a sales expert became an AdminUser (D17).
///
/// A separate table rather than columns on AdminUser: these are meaningful only
/// for role = SALES_EXPERT and would be permanently null for every other admin.
/// A row is created for every SALES_EXPERT by the migration, and upserted by the
/// service whenever one is created or a user's role becomes SALES_EXPERT — but
/// readers still left-join, so a missing profile degrades to nulls rather than
/// dropping the agent from the grid.
///
/// `reagent_id` is deliberately NOT here — see D17: in the reference it was a
/// side effect of the shared customers form, and filling it removed that expert
/// from the selectable-agent list (queryReagent, AdminController.php:420).
model SalesExpertProfile {
id String @id @default(cuid())
adminUserId String @unique
adminUser AdminUser @relation(fields: [adminUserId], references: [id], onDelete: Cascade)
/// Legacy customers.city_id — a filterable grid column
/// (ReagentListModel.js:14) and the source of the commission autofill
/// (ReagentCustomer.vue:215-228).
cityId String?
city City? @relation(fields: [cityId], references: [id])
/// Legacy customers.gram_level — the «سطح اعتبار» column, filterable.
/// Decimal(14,4) matches Customer.goldCreditLimitGrams exactly so the same
/// credit limit has identical precision on both tables.
gramLevel Decimal @default(0) @db.Decimal(14, 4)
/// Legacy customers.phone (varchar 15) and ncode (varchar 20). The reference
/// stores both and reads neither back; kept so the form matches it. Neither
/// is unique and neither has a validation rule in the reference — inventing
/// one would reject data the reference accepts.
phone String? @db.VarChar(15)
nationalCode String? @db.VarChar(20)
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@index([cityId])
}
model Customer {