S3PurchaseAuthorityContractTests.cs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  1. using Admin.NET.Plugin.AiDOP.DataPlatform.MdpRebuild;
  2. using Xunit;
  3. namespace Admin.NET.Plugin.AiDOP.Tests.DataPlatform;
  4. /// <summary>
  5. /// 采购交付 Authority(<c>mdp_std_purchase_order</c> → <c>dwd_supplier_delivery</c>)的生产契约。
  6. ///
  7. /// <para>这条链是 S8 Rule01 的唯一数据来源。它曾同时坏在三个地方,且三处根因都落在同一段
  8. /// STD 构建 SQL 上:丢了租户谓词(跨租户串值)、丢了批次作用域(孤儿只增不减)、
  9. /// 以及有两个业务 API 绕过它直写下游 DWD。本文件守住修好之后的形状。</para>
  10. /// </summary>
  11. public class S3PurchaseAuthorityContractTests
  12. {
  13. private static string Source(params string[] parts)
  14. {
  15. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  16. while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Plugin.AiDOP")))
  17. dir = dir.Parent;
  18. Assert.NotNull(dir);
  19. var full = Path.Combine(new[] { dir!.FullName, "Admin.NET.Plugin.AiDOP" }.Concat(parts).ToArray());
  20. Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
  21. return File.ReadAllText(full);
  22. }
  23. private static string S3Service() => Source("Supply", "S3MdpSyncTransformService.cs");
  24. private static int At(string code, string anchor)
  25. {
  26. var i = code.IndexOf(anchor, StringComparison.Ordinal);
  27. Assert.True(i >= 0, $"未找到锚点:{anchor}");
  28. return i;
  29. }
  30. /// <summary>取 <c>mdp_std_purchase_order</c> 那一条 INSERT 的 SQL 区间。</summary>
  31. private static string StdInsertBlock()
  32. {
  33. var code = S3Service();
  34. var start = At(code, "INSERT INTO mdp_std_purchase_order");
  35. var end = code.IndexOf("\"\"\"", start, StringComparison.Ordinal);
  36. Assert.True(end > start, "mdp_std_purchase_order 的 INSERT 未正常收尾");
  37. return code[start..end];
  38. }
  39. /// <summary>
  40. /// STD 构建的两个 enrichment JOIN 必须自带租户谓词。
  41. ///
  42. /// <para><b>为什么不能靠作用域注入兜底</b>:<c>MdpSqlScope.InjectTenantFactory</c> 的实现是
  43. /// <c>Regex.Replace(sql, @"\bWHERE\b", ...)</c>,它只改写 <c>WHERE</c>,
  44. /// 对 <c>JOIN ... ON</c> 零覆盖。这不是配置问题,是机制上够不到。</para>
  45. ///
  46. /// <para><b>后果不是行数膨胀</b>:扇出会被 <c>uk_po_line(tenant_id, po_no, po_line)</c> 的
  47. /// ON DUPLICATE KEY UPDATE 吸收,最终行数不变,但 item_name / supplier_code / order_date / buyer
  48. /// 这些 enrichment 值由 MySQL 任取一行决定 —— 症状是取值随机化,不是报错。
  49. /// 实测单租户扇出 870/220 = 3.95 倍,item_code 跨租户重名 5529 组。</para>
  50. /// </summary>
  51. [Fact]
  52. public void StdBuild_EnrichmentJoins_CarryTenantPredicate()
  53. {
  54. var block = StdInsertBlock();
  55. // 表头 enrichment:按 PurOrd 字符串匹配,必须限定同租户。
  56. Assert.Contains("m ON m.tenant_id = d.tenant_id", block);
  57. Assert.Contains("m.pur_ord = JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.PurOrd'))", block);
  58. // 物料 enrichment:按 ItemNum 字符串匹配,同样必须限定同租户。
  59. Assert.Contains("i ON i.tenant_id = d.tenant_id", block);
  60. Assert.Contains("i.item_num = JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.ItemNum'))", block);
  61. }
  62. /// <summary>
  63. /// DWD 构建的三个 JOIN 本来就是租户安全的,本批不得把它们改坏。
  64. /// <para>它们已带 <c>po.tenant_id=X.tenant_id AND po.factory_id=X.factory_id</c>,
  65. /// 泄漏点在 STD 而不在这里 —— 这条断言是防止后续改动误伤。</para>
  66. /// </summary>
  67. [Fact]
  68. public void DwdBuild_JoinsRemainTenantAndFactoryScoped()
  69. {
  70. var code = S3Service();
  71. var start = At(code, "INSERT INTO dwd_supplier_delivery");
  72. var end = code.IndexOf("\"\"\"", start, StringComparison.Ordinal);
  73. var block = code[start..end];
  74. Assert.Contains("ds ON po.tenant_id=ds.tenant_id AND po.factory_id=ds.factory_id", block);
  75. Assert.Contains("dr ON po.tenant_id=dr.tenant_id AND po.factory_id=dr.factory_id", block);
  76. Assert.Contains("s ON po.tenant_id=s.tenant_id AND po.factory_id=s.factory_id", block);
  77. }
  78. /// <summary>
  79. /// STD 构建必须按本轮批次收窄驱动行。
  80. /// <para>少了它,INSERT 会把整张贴源历史重新物化,并给死行盖上最新批次戳 ——
  81. /// 实测租户 797 因此在 STD 沉淀了 336 行源侧已不存在的采购订单行(518 行中的 64.9%),
  82. /// 且全部 518 行共享同一个批次号,连时间戳都认不出哪些是死的。</para>
  83. /// </summary>
  84. [Fact]
  85. public void StdBuild_DrivingRows_ScopedToCurrentBatch()
  86. {
  87. var block = StdInsertBlock();
  88. Assert.Contains("AND d.source_table='PurOrdDetail' AND d.sync_batch_id=@BatchId", block);
  89. }
  90. /// <summary>
  91. /// enrichment 必须按 (租户, 键) 去重取最新一行,且**不得**用 GROUP_CONCAT 实现。
  92. /// <para>贴源层同时存在两代 <c>source_biz_key</c>(旧 <c>Domain:PurOrd:Line</c> 与新的裸 RecID),
  93. /// 两代永远不会互相 ODKU 覆盖,因此同一个 PurOrd/ItemNum 在同租户内有多行 —— 不去重就仍会扇出。</para>
  94. /// <para>禁用 GROUP_CONCAT 的理由是硬的:<c>raw_data</c> 是 JSON 大字段,
  95. /// 而 <c>group_concat_max_len</c> 实测仅 1024,拼接必然截断、首元素本身就可能超限。</para>
  96. /// </summary>
  97. [Fact]
  98. public void StdBuild_EnrichmentDeduplicated_WithRowNumberNotGroupConcat()
  99. {
  100. var block = StdInsertBlock();
  101. Assert.Contains("ROW_NUMBER() OVER (", block);
  102. Assert.Contains("PARTITION BY x0.tenant_id, JSON_UNQUOTE(JSON_EXTRACT(x0.raw_data,'$.PurOrd'))", block);
  103. Assert.Contains("PARTITION BY y0.tenant_id, JSON_UNQUOTE(JSON_EXTRACT(y0.raw_data,'$.ItemNum'))", block);
  104. // 可执行 SQL 里不得出现 GROUP_CONCAT(注释里提到它是允许的)。
  105. var sqlOnly = string.Join('\n', block.Split('\n').Where(l => !l.TrimStart().StartsWith("--", StringComparison.Ordinal)));
  106. Assert.DoesNotContain("GROUP_CONCAT", sqlOnly);
  107. }
  108. /// <summary>
  109. /// 每轮淘汰必须存在,且只在全量路径执行。
  110. /// <para>只加批次过滤只能让死行「可识别」,不能「退役」—— 运行期链路里没有任何 DELETE。
  111. /// 而增量批次只含变更行,按它淘汰会把未变更的存量行全删掉,所以必须按路径分流。</para>
  112. /// </summary>
  113. [Fact]
  114. public void StdBuild_RetiresStaleRows_OnlyOnFullPath()
  115. {
  116. var code = S3Service();
  117. Assert.Contains("DELETE s FROM mdp_std_purchase_order s", code);
  118. Assert.Contains("IFNULL(s.sync_batch_id,'') <> @BatchId", code);
  119. // 安全闸门必须用 mdp_sync_log 的成功记录,而不是「贴源层有没有行」——
  120. // 后者会把源侧合法清空的租户永久锁在幽灵行上。
  121. Assert.Contains("FROM mdp_sync_log l", code);
  122. Assert.Contains("l.status='SUCCESS'", code);
  123. // 路径分流
  124. Assert.Contains("BuildStandardCommands(string batchId, DateTime now, bool retireStale)", code);
  125. Assert.Contains("TransformStandardAsync(batchId, now, retireStale: true, cancellationToken)", code);
  126. Assert.Contains("TransformStandardAsync(batchId, now, retireStale: false, cancellationToken)", code);
  127. }
  128. /// <summary>取一条语句的 SQL 区间(从锚点到原始字符串字面量的收尾 <c>"""</c>)。</summary>
  129. private static string StatementBlock(string startAnchor)
  130. {
  131. var code = S3Service();
  132. var start = At(code, startAnchor);
  133. var end = code.IndexOf("\"\"\"", start, StringComparison.Ordinal);
  134. Assert.True(end > start, $"语句未正常收尾:{startAnchor}");
  135. return code[start..end];
  136. }
  137. /// <summary>
  138. /// 本文件新增/改写的每条 SQL,经 <see cref="MdpSqlScope.InjectTenantFactory"/> 处理后
  139. /// 都不得出现「注入到不存在的列上」。
  140. ///
  141. /// <para><b>为什么必须用真注入器跑一遍,而不是比对源码文本</b>:这个坑有两个入口,
  142. /// 光看源码任何一处都不显眼,只有跑一遍注入才暴露。二者本地沙箱都实测炸过:</para>
  143. ///
  144. /// <list type="number">
  145. /// <item><b>空前缀</b> —— <c>ResolveSourcePrefix</c> 反向找最近的同层 <c>FROM 表 别名</c>。
  146. /// 派生表外层是 <c>FROM (</c>,别名正则匹配不到括号,于是回退成空前缀,生成裸
  147. /// <c>factory_id</c>;派生表 SELECT 列表里没有这一列 → 整轮转换失败。</item>
  148. /// <item><b>别名指向没有该列的表</b> —— 淘汰语句的 EXISTS 子查询若写成
  149. /// <c>l.tenant_id = s.tenant_id</c>(关联外层、不含 <c>@TenantId</c>),会被注入
  150. /// <c>COALESCE(NULLIF(l.factory_id,0),1)=@FactoryId</c>,而 <c>mdp_sync_log</c> 没有
  151. /// <c>factory_id</c> 列 → 同样整轮失败。</item>
  152. /// </list>
  153. ///
  154. /// <para>失败形态是<b>整轮跑批 FAILED</b>,不是少几行数据 —— 而 Authority 一轮不产出,
  155. /// S8 的恢复闸门就判 DEGRADED、整个租户的恢复停摆。所以这条守卫值得用真注入器跑。</para>
  156. /// </summary>
  157. [Theory]
  158. [InlineData("INSERT INTO mdp_std_purchase_order")]
  159. [InlineData("DELETE s FROM mdp_std_purchase_order s")]
  160. [InlineData("DELETE w FROM dwd_supplier_delivery w")]
  161. public void ScopeInjection_NeverTargetsAColumnThatDoesNotExist(string anchor)
  162. {
  163. var injected = MdpSqlScope.InjectTenantFactory(StatementBlock(anchor));
  164. // ① 空前缀:注入器没能定位别名。在本文件里所有表都带别名,因此这必定是缺陷。
  165. Assert.DoesNotContain("WHERE tenant_id=@TenantId", injected);
  166. // ② 前缀指向的表没有 factory_id:派生表别名(x/y/m/i)与 mdp_sync_log(l)都没有这一列。
  167. foreach (var alias in new[] { "x", "y", "m", "i", "l" })
  168. Assert.DoesNotContain($"NULLIF({alias}.factory_id", injected);
  169. }
  170. /// <summary>
  171. /// 驱动行必须**显式**带上租户与工厂谓词,不能依赖注入器补。
  172. ///
  173. /// <para>历史上它确实是靠注入的:<c>ResolveSourcePrefix</c> 反向找到 <c>FROM ... d</c>
  174. /// 就能补出 <c>d.</c> 前缀。但两个 enrichment 派生表插进 <c>FROM ... d</c> 与本
  175. /// <c>WHERE</c> 之间以后,反向扫描会先撞上派生表的 <c>FROM (</c>,别名正则匹配不到括号
  176. /// → 回退成空前缀,生成裸 <c>tenant_id</c>。而派生表 m / i 也各自暴露 <c>tenant_id</c>,
  177. /// 该列立刻变成歧义列 —— 整轮转换 FAILED,不是少几行。</para>
  178. ///
  179. /// <para>所以这不是风格偏好:只要这条 INSERT 还带派生表,作用域就必须写死。</para>
  180. /// </summary>
  181. [Fact]
  182. public void DrivingRows_CarryTenantAndFactoryExplicitly()
  183. {
  184. Assert.Contains(
  185. "WHERE d.tenant_id=@TenantId AND COALESCE(NULLIF(d.factory_id,0),1)=@FactoryId",
  186. StatementBlock("INSERT INTO mdp_std_purchase_order"));
  187. }
  188. /// <summary>
  189. /// Rule01 Authority 链上的三条语句必须**完全自带**作用域 —— 注入对它们应当是恒等变换。
  190. ///
  191. /// <para>这是本文件与注入器之间的边界声明:这三条 SQL 的作用域由字面 SQL 唯一决定,
  192. /// 不交给一个够不到 <c>JOIN...ON</c>、认不出派生表别名、且对 SQL 注释同样生效的正则去猜。
  193. /// 恒等变换是这里唯一可接受的结果;任何非恒等都意味着有一个 <c>WHERE</c> 漏了
  194. /// <c>@TenantId</c>,而它下一步会以整轮跑批失败的形式暴露。</para>
  195. /// </summary>
  196. [Theory]
  197. [InlineData("INSERT INTO mdp_std_purchase_order")]
  198. [InlineData("DELETE s FROM mdp_std_purchase_order s")]
  199. [InlineData("DELETE w FROM dwd_supplier_delivery w")]
  200. public void AuthorityStatements_AreFullySelfScoped(string anchor)
  201. {
  202. var raw = StatementBlock(anchor);
  203. Assert.Equal(raw, MdpSqlScope.InjectTenantFactory(raw));
  204. }
  205. /// <summary>
  206. /// DWD 必须做同日淘汰,且**放在 INSERT 之后**。
  207. /// <para>放之前会让快照短暂为空;此处无事务,并发的 Rule01 取数会读到空快照,
  208. /// 把全部在办异常判成已恢复 —— 那正是 Authority 健康门禁要防的形态。</para>
  209. /// <para>该淘汰同时保证 <c>MAX(stat_date)</c> 取到的是单一批次:实测同一天混入两个批次
  210. /// 已发生 3 次(如 2026-06-07 的 178 行 + 7 行)。</para>
  211. /// </summary>
  212. [Fact]
  213. public void DwdBuild_RetiresSameDayStaleRows_AfterInsert()
  214. {
  215. var code = S3Service();
  216. var insertAt = At(code, "INSERT INTO dwd_supplier_delivery");
  217. var deleteAt = code.IndexOf("DELETE w FROM dwd_supplier_delivery w", insertAt, StringComparison.Ordinal);
  218. Assert.True(deleteAt > insertAt, "同日淘汰必须在 INSERT 之后,否则快照会短暂为空");
  219. Assert.Contains("w.stat_date=@StatDate", code);
  220. Assert.Contains("IFNULL(w.sync_batch_id,'') <> @BatchId", code);
  221. Assert.Contains("BuildDwdAsync(batchId, now, retireStale: true, result, cancellationToken)", code);
  222. Assert.Contains("BuildDwdAsync(batchId, now, retireStale: false, result, cancellationToken)", code);
  223. }
  224. /// <summary>
  225. /// <c>dwd_supplier_delivery</c> 与 <c>mdp_std_purchase_order</c> 在运行期<b>只能有一个写入方</b>。
  226. ///
  227. /// <para>它们是 Rule01 的 Authority。此前另有两个业务 API 直写:
  228. /// <c>ProcessOutsourceOrderService</c> 把 <c>delivery_status</c> 硬编码为 <c>'OPEN'</c> 并放进
  229. /// ON DUPLICATE KEY UPDATE,一次建单就能把 MDP 刚判定的 COMPLETED / DELAYED 无条件翻回 OPEN;
  230. /// <c>DeliveryScheduleService</c> 则写出 CANCELLED / CLOSED / PARTIAL 三个没有任何读方认识的值。</para>
  231. ///
  232. /// <para>更严重的是它们能凭空造出一个「最新快照」:Rule01 取 <c>MAX(stat_date)</c> 那一天的全部行,
  233. /// 实测租户 797403760988229 的 2026-05-16 快照只有 2 行、且全部出自旁路写入 ——
  234. /// 中台停摆时,一次业务 API 调用就让 Rule01 在 2 行残缺数据上评估,而快照看上去还很新鲜。</para>
  235. ///
  236. /// <para>用「非注释行里出现写入语句」判定,避免被解释性注释里的表名误判。</para>
  237. /// </summary>
  238. [Fact]
  239. public void AuthorityTables_HaveExactlyOneRuntimeWriter()
  240. {
  241. // (文件, 表, 期望写入处数)
  242. var expectations = new[]
  243. {
  244. ("S3MdpSyncTransformService.cs", "dwd_supplier_delivery", 1),
  245. ("DeliveryScheduleService.cs", "dwd_supplier_delivery", 0),
  246. ("ProcessOutsourceOrderService.cs","dwd_supplier_delivery", 0),
  247. ("S3MdpSyncTransformService.cs", "mdp_std_purchase_order", 1),
  248. ("DeliveryScheduleService.cs", "mdp_std_purchase_order", 0),
  249. ("ProcessOutsourceOrderService.cs","mdp_std_purchase_order", 0)
  250. };
  251. foreach (var (file, table, expected) in expectations)
  252. {
  253. var writes = Source("Supply", file).Split('\n')
  254. .Where(l => !l.TrimStart().StartsWith("//", StringComparison.Ordinal))
  255. .Count(l => l.Contains($"INSERT INTO {table}", StringComparison.Ordinal)
  256. || l.Contains($"UPDATE {table}", StringComparison.Ordinal));
  257. Assert.True(writes == expected,
  258. $"{file} 对 {table} 的运行期写入应为 {expected} 处,实测 {writes} 处");
  259. }
  260. }
  261. /// <summary>
  262. /// 旁路写入方不得把 <c>delivery_status</c> 硬编码后写进 Authority。
  263. /// <para>状态只能由正式 MDP 转换从源侧派生(COMPLETED / DELAYED / OPEN 三值)。</para>
  264. /// </summary>
  265. [Fact]
  266. public void BypassServices_NoLongerHardcodeDeliveryStatus()
  267. {
  268. foreach (var f in new[] { "DeliveryScheduleService.cs", "ProcessOutsourceOrderService.cs" })
  269. {
  270. var code = Source("Supply", f);
  271. var sqlOnly = string.Join('\n', code.Split('\n')
  272. .Where(l => !l.TrimStart().StartsWith("//", StringComparison.Ordinal)));
  273. Assert.DoesNotContain("delivery_status=VALUES(delivery_status)", sqlOnly);
  274. Assert.DoesNotContain("'CANCELLED'", sqlOnly);
  275. Assert.DoesNotContain("'PARTIAL'", sqlOnly);
  276. }
  277. }
  278. /// <summary>
  279. /// 租户是唯一安全边界,不得被工厂替代。
  280. /// <para>该链的源侧根本没有可用的数字工厂号(源表只有 varchar 的 Site/POSite,
  281. /// 三个写入方都写字面量 1),所以 enrichment JOIN 只加租户、不加工厂 ——
  282. /// 加了反而会在 factory_id 为 NULL 与 1 混存时把行丢掉。</para>
  283. /// </summary>
  284. [Fact]
  285. public void StdBuild_EnrichmentJoins_DoNotSubstituteFactoryForTenant()
  286. {
  287. var block = StdInsertBlock();
  288. // 不得出现"只按工厂不按租户"的写法。
  289. Assert.DoesNotContain("m.factory_id = d.factory_id", block);
  290. Assert.DoesNotContain("i.factory_id = d.factory_id", block);
  291. }
  292. }