IqcInspectionMdpProjectionContractTests.cs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. using Xunit;
  2. namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse;
  3. /// <summary>
  4. /// S5 IQC 标准事实层投影(<c>mdp_std_iqc_inspection</c>,1.0.541)的生产契约。
  5. ///
  6. /// <para>这条链的价值在于让 S8 Stage-3 不再直读 <c>qms_qcp_inspbill</c> /
  7. /// <c>qms_qcp_inspecapplyn</c> / <c>qms_qcp_insappnentry</c> / <c>ApprovalFlowInstance</c>。
  8. /// 一旦投影逻辑被"顺手优化"破坏,S8 会在毫无征兆的情况下拿到错误的质量结论。</para>
  9. ///
  10. /// <para>本文件守住四条最易被破坏的边界:
  11. /// ① 质量结论只能由 <c>pd</c> 派生,<c>pd IS NULL</c> 绝不能滑成 PASS;
  12. /// ② 流程完成 ≠ 质量合格,两轴必须分开存;
  13. /// ③ 租户只能来自检验单,审批流表没有 TenantId、绝不可反向驱动;
  14. /// ④ 报检分录只认 <c>hid</c> 单值外键,不得"从申请单下挑一条",更不得推 PO 行号。</para>
  15. /// </summary>
  16. public class IqcInspectionMdpProjectionContractTests
  17. {
  18. private static string Source(params string[] parts)
  19. {
  20. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  21. while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Plugin.AiDOP")))
  22. dir = dir.Parent;
  23. Assert.NotNull(dir);
  24. var full = Path.Combine(new[] { dir!.FullName, "Admin.NET.Plugin.AiDOP" }.Concat(parts).ToArray());
  25. Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
  26. return File.ReadAllText(full);
  27. }
  28. private static string Service()
  29. => Source("MaterialWarehouse", "IqcInspectionMdpSyncService.cs");
  30. private static string Migration()
  31. {
  32. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  33. while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Web.Entry")))
  34. dir = dir.Parent;
  35. Assert.NotNull(dir);
  36. var full = Path.Combine(dir!.FullName, "Admin.NET.Web.Entry", "UpdateScripts", "1.0.541.sql");
  37. Assert.True(File.Exists(full), $"迁移脚本不存在:{full}");
  38. return File.ReadAllText(full);
  39. }
  40. private static int At(string code, string anchor)
  41. {
  42. var i = code.IndexOf(anchor, StringComparison.Ordinal);
  43. Assert.True(i >= 0, $"未找到锚点:{anchor}");
  44. return i;
  45. }
  46. private static string InsertBlock()
  47. {
  48. var code = Service();
  49. var start = At(code, "INSERT INTO mdp_std_iqc_inspection");
  50. var end = At(code, "private async Task<long> InsertRunLogAsync");
  51. Assert.True(end > start);
  52. return code[start..end];
  53. }
  54. // ── ① 质量结论只能由 pd 派生 ──────────────────────────────────────────────────
  55. [Fact]
  56. public void 质量结论必须严格由raw_pd派生()
  57. {
  58. var block = InsertBlock();
  59. Assert.Contains("CASE WHEN b.pd = 0 THEN 'PASS'", block, StringComparison.Ordinal);
  60. Assert.Contains("WHEN b.pd = 1 THEN 'FAIL'", block, StringComparison.Ordinal);
  61. Assert.Contains("ELSE 'PENDING' END", block, StringComparison.Ordinal);
  62. }
  63. [Fact]
  64. public void 禁止用FBILLSTATUS当质量Authority()
  65. {
  66. // 只扫 SQL 区间:服务头部的说明注释里正当地解释了"为什么不用它",不算违规
  67. var block = InsertBlock();
  68. // 实测 15 条 Approved 中 9 条 FBILLSTATUS 未回写,是有损镜像,绝不可作判据
  69. Assert.DoesNotContain("FBILLSTATUS", block, StringComparison.Ordinal);
  70. }
  71. [Fact]
  72. public void raw_pd原值必须一并保留_不得只落结论()
  73. {
  74. var block = InsertBlock();
  75. Assert.Contains("raw_pd", block, StringComparison.Ordinal);
  76. Assert.Contains("b.pd,", block, StringComparison.Ordinal);
  77. }
  78. // ── ② 流程与质量分轴 ─────────────────────────────────────────────────────────
  79. [Fact]
  80. public void 流程轴与质量轴必须各自成列()
  81. {
  82. var block = InsertBlock();
  83. foreach (var col in new[] { "raw_pd", "quality_result", "flow_status", "completion_time" })
  84. Assert.Contains(col, block, StringComparison.Ordinal);
  85. // 不得塌缩成单一布尔,否则 pd/flow 的四种组合无法表达
  86. Assert.DoesNotContain("is_completed", block, StringComparison.Ordinal);
  87. }
  88. [Fact]
  89. public void 完成时间只在流程终态才投影()
  90. {
  91. var block = InsertBlock();
  92. Assert.Contains("CASE WHEN i.Status = 2 THEN i.EndTime ELSE NULL END", block, StringComparison.Ordinal);
  93. }
  94. // ── ③ 租户方向单向 ───────────────────────────────────────────────────────────
  95. [Fact]
  96. public void 租户必须来自检验单而非审批流()
  97. {
  98. var block = InsertBlock();
  99. // 投出来的 tenant 只能是 b.tenant_id
  100. Assert.Contains("SELECT\n b.tenant_id,", block, StringComparison.Ordinal);
  101. // 审批流只按 BizType+BizId 反查,不参与租户判定
  102. Assert.Contains("i.BizType = @FlowBizType", block, StringComparison.Ordinal);
  103. Assert.Contains("i.BizId = b.id", block, StringComparison.Ordinal);
  104. Assert.DoesNotContain("i.TenantId", block, StringComparison.Ordinal);
  105. }
  106. [Fact]
  107. public void 本地业务JOIN必须显式带租户()
  108. {
  109. var block = InsertBlock();
  110. Assert.Contains("a.tenant_id = b.tenant_id", block, StringComparison.Ordinal);
  111. Assert.Contains("e.tenant_id = b.tenant_id", block, StringComparison.Ordinal);
  112. // Domain 不是租户边界,不得作为关联依据
  113. Assert.DoesNotContain("Domain =", block, StringComparison.Ordinal);
  114. }
  115. [Fact]
  116. public void 只投租户合法的检验单()
  117. {
  118. var block = InsertBlock();
  119. Assert.Contains("WHERE b.tenant_id > 0", block, StringComparison.Ordinal);
  120. }
  121. // ── ④ 分录解析与行级身份 ─────────────────────────────────────────────────────
  122. [Fact]
  123. public void 分录只认hid单值外键_不得从申请单挑一条()
  124. {
  125. var block = InsertBlock();
  126. Assert.Contains("e.id = b.hid", block, StringComparison.Ordinal);
  127. Assert.Contains("b.hid IS NOT NULL", block, StringComparison.Ordinal);
  128. // 不得出现 e.glid = a.id 这类"申请单下任取一条"的回退
  129. Assert.DoesNotContain("e.glid = a.id", block, StringComparison.Ordinal);
  130. }
  131. [Fact]
  132. public void 禁止投影任何行级身份()
  133. {
  134. // 只扫 SQL 区间:文档注释需要点名这些字段来说明"为什么刻意不投"
  135. var block = InsertBlock();
  136. foreach (var forbidden in new[] { "po_line", "OrdLine", "FSRCBILLENTRYSEQ", "FSRCBILLID", "receipt_line" })
  137. Assert.DoesNotContain(forbidden, block, StringComparison.Ordinal);
  138. }
  139. [Fact]
  140. public void 表结构不得含行级身份列()
  141. {
  142. var sql = Migration();
  143. foreach (var forbidden in new[] { "`po_line`", "`purchase_order_line`", "`receipt_line`", "`source_entry_seq`" })
  144. Assert.DoesNotContain(forbidden, sql, StringComparison.Ordinal);
  145. }
  146. // ── ⑤ 身份字段不得互相覆盖 ───────────────────────────────────────────────────
  147. [Fact]
  148. public void 采购单号与收货单号取自各自权威列()
  149. {
  150. var block = InsertBlock();
  151. Assert.Contains("NULLIF(e.purchase_order_no, '')", block, StringComparison.Ordinal);
  152. Assert.Contains("NULLIF(e.FSRCORDERNUM, '')", block, StringComparison.Ordinal);
  153. }
  154. // ── ⑥ Grain 与幂等 ───────────────────────────────────────────────────────────
  155. [Fact]
  156. public void Canonical键必须是租户加检验单()
  157. {
  158. var sql = Migration();
  159. Assert.Contains("UNIQUE KEY `uk_mdp_std_iqc_insp` (`tenant_id`, `source_row_id`)", sql, StringComparison.Ordinal);
  160. // source_row_id 必须就是检验单 id,否则 Grain 名不副实
  161. var block = InsertBlock();
  162. Assert.Contains("CAST(b.id AS CHAR)", block, StringComparison.Ordinal);
  163. }
  164. [Fact]
  165. public void 采购单号不得作为唯一键()
  166. {
  167. var sql = Migration();
  168. Assert.DoesNotContain("UNIQUE KEY `uk_mdp_std_iqc_insp` (`tenant_id`, `purchase_order_no`)", sql, StringComparison.Ordinal);
  169. }
  170. [Fact]
  171. public void 必须是幂等UPSERT()
  172. {
  173. var block = InsertBlock();
  174. Assert.Contains("ON DUPLICATE KEY UPDATE", block, StringComparison.Ordinal);
  175. // 后补的身份与流程结果都必须能刷新
  176. foreach (var col in new[] { "purchase_order_no=VALUES(purchase_order_no)",
  177. "raw_pd=VALUES(raw_pd)",
  178. "quality_result=VALUES(quality_result)",
  179. "flow_status=VALUES(flow_status)",
  180. "completion_time=VALUES(completion_time)" })
  181. Assert.Contains(col, block, StringComparison.Ordinal);
  182. }
  183. // ── ⑦ 边界:只读源、不做聚合 ─────────────────────────────────────────────────
  184. [Fact]
  185. public void 只写标准层_不得写任何业务源表()
  186. {
  187. var code = Service();
  188. // 允许且仅允许两个写入目标:标准层事实表 + 中台跑批日志
  189. var targets = code.Split("INSERT INTO ").Skip(1)
  190. .Select(x => x.Split('\n')[0].Trim())
  191. .Distinct()
  192. .OrderBy(x => x, StringComparer.Ordinal)
  193. .ToArray();
  194. Assert.Equal(new[] { "mdp_std_iqc_inspection", "mdp_transform_run_log" }, targets);
  195. // 业务源表一律只读
  196. foreach (var src in new[] { "UPDATE qms_qcp", "INSERT INTO qms_qcp", "DELETE FROM qms_qcp",
  197. "UPDATE ApprovalFlowInstance", "INSERT INTO ApprovalFlowInstance" })
  198. Assert.DoesNotContain(src, code, StringComparison.Ordinal);
  199. }
  200. [Fact]
  201. public void 本批不得做订单级完成聚合()
  202. {
  203. var code = Service();
  204. foreach (var forbidden in new[] { "MAX(i.EndTime)", "MAX(completion_time)", "ALL_IQC", "S3_PROCUREMENT_COMPLETE" })
  205. Assert.DoesNotContain(forbidden, code, StringComparison.Ordinal);
  206. }
  207. // ── ⑧ 上游批次资产不得回归 ───────────────────────────────────────────────────
  208. [Fact]
  209. public void 依赖的1_0_540采购单号列必须仍被读取()
  210. {
  211. var block = InsertBlock();
  212. Assert.Contains("e.purchase_order_no", block, StringComparison.Ordinal);
  213. }
  214. }