/**
|
* 采购订单审批 - 浏览器端到端测试
|
*
|
* 前置数据用 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);
|
})();
|