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:
co-authored by
Claude Opus 5
parent
e3af31948e
commit
2194b3d4d9
+180
@@ -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");
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user