S3ProcessOutsourceGrainContractTests.cs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. using Admin.NET.Plugin.AiDOP.DataPlatform.MdpRebuild;
  2. using Admin.NET.Plugin.AiDOP.Supply;
  3. using Xunit;
  4. namespace Admin.NET.Plugin.AiDOP.Tests.DataPlatform;
  5. /// <summary>
  6. /// 委外交付链(<c>mdp_std_process_outsource_order</c> → <c>dwd_process_outsource_delivery</c>)的**粒度契约**。
  7. ///
  8. /// <para>这张 DWD 曾同时混装两个互不相干的业务对象:</para>
  9. ///
  10. /// <list type="number">
  11. /// <item><b>种群 A(错误)</b> —— <c>RoutingOpDetail</c>(表注释「S0 标准工艺路线明细」,是**主数据**)
  12. /// 经 <c>S3_ROUTING_OUTSOURCE</c> 入站落 <c>mdp_stg_work_order_material</c>,再被一条
  13. /// <c>WHERE source_table='RoutingOpDetail'</c> 的 STD INSERT 当成委外交付事实物化。
  14. /// 它根本没有 <c>work_order</c> / <c>po_no</c> / <c>po_line</c> / 订单量 / 完成量 / 交期,
  15. /// <c>SupplierCode</c> 100% 为空,只有 0.48% 的行真是委外工序;
  16. /// 且该源表早已由 S0 维度层正确建模为 <c>bridge_item_routing_op</c>,属重复建模。</item>
  17. /// <item><b>种群 B(正确)</b> —— <c>PurOrdMaster(Potype='PW') JOIN PurOrdDetail</c>,
  18. /// 由 <c>Supply/ProcessOutsourceOrderService.cs</c> 写入 STD,字段齐全。
  19. /// 这是唯一真实的委外交付事实。</item>
  20. /// </list>
  21. ///
  22. /// <para><b>为什么是 P0</b>:种群 A 的 <c>po_no</c> / <c>po_line</c> 只能硬编码成 <c>NULL</c>,
  23. /// 而 DWD 的唯一键 <c>uk_work_op_stat</c> 含这两列 —— MySQL 唯一索引是 NULL-distinct 的,
  24. /// 于是 ON DUPLICATE KEY UPDATE 永不命中,每轮跑批全量追加,实测膨胀至 3244 万行。
  25. /// 症状不是报错,是一张越跑越大、且语义上根本不是委外交付的表。</para>
  26. ///
  27. /// <para>本文件守住止血之后的形状:种群 A 的两个环节(入站注册 + STD 物化)都不得回来,
  28. /// 种群 B 的链路必须完好,且 DWD 投影的 NULL 保护必须对称。</para>
  29. /// </summary>
  30. public class S3ProcessOutsourceGrainContractTests
  31. {
  32. private static string Source(params string[] parts)
  33. {
  34. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  35. while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Plugin.AiDOP")))
  36. dir = dir.Parent;
  37. Assert.NotNull(dir);
  38. var full = Path.Combine(new[] { dir!.FullName, "Admin.NET.Plugin.AiDOP" }.Concat(parts).ToArray());
  39. Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
  40. return File.ReadAllText(full);
  41. }
  42. private static string S3Service() => Source("Supply", "S3MdpSyncTransformService.cs");
  43. /// <summary>
  44. /// 取可执行 SQL/代码行 —— 剔除 C# 行注释与 SQL 行注释。
  45. /// <para>本文件的断言大量是「某个标识符不得再出现」,而移除它的那一处恰恰留下了
  46. /// 解释性注释、注释里必然提到这些标识符。不过滤会把注释误判成活代码。</para>
  47. /// </summary>
  48. private static string ExecutableLines(string code) =>
  49. string.Join('\n', code.Split('\n')
  50. .Where(l =>
  51. {
  52. var t = l.TrimStart();
  53. return !t.StartsWith("//", StringComparison.Ordinal)
  54. && !t.StartsWith("--", StringComparison.Ordinal)
  55. && !t.StartsWith("///", StringComparison.Ordinal);
  56. }));
  57. /// <summary>取一条语句的 SQL 区间(从锚点到原始字符串字面量的收尾 <c>"""</c>)。</summary>
  58. private static string StatementBlock(string code, string startAnchor)
  59. {
  60. var start = code.IndexOf(startAnchor, StringComparison.Ordinal);
  61. Assert.True(start >= 0, $"未找到锚点:{startAnchor}");
  62. var end = code.IndexOf("\"\"\"", start, StringComparison.Ordinal);
  63. Assert.True(end > start, $"语句未正常收尾:{startAnchor}");
  64. return code[start..end];
  65. }
  66. // ── Test A1 ─────────────────────────────────────────────────────────────────────
  67. /// <summary>
  68. /// 入站层不得再把 <c>RoutingOpDetail</c> 拉进数据中台。
  69. ///
  70. /// <para>这是最强的一档断言:直接读运行期真正使用的配置对象
  71. /// <see cref="S3MdpEntityConfig.All"/>,不是比对源码文本。</para>
  72. ///
  73. /// <para>同时按 <c>EntityCode</c> 与 <c>SourceTable</c> 两个维度断言,
  74. /// 是为了堵住「换个 entity_code 重新注册同一张源表」这条回归路径。</para>
  75. /// </summary>
  76. [Fact]
  77. public void Inbound_NoLongerRegisters_RoutingOpDetail()
  78. {
  79. Assert.DoesNotContain(S3MdpEntityConfig.All, c =>
  80. string.Equals(c.EntityCode, "S3_ROUTING_OUTSOURCE", StringComparison.Ordinal));
  81. Assert.DoesNotContain(S3MdpEntityConfig.All, c =>
  82. string.Equals(c.SourceTable, "RoutingOpDetail", StringComparison.Ordinal));
  83. }
  84. // ── Test A2 ─────────────────────────────────────────────────────────────────────
  85. /// <summary>
  86. /// STD 层不得再由本服务把 <c>RoutingOpDetail</c> 物化成委外交付订单。
  87. ///
  88. /// <para><b>断言强度说明</b>:<c>BuildStandardCommands</c> 是私有实例方法,
  89. /// 其宿主 <c>S3MdpSyncTransformService</c> 的构造依赖 SqlSugar / DI 容器,
  90. /// 纯单测里无法在不连库的前提下构造实例并枚举它产出的 SQL。
  91. /// 因此这里退化为对源码文本的断言 —— 由于该方法体是纯 <c>yield return</c> 原始字符串字面量、
  92. /// 不做任何运行期 SQL 拼接,文本断言与枚举产出在本例中是等价的。
  93. /// 配置层那一档(<see cref="Inbound_NoLongerRegisters_RoutingOpDetail"/>)才是强断言,
  94. /// 两者合起来锁死「贴源没有它 + STD 不读它」。</para>
  95. /// </summary>
  96. [Fact]
  97. public void StdBuild_NoLongerMaterializes_RoutingOpDetail()
  98. {
  99. var sqlOnly = ExecutableLines(S3Service());
  100. // 驱动集本身已不存在。
  101. Assert.DoesNotContain("source_table='RoutingOpDetail'", sqlOnly);
  102. Assert.DoesNotContain("RoutingOpDetail", sqlOnly);
  103. // 本服务对该 STD 表的写入处数必须归零 —— 写入方只剩 ProcessOutsourceOrderService。
  104. Assert.DoesNotContain("INSERT INTO mdp_std_process_outsource_order", sqlOnly);
  105. Assert.DoesNotContain("UPDATE mdp_std_process_outsource_order", sqlOnly);
  106. }
  107. /// <summary>
  108. /// <c>mdp_std_process_outsource_order</c> 在运行期只能有一个写入方,且必须是种群 B。
  109. /// <para>把「本服务写入归零」与「另一方仍在」写成同一条断言,
  110. /// 避免后续有人以为移除是把整条链都砍了。</para>
  111. /// </summary>
  112. [Fact]
  113. public void ProcessOutsourceStd_HasExactlyOneRuntimeWriter()
  114. {
  115. var expectations = new[]
  116. {
  117. ("S3MdpSyncTransformService.cs", 0),
  118. ("ProcessOutsourceOrderService.cs", 1)
  119. };
  120. foreach (var (file, expected) in expectations)
  121. {
  122. var writes = ExecutableLines(Source("Supply", file)).Split('\n')
  123. .Count(l => l.Contains("INSERT INTO mdp_std_process_outsource_order", StringComparison.Ordinal)
  124. || l.Contains("UPDATE mdp_std_process_outsource_order", StringComparison.Ordinal));
  125. Assert.True(writes == expected,
  126. $"{file} 对 mdp_std_process_outsource_order 的运行期写入应为 {expected} 处,实测 {writes} 处");
  127. }
  128. }
  129. /// <summary>
  130. /// 种群 B 必须真的带着 <c>po_no</c> / <c>po_line</c> 落库。
  131. /// <para>止血的前提是「剩下的那一个写入方字段齐全」。若它也写 NULL,
  132. /// 那么删掉种群 A 只是把膨胀速率降下来,并没有消除根因。</para>
  133. /// </summary>
  134. [Fact]
  135. public void PopulationB_SuppliesRealPoNoAndPoLine()
  136. {
  137. var block = StatementBlock(
  138. Source("Supply", "ProcessOutsourceOrderService.cs"),
  139. "INSERT INTO mdp_std_process_outsource_order");
  140. Assert.Contains("FROM PurOrdMaster m", block);
  141. Assert.Contains("JOIN PurOrdDetail d ON m.RecID = d.PurOrdRecID", block);
  142. Assert.Contains("m.Potype = 'PW'", block);
  143. // 唯一键的两列来自源侧真实值,而不是字面量 NULL。
  144. Assert.Contains("IFNULL(m.PurOrd, '')", block);
  145. Assert.Contains("CAST(IFNULL(d.Line, 0) AS CHAR)", block);
  146. }
  147. // ── Test A3 ─────────────────────────────────────────────────────────────────────
  148. /// <summary>
  149. /// 同表 <c>mdp_stg_work_order_material</c> 的其余三个分区必须原样保留。
  150. ///
  151. /// <para>移除的是**一个分区**,不是整张贴源表。工单主表 / 明细 / 工艺路线三个分区
  152. /// 各自有下游(<c>mdp_std_material_readiness</c> → <c>dwd_material_readiness</c>),
  153. /// 误删任何一个都会让物料齐套直接断供。这条是防止止血扩大化。</para>
  154. /// </summary>
  155. [Fact]
  156. public void WorkOrderMaterialPartitions_RemainRegistered()
  157. {
  158. var expected = new[]
  159. {
  160. ("S3_WORK_ORDER_MASTER", "WorkOrdMaster"),
  161. ("S3_WORK_ORDER_DETAIL", "WorkOrdDetail"),
  162. ("S3_WORK_ORDER_ROUTING", "WorkOrdRouting")
  163. };
  164. foreach (var (entityCode, sourceTable) in expected)
  165. {
  166. Assert.Contains(S3MdpEntityConfig.All, c =>
  167. string.Equals(c.EntityCode, entityCode, StringComparison.Ordinal)
  168. && string.Equals(c.SourceTable, sourceTable, StringComparison.Ordinal)
  169. && string.Equals(c.TargetTable, "mdp_stg_work_order_material", StringComparison.Ordinal));
  170. }
  171. // 该贴源表现在恰好由三个分区供给,不多不少。
  172. var partitions = S3MdpEntityConfig.All
  173. .Count(c => string.Equals(c.TargetTable, "mdp_stg_work_order_material", StringComparison.Ordinal));
  174. Assert.Equal(3, partitions);
  175. }
  176. // ── Test A4 ─────────────────────────────────────────────────────────────────────
  177. /// <summary>
  178. /// DWD 投影对唯一键两列的 NULL 保护必须**对称**。
  179. ///
  180. /// <para><c>uk_work_op_stat</c> 含 <c>po_no</c> 与 <c>po_line</c>。MySQL 唯一索引是
  181. /// NULL-distinct 的:只要其中**任意一列**为 NULL,该行就永远不与已有行冲突,
  182. /// ON DUPLICATE KEY UPDATE 永不命中 → 每轮跑批纯追加。
  183. /// 历史上 <c>po_line</c> 有 <c>IFNULL</c> 而 <c>po_no</c> 没有,保护形同虚设。</para>
  184. ///
  185. /// <para><b>注意顺序依赖</b>:这条保护只有在种群 A 被切断之后才是安全的。
  186. /// 若种群 A 仍在(其 po_no/po_line 恒为 NULL),加上 IFNULL 会让它们全部塌进
  187. /// 同一个键,实测 80,022 行会压成 381 行、静默丢掉 99.5% 的物料维 ——
  188. /// 那是把「膨胀」换成了「静默丢数」,更坏。</para>
  189. /// </summary>
  190. [Fact]
  191. public void DwdProjection_GuardsBothUniqueKeyColumns()
  192. {
  193. var block = StatementBlock(S3Service(), "INSERT INTO dwd_process_outsource_delivery");
  194. Assert.Contains("IFNULL(o.po_no,'')", block);
  195. Assert.Contains("IFNULL(o.po_line,'')", block);
  196. // 不得退回到裸 o.po_no(逗号紧跟,排除 IFNULL(o.po_no 这种被包住的写法)。
  197. var sqlOnly = ExecutableLines(block);
  198. Assert.DoesNotContain(" o.po_no,", sqlOnly);
  199. }
  200. // ── Test A5 ─────────────────────────────────────────────────────────────────────
  201. /// <summary>
  202. /// 回归防护:DWD 的 SELECT 不得再以任何形式引到种群 A。
  203. ///
  204. /// <para>它只能从 <c>mdp_std_process_outsource_order</c> 取数、并 LEFT JOIN 供应商维;
  205. /// 一旦有人为了「补回那 38,806 行」而在这里 UNION 回贴源层,本条立刻失败。</para>
  206. /// </summary>
  207. [Fact]
  208. public void DwdProjection_DoesNotReferenceRoutingOpDetailSource()
  209. {
  210. var sqlOnly = ExecutableLines(
  211. StatementBlock(S3Service(), "INSERT INTO dwd_process_outsource_delivery"));
  212. Assert.DoesNotContain("RoutingOpDetail", sqlOnly);
  213. Assert.DoesNotContain("mdp_stg_work_order_material", sqlOnly);
  214. Assert.DoesNotContain("UNION", sqlOnly);
  215. // 唯一允许的取数来源。
  216. Assert.Contains("FROM mdp_std_process_outsource_order o", sqlOnly);
  217. }
  218. /// <summary>
  219. /// 作用域注入不得被 DWD 投影里新增的 SQL 行注释带偏。
  220. ///
  221. /// <para><c>MdpSqlScope.InjectTenantFactory</c> 是正则实现:它改写 <c>WHERE</c>,
  222. /// 并反向扫描最近的 <c>FROM 表 别名</c> 来决定列前缀。注释对它同样「可见」——
  223. /// 若注释里出现 <c>WHERE</c> 或形似 <c>FROM x y</c> 的文字,前缀会被解析成错的东西,
  224. /// 生成的列要么是裸列(歧义列)、要么指向没有该列的表,两种都表现为<b>整轮跑批 FAILED</b>,
  225. /// 而不是少几行数据。</para>
  226. ///
  227. /// <para>本条断言的是<b>注释相对注入是惰性的</b>:先注入再剥注释,与先剥注释再注入,
  228. /// 结果必须逐字相同。它不去判定注入结果本身「对不对」(那由别的事实决定,
  229. /// 且本语句里确实存在一处先于本批就有的空前缀 —— 位于未取别名的单表子查询
  230. /// <c>FROM mdp_std_supplier</c> 内,那里裸列无歧义、无害),
  231. /// 只锁死「加注释不改变生成的 SQL」这一条,正是新增注释唯一可能引入的风险。</para>
  232. ///
  233. /// <para>同时确认 NULL 保护在注入后仍原样存活。</para>
  234. /// </summary>
  235. [Fact]
  236. public void DwdProjection_SqlComments_AreInertUnderScopeInjection()
  237. {
  238. var block = StatementBlock(S3Service(), "INSERT INTO dwd_process_outsource_delivery");
  239. static string StripSqlComments(string s) =>
  240. string.Join('\n', s.Split('\n')
  241. .Where(l => !l.TrimStart().StartsWith("--", StringComparison.Ordinal)));
  242. // 该语句确实带 SQL 行注释,否则这条断言是空转。
  243. Assert.NotEqual(block, StripSqlComments(block));
  244. Assert.Equal(
  245. MdpSqlScope.InjectTenantFactory(StripSqlComments(block)),
  246. StripSqlComments(MdpSqlScope.InjectTenantFactory(block)));
  247. // NULL 保护必须原样存活。
  248. var injected = MdpSqlScope.InjectTenantFactory(block);
  249. Assert.Contains("IFNULL(o.po_no,'')", injected);
  250. Assert.Contains("IFNULL(o.po_line,'')", injected);
  251. }
  252. }