using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse; /// /// S5 IQC 标准事实层投影(mdp_std_iqc_inspection,1.0.541)的生产契约。 /// /// 这条链的价值在于让 S8 Stage-3 不再直读 qms_qcp_inspbill / /// qms_qcp_inspecapplyn / qms_qcp_insappnentry / ApprovalFlowInstance。 /// 一旦投影逻辑被"顺手优化"破坏,S8 会在毫无征兆的情况下拿到错误的质量结论。 /// /// 本文件守住四条最易被破坏的边界: /// ① 质量结论只能由 pd 派生,pd IS NULL 绝不能滑成 PASS; /// ② 流程完成 ≠ 质量合格,两轴必须分开存; /// ③ 租户只能来自检验单,审批流表没有 TenantId、绝不可反向驱动; /// ④ 报检分录只认 hid 单值外键,不得"从申请单下挑一条",更不得推 PO 行号。 /// public class IqcInspectionMdpProjectionContractTests { private static string Source(params string[] parts) { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Plugin.AiDOP"))) dir = dir.Parent; Assert.NotNull(dir); var full = Path.Combine(new[] { dir!.FullName, "Admin.NET.Plugin.AiDOP" }.Concat(parts).ToArray()); Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}"); return File.ReadAllText(full); } private static string Service() => Source("MaterialWarehouse", "IqcInspectionMdpSyncService.cs"); private static string Migration() { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Web.Entry"))) dir = dir.Parent; Assert.NotNull(dir); var full = Path.Combine(dir!.FullName, "Admin.NET.Web.Entry", "UpdateScripts", "1.0.541.sql"); Assert.True(File.Exists(full), $"迁移脚本不存在:{full}"); return File.ReadAllText(full); } private static int At(string code, string anchor) { var i = code.IndexOf(anchor, StringComparison.Ordinal); Assert.True(i >= 0, $"未找到锚点:{anchor}"); return i; } private static string InsertBlock() { var code = Service(); var start = At(code, "INSERT INTO mdp_std_iqc_inspection"); var end = At(code, "private async Task InsertRunLogAsync"); Assert.True(end > start); return code[start..end]; } // ── ① 质量结论只能由 pd 派生 ────────────────────────────────────────────────── [Fact] public void 质量结论必须严格由raw_pd派生() { var block = InsertBlock(); Assert.Contains("CASE WHEN b.pd = 0 THEN 'PASS'", block, StringComparison.Ordinal); Assert.Contains("WHEN b.pd = 1 THEN 'FAIL'", block, StringComparison.Ordinal); Assert.Contains("ELSE 'PENDING' END", block, StringComparison.Ordinal); } [Fact] public void 禁止用FBILLSTATUS当质量Authority() { // 只扫 SQL 区间:服务头部的说明注释里正当地解释了"为什么不用它",不算违规 var block = InsertBlock(); // 实测 15 条 Approved 中 9 条 FBILLSTATUS 未回写,是有损镜像,绝不可作判据 Assert.DoesNotContain("FBILLSTATUS", block, StringComparison.Ordinal); } [Fact] public void raw_pd原值必须一并保留_不得只落结论() { var block = InsertBlock(); Assert.Contains("raw_pd", block, StringComparison.Ordinal); Assert.Contains("b.pd,", block, StringComparison.Ordinal); } // ── ② 流程与质量分轴 ───────────────────────────────────────────────────────── [Fact] public void 流程轴与质量轴必须各自成列() { var block = InsertBlock(); foreach (var col in new[] { "raw_pd", "quality_result", "flow_status", "completion_time" }) Assert.Contains(col, block, StringComparison.Ordinal); // 不得塌缩成单一布尔,否则 pd/flow 的四种组合无法表达 Assert.DoesNotContain("is_completed", block, StringComparison.Ordinal); } [Fact] public void 完成时间只在流程终态才投影() { var block = InsertBlock(); Assert.Contains("CASE WHEN i.Status = 2 THEN i.EndTime ELSE NULL END", block, StringComparison.Ordinal); } // ── ③ 租户方向单向 ─────────────────────────────────────────────────────────── [Fact] public void 租户必须来自检验单而非审批流() { var block = InsertBlock(); // 投出来的 tenant 只能是 b.tenant_id Assert.Contains("SELECT\n b.tenant_id,", block, StringComparison.Ordinal); // 审批流只按 BizType+BizId 反查,不参与租户判定 Assert.Contains("i.BizType = @FlowBizType", block, StringComparison.Ordinal); Assert.Contains("i.BizId = b.id", block, StringComparison.Ordinal); Assert.DoesNotContain("i.TenantId", block, StringComparison.Ordinal); } [Fact] public void 本地业务JOIN必须显式带租户() { var block = InsertBlock(); Assert.Contains("a.tenant_id = b.tenant_id", block, StringComparison.Ordinal); Assert.Contains("e.tenant_id = b.tenant_id", block, StringComparison.Ordinal); // Domain 不是租户边界,不得作为关联依据 Assert.DoesNotContain("Domain =", block, StringComparison.Ordinal); } [Fact] public void 只投租户合法的检验单() { var block = InsertBlock(); Assert.Contains("WHERE b.tenant_id > 0", block, StringComparison.Ordinal); } // ── ④ 分录解析与行级身份 ───────────────────────────────────────────────────── [Fact] public void 分录只认hid单值外键_不得从申请单挑一条() { var block = InsertBlock(); Assert.Contains("e.id = b.hid", block, StringComparison.Ordinal); Assert.Contains("b.hid IS NOT NULL", block, StringComparison.Ordinal); // 不得出现 e.glid = a.id 这类"申请单下任取一条"的回退 Assert.DoesNotContain("e.glid = a.id", block, StringComparison.Ordinal); } [Fact] public void 禁止投影任何行级身份() { // 只扫 SQL 区间:文档注释需要点名这些字段来说明"为什么刻意不投" var block = InsertBlock(); foreach (var forbidden in new[] { "po_line", "OrdLine", "FSRCBILLENTRYSEQ", "FSRCBILLID", "receipt_line" }) Assert.DoesNotContain(forbidden, block, StringComparison.Ordinal); } [Fact] public void 表结构不得含行级身份列() { var sql = Migration(); foreach (var forbidden in new[] { "`po_line`", "`purchase_order_line`", "`receipt_line`", "`source_entry_seq`" }) Assert.DoesNotContain(forbidden, sql, StringComparison.Ordinal); } // ── ⑤ 身份字段不得互相覆盖 ─────────────────────────────────────────────────── [Fact] public void 采购单号与收货单号取自各自权威列() { var block = InsertBlock(); Assert.Contains("NULLIF(e.purchase_order_no, '')", block, StringComparison.Ordinal); Assert.Contains("NULLIF(e.FSRCORDERNUM, '')", block, StringComparison.Ordinal); } // ── ⑥ Grain 与幂等 ─────────────────────────────────────────────────────────── [Fact] public void Canonical键必须是租户加检验单() { var sql = Migration(); Assert.Contains("UNIQUE KEY `uk_mdp_std_iqc_insp` (`tenant_id`, `source_row_id`)", sql, StringComparison.Ordinal); // source_row_id 必须就是检验单 id,否则 Grain 名不副实 var block = InsertBlock(); Assert.Contains("CAST(b.id AS CHAR)", block, StringComparison.Ordinal); } [Fact] public void 采购单号不得作为唯一键() { var sql = Migration(); Assert.DoesNotContain("UNIQUE KEY `uk_mdp_std_iqc_insp` (`tenant_id`, `purchase_order_no`)", sql, StringComparison.Ordinal); } [Fact] public void 必须是幂等UPSERT() { var block = InsertBlock(); Assert.Contains("ON DUPLICATE KEY UPDATE", block, StringComparison.Ordinal); // 后补的身份与流程结果都必须能刷新 foreach (var col in new[] { "purchase_order_no=VALUES(purchase_order_no)", "raw_pd=VALUES(raw_pd)", "quality_result=VALUES(quality_result)", "flow_status=VALUES(flow_status)", "completion_time=VALUES(completion_time)" }) Assert.Contains(col, block, StringComparison.Ordinal); } // ── ⑦ 边界:只读源、不做聚合 ───────────────────────────────────────────────── [Fact] public void 只写标准层_不得写任何业务源表() { var code = Service(); // 允许且仅允许两个写入目标:标准层事实表 + 中台跑批日志 var targets = code.Split("INSERT INTO ").Skip(1) .Select(x => x.Split('\n')[0].Trim()) .Distinct() .OrderBy(x => x, StringComparer.Ordinal) .ToArray(); Assert.Equal(new[] { "mdp_std_iqc_inspection", "mdp_transform_run_log" }, targets); // 业务源表一律只读 foreach (var src in new[] { "UPDATE qms_qcp", "INSERT INTO qms_qcp", "DELETE FROM qms_qcp", "UPDATE ApprovalFlowInstance", "INSERT INTO ApprovalFlowInstance" }) Assert.DoesNotContain(src, code, StringComparison.Ordinal); } [Fact] public void 本批不得做订单级完成聚合() { var code = Service(); foreach (var forbidden in new[] { "MAX(i.EndTime)", "MAX(completion_time)", "ALL_IQC", "S3_PROCUREMENT_COMPLETE" }) Assert.DoesNotContain(forbidden, code, StringComparison.Ordinal); } // ── ⑧ 上游批次资产不得回归 ─────────────────────────────────────────────────── [Fact] public void 依赖的1_0_540采购单号列必须仍被读取() { var block = InsertBlock(); Assert.Contains("e.purchase_order_no", block, StringComparison.Ordinal); } }