Просмотр исходного кода

test(s0): A2 四代表页 UI 删除拦截回归

batch1 Department / Location:直接 UI 点删除验证 409 toast + 行未消失
batch2 Material / Supplier:
- Material 通过 routing-op-details 反查定位必有引用样本,UI 删除验证 409
- Supplier 因 SRM schema 不匹配(icitem* 字段族 vs SafeCount 查询的 Supplier/SupplierNumber)
  无法稳妥定位样本,标 Block,不执行运行态删除
YY968XX 1 месяц назад
Родитель
Сommit
7b84f54b02
2 измененных файлов с 214 добавлено и 0 удалено
  1. 100 0
      Web/tests/e2e/s0/a2-ui-batch1.spec.ts
  2. 114 0
      Web/tests/e2e/s0/a2-ui-batch2.spec.ts

+ 100 - 0
Web/tests/e2e/s0/a2-ui-batch1.spec.ts

@@ -0,0 +1,100 @@
+import { test, expect } from '../fixtures/auth';
+import type { Page, Request, Response } from '@playwright/test';
+
+/**
+ * A2 UI 回归 第一批:Location / Department
+ * 验证:
+ *  1. 删除按钮点击 → 确认弹窗 → 后端 409 → 红色 toast 显示业务文案
+ *  2. 列表行不消失(未误删)
+ *  3. 不出现 success toast(无假成功)
+ */
+
+const TARGETS = [
+  {
+    name: 'Department D-PROD',
+    url: '/#/aidop/s0/warehouse/department',
+    apiList: /\/api\/s0\/warehouse\/departments\?/,
+    apiDelete: /\/api\/s0\/warehouse\/departments\/\d+/,
+    code: 'D-PROD',
+    expectMsg: /引用该部门|EmployeeMaster/,
+  },
+  {
+    name: 'Location 1001',
+    url: '/#/aidop/s0/warehouse/location',
+    apiList: /\/api\/s0\/warehouse\/locations\?/,
+    apiDelete: /\/api\/s0\/warehouse\/locations\/\d+/,
+    code: '1001',
+    expectMsg: /引用该库位|ItemMaster|LocationShelf/,
+  },
+] as const;
+
+async function findRowByCode(page: Page, code: string) {
+  const row = page.locator('tr', { hasText: code }).first();
+  await expect(row).toBeVisible({ timeout: 15_000 });
+  return row;
+}
+
+for (const t of TARGETS) {
+  test(`${t.name} 删除被引用 → 409 + 红色 toast + 行未消失`, async ({ authedPage }) => {
+    // 探测可用路由:尝试硬编码 url,若 404 则尝试常见替代名
+    const candidates = [
+      t.url,
+      t.url.replace(/s$/, '-list'),
+      t.url.replace(/s$/, ''),
+      t.url.replace('/aidop/s0', '/s0'),
+    ];
+    let landed = false;
+    for (const u of candidates) {
+      await authedPage.goto(u, { waitUntil: 'domcontentloaded' });
+      await authedPage.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => {});
+      const has404 = await authedPage.locator('text=/404|没有找到|未找到页面/').first().isVisible().catch(() => false);
+      const hasTable = await authedPage.locator('table tbody tr').first().isVisible({ timeout: 8_000 }).catch(() => false);
+      if (!has404 && hasTable) { landed = true; break; }
+    }
+    test.skip(!landed, `未能定位 ${t.name} 列表页`);
+
+    const row = await findRowByCode(authedPage, t.code);
+    const rowCount = await authedPage.locator('table tbody tr').count();
+
+    // 监听删除请求
+    let deleteResp: Response | null = null;
+    authedPage.on('response', (r) => {
+      if (t.apiDelete.test(r.url()) && r.request().method() === 'DELETE') deleteResp = r;
+    });
+
+    await row.getByRole('button', { name: /删除/ }).click();
+    const confirm = authedPage.locator('.el-message-box').getByRole('button', { name: /确\s*定/ });
+    await expect(confirm).toBeVisible({ timeout: 5_000 });
+    await confirm.click();
+
+    // 等到删除接口回包
+    await expect.poll(() => deleteResp?.status() ?? 0, { timeout: 10_000 }).toBe(409);
+    const body = await deleteResp!.json().catch(() => null);
+    test.info().annotations.push({
+      type: 'api',
+      description: `DELETE ${deleteResp!.url()} → ${deleteResp!.status()} body=${JSON.stringify(body).slice(0, 200)}`,
+    });
+    expect(body?.code, '业务码应为 S01006').toBe('S01006');
+    expect(String(body?.message ?? '')).toMatch(t.expectMsg);
+
+    // 关键 UI 证据:错误 toast 出现,业务文案可见
+    const errToast = authedPage.locator('.el-message--error, .el-message.is-error').first();
+    await expect(errToast, '应出现红色 ElMessage error toast').toBeVisible({ timeout: 5_000 });
+    const toastText = (await errToast.innerText()).replace(/\s+/g, '');
+    test.info().annotations.push({ type: 'toast', description: toastText });
+    expect(toastText).toMatch(t.expectMsg);
+
+    // 不应出现 success toast(无假成功)
+    const okToast = authedPage.locator('.el-message--success').first();
+    expect(await okToast.isVisible().catch(() => false), '不应出现 success toast').toBeFalsy();
+
+    // 列表行未消失
+    await authedPage.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => {});
+    const stillThere = authedPage.locator('tr', { hasText: t.code }).first();
+    await expect(stillThere, '行不应被删除').toBeVisible({ timeout: 3_000 });
+    const rowCount2 = await authedPage.locator('table tbody tr').count();
+    expect(rowCount2, '行数不应减少').toBe(rowCount);
+
+    await authedPage.screenshot({ path: `test-results/a2-ui-${t.code.replace(/[^\w-]/g, '_')}.png`, fullPage: true });
+  });
+}

+ 114 - 0
Web/tests/e2e/s0/a2-ui-batch2.spec.ts

@@ -0,0 +1,114 @@
+import { test, expect } from '../fixtures/auth';
+import type { Page, Response } from '@playwright/test';
+
+/**
+ * A2 UI 回归 第二批:Material / Supplier
+ * Material:通过 routing-op-details 反查找到必有引用的样本 → 实测删除拦截
+ * Supplier:唯一下游 SRM 列表接口 S01999 不可用,无法稳妥定位"必有引用"样本,标 Block
+ */
+
+async function token(page: Page) {
+  return page.evaluate(() => {
+    for (let i = 0; i < localStorage.length; i++) {
+      const k = localStorage.key(i)!;
+      if (/access-token$/i.test(k) && !/x-access-token$/i.test(k)) {
+        const raw = localStorage.getItem(k)!;
+        try { const v = JSON.parse(raw); return typeof v === 'string' ? v : v?.value ?? raw; } catch { return raw; }
+      }
+    }
+    return null;
+  });
+}
+
+async function pickMaterialReferencedByRouting(page: Page): Promise<{ id: number; itemNum: string } | null> {
+  const tk = await token(page);
+  const headers = { Authorization: `Bearer ${tk!}` };
+  const base = new URL(page.url()).origin;
+  const r = await page.request.get(
+    `${base}/api/s0/manufacturing/routing-op-details?page=1&pageSize=200&tenantId=1&factoryId=1`,
+    { headers },
+  );
+  if (!r.ok()) return null;
+  const codes: string[] = [...new Set<string>(((await r.json()).list ?? []).map((x: any) => x.materialCode).filter(Boolean))];
+  for (const c of codes) {
+    const lookup = await page.request.get(
+      `${base}/api/s0/sales/materials?ItemNum=${encodeURIComponent(c)}&page=1&pageSize=5&tenantId=1&factoryId=1`,
+      { headers },
+    );
+    const j = await lookup.json().catch(() => null);
+    const hits = j?.list ?? [];
+    if (hits.length > 0) return { id: Number(hits[0].id), itemNum: hits[0].itemNum };
+  }
+  return null;
+}
+
+test('Material 删除被工艺引用 → 409 + 红色 toast + 行未消失', async ({ authedPage }) => {
+  await authedPage.goto('/#/dashboard/home', { waitUntil: 'domcontentloaded' });
+  const sample = await pickMaterialReferencedByRouting(authedPage);
+  test.skip(!sample, '未找到被 routing-op-details 引用的物料样本');
+  test.info().annotations.push({ type: 'sample', description: JSON.stringify(sample) });
+
+  await authedPage.goto('/#/aidop/s0/sales/material', { waitUntil: 'domcontentloaded' });
+  await authedPage.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => {});
+  await expect(authedPage.locator('table tbody tr').first()).toBeVisible({ timeout: 15_000 });
+
+  // 页面筛选栏有两个输入:placeholder="编码/名称" 的 keyword + 物料编码单独输入
+  const kw = authedPage.locator('input[placeholder*="编码/名称"]').first();
+  await kw.fill(sample!.itemNum);
+  await authedPage.getByRole('button', { name: /查询/ }).first().click();
+  await authedPage.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => {});
+  const row = authedPage.locator('tr', { hasText: sample!.itemNum }).first();
+  await expect(row, '应在列表中找到目标物料').toBeVisible({ timeout: 10_000 });
+
+  let deleteResp: Response | null = null;
+  authedPage.on('response', (r) => {
+    if (/\/api\/s0\/sales\/materials\/\d+/.test(r.url()) && r.request().method() === 'DELETE') deleteResp = r;
+  });
+
+  await row.getByRole('button', { name: /删除/ }).click();
+  const confirm = authedPage.locator('.el-message-box').getByRole('button', { name: /确\s*定/ });
+  await expect(confirm).toBeVisible({ timeout: 5_000 });
+  await confirm.click();
+
+  await expect.poll(() => deleteResp?.status() ?? 0, { timeout: 10_000 }).toBe(409);
+  const body = await deleteResp!.json().catch(() => null);
+  test.info().annotations.push({
+    type: 'api',
+    description: `DELETE → ${deleteResp!.status()} body=${JSON.stringify(body).slice(0, 200)}`,
+  });
+  expect(body?.code).toBe('S01006');
+  expect(String(body?.message ?? '')).toMatch(/引用该物料|RoutingOpDetail|工艺路线|物料替代/);
+
+  const errToast = authedPage.locator('.el-message--error, .el-message.is-error').first();
+  await expect(errToast).toBeVisible({ timeout: 5_000 });
+  const toastText = (await errToast.innerText()).replace(/\s+/g, '');
+  test.info().annotations.push({ type: 'toast', description: toastText });
+  expect(toastText).toMatch(/引用该物料|工艺路线|物料替代/);
+
+  const okToast = authedPage.locator('.el-message--success').first();
+  expect(await okToast.isVisible().catch(() => false), '不应出现 success toast').toBeFalsy();
+
+  await authedPage.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => {});
+  await expect(authedPage.locator('tr', { hasText: sample!.itemNum }).first()).toBeVisible({ timeout: 3_000 });
+});
+
+test('Supplier UI 回归 → Block(样本不足)', async ({ authedPage }) => {
+  // 唯一下游 SRM 接口 S01999 schema mismatch,Reference checker 走 SafeCount 兜底为 0
+  // 任何 Supplier DELETE 都会被放行 → 盲删风险高,按要求标 Block,不执行删除
+  await authedPage.goto('/#/dashboard/home', { waitUntil: 'domcontentloaded' });
+  const tk = await token(authedPage);
+  const r = await authedPage.request.get(
+    `${new URL(authedPage.url()).origin}/api/s0/supply/srm-purchases?page=1&pageSize=1&tenantId=1&factoryId=1`,
+    { headers: { Authorization: `Bearer ${tk!}` } },
+  );
+  const body = await r.json().catch(() => null);
+  test.info().annotations.push({
+    type: 'block-reason',
+    description: `SRM 列表接口 HTTP ${r.status()} body=${JSON.stringify(body).slice(0, 200)}`,
+  });
+  test.info().annotations.push({
+    type: 'impact',
+    description: 'SRM SafeCount 兜底为 0 → Supplier DELETE 会被放行 → 任何运行态删除都不安全;BUG-S0-SRMPURCHASE-SCHEMA-MISMATCH 修复前不做 UI 回归',
+  });
+  test.skip(true, 'Supplier 下游 SRM 不可用,标 Block;详见 annotations');
+});