Files
tehran_taj/apps/api/scripts/test-sales-expert-roundtrip.cjs
T
s.esmaeiliandClaude Opus 5 2194b3d4d9 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>
2026-09-22 17:50:47 +03:30

170 lines
8.9 KiB
JavaScript

/**
* 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); });