using Admin.NET.Plugin.AiDOP.DataPlatform.MdpRebuild; using Admin.NET.Plugin.AiDOP.Supply; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.DataPlatform; /// /// 委外交付链(mdp_std_process_outsource_orderdwd_process_outsource_delivery)的**粒度契约**。 /// /// 这张 DWD 曾同时混装两个互不相干的业务对象: /// /// /// 种群 A(错误) —— RoutingOpDetail(表注释「S0 标准工艺路线明细」,是**主数据**) /// 经 S3_ROUTING_OUTSOURCE 入站落 mdp_stg_work_order_material,再被一条 /// WHERE source_table='RoutingOpDetail' 的 STD INSERT 当成委外交付事实物化。 /// 它根本没有 work_order / po_no / po_line / 订单量 / 完成量 / 交期, /// SupplierCode 100% 为空,只有 0.48% 的行真是委外工序; /// 且该源表早已由 S0 维度层正确建模为 bridge_item_routing_op,属重复建模。 /// 种群 B(正确) —— PurOrdMaster(Potype='PW') JOIN PurOrdDetail, /// 由 Supply/ProcessOutsourceOrderService.cs 写入 STD,字段齐全。 /// 这是唯一真实的委外交付事实。 /// /// /// 为什么是 P0:种群 A 的 po_no / po_line 只能硬编码成 NULL, /// 而 DWD 的唯一键 uk_work_op_stat 含这两列 —— MySQL 唯一索引是 NULL-distinct 的, /// 于是 ON DUPLICATE KEY UPDATE 永不命中,每轮跑批全量追加,实测膨胀至 3244 万行。 /// 症状不是报错,是一张越跑越大、且语义上根本不是委外交付的表。 /// /// 本文件守住止血之后的形状:种群 A 的两个环节(入站注册 + STD 物化)都不得回来, /// 种群 B 的链路必须完好,且 DWD 投影的 NULL 保护必须对称。 /// public class S3ProcessOutsourceGrainContractTests { 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 S3Service() => Source("Supply", "S3MdpSyncTransformService.cs"); /// /// 取可执行 SQL/代码行 —— 剔除 C# 行注释与 SQL 行注释。 /// 本文件的断言大量是「某个标识符不得再出现」,而移除它的那一处恰恰留下了 /// 解释性注释、注释里必然提到这些标识符。不过滤会把注释误判成活代码。 /// private static string ExecutableLines(string code) => string.Join('\n', code.Split('\n') .Where(l => { var t = l.TrimStart(); return !t.StartsWith("//", StringComparison.Ordinal) && !t.StartsWith("--", StringComparison.Ordinal) && !t.StartsWith("///", StringComparison.Ordinal); })); /// 取一条语句的 SQL 区间(从锚点到原始字符串字面量的收尾 """)。 private static string StatementBlock(string code, string startAnchor) { var start = code.IndexOf(startAnchor, StringComparison.Ordinal); Assert.True(start >= 0, $"未找到锚点:{startAnchor}"); var end = code.IndexOf("\"\"\"", start, StringComparison.Ordinal); Assert.True(end > start, $"语句未正常收尾:{startAnchor}"); return code[start..end]; } // ── Test A1 ───────────────────────────────────────────────────────────────────── /// /// 入站层不得再把 RoutingOpDetail 拉进数据中台。 /// /// 这是最强的一档断言:直接读运行期真正使用的配置对象 /// ,不是比对源码文本。 /// /// 同时按 EntityCodeSourceTable 两个维度断言, /// 是为了堵住「换个 entity_code 重新注册同一张源表」这条回归路径。 /// [Fact] public void Inbound_NoLongerRegisters_RoutingOpDetail() { Assert.DoesNotContain(S3MdpEntityConfig.All, c => string.Equals(c.EntityCode, "S3_ROUTING_OUTSOURCE", StringComparison.Ordinal)); Assert.DoesNotContain(S3MdpEntityConfig.All, c => string.Equals(c.SourceTable, "RoutingOpDetail", StringComparison.Ordinal)); } // ── Test A2 ───────────────────────────────────────────────────────────────────── /// /// STD 层不得再由本服务把 RoutingOpDetail 物化成委外交付订单。 /// /// 断言强度说明BuildStandardCommands 是私有实例方法, /// 其宿主 S3MdpSyncTransformService 的构造依赖 SqlSugar / DI 容器, /// 纯单测里无法在不连库的前提下构造实例并枚举它产出的 SQL。 /// 因此这里退化为对源码文本的断言 —— 由于该方法体是纯 yield return 原始字符串字面量、 /// 不做任何运行期 SQL 拼接,文本断言与枚举产出在本例中是等价的。 /// 配置层那一档()才是强断言, /// 两者合起来锁死「贴源没有它 + STD 不读它」。 /// [Fact] public void StdBuild_NoLongerMaterializes_RoutingOpDetail() { var sqlOnly = ExecutableLines(S3Service()); // 驱动集本身已不存在。 Assert.DoesNotContain("source_table='RoutingOpDetail'", sqlOnly); Assert.DoesNotContain("RoutingOpDetail", sqlOnly); // 本服务对该 STD 表的写入处数必须归零 —— 写入方只剩 ProcessOutsourceOrderService。 Assert.DoesNotContain("INSERT INTO mdp_std_process_outsource_order", sqlOnly); Assert.DoesNotContain("UPDATE mdp_std_process_outsource_order", sqlOnly); } /// /// mdp_std_process_outsource_order 在运行期只能有一个写入方,且必须是种群 B。 /// 把「本服务写入归零」与「另一方仍在」写成同一条断言, /// 避免后续有人以为移除是把整条链都砍了。 /// [Fact] public void ProcessOutsourceStd_HasExactlyOneRuntimeWriter() { var expectations = new[] { ("S3MdpSyncTransformService.cs", 0), ("ProcessOutsourceOrderService.cs", 1) }; foreach (var (file, expected) in expectations) { var writes = ExecutableLines(Source("Supply", file)).Split('\n') .Count(l => l.Contains("INSERT INTO mdp_std_process_outsource_order", StringComparison.Ordinal) || l.Contains("UPDATE mdp_std_process_outsource_order", StringComparison.Ordinal)); Assert.True(writes == expected, $"{file} 对 mdp_std_process_outsource_order 的运行期写入应为 {expected} 处,实测 {writes} 处"); } } /// /// 种群 B 必须真的带着 po_no / po_line 落库。 /// 止血的前提是「剩下的那一个写入方字段齐全」。若它也写 NULL, /// 那么删掉种群 A 只是把膨胀速率降下来,并没有消除根因。 /// [Fact] public void PopulationB_SuppliesRealPoNoAndPoLine() { var block = StatementBlock( Source("Supply", "ProcessOutsourceOrderService.cs"), "INSERT INTO mdp_std_process_outsource_order"); Assert.Contains("FROM PurOrdMaster m", block); Assert.Contains("JOIN PurOrdDetail d ON m.RecID = d.PurOrdRecID", block); Assert.Contains("m.Potype = 'PW'", block); // 唯一键的两列来自源侧真实值,而不是字面量 NULL。 Assert.Contains("IFNULL(m.PurOrd, '')", block); Assert.Contains("CAST(IFNULL(d.Line, 0) AS CHAR)", block); } // ── Test A3 ───────────────────────────────────────────────────────────────────── /// /// 同表 mdp_stg_work_order_material 的其余三个分区必须原样保留。 /// /// 移除的是**一个分区**,不是整张贴源表。工单主表 / 明细 / 工艺路线三个分区 /// 各自有下游(mdp_std_material_readinessdwd_material_readiness), /// 误删任何一个都会让物料齐套直接断供。这条是防止止血扩大化。 /// [Fact] public void WorkOrderMaterialPartitions_RemainRegistered() { var expected = new[] { ("S3_WORK_ORDER_MASTER", "WorkOrdMaster"), ("S3_WORK_ORDER_DETAIL", "WorkOrdDetail"), ("S3_WORK_ORDER_ROUTING", "WorkOrdRouting") }; foreach (var (entityCode, sourceTable) in expected) { Assert.Contains(S3MdpEntityConfig.All, c => string.Equals(c.EntityCode, entityCode, StringComparison.Ordinal) && string.Equals(c.SourceTable, sourceTable, StringComparison.Ordinal) && string.Equals(c.TargetTable, "mdp_stg_work_order_material", StringComparison.Ordinal)); } // 该贴源表现在恰好由三个分区供给,不多不少。 var partitions = S3MdpEntityConfig.All .Count(c => string.Equals(c.TargetTable, "mdp_stg_work_order_material", StringComparison.Ordinal)); Assert.Equal(3, partitions); } // ── Test A4 ───────────────────────────────────────────────────────────────────── /// /// DWD 投影对唯一键两列的 NULL 保护必须**对称**。 /// /// uk_work_op_statpo_nopo_line。MySQL 唯一索引是 /// NULL-distinct 的:只要其中**任意一列**为 NULL,该行就永远不与已有行冲突, /// ON DUPLICATE KEY UPDATE 永不命中 → 每轮跑批纯追加。 /// 历史上 po_lineIFNULLpo_no 没有,保护形同虚设。 /// /// 注意顺序依赖:这条保护只有在种群 A 被切断之后才是安全的。 /// 若种群 A 仍在(其 po_no/po_line 恒为 NULL),加上 IFNULL 会让它们全部塌进 /// 同一个键,实测 80,022 行会压成 381 行、静默丢掉 99.5% 的物料维 —— /// 那是把「膨胀」换成了「静默丢数」,更坏。 /// [Fact] public void DwdProjection_GuardsBothUniqueKeyColumns() { var block = StatementBlock(S3Service(), "INSERT INTO dwd_process_outsource_delivery"); Assert.Contains("IFNULL(o.po_no,'')", block); Assert.Contains("IFNULL(o.po_line,'')", block); // 不得退回到裸 o.po_no(逗号紧跟,排除 IFNULL(o.po_no 这种被包住的写法)。 var sqlOnly = ExecutableLines(block); Assert.DoesNotContain(" o.po_no,", sqlOnly); } // ── Test A5 ───────────────────────────────────────────────────────────────────── /// /// 回归防护:DWD 的 SELECT 不得再以任何形式引到种群 A。 /// /// 它只能从 mdp_std_process_outsource_order 取数、并 LEFT JOIN 供应商维; /// 一旦有人为了「补回那 38,806 行」而在这里 UNION 回贴源层,本条立刻失败。 /// [Fact] public void DwdProjection_DoesNotReferenceRoutingOpDetailSource() { var sqlOnly = ExecutableLines( StatementBlock(S3Service(), "INSERT INTO dwd_process_outsource_delivery")); Assert.DoesNotContain("RoutingOpDetail", sqlOnly); Assert.DoesNotContain("mdp_stg_work_order_material", sqlOnly); Assert.DoesNotContain("UNION", sqlOnly); // 唯一允许的取数来源。 Assert.Contains("FROM mdp_std_process_outsource_order o", sqlOnly); } /// /// 作用域注入不得被 DWD 投影里新增的 SQL 行注释带偏。 /// /// MdpSqlScope.InjectTenantFactory 是正则实现:它改写 WHERE, /// 并反向扫描最近的 FROM 表 别名 来决定列前缀。注释对它同样「可见」—— /// 若注释里出现 WHERE 或形似 FROM x y 的文字,前缀会被解析成错的东西, /// 生成的列要么是裸列(歧义列)、要么指向没有该列的表,两种都表现为整轮跑批 FAILED, /// 而不是少几行数据。 /// /// 本条断言的是注释相对注入是惰性的:先注入再剥注释,与先剥注释再注入, /// 结果必须逐字相同。它不去判定注入结果本身「对不对」(那由别的事实决定, /// 且本语句里确实存在一处先于本批就有的空前缀 —— 位于未取别名的单表子查询 /// FROM mdp_std_supplier 内,那里裸列无歧义、无害), /// 只锁死「加注释不改变生成的 SQL」这一条,正是新增注释唯一可能引入的风险。 /// /// 同时确认 NULL 保护在注入后仍原样存活。 /// [Fact] public void DwdProjection_SqlComments_AreInertUnderScopeInjection() { var block = StatementBlock(S3Service(), "INSERT INTO dwd_process_outsource_delivery"); static string StripSqlComments(string s) => string.Join('\n', s.Split('\n') .Where(l => !l.TrimStart().StartsWith("--", StringComparison.Ordinal))); // 该语句确实带 SQL 行注释,否则这条断言是空转。 Assert.NotEqual(block, StripSqlComments(block)); Assert.Equal( MdpSqlScope.InjectTenantFactory(StripSqlComments(block)), StripSqlComments(MdpSqlScope.InjectTenantFactory(block))); // NULL 保护必须原样存活。 var injected = MdpSqlScope.InjectTenantFactory(block); Assert.Contains("IFNULL(o.po_no,'')", injected); Assert.Contains("IFNULL(o.po_line,'')", injected); } }