| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250 |
- using Xunit;
- namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse;
- /// <summary>
- /// S5 IQC 标准事实层投影(<c>mdp_std_iqc_inspection</c>,1.0.541)的生产契约。
- ///
- /// <para>这条链的价值在于让 S8 Stage-3 不再直读 <c>qms_qcp_inspbill</c> /
- /// <c>qms_qcp_inspecapplyn</c> / <c>qms_qcp_insappnentry</c> / <c>ApprovalFlowInstance</c>。
- /// 一旦投影逻辑被"顺手优化"破坏,S8 会在毫无征兆的情况下拿到错误的质量结论。</para>
- ///
- /// <para>本文件守住四条最易被破坏的边界:
- /// ① 质量结论只能由 <c>pd</c> 派生,<c>pd IS NULL</c> 绝不能滑成 PASS;
- /// ② 流程完成 ≠ 质量合格,两轴必须分开存;
- /// ③ 租户只能来自检验单,审批流表没有 TenantId、绝不可反向驱动;
- /// ④ 报检分录只认 <c>hid</c> 单值外键,不得"从申请单下挑一条",更不得推 PO 行号。</para>
- /// </summary>
- 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<long> 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);
- }
- }
|