Browse Source

fix(s0): repair srm reference delete guard

YY968XX 5 tháng trước cách đây
mục cha
commit
50359ac3

+ 1 - 1
Web/package.json

@@ -1,7 +1,7 @@
 {
 	"name": "admin.net",
 	"type": "module",
-	"version": "2.4.90",
+	"version": "2.4.91",
 	"packageManager": "pnpm@10.32.1",
 	"lastBuildTime": "2026.03.15",
 	"description": "Admin.NET 站在巨人肩膀上的 .NET 通用权限开发框架",

+ 104 - 0
Web/tests/e2e/s0/srm-fix-regression.spec.ts

@@ -0,0 +1,104 @@
+import { test, expect } from '../fixtures/auth';
+import type { Page, Response } from '@playwright/test';
+
+/**
+ * SRM schema mismatch 修复后回归:
+ *  Supplier 删除拦截:用 SupplierNumber 真实列查 SRM,应正确拦截
+ *  Material 抽查:原 Routing 引用样本仍应被拦截(不受 SRM 分支移除影响)
+ */
+
+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 api<T = any>(page: Page, method: 'GET' | 'DELETE', url: string): Promise<{ status: number; body: T | string | null }> {
+  const tk = await token(page);
+  const base = new URL(page.url()).origin;
+  const r = await page.request.fetch(`${base}${url}`, {
+    method,
+    headers: { Authorization: `Bearer ${tk!}` },
+  });
+  let body: any = null;
+  try { body = await r.json(); } catch { body = await r.text().catch(() => null); }
+  return { status: r.status(), body };
+}
+
+test('Supplier 删除被 SRM 引用 → 409 + 红色 toast(修复后)', async ({ authedPage }) => {
+  // 已知样本:Supp=10001875 在 SRM 有 390 条引用
+  const SAMPLE_SUPP = '10001875';
+
+  await authedPage.goto('/#/dashboard/home', { waitUntil: 'domcontentloaded' });
+  // 反查 id
+  const lookup = await api(authedPage, 'GET', `/api/s0/supply/suppliers?Supp=${SAMPLE_SUPP}&page=1&pageSize=5&tenantId=1&factoryId=1`);
+  const items: any[] = (lookup.body as any)?.list ?? [];
+  test.skip(items.length === 0, `供应商样本 ${SAMPLE_SUPP} 不存在`);
+  const sample = { id: Number(items[0].id), supp: items[0].supp };
+  test.info().annotations.push({ type: 'sample', description: JSON.stringify(sample) });
+
+  // 走 UI:Supplier 列表页 → 搜索 → 删除
+  await authedPage.goto('/#/aidop/s0/supply/supplier', { waitUntil: 'domcontentloaded' });
+  await authedPage.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => {});
+  await expect(authedPage.locator('table tbody tr').first()).toBeVisible({ timeout: 15_000 });
+
+  const kw = authedPage.locator('input[placeholder*="编码/简称"]').first();
+  await expect(kw).toBeVisible({ timeout: 5_000 });
+  await kw.fill(SAMPLE_SUPP);
+  const respWait = authedPage.waitForResponse((r) => /\/api\/s0\/supply\/suppliers\?/.test(r.url()) && r.request().method() === 'GET', { timeout: 10_000 });
+  await authedPage.getByRole('button', { name: /^查询$/ }).first().click();
+  await respWait;
+  await authedPage.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => {});
+  const row = authedPage.locator('tr', { hasText: SAMPLE_SUPP }).first();
+  await expect(row).toBeVisible({ timeout: 10_000 });
+
+  let delResp: Response | null = null;
+  authedPage.on('response', (r) => {
+    if (/\/api\/s0\/supply\/suppliers\/\d+/.test(r.url()) && r.request().method() === 'DELETE') delResp = 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(() => delResp?.status() ?? 0, { timeout: 10_000 }).toBe(409);
+  const body = await delResp!.json().catch(() => null);
+  test.info().annotations.push({
+    type: 'api',
+    description: `DELETE → ${delResp!.status()} body=${JSON.stringify(body).slice(0, 200)}`,
+  });
+  expect(body?.code).toBe('S01006');
+  expect(String(body?.message ?? '')).toMatch(/引用该供应商|SrmPurchase|货源清单/);
+
+  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)).toBeFalsy();
+
+  await expect(authedPage.locator('tr', { hasText: SAMPLE_SUPP }).first()).toBeVisible({ timeout: 3_000 });
+});
+
+test('Material 连带回归:原 Routing 引用样本仍 409', async ({ authedPage }) => {
+  // 直接 API 验:DELETE 物料 id 4479 (itemNum 3132C0G6, 9 条 routing 引用)
+  await authedPage.goto('/#/dashboard/home', { waitUntil: 'domcontentloaded' });
+  const r = await api(authedPage, 'DELETE', '/api/s0/sales/materials/4479?tenantId=1&factoryId=1');
+  test.info().annotations.push({
+    type: 'api',
+    description: `DELETE /materials/4479 → ${r.status} body=${JSON.stringify(r.body).slice(0, 200)}`,
+  });
+  expect(r.status).toBe(409);
+  expect((r.body as any)?.code).toBe('S01006');
+  expect(String((r.body as any)?.message ?? '')).toMatch(/工艺路线明细/);
+});

+ 3 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.57</AssemblyVersion>
-    <FileVersion>1.0.57</FileVersion>
-    <Version>1.0.57</Version>
+    <AssemblyVersion>1.0.58</AssemblyVersion>
+    <FileVersion>1.0.58</FileVersion>
+    <Version>1.0.58</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 9 - 6
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AdoS0ReferenceChecker.cs

@@ -84,10 +84,11 @@ public sealed class AdoS0ReferenceChecker
             .CountAsync();
         if (sub > 0) return (sub, "物料替代关系 (ItemSubstituteDetail)");
 
-        var srm = await SafeCountAsync(
-            () => _db.Queryable<AdoS0SrmPurchase>().Where(x => x.MaterialCode == itemNum),
-            "SrmPurchase.MaterialCode");
-        if (srm > 0) return (srm, "货源清单 (SrmPurchase)");
+        // 不查 SRM:srm_purchase.icitem_id 是上游系统(金蝶等)的物料 ID,
+        // 与本地 AdoS0ItemMaster.Id 不在同一坐标系,无法直接 JOIN;
+        // SRM 表也没有物料编码 (ItemNum) 字符串列。
+        // 物料防线已由本地 RoutingOpDetail / ItemSubstituteDetail 覆盖。
+        // 详见 BUG-S0-SRMPURCHASE-SCHEMA-MISMATCH。
 
         return null;
     }
@@ -100,9 +101,11 @@ public sealed class AdoS0ReferenceChecker
     {
         if (string.IsNullOrWhiteSpace(supp)) return null;
 
+        // 只用 SupplierNumber(真实列)。原 OR 分支的 x.Supplier 是 [SugarColumn(IsIgnore = true)]
+        // 展示字段,进 Where 会抛异常 → SafeCount 兜底 0,实际保护失效(BUG-S0-SRMPURCHASE-SCHEMA-MISMATCH)。
         var srm = await SafeCountAsync(
-            () => _db.Queryable<AdoS0SrmPurchase>().Where(x => x.Supplier == supp || x.SupplierNumber == supp),
-            "SrmPurchase.Supplier");
+            () => _db.Queryable<AdoS0SrmPurchase>().Where(x => x.SupplierNumber == supp),
+            "SrmPurchase.SupplierNumber");
         if (srm > 0) return (srm, "货源清单 (SrmPurchase)");
 
         return null;