|
|
@@ -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');
|
|
|
+});
|