IqcDocumentIdentityContractTests.cs 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  1. using Xunit;
  2. namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse;
  3. /// <summary>
  4. /// S5 IQC 报检分录「采购单号」文档级身份的生产契约(1.0.540)。
  5. ///
  6. /// <para>背景:<c>qms_qcp_insappnentry</c> 是 165 侧报检分录的本地镜像。热关注登记的是
  7. /// <c>BizType='PUR_ORDER'</c> + <c>BizKey=&lt;采购单号&gt;</c>,取数 SQL 也写着
  8. /// <c>WHERE d.OrdNbr=@k</c> —— 也就是说采购单号在镜像那一刻就是确定的,
  9. /// 但历史实现只 <c>SELECT e.*</c>,采购单号在 QUERY 层被丢弃。</para>
  10. ///
  11. /// <para>本文件守住修好之后的形状,尤其守住三条容易被"顺手优化"破坏的边界:
  12. /// ① 采购单号取<b>标量</b> purOrd,不从 JOIN 行取(否则受扇出与行序影响);
  13. /// ② <c>FSRCORDERNUM</c> 仍是<b>收货单号</b>,不得被改写成采购单号;
  14. /// ③ 不得借机补 PO <b>行号</b> —— 上游 <c>FSRCBILLID</c>/<c>FSRCBILLENTRYSEQ</c> 实测全空,
  15. /// 属 SOURCE CONTRACT GAP,任何本地推导都是弱关联。</para>
  16. /// </summary>
  17. public class IqcDocumentIdentityContractTests
  18. {
  19. private static string Source(params string[] parts)
  20. {
  21. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  22. while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Plugin.AiDOP")))
  23. dir = dir.Parent;
  24. Assert.NotNull(dir);
  25. var full = Path.Combine(new[] { dir!.FullName, "Admin.NET.Plugin.AiDOP" }.Concat(parts).ToArray());
  26. Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
  27. return File.ReadAllText(full);
  28. }
  29. private static string HotWatch() => Source("DataPlatform", "HotWatch", "MdpHotWatchService.cs");
  30. private static int At(string code, string anchor)
  31. {
  32. var i = code.IndexOf(anchor, StringComparison.Ordinal);
  33. Assert.True(i >= 0, $"未找到锚点:{anchor}");
  34. return i;
  35. }
  36. /// <summary>取 <c>qms_qcp_insappnentry</c> 那一条 UPSERT 的完整区间(含参数绑定)。</summary>
  37. private static string EntryUpsertBlock()
  38. {
  39. var code = HotWatch();
  40. var start = At(code, "INSERT INTO qms_qcp_insappnentry");
  41. var end = At(code, "INSERT INTO qms_qcp_inspbill");
  42. Assert.True(end > start, "分录 UPSERT 应出现在检验单 UPSERT 之前");
  43. return code[start..end];
  44. }
  45. // ── ① 采购单号确实被写入,且来自 watch.BizKey ──────────────────────────────────
  46. [Fact]
  47. public void 分录UPSERT_必须写入采购单号列()
  48. {
  49. var block = EntryUpsertBlock();
  50. Assert.Contains("purchase_order_no", block, StringComparison.Ordinal);
  51. Assert.Contains("@PurchaseOrderNo", block, StringComparison.Ordinal);
  52. }
  53. [Fact]
  54. public void 采购单号必须取标量purOrd_不得从JOIN行取()
  55. {
  56. var block = EntryUpsertBlock();
  57. // 必须绑定局部标量 poNo(由 purOrd 归一而来)
  58. Assert.Contains("new SugarParameter(\"@PurchaseOrderNo\", poNo", block, StringComparison.Ordinal);
  59. // 绝不允许从 row 字典里取 —— 那会把 JOIN 扇出与行序引入身份值
  60. Assert.DoesNotContain("\"@PurchaseOrderNo\", GetString(row", block, StringComparison.Ordinal);
  61. Assert.DoesNotContain("GetString(row, \"OrdNbr\")", block, StringComparison.Ordinal);
  62. }
  63. [Fact]
  64. public void poNo必须由purOrd归一而来_空值不伪造()
  65. {
  66. var code = HotWatch();
  67. Assert.Contains(
  68. "var poNo = string.IsNullOrWhiteSpace(purOrd) ? null : purOrd.Trim();",
  69. code,
  70. StringComparison.Ordinal);
  71. }
  72. // ── ② 既有语义不被破坏 ────────────────────────────────────────────────────────
  73. [Fact]
  74. public void FSRCORDERNUM必须仍绑定源行的收货单号()
  75. {
  76. var block = EntryUpsertBlock();
  77. Assert.Contains(
  78. "new SugarParameter(\"@FSRCORDERNUM\", GetString(row, \"FSRCORDERNUM\")",
  79. block,
  80. StringComparison.Ordinal);
  81. // 不得把采购单号灌进收货号列
  82. Assert.DoesNotContain("\"@FSRCORDERNUM\", poNo", block, StringComparison.Ordinal);
  83. }
  84. [Fact]
  85. public void 取数SQL不得为了带PO号而改成取JOIN列()
  86. {
  87. var code = HotWatch();
  88. var start = At(code, "case \"QMS_QCP_INSAPPNENTRY\":");
  89. var end = At(code, "case \"QMS_QCP_INSPECAPPLYN\":");
  90. var sql = code[start..end];
  91. // 仍只取 e.*:这是"免疫扇出"的设计前提,改成 e.*, d.* 会让一行分录因多个 PO 行而重复
  92. Assert.Contains("SELECT TOP 200 e.*", sql, StringComparison.Ordinal);
  93. Assert.DoesNotContain("d.OrdNbr AS", sql, StringComparison.Ordinal);
  94. // 作用域键必须仍是 d.OrdNbr=@k —— 这是 poNo 与每行 d.OrdNbr 恒等的依据
  95. Assert.Contains("d.OrdNbr = @k", sql, StringComparison.Ordinal);
  96. }
  97. // ── ③ 不得借机补行级身份 ──────────────────────────────────────────────────────
  98. [Fact]
  99. public void 禁止写入PO行号_行级身份属源契约缺口()
  100. {
  101. var block = EntryUpsertBlock();
  102. // FSRCBILLENTRYSEQ / FSRCBILLID 上游实测全空,任何本地推导都是弱关联
  103. Assert.DoesNotContain("FSRCBILLENTRYSEQ", block, StringComparison.Ordinal);
  104. Assert.DoesNotContain("FSRCBILLID", block, StringComparison.Ordinal);
  105. Assert.DoesNotContain("purchase_order_line", block, StringComparison.Ordinal);
  106. Assert.DoesNotContain("OrdLine", block, StringComparison.Ordinal);
  107. }
  108. // ── ④ 幂等与租户 ─────────────────────────────────────────────────────────────
  109. [Fact]
  110. public void 分录UPSERT必须按主键幂等_不因扇出重复建行()
  111. {
  112. var block = EntryUpsertBlock();
  113. Assert.Contains("ON DUPLICATE KEY UPDATE", block, StringComparison.Ordinal);
  114. // 主键沿用 165 的 id:同一分录被多次镜像只会更新,不会新增
  115. Assert.Contains("new SugarParameter(\"@Id\", id)", block, StringComparison.Ordinal);
  116. }
  117. [Fact]
  118. public void 采购单号为空时不得把已有权威值打回NULL()
  119. {
  120. var block = EntryUpsertBlock();
  121. Assert.Contains(
  122. "purchase_order_no = IF(IFNULL(VALUES(purchase_order_no),'')='',",
  123. block,
  124. StringComparison.Ordinal);
  125. }
  126. [Fact]
  127. public void 租户必须来自参数而非远端Domain()
  128. {
  129. var block = EntryUpsertBlock();
  130. Assert.Contains("new SugarParameter(\"@TenantId\", tenantId)", block, StringComparison.Ordinal);
  131. // 既有的"租户只在为空时写入"守卫不得丢
  132. Assert.Contains(
  133. "tenant_id = IF(IFNULL(tenant_id,0)=0, VALUES(tenant_id), tenant_id)",
  134. block,
  135. StringComparison.Ordinal);
  136. }
  137. [Fact]
  138. public void 无法解析租户时必须整体跳过_不得落无主数据()
  139. {
  140. var code = HotWatch();
  141. var start = At(code, "private async Task ApplyIqcAsync");
  142. var body = code[start..(start + 1200)];
  143. Assert.Contains("if (tenantId <= 0)", body, StringComparison.Ordinal);
  144. Assert.Contains("return;", body, StringComparison.Ordinal);
  145. }
  146. // ── ⑤ 身份与质量正交 ─────────────────────────────────────────────────────────
  147. [Fact]
  148. public void 镜像不得按检验判定过滤_身份与质量是正交维度()
  149. {
  150. var code = HotWatch();
  151. var start = At(code, "private async Task ApplyIqcAsync");
  152. var end = At(code, "private async Task ApplyPurchaseReceiptAsync");
  153. var body = code[start..end];
  154. // pd=0/pd=1 都必须照常镜像,否则不合格单将拿不到采购单身份
  155. Assert.DoesNotContain("pd = 0", body, StringComparison.Ordinal);
  156. Assert.DoesNotContain("pd=0", body, StringComparison.Ordinal);
  157. }
  158. // ── ⑥ 检验单经 FK 回到分录的链路不得断 ────────────────────────────────────────
  159. [Fact]
  160. public void 检验单必须保留回到分录的外键()
  161. {
  162. var code = HotWatch();
  163. var start = At(code, "INSERT INTO qms_qcp_inspbill");
  164. var block = code[start..(start + 2600)];
  165. // hid → insappnentry.id,lydjbh → inspecapplyn.FBILLNO:
  166. // 有这两把 FK,检验单就不需要重复存采购单号
  167. Assert.Contains("hid", block, StringComparison.Ordinal);
  168. Assert.Contains("lydjbh", block, StringComparison.Ordinal);
  169. }
  170. // ── ⑦ 1.0.536 采购身份桥不得回归 ──────────────────────────────────────────────
  171. [Fact]
  172. public void S3身份桥的采购申请投影不得被移除()
  173. {
  174. var s3 = Source("Supply", "S3MdpSyncTransformService.cs");
  175. Assert.Contains("purchase_request_no", s3, StringComparison.Ordinal);
  176. Assert.Contains("sales_order_entry_id", s3, StringComparison.Ordinal);
  177. }
  178. }