1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
/**
 * 采购订单审批 - 浏览器端到端测试
 *
 * 前置数据用 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);
})();