fix(admin-panel): retail order status editing posted a body with no orderId

views/order/List.vue handed the timeline activity entry straight to
single/order-sts/edit. That entry is {status, title, proceed, uaj, note} and has
no order id, so UpdateOrderStatusDto rejected every call with 400 — neither
"ثبت وضعیت" nor "حذف وضعیت" worked on the retail order screen. Both handlers now
take the id from tempRow, matching OmdeList.vue and TaminList.vue, which were
already correct.

Found by the new scripts/smoke-admin.cjs (43/43), which walks login, customer
create, sales-expert create/edit, product+variant create/edit and order
list/detail/status, asserting the database changed rather than trusting a 200.

[ref: henza_server/routes/admin.php:59]

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-09-22 21:30:58 +03:30
co-authored by Claude Opus 5
parent 676ec3ed72
commit 0983c42788
3 changed files with 354 additions and 3 deletions
+20 -3
View File
@@ -198,9 +198,22 @@ async function loadDetail(id: string) {
return data;
}
/**
* Both handlers receive one ACTIVITY entry from the timeline
* (`{status, title, proceed, uaj, note}` — admin-orders.service.ts:249-258),
* which carries no order id. Posting that object straight through left
* `orderId` missing and UpdateOrderStatusDto rejected every call with 400, so
* neither "ثبت وضعیت" nor "حذف وضعیت" worked on this screen at all. The id
* comes from `tempRow`, the order loaded when the dialog opened. OmdeList.vue
* already built its body this way (OmdeList.vue:187).
*/
async function setSts(row: Record<string, any>) {
try {
await api.post('single/order-sts/edit', row);
await api.post('single/order-sts/edit', {
orderId: tempRow.value?.id,
status: row.status,
note: row.note,
});
visibleSts.value = false;
message.info('ویرایش موفق آمیز');
tbl.value?.fetch();
@@ -211,8 +224,12 @@ async function setSts(row: Record<string, any>) {
async function removeSts(row: Record<string, any>) {
try {
row.remove = true;
await api.post('single/order-sts/edit', row);
await api.post('single/order-sts/edit', {
orderId: tempRow.value?.id,
status: row.status,
note: row.note,
remove: true,
});
visibleSts.value = false;
message.info('ویرایش موفق آمیز');
tbl.value?.fetch();
+308
View File
@@ -0,0 +1,308 @@
/**
* Delivery smoke test — the admin flows the owner listed (D19 step 3):
*
* login → create a customer → create and edit a sales expert
* → create and edit a product with variants
* → view an order and change its status
*
* This is a WORKING check, not a parity audit: each step asserts that the
* operation actually took effect in the database, not merely that the HTTP
* call returned 200. A 200 with a silently-dropped field is exactly the class
* of bug this is meant to catch.
*
* Every Persian string is a literal in this file and travels as UTF-8 JSON —
* never as a shell argument (D15).
*
* Usage: node scripts/smoke-admin.cjs [apiBaseUrl]
*/
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
const API = (process.argv[2] || 'http://localhost:3000/api').replace(/\/$/, '');
let pass = 0;
let fail = 0;
const failures = [];
const chk = (cond, msg) => {
if (cond) {
pass++;
console.log(' PASS ' + msg);
} else {
fail++;
failures.push(msg);
console.log(' FAIL ' + msg);
}
};
const step = (t) => console.log('\n' + t);
/** Records created by this run, removed at the end. */
const created = { customers: [], adminUsers: [], products: [] };
let H = {};
const call = async (method, path, body) => {
const res = await fetch(`${API}/${path}`, {
method,
headers: H,
body: body === undefined ? undefined : JSON.stringify(body),
});
let payload = null;
try {
payload = await res.json();
} catch {
/* empty body */
}
return { status: res.status, ok: res.ok, body: payload };
};
(async () => {
// ── 1. LOGIN ────────────────────────────────────────────────────────────
step('1 — ADMIN LOGIN');
const login = await fetch(`${API}/admin/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone: '09128081392', password: '09128081392' }),
});
const auth = await login.json();
chk(login.status === 200 || login.status === 201, `POST admin/auth/login -> ${login.status}`);
chk(typeof auth.api_token === 'string', 'a token came back');
if (!auth.api_token) {
console.log('\nCannot continue without a token.');
process.exit(1);
}
H = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + auth.api_token };
const unique = String(Date.now()).slice(-7);
// ── 2. CREATE A CUSTOMER ────────────────────────────────────────────────
step('2 — CREATE A WHOLESALE CUSTOMER');
const custMobile = '0912' + unique;
const cust = await call('POST', 'admin/customers-omde/create', {
fname: 'سمیرا',
lname: 'کریمی',
name: 'سمیرا کریمی',
mobile: custMobile,
percent: 3,
type: 2,
store_name: 'گالری سمیرا',
gram_level: 150,
});
chk(cust.ok, `POST customers-omde/create -> ${cust.status}`);
const custId = cust.body?.id;
if (custId) created.customers.push(custId);
chk(Boolean(custId), 'an id came back');
if (custId) {
const row = await prisma.customer.findUnique({ where: { id: custId } });
chk(row?.firstName === 'سمیرا' && row?.lastName === 'کریمی', 'the name parts were stored separately');
chk(row?.fullName === 'سمیرا کریمی', 'fullName was rebuilt by the trigger');
chk(String(row?.makingChargeOffsetPercent) === '3', 'percent persisted as 3');
chk(row?.storeName === 'گالری سمیرا', 'store_name persisted');
}
step('2b — Persian digits in the mobile are normalised (D19 gap 2)');
const faMobile = '۰۹۱۳' + unique;
const faCust = await call('POST', 'admin/customers-omde/create', {
fname: 'نگین',
lname: 'مرادی',
mobile: faMobile,
percent: 3,
type: 2,
});
chk(faCust.ok, `create with Persian digits -> ${faCust.status}`);
if (faCust.body?.id) {
created.customers.push(faCust.body.id);
const row = await prisma.customer.findUnique({ where: { id: faCust.body.id } });
chk(row?.mobile === '0913' + unique, `stored as ASCII "${row?.mobile}" (not Persian digits)`);
}
// ── 3. SALES EXPERT: CREATE, READ BACK, EDIT ────────────────────────────
step('3 — CREATE A SALES EXPERT');
const expMobile = '0914' + unique;
const exp = await call('POST', 'admin/users', {
fname: 'محمد علی',
lname: 'احمدی',
name: 'محمد علی احمدی',
mobile: expMobile,
password: 'Test12345',
role: 'SALES_EXPERT',
percent: 4.5,
gram_level: 250,
});
chk(exp.ok, `POST admin/users -> ${exp.status}`);
const expId = exp.body?.id;
if (expId) created.adminUsers.push(expId);
chk(Boolean(expId), 'an id came back');
if (expId) {
const row = await prisma.adminUser.findUnique({
where: { id: expId },
include: { salesExpertProfile: true },
});
chk(row?.firstName === 'محمد علی', 'a two-token first name was NOT re-split');
chk(row?.lastName === 'احمدی', 'lastName stored');
chk(Boolean(row?.salesExpertProfile), 'the SalesExpertProfile was auto-created');
step('3b — EDIT the sales expert (one field only)');
const edit = await call('PUT', `admin/users/${expId}`, { gram_level: 999 });
chk(edit.ok, `PUT admin/users/:id -> ${edit.status}`);
const after = await prisma.adminUser.findUnique({
where: { id: expId },
include: { salesExpertProfile: true },
});
chk(String(after?.salesExpertProfile?.gramLevel) === '999', 'gram_level was persisted');
chk(after?.firstName === 'محمد علی' && after?.lastName === 'احمدی', 'the name was untouched');
const grid = await call('POST', 'admin/customers-omde/reagent/list', { page: 1, limit: 200 });
chk(grid.ok, `POST reagent/list -> ${grid.status}`);
const mine = (grid.body?.rows ?? []).find((r) => r.id === expId);
chk(Boolean(mine), 'the expert appears in the grid');
chk(mine?.fname === 'محمد علی' && mine?.lname === 'احمدی', 'the grid shows the real name parts');
}
// ── 4. PRODUCT + VARIANTS: CREATE, READ BACK, EDIT ──────────────────────
step('4 — CREATE A PRODUCT WITH VARIANTS');
const prod = await call('POST', 'admin/products/create', {
title: 'انگشتر طلا ' + unique,
body_html: 'توضیح آزمایشی',
product_code: 'SMK-' + unique,
show_type: 3,
is_active: true,
variants: [
{ title: 'سایز ۱', grams: 2.5, percentage_pay: 7, qty: 4 },
{ title: 'سایز ۲', grams: 3.25, percentage_pay: 7, qty: 0, is_pre_order: true },
],
});
chk(prod.ok, `POST admin/products/create -> ${prod.status}`);
const prodId = prod.body?.id;
if (prodId) created.products.push(prodId);
chk(Boolean(prodId), 'an id came back');
if (prodId) {
const variants = await prisma.productVariant.findMany({
where: { productId: prodId },
orderBy: { title: 'asc' },
});
chk(variants.length === 2, `both variants were created (got ${variants.length})`);
chk(
variants.some((v) => String(v.rashWeightGrams) === '2.5'),
'the 2.5g weight round-tripped exactly (no float drift)',
);
chk(
variants.some((v) => String(v.defaultWagePercent) === '7'),
'the making charge (اجرت) persisted as 7',
);
chk(variants.every((v) => Boolean(v.sku)), 'every variant got a SKU');
chk(
variants.some((v) => v.isPreOrder === true),
'the pre-order flag persisted',
);
step('4b — OPEN the product for edit');
const show = await call('GET', `admin/products/${prodId}`);
chk(show.ok, `GET admin/products/:id -> ${show.status}`);
chk(show.body?.title === 'انگشتر طلا ' + unique, 'the title reads back intact');
// The form reads `variants_all` (AddProduct.vue:488), not `variants`.
const shownVariants = show.body?.variants_all ?? [];
chk(shownVariants.length === 2, `the edit form receives both variants (got ${shownVariants.length})`);
step('4c — EDIT the product');
const upd = await call('PUT', `admin/products/${prodId}`, { title: 'انگشتر طلا ویرایش‌شده' });
chk(upd.ok, `PUT admin/products/:id -> ${upd.status}`);
const afterP = await prisma.product.findUnique({ where: { id: prodId } });
chk(afterP?.title === 'انگشتر طلا ویرایش‌شده', 'the new title persisted');
chk(afterP?.productCode === 'SMK-' + unique, 'product_code was NOT wiped by the partial edit');
const stillThere = await prisma.productVariant.count({ where: { productId: prodId } });
chk(stillThere === 2, `the variants survived the edit (${stillThere} of 2)`);
}
// ── 5. ORDERS: LIST, VIEW, CHANGE STATUS ────────────────────────────────
step('5 — ORDER LIST / DETAIL / STATUS');
const orders = await call('POST', 'admin/orders/list', { page: 1, limit: 5 });
chk(orders.ok, `POST admin/orders/list -> ${orders.status}`);
const wholesale = await call('POST', 'admin/orders/wholesale/list', { page: 1, limit: 5 });
chk(wholesale.ok, `POST admin/orders/wholesale/list -> ${wholesale.status}`);
const statusOpts = await call('GET', 'admin/orders/status-options');
chk(statusOpts.ok, `GET admin/orders/status-options -> ${statusOpts.status}`);
const anyOrder =
(orders.body?.rows ?? [])[0] ?? (wholesale.body?.rows ?? [])[0] ?? null;
if (!anyOrder) {
console.log(' (no order exists in this database — detail/status checks skipped)');
} else {
const detail = await call('GET', `admin/orders/${anyOrder.id}`);
chk(detail.ok, `GET admin/orders/:id -> ${detail.status}`);
chk(Boolean(detail.body?.id), 'the order detail came back');
const before = await prisma.order.findUnique({ where: { id: anyOrder.id } });
const options = Array.isArray(statusOpts.body) ? statusOpts.body : [];
// Pick a status that is NOT the current one, so the write is observable.
const targetOpt = options.find(
(o) => String(o.id ?? o.value) !== String(before?.status),
);
if (!targetOpt || !before) {
console.log(' (no alternative status available — status change skipped)');
} else {
const target = String(targetOpt.id ?? targetOpt.value);
// UpdateOrderStatusDto requires `orderId` — the exact field the retail
// List.vue screen was failing to send (fixed in this batch).
const res = await call('POST', 'admin/orders/status', {
orderId: anyOrder.id,
status: target,
});
chk(res.ok, `POST admin/orders/status -> ${res.status}`);
const after = await prisma.order.findUnique({ where: { id: anyOrder.id } });
chk(String(after?.status) === target, `the status actually changed to ${target}`);
// Put it back exactly as it was — this is live data, not a fixture.
await prisma.order.update({
where: { id: anyOrder.id },
data: { status: before.status },
});
const restored = await prisma.order.findUnique({ where: { id: anyOrder.id } });
chk(restored?.status === before.status, 'the original status was restored');
}
}
// ── CLEANUP ─────────────────────────────────────────────────────────────
step('CLEANUP — removing only the rows this run created');
if (created.products.length) {
await prisma.productVariant.deleteMany({ where: { productId: { in: created.products } } });
await prisma.productImage.deleteMany({ where: { productId: { in: created.products } } });
await prisma.product.deleteMany({ where: { id: { in: created.products } } });
}
if (created.adminUsers.length) {
await prisma.salesExpertProfile.deleteMany({
where: { adminUserId: { in: created.adminUsers } },
});
await prisma.adminUser.deleteMany({ where: { id: { in: created.adminUsers } } });
}
if (created.customers.length) {
await prisma.customer.deleteMany({ where: { id: { in: created.customers } } });
}
console.log(
` deleted: ${created.products.length} product(s) + their variants/images, ` +
`${created.adminUsers.length} admin user(s) + profiles, ` +
`${created.customers.length} customer(s). No pre-existing data was removed.`,
);
console.log(`\n${'='.repeat(60)}`);
console.log(`ADMIN SMOKE: passed ${pass} failed ${fail}`);
if (fail) {
console.log('\nFailures:');
failures.forEach((f) => console.log(' - ' + f));
}
console.log('='.repeat(60));
await prisma.$disconnect();
process.exit(fail ? 1 : 0);
})().catch(async (e) => {
console.error('\nSMOKE ABORTED:', e);
await prisma.$disconnect();
process.exit(1);
});
+26
View File
@@ -611,3 +611,29 @@ appeared instead of an error — no operation was blocked (D20).
**Data touched:** `test-master-otp-disabled.cjs` deletes the `CustomerOtp` rows
**for its own test number only** (to clear the 60s resend cooldown); 1 row was
deleted on the recorded run. No other data was created, changed or removed.
### Admin smoke test + a real bug it caught (D19 step 3)
New `apps/api/scripts/smoke-admin.cjs` — **43/43**. Walks the flows the owner
listed: login → create a customer → create and edit a sales expert → create and
edit a product with variants → list, view and change an order's status. Each
step asserts the DATABASE changed, not just that the call returned 200.
**ORD-01 (fixed) — retail order status editing was completely broken.**
`views/order/List.vue:203,215` posted the timeline ACTIVITY entry straight to
`single/order-sts/edit`. That object is `{status, title, proceed, uaj, note}`
(`admin-orders.service.ts:249-258`) and carries **no order id**, so
`UpdateOrderStatusDto` rejected every call with 400: neither «ثبت وضعیت» nor
«حذف وضعیت» worked on that screen. Both handlers now build the body from
`tempRow.id`, the order loaded when the dialog opens — the shape `OmdeList.vue:187`
and `TaminList.vue:169,184` already used. Those two screens were checked and
were already correct; `List.vue` was the only broken caller.
Two assertions in the first draft of the smoke test were wrong, not the product:
the product form reads `variants_all` (not `variants`), and the status endpoint
takes `orderId` (not `id`/`sts`). Both corrected.
**Data touched:** the script creates and then deletes its own rows only
(1 product + its variants/images, 1 admin user + profile, 2 customers per run).
The order status change is made against an existing order and then **restored to
its original value** in the same run; no pre-existing row is left modified.