/** * 采购订单审批 - 浏览器端到端测试 * * 前置数据用 admin-api 直接准备(一次跑完留下:1 张未提交 + 1 张审批中), * 之后全部走真实浏览器交互验证按钮可见性、弹窗校验与状态流转。 * * 用法:node scripts/test-purchase-order-ui.js */ const { chromium } = require('playwright'); const path = require('path'); const fs = require('fs'); const API = process.env.TEST_API || 'http://127.0.0.1:48080/admin-api'; const BASE = process.env.TEST_BASE || 'http://localhost:5666'; const USER = process.env.TEST_USER || 'admin'; const PASS = process.env.TEST_PASS || 'admin123'; const BIZ_TYPE = 'erp_purchase_order_approve'; const PRODUCT_ID = Number(process.env.PRODUCT_ID || 57); const PRODUCT_UNIT_ID = Number(process.env.PRODUCT_UNIT_ID || 9); const SHOT_DIR = path.join(__dirname, 'shots'); if (!fs.existsSync(SHOT_DIR)) fs.mkdirSync(SHOT_DIR, { recursive: true }); const results = []; function ok(name, extra = '') { results.push({ pass: true, name }); console.log(` ✅ ${name}${extra ? ' — ' + extra : ''}`); } function fail(name, extra = '') { results.push({ pass: false, name, extra }); console.log(` ❌ ${name}${extra ? ' — ' + extra : ''}`); } let TOKEN = ''; async function api(method, p, body) { const res = await fetch(API + p, { method, headers: { 'Content-Type': 'application/json', 'tenant-id': '1', ...(TOKEN ? { Authorization: `Bearer ${TOKEN}` } : {}), }, body: body ? JSON.stringify(body) : undefined, }); try { return await res.json(); } catch { return null; } } async function seed() { console.log('\n[S] 准备测试数据(接口)'); const lg = await api('POST', '/system/auth/login', { username: USER, password: PASS }); if (!lg || lg.code !== 0) { fail('接口登录失败', JSON.stringify(lg)); process.exit(1); } TOKEN = lg.data.accessToken; ok('接口登录成功'); const page = await api('GET', `/system/approval-config/page?pageNo=1&pageSize=100&bizType=${BIZ_TYPE}`); const cfg = (page?.data?.list || []).find((x) => x.bizType === BIZ_TYPE); if (!cfg) { fail('缺少审批配置种子'); process.exit(1); } await api('POST', '/system/approval-config/save', { id: cfg.id, bizType: cfg.bizType, bizName: cfg.bizName, approvalEnabled: true, remark: cfg.remark, userIds: [1], }); ok('审批已启用,审批人=admin'); // 每次跑都用唯一命名的供应商,便于在列表里精确锁定本次种子单据 const seq = Date.now().toString().slice(-6); const supplierName = `UIAUTOSUP${seq}`; let supplierId = null; const c = await api('POST', '/srm/supplier/create', { code: `UIAUTOSUP${seq}`, name: supplierName, status: 0, }); supplierId = c?.data; if (!supplierId) { const sup = await api('GET', '/srm/supplier/simple-list'); const hit = (sup?.data || []).find((x) => x.name === supplierName) || (sup?.data || [])[0]; supplierId = hit?.id; } if (!supplierId) { fail('无法准备供应商(注意 ERP/SRM 数据源不一致问题)', JSON.stringify(c)); process.exit(1); } ok('供应商就绪', `supplierId=${supplierId} name=${supplierName}`); const mk = async () => { return api('POST', '/purchase/order/create', { supplierId, orderTime: '2026-09-16 10:00:00', discountPercent: 0, depositPrice: 0, remark: supplierName, items: [ { productId: PRODUCT_ID, productUnitId: PRODUCT_UNIT_ID, productPrice: 100, count: 5, taxPercent: 13, }, ], }); }; const a = await mk(); const b = await mk(); if (a?.code !== 0 || b?.code !== 0) { fail('创建测试订单失败', JSON.stringify({ a, b })); process.exit(1); } const draftId = a.data; const approvingId = b.data; const sub = await api('PUT', `/purchase/order/submit?id=${approvingId}`); if (sub?.code !== 0) { fail('预置「审批中」单据失败', JSON.stringify(sub)); process.exit(1); } ok('数据就绪', `草稿 id=${draftId},审批中 id=${approvingId}`); return { approvingId, draftId, supplierName }; } async function shot(page, name) { const p = path.join(SHOT_DIR, `${name}.png`); await page.screenshot({ path: p, fullPage: true }); console.log(` 📸 ${p}`); } /** * 取数据行与固定右列行,按索引配对。 * 注意:本页「状态」列是 fixed:right,与操作按钮同处固定右列容器, * 因此 main 里不含状态,必须从 fixed 文本里读状态。 */ async function rowPairs(page) { const dataRows = await page.locator('.vxe-table--main-wrapper .vxe-body--row').all(); const fixedRows = await page.locator('.vxe-table--fixed-right-wrapper .vxe-body--row').all(); const out = []; const n = Math.min(dataRows.length, fixedRows.length); for (let i = 0; i < n; i++) { out.push({ main: (await dataRows[i].innerText()).replace(/\s+/g, ' '), fixed: (await fixedRows[i].innerText()).replace(/\s+/g, ' ').trim(), actionRow: fixedRows[i], }); } return out; } (async () => { const seeded = await seed(); const browser = await chromium.launch({ headless: true, channel: 'chrome' }); const page = await browser.newPage({ viewport: { width: 1680, height: 950 } }); const apiErrors = []; page.on('response', async (r) => { if (!r.url().includes('/admin-api/')) return; try { const b = await r.json(); if (b && typeof b.code === 'number' && b.code !== 0) { apiErrors.push({ url: r.url().replace(BASE, ''), code: b.code, msg: b.msg }); } } catch { /* ignore */ } }); try { // 1. 登录 console.log('\n[1] 浏览器登录'); await page.goto(`${BASE}/auth/login`, { waitUntil: 'networkidle', timeout: 60000 }); await page.locator('input[placeholder="请输入用户名"]').first().fill(USER); await page.locator('input[type="password"]').first().fill(PASS); await page.locator('button[aria-label="login"]').first().click(); await page.waitForURL((u) => !u.pathname.includes('/login'), { timeout: 30000 }); ok('登录成功'); // 2. 审批配置页应能看到采购订单审核 console.log('\n[2] 审批配置页'); await page.goto(`${BASE}/system/approval-config`, { waitUntil: 'networkidle', timeout: 60000 }); await page.waitForTimeout(2500); const cfgRow = page.locator('.vxe-body--row', { hasText: '采购订单审核' }); if ((await cfgRow.count()) > 0) { const t = (await cfgRow.first().innerText()).replace(/\s+/g, ' '); if (t.includes('已启用')) ok('采购订单审批显示已启用', t.slice(0, 100)); else fail('采购订单审批未显示已启用', t.slice(0, 100)); if (t.includes('超级管理员')) ok('审批人显示超级管理员'); else fail('审批人未显示', t.slice(0, 100)); } else { fail('配置页找不到「采购订单审核」行'); } await shot(page, 'po-01-config'); // 3. 采购订单列表按钮可见性 console.log('\n[3] 采购订单列表按钮'); await page.goto(`${BASE}/purchase/order`, { waitUntil: 'networkidle', timeout: 60000 }); await page.waitForTimeout(3000); await shot(page, 'po-02-list'); const pairs = await rowPairs(page); if (pairs.length === 0) { fail('列表无数据行'); throw new Error('empty grid'); } // 本次种子单据靠唯一供应商名定位(列表不展示备注列) const mine = pairs.filter((p) => p.main.includes(seeded.supplierName)); if (mine.length < 2) { fail('未能在列表中定位到本次种子订单', `命中 ${mine.length} 行 / 共 ${pairs.length} 行`); pairs.forEach((p) => console.log(' ROW:', p.main.slice(0, 60), '||', p.fixed)); } else { ok('按供应商名定位到本次种子单据', `${mine.length} 行`); } const draft = mine.find((p) => p.fixed.includes('草稿')); const approving = mine.find((p) => p.fixed.includes('审批中')); if (draft) ok('草稿单渲染为「草稿」', draft.fixed); else fail('未找到草稿状态的种子订单', mine.map((p) => p.fixed).join(' | ')); if (approving) ok('审批中单渲染为「审批中」', approving.fixed); else fail('未找到审批中状态的种子订单', mine.map((p) => p.fixed).join(' | ')); if (draft) { const hasSubmit = /提交/.test(draft.fixed); const hasAudit = draft.fixed.includes('审核'); const hasEdit = /(修改|编辑)/.test(draft.fixed); if (hasSubmit && hasEdit && !hasAudit) ok('草稿行:有「提交」「修改」,无审核按钮', draft.fixed); else fail('草稿行按钮不符', draft.fixed); } if (approving) { const hasBoth = approving.fixed.includes('审核不通过') && /审核(?!\s*不通过)/.test(approving.fixed.replace('审核不通过', '')); const noSubmitEdit = !/提交/.test(approving.fixed) && !/(修改|编辑)/.test(approving.fixed); if (hasBoth && noSubmitEdit) ok('审批中行:有「审核」「审核不通过」,无提交/修改', approving.fixed); else fail('审批中行按钮不符', approving.fixed); } // 4. 走一遍审核不通过弹窗(含必填校验) if (approving) { console.log('\n[4] 审核不通过弹窗'); await approving.actionRow.getByText('审核不通过', { exact: true }).first().click(); await page.waitForTimeout(1200); const dialog = page.locator('[role="dialog"]').last(); if ((await dialog.count()) === 0) { fail('未弹出审核弹窗'); } else { const dlgText = (await dialog.innerText()).replace(/\s+/g, ' '); ok('弹窗已打开', dlgText.slice(0, 120)); const btns = await dialog.locator('button').allInnerTexts(); console.log(' 弹窗按钮:', JSON.stringify(btns.map((b) => b.trim()).filter(Boolean))); // 空原因直接确认,应被前端必填拦住(弹窗不关) const confirmBtn = dialog .locator('button') .filter({ hasText: /确\s*认|确\s*定|提\s*交|OK/ }) .last(); await confirmBtn.click(); await page.waitForTimeout(1200); const stillOpen = (await page.locator('[role="dialog"]').count()) > 0; if (stillOpen) ok('未填原因时被必填校验拦下'); else fail('未填原因竟提交成功(前端校验缺失)'); // 填原因后确认 await dialog.locator('textarea').first().fill('UI测试:单价偏高,退回重议'); const resp = page .waitForResponse( (r) => r.url().includes('/purchase/order/audit'), { timeout: 15000 }, ) .catch(() => null); await confirmBtn.click(); const res = await resp; if (!res) fail('未捕获 audit 响应'); else { const body = await res.json().catch(() => null); if (body?.code === 0) ok('驳回提交成功', JSON.stringify(body)); else fail('驳回失败', JSON.stringify(body)); } await page.waitForTimeout(2000); } await shot(page, 'po-03-after-reject'); // 5. 状态应变为审批不通过,且可重新修改 await page.reload({ waitUntil: 'networkidle' }); await page.waitForTimeout(3000); const pairs2 = await rowPairs(page); const mine2 = pairs2.filter((p) => p.main.includes(seeded.supplierName)); const rejected = mine2.find((p) => p.fixed.includes('审批不通过')); if (rejected) { ok('驳回后渲染为「审批不通过」', rejected.fixed); const canResubmit = /提交/.test(rejected.fixed) && /(修改|编辑)/.test(rejected.fixed); const noAudit = !rejected.fixed.includes('审核'); if (canResubmit && noAudit) ok('驳回单可「修改」并可重新「提交」,且不再显示审核按钮', rejected.fixed); else fail('驳回单入口不符', rejected.fixed); } else { fail('未找到审批不通过的种子订单', mine2.map((p) => p.fixed).join(' | ')); } await shot(page, 'po-04-rejected'); } console.log(`\n(种子单据 id:草稿=${seeded.draftId} 审批中→驳回=${seeded.approvingId})`); } catch (e) { fail('执行异常', e.message); await shot(page, 'po-99-crash').catch(() => {}); } console.log('\n===== 捕获到的接口业务错误 ====='); const relevant = apiErrors.filter( (e) => !e.url.includes('/auth/login') && !e.url.includes('simple-list') && !e.url.includes('/dict') && !e.url.includes('unread-count'), ); if (relevant.length === 0) console.log(' (无)'); relevant.forEach((e) => console.log(` ${e.url} → code=${e.code} msg=${e.msg}`)); await browser.close(); const passed = results.filter((r) => r.pass).length; console.log(`\n===== 汇总:${passed}/${results.length} 通过 =====`); results.filter((r) => !r.pass).forEach((r) => console.log(` 失败: ${r.name} ${r.extra}`)); process.exit(passed === results.length ? 0 : 1); })();