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 {
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* D12 rule E0 round-trip test for the SALES EXPERT entity, plus the two grid
|
||||
* filters D17 required.
|
||||
*
|
||||
* All Persian input is written here as literals and sent as UTF-8 JSON, never
|
||||
* through a shell argument (D15).
|
||||
*/
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
const API = 'http://localhost:3077/api';
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const chk = (c, m) => { c ? pass++ : fail++; console.log((c ? ' PASS ' : ' FAIL ') + m); };
|
||||
|
||||
const FIRST = 'محمد علی';
|
||||
const LAST = 'احمدی';
|
||||
|
||||
async function login() {
|
||||
const r = await fetch(API + '/admin/auth/login', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ phone: '09128081392', password: '09128081392' }),
|
||||
});
|
||||
return (await r.json()).api_token;
|
||||
}
|
||||
|
||||
const snap = async (id) => {
|
||||
const u = await prisma.adminUser.findUnique({
|
||||
where: { id }, include: { salesExpertProfile: true },
|
||||
});
|
||||
const { updatedAt, ...profile } = u.salesExpertProfile ?? {};
|
||||
return JSON.stringify({
|
||||
fullName: u.fullName, firstName: u.firstName, lastName: u.lastName,
|
||||
mobile: u.mobile, role: u.role, wagePercent: String(u.wagePercent),
|
||||
address: u.address, storeName: u.storeName,
|
||||
profile: { ...profile, gramLevel: String(profile.gramLevel) },
|
||||
});
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const token = await login();
|
||||
const H = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token };
|
||||
const city = await prisma.city.findFirst({ select: { id: true, name: true } });
|
||||
const city2 = await prisma.city.findFirst({ where: { id: { not: city.id } }, select: { id: true, name: true } });
|
||||
const mobile = '0912' + String(Date.now()).slice(-7);
|
||||
|
||||
console.log('STEP 1 — create a sales expert with EVERY field populated');
|
||||
const createBody = {
|
||||
fname: FIRST, lname: LAST, mobile, password: 'Test12345',
|
||||
role: 'SALES_EXPERT', percent: 4.5,
|
||||
city_id: city.id, gram_level: 250.5, phone: '02188887777', ncode: '1234567890',
|
||||
address: 'تهران، خیابان ولیعصر', store_name: 'طلای محمد', pasazh_name: 'پاساژ بزرگ', mcode: 'SE-001',
|
||||
};
|
||||
const cr = await fetch(API + '/admin/users', { method: 'POST', headers: H, body: JSON.stringify(createBody) });
|
||||
const created = await cr.json();
|
||||
if (!created.id) { console.log(' create failed:', JSON.stringify(created).slice(0, 300)); process.exit(1); }
|
||||
const id = created.id;
|
||||
console.log(' id:', id);
|
||||
|
||||
const row = await prisma.adminUser.findUnique({ where: { id }, include: { salesExpertProfile: true } });
|
||||
chk(row.firstName === FIRST, `firstName stored as «${FIRST}» — a two-token first name is NOT re-split`);
|
||||
chk(row.lastName === LAST, `lastName stored as «${LAST}»`);
|
||||
chk(row.fullName === `${FIRST} ${LAST}`, 'fullName rebuilt by the trigger');
|
||||
chk(!!row.salesExpertProfile, 'SalesExpertProfile auto-created on create');
|
||||
chk(row.salesExpertProfile?.cityId === city.id, 'profile.cityId stored');
|
||||
chk(String(row.salesExpertProfile?.gramLevel) === '250.5', 'profile.gramLevel stored (250.5)');
|
||||
chk(row.salesExpertProfile?.phone === '02188887777', 'profile.phone stored');
|
||||
chk(row.salesExpertProfile?.nationalCode === '1234567890', 'profile.nationalCode stored');
|
||||
|
||||
console.log('\nSTEP 2 — open the edit form: every field populated, separately');
|
||||
const g = await (await fetch(API + `/admin/users/${id}`, { headers: H })).json();
|
||||
chk(g.fname === FIRST, `fname === «${FIRST}» (NOT the whole name)`);
|
||||
chk(g.lname === LAST, `lname === «${LAST}» (NOT empty)`);
|
||||
chk(Number(g.percent) === 4.5, 'percent === 4.5');
|
||||
|
||||
const listed = await (await fetch(API + '/admin/customers-omde/reagent/list', {
|
||||
method: 'POST', headers: H, body: JSON.stringify({ page: 1, limit: 200 }),
|
||||
})).json();
|
||||
const mine = (listed.rows ?? []).find((x) => x.id === id);
|
||||
chk(!!mine, 'the new expert appears in the reagent grid');
|
||||
chk(mine?.fname === FIRST && mine?.lname === LAST, 'grid shows the real name parts');
|
||||
chk(mine?.city_id === city.id, 'grid returns a REAL city_id (was hardcoded null)');
|
||||
chk(mine?.city?.title === city.name, 'grid returns the city object for the column');
|
||||
chk(String(mine?.gram_level) === '250.5', 'grid returns a REAL gram_level (was hardcoded null)');
|
||||
chk(mine?.phone === '02188887777', 'grid returns phone');
|
||||
chk(mine?.ncode === '1234567890', 'grid returns ncode');
|
||||
|
||||
console.log('\nSTEP 3 — save with NO changes: the row must be byte-identical');
|
||||
const before = await snap(id);
|
||||
await fetch(API + `/admin/users/${id}`, {
|
||||
method: 'PUT', headers: H,
|
||||
body: JSON.stringify({
|
||||
fname: g.fname, lname: g.lname, mobile: g.mobile, role: 'SALES_EXPERT',
|
||||
percent: Number(g.percent), city_id: mine.city_id, gram_level: Number(mine.gram_level),
|
||||
phone: mine.phone, ncode: mine.ncode, address: g.address,
|
||||
store_name: g.store_name, pasazh_name: g.pasazh_name, mcode: g.mcode,
|
||||
}),
|
||||
});
|
||||
const after = await snap(id);
|
||||
chk(before === after, 'no-op save changed nothing');
|
||||
if (before !== after) console.log(' before: ' + before + '\n after : ' + after);
|
||||
|
||||
console.log('\nSTEP 4a — change ONLY lastName');
|
||||
await fetch(API + `/admin/users/${id}`, {
|
||||
method: 'PUT', headers: H, body: JSON.stringify({ lname: 'محمدی' }),
|
||||
});
|
||||
const s4 = JSON.parse(await snap(id));
|
||||
const b4 = JSON.parse(after);
|
||||
chk(s4.lastName === 'محمدی' && s4.firstName === FIRST, `only lastName changed; firstName still «${FIRST}»`);
|
||||
chk(s4.fullName === `${FIRST} محمدی`, 'fullName rebuilt by the trigger');
|
||||
chk(JSON.stringify(s4.profile) === JSON.stringify(b4.profile), 'the profile was untouched');
|
||||
|
||||
console.log('\nSTEP 4b — change ONLY gramLevel');
|
||||
const b5 = JSON.parse(await snap(id));
|
||||
await fetch(API + `/admin/users/${id}`, {
|
||||
method: 'PUT', headers: H, body: JSON.stringify({ gram_level: 999.25 }),
|
||||
});
|
||||
const s5 = JSON.parse(await snap(id));
|
||||
const diff = Object.keys(s5.profile).filter((k) => String(s5.profile[k]) !== String(b5.profile[k]));
|
||||
chk(diff.length === 1 && diff[0] === 'gramLevel', 'only gramLevel changed: ' + diff.join(','));
|
||||
chk(s5.firstName === b5.firstName && s5.lastName === b5.lastName, 'the name was untouched');
|
||||
|
||||
console.log('\nSTEP 5 — the two grid FILTERS');
|
||||
const byCity = await (await fetch(API + '/admin/customers-omde/reagent/list', {
|
||||
method: 'POST', headers: H,
|
||||
body: JSON.stringify({ page: 1, limit: 200, filters: [{ field: 'city_id', op: '=', value: city.id }] }),
|
||||
})).json();
|
||||
chk((byCity.rows ?? []).some((x) => x.id === id), `city filter (${city.name}) returns the expert`);
|
||||
chk((byCity.rows ?? []).every((x) => x.city_id === city.id), 'city filter returns ONLY that city');
|
||||
|
||||
if (city2) {
|
||||
const otherCity = await (await fetch(API + '/admin/customers-omde/reagent/list', {
|
||||
method: 'POST', headers: H,
|
||||
body: JSON.stringify({ page: 1, limit: 200, filters: [{ field: 'city_id', op: '=', value: city2.id }] }),
|
||||
})).json();
|
||||
chk(!(otherCity.rows ?? []).some((x) => x.id === id), `filtering by a DIFFERENT city (${city2.name}) excludes it`);
|
||||
}
|
||||
|
||||
const byGram = await (await fetch(API + '/admin/customers-omde/reagent/list', {
|
||||
method: 'POST', headers: H,
|
||||
body: JSON.stringify({ page: 1, limit: 200, filters: [{ field: 'gram_level', op: '=', value: 999.25 }] }),
|
||||
})).json();
|
||||
chk((byGram.rows ?? []).some((x) => x.id === id), 'gram_level filter returns the expert');
|
||||
chk((byGram.rows ?? []).every((x) => String(x.gram_level) === '999.25'), 'gram_level filter returns ONLY matches');
|
||||
|
||||
console.log('\nSTEP 6 — an expert with NO profile still lists (left join)');
|
||||
const orphan = await prisma.adminUser.create({
|
||||
data: { fullName: 'بدون پروفایل', firstName: 'بدون', lastName: 'پروفایل',
|
||||
mobile: '0913' + String(Date.now()).slice(-7), passwordHash: 'x', role: 'SALES_EXPERT' },
|
||||
});
|
||||
const l2 = await (await fetch(API + '/admin/customers-omde/reagent/list', {
|
||||
method: 'POST', headers: H, body: JSON.stringify({ page: 1, limit: 200 }),
|
||||
})).json();
|
||||
const o = (l2.rows ?? []).find((x) => x.id === orphan.id);
|
||||
chk(!!o, 'an expert without a profile still appears in the grid');
|
||||
chk(o?.city_id === null && o?.gram_level === null, 'its city/gram cells are null, not an error');
|
||||
|
||||
console.log('\nSTEP 7 — the referrer dropdown lists every active expert');
|
||||
const opts = await (await fetch(API + '/admin/customers-omde/query/reagent', { headers: H })).json();
|
||||
chk(opts.some((x) => x.id === id), 'the new expert is selectable as a referrer');
|
||||
chk(opts.some((x) => x.id === orphan.id), 'the profile-less expert is selectable too');
|
||||
|
||||
await prisma.salesExpertProfile.deleteMany({ where: { adminUserId: { in: [id, orphan.id] } } });
|
||||
await prisma.adminUser.deleteMany({ where: { id: { in: [id, orphan.id] } } });
|
||||
console.log(`\nCleanup: deleted 2 test AdminUser rows (${id}, ${orphan.id}) and their profiles.`);
|
||||
|
||||
console.log(`\npassed: ${pass} failed: ${fail}`);
|
||||
await prisma.$disconnect();
|
||||
process.exit(fail ? 1 : 0);
|
||||
})().catch(async (e) => { console.error(e); await prisma.$disconnect(); process.exit(1); });
|
||||
@@ -125,8 +125,9 @@ export class AdminCommentsService {
|
||||
customer: {
|
||||
id: c.customer.id,
|
||||
name: c.customer.fullName,
|
||||
fname: c.customer.fullName,
|
||||
lname: '',
|
||||
// D0 — real name parts
|
||||
fname: c.customer.firstName ?? c.customer.fullName,
|
||||
lname: c.customer.lastName ?? '',
|
||||
mobile: c.customer.mobile,
|
||||
},
|
||||
caj: c.createdAt,
|
||||
|
||||
@@ -322,10 +322,16 @@ export class AdminCustomersService {
|
||||
|
||||
const aliases: Record<string, string> = {
|
||||
name: 'fullName',
|
||||
lname: 'fullName',
|
||||
// D0/D17 — real columns on AdminUser now, as on Customer.
|
||||
fname: 'firstName',
|
||||
lname: 'lastName',
|
||||
caj: 'createdAt',
|
||||
active: 'isActive',
|
||||
percent: 'wagePercent',
|
||||
// D17 — these live on the related SalesExpertProfile, so the grid's two
|
||||
// filters have to reach through the relation.
|
||||
city_id: 'salesExpertProfile.cityId',
|
||||
gram_level: 'salesExpertProfile.gramLevel',
|
||||
};
|
||||
const mapField = (f?: string) => (f ? (aliases[f] ?? f) : f);
|
||||
|
||||
@@ -344,8 +350,21 @@ export class AdminCustomersService {
|
||||
};
|
||||
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, {
|
||||
searchable: ['fullName', 'mobile'],
|
||||
allowed: ['id', 'fullName', 'mobile', 'isActive', 'createdAt', 'wagePercent', 'role'],
|
||||
searchable: ['fullName', 'firstName', 'lastName', 'mobile'],
|
||||
allowed: [
|
||||
'id',
|
||||
'fullName',
|
||||
'firstName',
|
||||
'lastName',
|
||||
'mobile',
|
||||
'isActive',
|
||||
'createdAt',
|
||||
'wagePercent',
|
||||
'role',
|
||||
// D17 — the grid's «شهر» and «سطح اعتبار» filters.
|
||||
'salesExpertProfile.cityId',
|
||||
'salesExpertProfile.gramLevel',
|
||||
],
|
||||
defaultOrderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
@@ -359,7 +378,14 @@ export class AdminCustomersService {
|
||||
orderBy: orderBy as Prisma.AdminUserOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: { branch: true, _count: { select: { referredCustomers: true } } },
|
||||
// D17 — a LEFT JOIN by nature in Prisma: an expert without a profile
|
||||
// still comes back, with `salesExpertProfile` null, so a missing row
|
||||
// degrades to empty cells rather than dropping the agent from the grid.
|
||||
include: {
|
||||
branch: true,
|
||||
salesExpertProfile: { include: { city: true } },
|
||||
_count: { select: { referredCustomers: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.adminUser.count({ where: scoped }),
|
||||
]);
|
||||
@@ -374,11 +400,17 @@ export class AdminCustomersService {
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.fullName,
|
||||
fname: r.fullName,
|
||||
lname: '',
|
||||
// D0 requirement 7 — the real parts, as on Customer. This used to be
|
||||
// `fname: r.fullName, lname: ''`, the same defect the customer forms had.
|
||||
fname: r.firstName ?? r.fullName,
|
||||
lname: r.lastName ?? '',
|
||||
mobile: r.mobile,
|
||||
city_id: null,
|
||||
city: null,
|
||||
// D17 — real values now, so the grid's city column and its filter work.
|
||||
// Null-safe: an expert with no profile row simply shows empty.
|
||||
city_id: r.salesExpertProfile?.cityId ?? null,
|
||||
city: r.salesExpertProfile?.city
|
||||
? { id: r.salesExpertProfile.city.id, title: r.salesExpertProfile.city.name }
|
||||
: null,
|
||||
percent: r.wagePercent,
|
||||
grams_sell: agg._sum.totalGoldWeightGrams ?? 0,
|
||||
child: r._count.referredCustomers,
|
||||
@@ -388,12 +420,15 @@ export class AdminCustomersService {
|
||||
caj: r.createdAt,
|
||||
uaj: r.createdAt,
|
||||
branch: r.branch ? { id: r.branch.id, name: r.branch.name } : null,
|
||||
// Legacy columns an AdminUser has no counterpart for.
|
||||
gram_level: null,
|
||||
// D17 — from SalesExpertProfile. Null-safe for an expert with no row.
|
||||
gram_level: r.salesExpertProfile?.gramLevel ?? null,
|
||||
phone: r.salesExpertProfile?.phone ?? null,
|
||||
ncode: r.salesExpertProfile?.nationalCode ?? null,
|
||||
// Still genuinely absent: a sales expert is an AdminUser and has no
|
||||
// ledger of its own, so it carries no gram or rial debt (D10).
|
||||
remain: null,
|
||||
rial_remain: null,
|
||||
phone: null,
|
||||
address: null,
|
||||
address: r.address ?? null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -182,8 +182,9 @@ export class AdminDocumentsService {
|
||||
customer: {
|
||||
id: e.customer.id,
|
||||
name: e.customer.fullName,
|
||||
fname: e.customer.fullName,
|
||||
lname: '',
|
||||
// D0 — real name parts
|
||||
fname: e.customer.firstName ?? e.customer.fullName,
|
||||
lname: e.customer.lastName ?? '',
|
||||
mobile: e.customer.mobile,
|
||||
},
|
||||
posted_by: e.postedByAdminUser ? e.postedByAdminUser.fullName : null,
|
||||
@@ -432,8 +433,15 @@ export class AdminDocumentsService {
|
||||
const rows = await this.prisma.customer.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: { fullName: 'asc' },
|
||||
select: { id: true, fullName: true, mobile: true },
|
||||
select: { id: true, fullName: true, firstName: true, lastName: true, mobile: true },
|
||||
});
|
||||
return rows.map((c) => ({ id: c.id, name: c.fullName, fname: c.fullName, lname: '', mobile: c.mobile }));
|
||||
// D0 — real name parts.
|
||||
return rows.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.fullName,
|
||||
fname: c.firstName ?? c.fullName,
|
||||
lname: c.lastName ?? '',
|
||||
mobile: c.mobile,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,8 +129,8 @@ export class AdminOrdersService {
|
||||
id: o.customer.id,
|
||||
// Legacy split names into fname/lname; the new schema has one fullName.
|
||||
// Both are exposed so ported templates binding either keep working.
|
||||
fname: o.customer.fullName,
|
||||
lname: '',
|
||||
fname: o.customer.firstName ?? o.customer.fullName,
|
||||
lname: o.customer.lastName ?? '',
|
||||
fullName: o.customer.fullName,
|
||||
phone: o.customer.mobile,
|
||||
},
|
||||
|
||||
@@ -92,8 +92,9 @@ export class AdminPreOrdersService {
|
||||
workshop: p.workshop ? { id: p.workshop.id, name: p.workshop.name } : null,
|
||||
customer: {
|
||||
id: p.customer.id,
|
||||
fname: p.customer.fullName,
|
||||
lname: '',
|
||||
// D0 — real name parts
|
||||
fname: p.customer.firstName ?? p.customer.fullName,
|
||||
lname: p.customer.lastName ?? '',
|
||||
name: p.customer.fullName,
|
||||
fullName: p.customer.fullName,
|
||||
mobile: p.customer.mobile,
|
||||
|
||||
@@ -93,13 +93,24 @@ export class AdminUsersService {
|
||||
private toRow(
|
||||
u: Prisma.AdminUserGetPayload<{
|
||||
include: { userType: true; branch: true; avatarFile: true };
|
||||
}>,
|
||||
}> & {
|
||||
// D17 — present on the detail read; absent from the list query, hence optional.
|
||||
salesExpertProfile?: {
|
||||
cityId: string | null;
|
||||
gramLevel: Prisma.Decimal;
|
||||
phone: string | null;
|
||||
nationalCode: string | null;
|
||||
city?: { id: string; name: string } | null;
|
||||
} | null;
|
||||
},
|
||||
) {
|
||||
return {
|
||||
id: u.id,
|
||||
name: u.fullName,
|
||||
fname: u.fullName,
|
||||
lname: '',
|
||||
// D0 requirement 7 — the real parts. The fallback only matters for a row
|
||||
// written before the 20260922180000 migration backfilled them.
|
||||
fname: u.firstName ?? u.fullName,
|
||||
lname: u.lastName ?? '',
|
||||
mobile: u.mobile,
|
||||
role: u.role,
|
||||
ut: u.userTypeId,
|
||||
@@ -119,13 +130,28 @@ export class AdminUsersService {
|
||||
mcode: u.memberCode,
|
||||
image_id: u.avatarFileId,
|
||||
image: u.avatarFile ? { id: u.avatarFile.id, image: u.avatarFile.url } : null,
|
||||
// D17 — from SalesExpertProfile. Null for a non-expert, and null-safe for
|
||||
// an expert whose profile row is somehow missing.
|
||||
city_id: u.salesExpertProfile?.cityId ?? null,
|
||||
city: u.salesExpertProfile?.city
|
||||
? { id: u.salesExpertProfile.city.id, title: u.salesExpertProfile.city.name }
|
||||
: null,
|
||||
gram_level: u.salesExpertProfile?.gramLevel ?? null,
|
||||
phone: u.salesExpertProfile?.phone ?? null,
|
||||
ncode: u.salesExpertProfile?.nationalCode ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const u = await this.prisma.adminUser.findUnique({
|
||||
where: { id },
|
||||
include: { userType: true, branch: true, avatarFile: true },
|
||||
// D17 — the edit form needs the sales-expert fields too.
|
||||
include: {
|
||||
userType: true,
|
||||
branch: true,
|
||||
avatarFile: true,
|
||||
salesExpertProfile: { include: { city: true } },
|
||||
},
|
||||
});
|
||||
if (!u) throw new NotFoundException('کاربر یافت نشد');
|
||||
return this.toRow(u);
|
||||
@@ -140,7 +166,11 @@ export class AdminUsersService {
|
||||
* کاربری") actually chosen. UserCreate.vue now always sends one.
|
||||
*/
|
||||
async create(data: Record<string, unknown>) {
|
||||
const fullName = String(data.name ?? data.fname ?? '').trim();
|
||||
// D0 requirement 7 — store the real name parts, as Customer does. `name`
|
||||
// is still accepted for 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 password = String(data.password ?? '');
|
||||
const role = data.role as AdminRole | undefined;
|
||||
@@ -156,6 +186,8 @@ export class AdminUsersService {
|
||||
return this.prisma.adminUser.create({
|
||||
data: {
|
||||
fullName,
|
||||
firstName: firstName || null,
|
||||
lastName: lastName || null,
|
||||
mobile,
|
||||
passwordHash: await bcrypt.hash(password, 10),
|
||||
role,
|
||||
@@ -173,18 +205,60 @@ export class AdminUsersService {
|
||||
pasazhName: (data.pasazh_name as string) || null,
|
||||
memberCode: (data.mcode as string) || null,
|
||||
avatarFileId: (data.image_id as string) || null,
|
||||
// D17 — a sales expert always gets its profile row, so the grid's city
|
||||
// and credit-limit columns have somewhere to read from immediately.
|
||||
...(role === 'SALES_EXPERT'
|
||||
? { salesExpertProfile: { create: this.salesExpertProfileData(data) } }
|
||||
: {}),
|
||||
},
|
||||
include: {
|
||||
userType: true,
|
||||
branch: true,
|
||||
avatarFile: true,
|
||||
salesExpertProfile: { include: { city: true } },
|
||||
},
|
||||
include: { userType: true, branch: true, avatarFile: true },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The SalesExpertProfile columns as posted by ReagentCustomer.vue (D17).
|
||||
*
|
||||
* `phone` and `nationalCode` are stored exactly as the reference does:
|
||||
* optional, not unique, no format rule — the reference validates only
|
||||
* `mobile` and `percent` on this form (AdminController.php:354).
|
||||
*/
|
||||
private salesExpertProfileData(data: Record<string, unknown>) {
|
||||
return {
|
||||
cityId: (data.city_id as string) || null,
|
||||
gramLevel:
|
||||
data.gram_level !== undefined && data.gram_level !== null && data.gram_level !== ''
|
||||
? new Prisma.Decimal(String(data.gram_level))
|
||||
: new Prisma.Decimal(0),
|
||||
phone: (data.phone as string) || null,
|
||||
nationalCode: (data.ncode as string) || null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Legacy `PUT users/{id}`. */
|
||||
async update(id: string, data: Record<string, unknown>) {
|
||||
const existing = await this.prisma.adminUser.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('کاربر یافت نشد');
|
||||
|
||||
const patch: Prisma.AdminUserUpdateInput = {};
|
||||
if (data.name !== undefined) patch.fullName = String(data.name);
|
||||
|
||||
// D0 requirement 7 — write the parts and let the
|
||||
// admin_user_sync_full_name trigger rebuild `fullName`. When a caller sends
|
||||
// both, the parts win (see the migration 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);
|
||||
}
|
||||
|
||||
if (data.mobile !== undefined) {
|
||||
const mobile = String(data.mobile);
|
||||
if (mobile !== existing.mobile) {
|
||||
@@ -220,10 +294,47 @@ export class AdminUsersService {
|
||||
: { disconnect: true };
|
||||
}
|
||||
|
||||
// D17 — the profile follows the role.
|
||||
//
|
||||
// `upsert` covers all three cases in one statement: a user becoming a sales
|
||||
// expert gets a profile; an existing expert's profile is updated; and an
|
||||
// expert whose profile went missing gets one back rather than erroring.
|
||||
// A profile is never deleted when the role changes away — that would
|
||||
// silently discard the operator's city and credit limit on a mistaken role
|
||||
// switch, and it costs nothing to keep.
|
||||
const effectiveRole = (patch.role as AdminRole | undefined) ?? existing.role;
|
||||
const touchesProfile =
|
||||
data.city_id !== undefined ||
|
||||
data.gram_level !== undefined ||
|
||||
data.phone !== undefined ||
|
||||
data.ncode !== undefined;
|
||||
|
||||
if (effectiveRole === 'SALES_EXPERT' && (touchesProfile || patch.role !== undefined)) {
|
||||
const profile = this.salesExpertProfileData(data);
|
||||
patch.salesExpertProfile = {
|
||||
upsert: {
|
||||
create: profile,
|
||||
// Only the keys the caller actually sent, so an untouched field is
|
||||
// not overwritten with a default on a partial (diff-send) save.
|
||||
update: {
|
||||
...(data.city_id !== undefined ? { cityId: profile.cityId } : {}),
|
||||
...(data.gram_level !== undefined ? { gramLevel: profile.gramLevel } : {}),
|
||||
...(data.phone !== undefined ? { phone: profile.phone } : {}),
|
||||
...(data.ncode !== undefined ? { nationalCode: profile.nationalCode } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return this.prisma.adminUser.update({
|
||||
where: { id },
|
||||
data: patch,
|
||||
include: { userType: true, branch: true, avatarFile: true },
|
||||
include: {
|
||||
userType: true,
|
||||
branch: true,
|
||||
avatarFile: true,
|
||||
salesExpertProfile: { include: { city: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user