| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288 |
- using Admin.NET.Plugin.AiDOP.DataPlatform.MdpRebuild;
- using Admin.NET.Plugin.AiDOP.Supply;
- using Xunit;
- namespace Admin.NET.Plugin.AiDOP.Tests.DataPlatform;
- /// <summary>
- /// 委外交付链(<c>mdp_std_process_outsource_order</c> → <c>dwd_process_outsource_delivery</c>)的**粒度契约**。
- ///
- /// <para>这张 DWD 曾同时混装两个互不相干的业务对象:</para>
- ///
- /// <list type="number">
- /// <item><b>种群 A(错误)</b> —— <c>RoutingOpDetail</c>(表注释「S0 标准工艺路线明细」,是**主数据**)
- /// 经 <c>S3_ROUTING_OUTSOURCE</c> 入站落 <c>mdp_stg_work_order_material</c>,再被一条
- /// <c>WHERE source_table='RoutingOpDetail'</c> 的 STD INSERT 当成委外交付事实物化。
- /// 它根本没有 <c>work_order</c> / <c>po_no</c> / <c>po_line</c> / 订单量 / 完成量 / 交期,
- /// <c>SupplierCode</c> 100% 为空,只有 0.48% 的行真是委外工序;
- /// 且该源表早已由 S0 维度层正确建模为 <c>bridge_item_routing_op</c>,属重复建模。</item>
- /// <item><b>种群 B(正确)</b> —— <c>PurOrdMaster(Potype='PW') JOIN PurOrdDetail</c>,
- /// 由 <c>Supply/ProcessOutsourceOrderService.cs</c> 写入 STD,字段齐全。
- /// 这是唯一真实的委外交付事实。</item>
- /// </list>
- ///
- /// <para><b>为什么是 P0</b>:种群 A 的 <c>po_no</c> / <c>po_line</c> 只能硬编码成 <c>NULL</c>,
- /// 而 DWD 的唯一键 <c>uk_work_op_stat</c> 含这两列 —— MySQL 唯一索引是 NULL-distinct 的,
- /// 于是 ON DUPLICATE KEY UPDATE 永不命中,每轮跑批全量追加,实测膨胀至 3244 万行。
- /// 症状不是报错,是一张越跑越大、且语义上根本不是委外交付的表。</para>
- ///
- /// <para>本文件守住止血之后的形状:种群 A 的两个环节(入站注册 + STD 物化)都不得回来,
- /// 种群 B 的链路必须完好,且 DWD 投影的 NULL 保护必须对称。</para>
- /// </summary>
- 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");
- /// <summary>
- /// 取可执行 SQL/代码行 —— 剔除 C# 行注释与 SQL 行注释。
- /// <para>本文件的断言大量是「某个标识符不得再出现」,而移除它的那一处恰恰留下了
- /// 解释性注释、注释里必然提到这些标识符。不过滤会把注释误判成活代码。</para>
- /// </summary>
- 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);
- }));
- /// <summary>取一条语句的 SQL 区间(从锚点到原始字符串字面量的收尾 <c>"""</c>)。</summary>
- 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 ─────────────────────────────────────────────────────────────────────
- /// <summary>
- /// 入站层不得再把 <c>RoutingOpDetail</c> 拉进数据中台。
- ///
- /// <para>这是最强的一档断言:直接读运行期真正使用的配置对象
- /// <see cref="S3MdpEntityConfig.All"/>,不是比对源码文本。</para>
- ///
- /// <para>同时按 <c>EntityCode</c> 与 <c>SourceTable</c> 两个维度断言,
- /// 是为了堵住「换个 entity_code 重新注册同一张源表」这条回归路径。</para>
- /// </summary>
- [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 ─────────────────────────────────────────────────────────────────────
- /// <summary>
- /// STD 层不得再由本服务把 <c>RoutingOpDetail</c> 物化成委外交付订单。
- ///
- /// <para><b>断言强度说明</b>:<c>BuildStandardCommands</c> 是私有实例方法,
- /// 其宿主 <c>S3MdpSyncTransformService</c> 的构造依赖 SqlSugar / DI 容器,
- /// 纯单测里无法在不连库的前提下构造实例并枚举它产出的 SQL。
- /// 因此这里退化为对源码文本的断言 —— 由于该方法体是纯 <c>yield return</c> 原始字符串字面量、
- /// 不做任何运行期 SQL 拼接,文本断言与枚举产出在本例中是等价的。
- /// 配置层那一档(<see cref="Inbound_NoLongerRegisters_RoutingOpDetail"/>)才是强断言,
- /// 两者合起来锁死「贴源没有它 + STD 不读它」。</para>
- /// </summary>
- [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);
- }
- /// <summary>
- /// <c>mdp_std_process_outsource_order</c> 在运行期只能有一个写入方,且必须是种群 B。
- /// <para>把「本服务写入归零」与「另一方仍在」写成同一条断言,
- /// 避免后续有人以为移除是把整条链都砍了。</para>
- /// </summary>
- [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} 处");
- }
- }
- /// <summary>
- /// 种群 B 必须真的带着 <c>po_no</c> / <c>po_line</c> 落库。
- /// <para>止血的前提是「剩下的那一个写入方字段齐全」。若它也写 NULL,
- /// 那么删掉种群 A 只是把膨胀速率降下来,并没有消除根因。</para>
- /// </summary>
- [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 ─────────────────────────────────────────────────────────────────────
- /// <summary>
- /// 同表 <c>mdp_stg_work_order_material</c> 的其余三个分区必须原样保留。
- ///
- /// <para>移除的是**一个分区**,不是整张贴源表。工单主表 / 明细 / 工艺路线三个分区
- /// 各自有下游(<c>mdp_std_material_readiness</c> → <c>dwd_material_readiness</c>),
- /// 误删任何一个都会让物料齐套直接断供。这条是防止止血扩大化。</para>
- /// </summary>
- [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 ─────────────────────────────────────────────────────────────────────
- /// <summary>
- /// DWD 投影对唯一键两列的 NULL 保护必须**对称**。
- ///
- /// <para><c>uk_work_op_stat</c> 含 <c>po_no</c> 与 <c>po_line</c>。MySQL 唯一索引是
- /// NULL-distinct 的:只要其中**任意一列**为 NULL,该行就永远不与已有行冲突,
- /// ON DUPLICATE KEY UPDATE 永不命中 → 每轮跑批纯追加。
- /// 历史上 <c>po_line</c> 有 <c>IFNULL</c> 而 <c>po_no</c> 没有,保护形同虚设。</para>
- ///
- /// <para><b>注意顺序依赖</b>:这条保护只有在种群 A 被切断之后才是安全的。
- /// 若种群 A 仍在(其 po_no/po_line 恒为 NULL),加上 IFNULL 会让它们全部塌进
- /// 同一个键,实测 80,022 行会压成 381 行、静默丢掉 99.5% 的物料维 ——
- /// 那是把「膨胀」换成了「静默丢数」,更坏。</para>
- /// </summary>
- [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 ─────────────────────────────────────────────────────────────────────
- /// <summary>
- /// 回归防护:DWD 的 SELECT 不得再以任何形式引到种群 A。
- ///
- /// <para>它只能从 <c>mdp_std_process_outsource_order</c> 取数、并 LEFT JOIN 供应商维;
- /// 一旦有人为了「补回那 38,806 行」而在这里 UNION 回贴源层,本条立刻失败。</para>
- /// </summary>
- [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);
- }
- /// <summary>
- /// 作用域注入不得被 DWD 投影里新增的 SQL 行注释带偏。
- ///
- /// <para><c>MdpSqlScope.InjectTenantFactory</c> 是正则实现:它改写 <c>WHERE</c>,
- /// 并反向扫描最近的 <c>FROM 表 别名</c> 来决定列前缀。注释对它同样「可见」——
- /// 若注释里出现 <c>WHERE</c> 或形似 <c>FROM x y</c> 的文字,前缀会被解析成错的东西,
- /// 生成的列要么是裸列(歧义列)、要么指向没有该列的表,两种都表现为<b>整轮跑批 FAILED</b>,
- /// 而不是少几行数据。</para>
- ///
- /// <para>本条断言的是<b>注释相对注入是惰性的</b>:先注入再剥注释,与先剥注释再注入,
- /// 结果必须逐字相同。它不去判定注入结果本身「对不对」(那由别的事实决定,
- /// 且本语句里确实存在一处先于本批就有的空前缀 —— 位于未取别名的单表子查询
- /// <c>FROM mdp_std_supplier</c> 内,那里裸列无歧义、无害),
- /// 只锁死「加注释不改变生成的 SQL」这一条,正是新增注释唯一可能引入的风险。</para>
- ///
- /// <para>同时确认 NULL 保护在注入后仍原样存活。</para>
- /// </summary>
- [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);
- }
- }
|