diff --git a/apps/admin-panel/src/views/order/List.vue b/apps/admin-panel/src/views/order/List.vue index 3963919..4ddf769 100644 --- a/apps/admin-panel/src/views/order/List.vue +++ b/apps/admin-panel/src/views/order/List.vue @@ -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) { 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) { async function removeSts(row: Record) { 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(); diff --git a/apps/api/scripts/smoke-admin.cjs b/apps/api/scripts/smoke-admin.cjs new file mode 100644 index 0000000..b785360 --- /dev/null +++ b/apps/api/scripts/smoke-admin.cjs @@ -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); +}); diff --git a/docs/parity/CHANGELOG.md b/docs/parity/CHANGELOG.md index 5cff041..420ab09 100644 --- a/docs/parity/CHANGELOG.md +++ b/docs/parity/CHANGELOG.md @@ -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.