Bladeren bron

fix(aidop): stop P0 data-platform write amplification | server 1.0.548

P0-A: 切断 RoutingOpDetail 错误入站链
- 移除 S3MdpEntityConfig.All 中的 S3_ROUTING_OUTSOURCE 注册。RoutingOpDetail 是
  「S0 标准工艺路线明细」主数据,已由 S0 维度层建模为 bridge_item_routing_op,
  S3 这份属重复建模。
- 移除 BuildStandardCommands 里 RoutingOpDetail -> mdp_std_process_outsource_order
  的 STD INSERT。该语句驱动集是 WHERE source_table='RoutingOpDetail',只服务这一个
  来源;其中 po_no / po_line 因源侧无对应字段被硬编码为 NULL。
- BuildDwdCommands 投影补 IFNULL(o.po_no,''),与既有的 IFNULL(o.po_line,'') 对称。
  uk_work_op_stat 含这两列,MySQL 唯一索引 NULL-distinct 语义使 ODKU 永不命中,
  每轮跑批全量追加,实测膨胀至 3262 万行。
  注意顺序:该保护必须在切断入站之后才安全,否则会把大量不同主数据压进少数键。
- mdp_std_process_outsource_order 的写入方由 2 个收敛为 1 个
  (Supply/ProcessOutsourceOrderService.cs,PurOrdMaster Potype='PW'),未改动。

P0-B: S0Dim 写前 checksum 闸门
- S0DimMaterializer.RunAsync 在 source_count 之后、staging purge 之前插入纯只读闸门:
  source checksum 与 dim checksum 一致则置 SKIPPED_NO_CHANGE 并返回,不 purge、
  不 pull、不删 dim、不跑写后对账;不一致则原 FULL 路径逐字不变。
- 正确性依据:dim 事务内的 AssertBlockingAsync 已断言 source == dim,故任一轮
  SUCCESS 之后两者必然相等;下一轮仍相等即证明源侧无变化。不依赖任何持久化状态。
- S0DimReconciler 把 Layer 3 的 checksum 读取抽为 public ReadChecksumPairAsync,
  闸门与写后对账共用同一条路径,杜绝口径漂移(纯重构,判据德摩根等价)。
- 保留 FULL,未引入 INCR/ROLLING/watermark;未改 mdp_entity 配置;
  未改 MdpStagingWriter / MdpDbPullExecutor / MdpStdFullReplace / MdpSyncWindowResolver。

测试: 新增契约测试 33 条 (8 + 25),S3/DataPlatform/S0Dim 回归 362 条全绿。
YY968XX 3 dagen geleden
bovenliggende
commit
177e890e4c

+ 3 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.547</AssemblyVersion>
-    <FileVersion>1.0.547</FileVersion>
-    <Version>1.0.547</Version>
+    <AssemblyVersion>1.0.548</AssemblyVersion>
+    <FileVersion>1.0.548</FileVersion>
+    <Version>1.0.548</Version>
   </PropertyGroup>
   </PropertyGroup>
 
 
   <ItemGroup>
   <ItemGroup>

+ 288 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/DataPlatform/S3ProcessOutsourceGrainContractTests.cs

@@ -0,0 +1,288 @@
+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);
+    }
+}

+ 433 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S0/Dim/S0DimChecksumGateTests.cs

@@ -0,0 +1,433 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using Admin.NET.Plugin.AiDOP.DataPlatform.S0Dim;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S0.Dim;
+
+/// <summary>
+/// <b>P0-B · 写前校验和闸门</b>(<c>S0DimMaterializer</c> 的 [1.5] 步)契约测试。
+///
+/// <para>闸门本身在 <c>S0DimMaterializer.RunAsync</c> 里编排,而该方法依赖
+/// <c>ISqlSugarClient</c> / <c>MdpSourcePullDispatcher</c> 等一串需要真实数据库的协作者,
+/// 无法在本项目现有的「纯单测、不连库」范式下直接跑。故按两层拆测:</para>
+///
+/// <list type="number">
+/// <item><b>判定层</b> —— 闸门的判据被抽成纯函数
+/// <see cref="S0DimChecksumPair.IsMatchOf"/>,对它做表驱动断言(B1–B7 场景逐条覆盖);</item>
+/// <item><b>编排层</b> —— 对 <c>S0DimMaterializer.cs</c> 的源码做结构断言:
+/// 闸门必须出现在**任何** purge / pull / DELETE / INSERT 之前,且跳过分支必须直接 return。
+/// 这一层无法证明运行时行为,但能把「闸门被挪到写操作之后」这种致命回归钉在测试期。</item>
+/// </list>
+///
+/// <para>⚠️ 本文件**不**验证 checksum 值本身算得对不对 —— 那是
+/// <c>S0DimSqlBuilder.BuildSourceChecksumSql</c> / <c>BuildDimChecksumSql</c> 的既有职责,
+/// 且已被 dim 事务内的写后对账每轮实证。本文件只验证「闸门用的是同一套口径」。</para>
+/// </summary>
+public class S0DimChecksumGateTests
+{
+    private const StringComparison Ord = StringComparison.Ordinal;
+
+    // ────────────────────────────────────────────────────────────────
+    // 第一层 · 判定纯函数(B1–B7)
+    // ────────────────────────────────────────────────────────────────
+
+    /// <summary>
+    /// B1 · 源与 dim 完全一致 → 判定为「无变化」,整轮跳过(0 次 DELETE / 0 次 INSERT)。
+    ///
+    /// <para>「0 次 DELETE / 0 次 INSERT」由第二层的
+    /// <see cref="Skip_branch_must_return_before_any_write"/> 从编排结构上保证:
+    /// 跳过分支在 purge / pull / ReplaceAsync 之前 <c>return</c>,它们根本不会被执行到。</para>
+    /// </summary>
+    [Theory]
+    [InlineData(0, "0", 0, "0")]                                  // 空源 + 空 dim:也是「一致」,无事可做
+    [InlineData(1, "42", 1, "42")]
+    [InlineData(57199, "66123456789012345678901", 57199, "66123456789012345678901")]
+    public void B1_identical_source_and_dim_is_no_change(
+        int srcCnt, string srcChk, int dimCnt, string dimChk)
+    {
+        Assert.True(Match(srcCnt, srcChk, dimCnt, dimChk));
+    }
+
+    /// <summary>B2 · 首次运行:源有数据、dim 还是空的 → 必须放行走 FULL。</summary>
+    [Theory]
+    [InlineData(10, "123", 0, "0")]
+    [InlineData(1, "7", 0, "0")]
+    public void B2_first_run_empty_dim_must_refresh(int srcCnt, string srcChk, int dimCnt, string dimChk)
+    {
+        Assert.False(Match(srcCnt, srcChk, dimCnt, dimChk));
+    }
+
+    /// <summary>
+    /// B3 · 源变空(上游把这批主数据删光了):src=0 而 dim 仍有行 → 必须放行走 FULL,
+    /// 让 FULL REPLACE 把 dim 清到 0。闸门绝不能因为「源是空的」就跳过,
+    /// 否则 dim 会永久保留已被删除的主数据。
+    /// </summary>
+    [Fact]
+    public void B3_source_became_empty_must_refresh_and_not_be_swallowed()
+    {
+        Assert.False(Match(0, "0", 5, "999"));
+        // 更隐蔽的一种:源清空后 checksum 也变 0,只比 checksum 就会误判「一致」。
+        // count 参与判定才能拦住它 —— 这正是 B4 要求「不得只比 checksum」的现实理由之一。
+        Assert.False(Match(0, "0", 5, "0"));
+    }
+
+    /// <summary>
+    /// B4 · 业务键不变、属性列变了 → checksum 变、count 不变 → 必须放行。
+    /// 证明闸门**不是只比业务键**。
+    /// </summary>
+    [Fact]
+    public void B4_attribute_only_change_must_refresh()
+    {
+        Assert.False(Match(10, "1000", 10, "1001"));
+    }
+
+    /// <summary>
+    /// B4 补强 · 从 SQL 层证明 checksum 覆盖的是「业务键 + 全部非租户属性列」,
+    /// 而不是只有业务键。任一属性列漏出 checksum,该列的变化都会被闸门永久吞掉。
+    /// </summary>
+    [Fact]
+    public void B4_checksum_sql_must_cover_every_non_tenant_attribute_column()
+    {
+        foreach (var def in S0DimCatalog.All)
+        {
+            var srcSql = S0DimSqlBuilder.BuildSourceChecksumSql(def);
+            var dimSql = S0DimSqlBuilder.BuildDimChecksumSql(def);
+            var attrs = def.Columns.Where(c => c.Kind != S0DimValueKind.TenantIdColumn).ToList();
+
+            Assert.NotEmpty(attrs);
+            foreach (var c in attrs)
+            {
+                Assert.True(srcSql.Contains($"`{c.JsonPath}`", Ord),
+                    $"{def.Key}: 源侧 checksum 漏了属性列 {c.JsonPath},该列的变化将被闸门吞掉");
+                Assert.True(dimSql.Contains($"`{c.TargetColumn}`", Ord),
+                    $"{def.Key}: dim 侧 checksum 漏了属性列 {c.TargetColumn}");
+            }
+
+            // 业务键也必须在校验和里(否则「删一行 + 加一行」且属性和恰好相抵时不可见)
+            foreach (var bk in def.SourceBizKeyColumns)
+                Assert.True(srcSql.Contains($"`{bk}`", Ord), $"{def.Key}: 源侧 checksum 漏了业务键列 {bk}");
+        }
+    }
+
+    /// <summary>
+    /// B5 · dim 被外部破坏(源没动、dim 少了一行 / 多了一行)→ 必须放行,
+    /// 由 FULL REPLACE 自动修复。闸门不能把「dim 坏了」误读成「无变化」。
+    /// </summary>
+    [Theory]
+    [InlineData(10, "1000", 9, "900")]    // 少一行
+    [InlineData(10, "1000", 11, "1100")]  // 多一行
+    [InlineData(10, "1000", 10, "900")]   // 行数对得上但内容被改过
+    public void B5_corrupted_dim_must_refresh(int srcCnt, string srcChk, int dimCnt, string dimChk)
+    {
+        Assert.False(Match(srcCnt, srcChk, dimCnt, dimChk));
+    }
+
+    /// <summary>
+    /// B6 · 上一轮 FAILED(dim 回滚、停在更旧的快照)后重跑 → 与源不一致 → 必须重新 FULL。
+    /// 这是闸门推理链的关键反面:闸门的「相等即可跳过」只有在**上一轮真的成功过**时才成立,
+    /// 而失败轮恰恰不会让两者相等,所以推理是自洽的。
+    /// </summary>
+    [Fact]
+    public void B6_rerun_after_failed_round_must_refresh()
+    {
+        Assert.False(Match(120, "555", 100, "444"));
+    }
+
+    /// <summary>
+    /// B7 · 租户隔离:判定是**逐租户**的纯函数,租户 A 相等不会让租户 B 跳过,反之亦然。
+    /// </summary>
+    [Fact]
+    public void B7_tenant_scoped_decision_must_not_cross_contaminate()
+    {
+        // 租户 A:一致 → 跳过
+        Assert.True(Match(10, "1000", 10, "1000"));
+        // 租户 B:不一致 → 刷新。两次调用之间无共享状态,A 的结论不会外溢到 B。
+        Assert.False(Match(20, "2000", 19, "1900"));
+    }
+
+    /// <summary>
+    /// B7 补强 · 两条 checksum SQL 都必须带 <c>tenant_id = @TenantId</c> 谓词。
+    /// 少了它,闸门就会拿跨租户聚合值判等 —— 一个租户的变化会被另一个租户的数据掩盖。
+    /// </summary>
+    [Fact]
+    public void B7_checksum_sql_must_be_tenant_scoped()
+    {
+        foreach (var def in S0DimCatalog.All)
+        {
+            Assert.Contains("`tenant_id` = @TenantId", S0DimSqlBuilder.BuildSourceChecksumSql(def), Ord);
+            Assert.Contains("`tenant_id` = @TenantId", S0DimSqlBuilder.BuildDimChecksumSql(def), Ord);
+        }
+    }
+
+    /// <summary>
+    /// 判定必须**同时**比较 count 与 checksum。
+    /// 只比其中一个都会漏判(count 相同内容变了 / 内容和相同但行数变了)。
+    /// </summary>
+    [Fact]
+    public void Decision_must_compare_both_count_and_checksum()
+    {
+        Assert.False(Match(10, "1000", 11, "1000"));  // checksum 相同、count 不同 → 不得跳过
+        Assert.False(Match(10, "1000", 10, "1001"));  // count 相同、checksum 不同 → 不得跳过
+        Assert.True(Match(10, "1000", 10, "1000"));   // 两者都相同才跳过
+    }
+
+    /// <summary>校验和是 DECIMAL(40,0) 的大整数:判等必须精确,不能退化成浮点近似。</summary>
+    [Fact]
+    public void Decision_must_compare_large_checksums_exactly()
+    {
+        const string a = "66123456789012345678901";
+        const string b = "66123456789012345678902"; // 仅末位不同
+        Assert.False(Match(10, a, 10, b));
+        Assert.True(Match(10, a, 10, a));
+    }
+
+    /// <summary>读数对象的 IsMatch 与静态判据必须是同一个判断,不能各写一份。</summary>
+    [Fact]
+    public void IsMatch_property_must_delegate_to_the_static_predicate()
+    {
+        foreach (var (sc, sk, dc, dk) in new[]
+                 {
+                     (10, 1000m, 10, 1000m),
+                     (10, 1000m, 10, 1001m),
+                     (10, 1000m, 9, 1000m),
+                     (0, 0m, 0, 0m)
+                 })
+        {
+            var pair = Pair(sc, sk, dc, dk);
+            Assert.Equal(S0DimChecksumPair.IsMatchOf(sc, sk, dc, dk), pair.IsMatch);
+        }
+    }
+
+    /// <summary>
+    /// 校验和外化成字符串,且与 <c>S0DimReconcileResult</c> 的格式一致(十进制、无分组分隔符)。
+    /// 20+ 位整数一旦以 JSON 数字外化,经 MySQL JSON / JS 取出会退化成 DOUBLE 丢精度。
+    /// </summary>
+    [Fact]
+    public void Checksum_must_be_externalized_as_exact_decimal_string()
+    {
+        var pair = Pair(3, 66123456789012345678901m, 3, 66123456789012345678901m);
+        Assert.Equal("66123456789012345678901", pair.SourceChecksum);
+        Assert.Equal("66123456789012345678901", pair.DimChecksum);
+        Assert.DoesNotContain(",", pair.SourceChecksum, Ord);
+        Assert.DoesNotContain(".", pair.SourceChecksum, Ord);
+    }
+
+    // ────────────────────────────────────────────────────────────────
+    // 第二层 · 状态语义
+    // ────────────────────────────────────────────────────────────────
+
+    /// <summary>
+    /// 跳过状态必须是独立取值:既不是 FAILED,也**不得**与 SUCCESS 相同。
+    /// 混同 SUCCESS 会让「自动刷新是否真的在工作」不可观测。
+    /// </summary>
+    [Fact]
+    public void Skip_status_must_be_distinct_from_success_and_failed()
+    {
+        Assert.Equal("SKIPPED_NO_CHANGE", S0DimMaterializeResult.StatusSkippedNoChange);
+        Assert.NotEqual(S0DimMaterializeResult.StatusSuccess, S0DimMaterializeResult.StatusSkippedNoChange);
+        Assert.NotEqual(S0DimMaterializeResult.StatusFailed, S0DimMaterializeResult.StatusSkippedNoChange);
+        Assert.Equal("NO_CHANGE", S0DimMaterializeResult.SkipReasonNoChange);
+
+        // mdp_transform_run_log.status 是 VARCHAR(30)(1.0.130.sql)——
+        // 新状态值必须装得下,否则整条 run log 写入会在运行期报错。
+        Assert.True(S0DimMaterializeResult.StatusSkippedNoChange.Length <= 30);
+    }
+
+    /// <summary>
+    /// 跳过的那一轮必须能被事后复核:<c>summary_json</c> 里要有
+    /// source/dim 的 count 与 checksum、以及 skip_reason。
+    /// 序列化选项与 <c>S0DimRefreshService</c> 一致(PascalCase,无命名策略)。
+    /// </summary>
+    [Fact]
+    public void Skipped_item_must_be_auditable_in_summary_json()
+    {
+        var item = new S0DimMaterializeResult
+        {
+            Key = "WORK_CENTER",
+            TenantId = 797403760988229L,
+            BatchId = "S0DIM_797403760988229_20260915000000000",
+            Status = S0DimMaterializeResult.StatusSkippedNoChange,
+            SkipReason = S0DimMaterializeResult.SkipReasonNoChange,
+            DimRows = 42,
+            Checksum = Pair(42, 66123456789012345678901m, 42, 66123456789012345678901m)
+        };
+
+        var json = JsonSerializer.Serialize(item, new JsonSerializerOptions
+        {
+            Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
+        });
+
+        Assert.Contains("\"Status\":\"SKIPPED_NO_CHANGE\"", json, Ord);
+        Assert.Contains("\"SkipReason\":\"NO_CHANGE\"", json, Ord);
+        Assert.Contains("\"SourceCount\":42", json, Ord);
+        Assert.Contains("\"DimCount\":42", json, Ord);
+        Assert.Contains("\"SourceChecksum\":\"66123456789012345678901\"", json, Ord);
+        Assert.Contains("\"DimChecksum\":\"66123456789012345678901\"", json, Ord);
+        // 跳过时这三项必须是 0(本轮一行都没动),DimRows 则是 dim 真实行数
+        Assert.Contains("\"StagingPurged\":0", json, Ord);
+        Assert.Contains("\"SourcePulled\":0", json, Ord);
+        Assert.Contains("\"StagingWritten\":0", json, Ord);
+        Assert.Contains("\"DimRows\":42", json, Ord);
+        // 大整数不得以 JSON 数字外化(会丢精度)
+        Assert.DoesNotContain("66123456789012345678901,", json, Ord);
+        Assert.DoesNotContain(":66123456789012345678901", json, Ord);
+    }
+
+    // ────────────────────────────────────────────────────────────────
+    // 第三层 · 编排结构(源码断言)
+    // ────────────────────────────────────────────────────────────────
+
+    /// <summary>
+    /// 闸门必须位于**任何**写操作之前。
+    ///
+    /// <para>这是本批次唯一真正致命的回归形态:闸门若被挪到 purge / pull 之后,
+    /// 它就不再能避免无用功,而且会在「已经把 staging 清空」之后才决定跳过。</para>
+    /// </summary>
+    [Fact]
+    public void Gate_must_run_before_any_write_operation()
+    {
+        var src = MaterializerSource();
+        var body = RunAsyncBody(src);
+
+        // 锚点必须是**真实调用**而不是裸方法名:RunAsync 里闸门上方有一段解释性注释
+        // 同样提到 ReadChecksumPairAsync,裸串会先命中注释 —— 那样即便有人把调用搬到
+        // purge 之后、只把注释留在原位,本断言仍会通过,形同虚设(已用 mutation 实证)。
+        var gate = body.IndexOf("await _reconciler.ReadChecksumPairAsync", Ord);
+        Assert.True(gate >= 0, "写前闸门不见了:RunAsync 里找不到 await _reconciler.ReadChecksumPairAsync");
+
+        foreach (var write in new[]
+                 {
+                     "_purge.PurgeAsync",                 // [2] staging DELETE
+                     "PullAllByEntityCodeAsync",          // [3] staging INSERT(逐行路径)
+                     "_sameDbLoader.LoadAsync",           // [3] staging INSERT(同库快路径)
+                     "MdpStdFullReplace.ReplaceAsync",    // [6] dim DELETE + INSERT
+                     "BuildInsertSql"                     // [6] dim INSERT
+                 })
+        {
+            var at = body.IndexOf(write, Ord);
+            Assert.True(at >= 0, $"RunAsync 里找不到 {write},本断言的锚点已失效,请同步更新");
+            Assert.True(gate < at, $"写前闸门必须在 {write} 之前,实际在其之后(闸门位置 {gate} > {at})");
+        }
+    }
+
+    /// <summary>跳过分支必须**立即 return**,不得继续往下走到 purge / pull / replace。</summary>
+    [Fact]
+    public void Skip_branch_must_return_before_any_write()
+    {
+        var body = RunAsyncBody(MaterializerSource());
+        var gate = body.IndexOf("if (checksum.IsMatch)", Ord);
+        Assert.True(gate >= 0, "找不到闸门判定分支 if (checksum.IsMatch)");
+
+        var purge = body.IndexOf("_purge.PurgeAsync", Ord);
+        var branch = body[gate..purge];
+
+        Assert.Contains("return result;", branch, Ord);
+        Assert.Contains(nameof(S0DimMaterializeResult.StatusSkippedNoChange), branch, Ord);
+        // 跳过分支里不得出现任何写动作
+        Assert.DoesNotContain("PurgeAsync", branch, Ord);
+        Assert.DoesNotContain("ReplaceAsync", branch, Ord);
+        Assert.DoesNotContain("PullAll", branch, Ord);
+    }
+
+    /// <summary>
+    /// 跳过时 <c>DimRows</c> 必须填 dim 当前真实行数,**不能填 0** ——
+    /// 0 会被 run log 的读者读成「dim 被清空了」。
+    /// </summary>
+    [Fact]
+    public void Skip_branch_must_report_actual_dim_rows_not_zero()
+    {
+        var body = RunAsyncBody(MaterializerSource());
+        var gate = body.IndexOf("if (checksum.IsMatch)", Ord);
+        var branch = body[gate..body.IndexOf("_purge.PurgeAsync", Ord)];
+
+        Assert.Contains("result.DimRows = checksum.DimCount;", branch, Ord);
+        Assert.DoesNotContain("result.DimRows = 0", branch, Ord);
+    }
+
+    /// <summary>
+    /// 闸门与写后对账必须**共用同一条读取路径**(<c>S0DimReconciler.ReadChecksumPairAsync</c>)。
+    ///
+    /// <para>闸门的正确性完全依赖「写后对账断言过 source==dim」这条推理。
+    /// 一旦两处各自读一套 checksum,推理会在某次单边修改后静默失效 ——
+    /// 表现为「明明变了却一直跳过」,且没有任何报错。</para>
+    /// </summary>
+    [Fact]
+    public void Gate_and_post_write_reconcile_must_share_one_read_path()
+    {
+        var materializer = MaterializerSource();
+        var reconciler = File.ReadAllText(FindS0DimSource("S0DimReconciler.cs"));
+
+        // 写后对账(ReconcileAsync 的 Layer 3)也走同一个方法
+        Assert.Contains("await ReadChecksumPairAsync(def, tenantId, ct)", reconciler, Ord);
+
+        // checksum SQL 只允许在这一个方法里被读取 —— 别处出现即意味着出现了第二套口径
+        Assert.Equal(1, Occurrences(reconciler, "BuildSourceChecksumSql"));
+        Assert.Equal(1, Occurrences(reconciler, "BuildDimChecksumSql"));
+        Assert.Equal(0, Occurrences(materializer, "BuildSourceChecksumSql"));
+        Assert.Equal(0, Occurrences(materializer, "BuildDimChecksumSql"));
+    }
+
+    /// <summary>
+    /// 闸门不得引入 INCR / 水位 / anti-join 等语义:本轮只做 FULL + 写前判等。
+    /// <c>AssertEntityContractAsync</c> 对 sync_mode / incr_column 的 fail-fast 必须原样保留。
+    /// </summary>
+    [Fact]
+    public void Gate_must_not_introduce_incremental_semantics()
+    {
+        var src = MaterializerSource();
+        Assert.Contains("sync_mode 必须为 FULL", src, Ord);
+        Assert.Contains("incr_column 必须为空", src, Ord);
+        Assert.Contains("FullRefresh = true", src, Ord);
+    }
+
+    // ────────────────────────────────────────────────────────────────
+    // helpers
+    // ────────────────────────────────────────────────────────────────
+
+    private static bool Match(int srcCnt, string srcChk, int dimCnt, string dimChk) =>
+        S0DimChecksumPair.IsMatchOf(srcCnt, decimal.Parse(srcChk), dimCnt, decimal.Parse(dimChk));
+
+    private static S0DimChecksumPair Pair(int srcCnt, decimal srcChk, int dimCnt, decimal dimChk) =>
+        new()
+        {
+            SourceCount = srcCnt,
+            SourceChecksumValue = srcChk,
+            DimCount = dimCnt,
+            DimChecksumValue = dimChk
+        };
+
+    private static int Occurrences(string haystack, string needle)
+    {
+        int n = 0, at = 0;
+        while ((at = haystack.IndexOf(needle, at, Ord)) >= 0) { n++; at += needle.Length; }
+        return n;
+    }
+
+    private static string MaterializerSource() => File.ReadAllText(FindS0DimSource("S0DimMaterializer.cs"));
+
+    /// <summary>截取 RunAsync 方法体(到下一个成员声明为止),避免命中类注释里的示意文字。</summary>
+    private static string RunAsyncBody(string src)
+    {
+        var start = src.IndexOf("public async Task<S0DimMaterializeResult> RunAsync(", Ord);
+        Assert.True(start >= 0, "找不到 RunAsync 方法");
+        var end = src.IndexOf("private const int MaterializeCommandTimeoutSeconds", Ord);
+        Assert.True(end > start, "RunAsync 方法体的结束锚点已失效,请同步更新本测试");
+        return src[start..end];
+    }
+
+    private static string FindS0DimSource(string fileName)
+    {
+        var dir = new DirectoryInfo(AppContext.BaseDirectory);
+        while (dir != null)
+        {
+            var candidate = Path.Combine(dir.FullName,
+                "Admin.NET.Plugin.AiDOP", "DataPlatform", "S0Dim", fileName);
+            if (File.Exists(candidate)) return candidate;
+            dir = dir.Parent;
+        }
+        throw new FileNotFoundException($"未找到 {fileName}");
+    }
+}

+ 38 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/S0Dim/S0DimMaterializer.cs

@@ -12,6 +12,8 @@ namespace Admin.NET.Plugin.AiDOP.DataPlatform.S0Dim;
 /// <code>
 /// <code>
 /// [0] 前置校验(只读):mdp_entity 配置与 definition 一致 + 源侧业务键无重复 + 数据量未超单页
 /// [0] 前置校验(只读):mdp_entity 配置与 definition 一致 + 源侧业务键无重复 + 数据量未超单页
 /// [1] source_count(只读,按租户)
 /// [1] source_count(只读,按租户)
+/// [1.5] 写前校验和闸门(只读):source 与 dim 的 (count, checksum) 一致 → SKIPPED_NO_CHANGE 直接返回,
+///       **不执行**下面任何一步;不一致则原样放行
 /// ── 阶段 I:staging(非事务,可重跑)
 /// ── 阶段 I:staging(非事务,可重跑)
 /// [2] purge 本租户+本源系统+本源表 分区
 /// [2] purge 本租户+本源系统+本源表 分区
 /// [3] FULL pull(源侧/写入侧双重租户过滤)
 /// [3] FULL pull(源侧/写入侧双重租户过滤)
@@ -86,6 +88,40 @@ public sealed class S0DimMaterializer : ITransient
             var sourceCount = await _db.Ado.GetIntAsync(S0DimSqlBuilder.BuildSourceCountSql(def), ps);
             var sourceCount = await _db.Ado.GetIntAsync(S0DimSqlBuilder.BuildSourceCountSql(def), ps);
             await AssertNoSourceDuplicateAsync(def, tenantId, sourceCount, batchSize, ps);
             await AssertNoSourceDuplicateAsync(def, tenantId, sourceCount, batchSize, ps);
 
 
+            // [1.5] 写前校验和闸门(**纯只读**,在任何 DELETE / INSERT 之前)
+            //
+            //   判据:source 与 dim 的 (count, checksum) 完全一致 → 本轮什么都不必做。
+            //
+            //   正确性来自下面 [6] 自己:dim 事务内的 _reconciler.AssertBlockingAsync 断言了
+            //   source checksum == dim checksum,不等即抛异常回滚。
+            //   ⇒ 任何一轮 SUCCESS 之后两者必然相等;
+            //   ⇒ 下一轮开跑时若**仍然**相等,说明源侧自上轮以来没有任何会影响 dim 的变化,
+            //      整轮 purge → pull → FULL REPLACE 是纯无用功(还要白白重写整张租户分区)。
+            //   不相等则原样放行,[2]..[6] 逐字不变 —— 闸门只减少「无变化时的重复劳动」,
+            //   不改变任何一次真实刷新的语义。
+            //
+            //   口径与写后对账**同源**:两边都只走 S0DimReconciler.ReadChecksumPairAsync,
+            //   不在这里另起一套算法(否则上面那条推理会在某次单边修改后静默失效)。
+            //   count 与 checksum 必须同时相等,见 S0DimChecksumPair.IsMatchOf 的注释。
+            //
+            //   注意闸门的位置:它在 [0] 前置校验**之后** —— 配置漂移 / 源侧业务键重复
+            //   这些质量门禁照常每轮都跑,不因「数据没变」而被跳过。
+            var checksum = await _reconciler.ReadChecksumPairAsync(def, tenantId, ct);
+            result.Checksum = checksum;
+            if (checksum.IsMatch)
+            {
+                result.SkipReason = S0DimMaterializeResult.SkipReasonNoChange;
+                // StagingPurged / SourcePulled / StagingWritten 保持 0:本轮确实一行都没动。
+                // DimRows **不填 0** —— 填 0 会被读成「dim 被清空了」。填 dim 当前真实行数。
+                result.DimRows = checksum.DimCount;
+                result.Status = S0DimMaterializeResult.StatusSkippedNoChange;
+                _logger.LogInformation(
+                    "[S0Dim] {Key} tenant={Tenant} batch={Batch} 跳过:source 与 dim 校验和一致" +
+                    "(count={Cnt} chk={Chk}),staging / dim 均未触碰",
+                    def.Key, tenantId, batchId, checksum.DimCount, checksum.DimChecksum);
+                return result;
+            }
+
             // [2] purge:三段谓词精确到本租户 + 本源系统 + 本源表
             // [2] purge:三段谓词精确到本租户 + 本源系统 + 本源表
             result.StagingPurged = await _purge.PurgeAsync(def, tenantId, ct);
             result.StagingPurged = await _purge.PurgeAsync(def, tenantId, ct);
 
 
@@ -141,7 +177,7 @@ public sealed class S0DimMaterializer : ITransient
                 },
                 },
                 ct);
                 ct);
 
 
-            result.Status = "SUCCESS";
+            result.Status = S0DimMaterializeResult.StatusSuccess;
             _logger.LogInformation(
             _logger.LogInformation(
                 "[S0Dim] {Key} tenant={Tenant} batch={Batch} purged={Purged} pulled={Pulled} stg={Stg} dim={Dim}",
                 "[S0Dim] {Key} tenant={Tenant} batch={Batch} purged={Purged} pulled={Pulled} stg={Stg} dim={Dim}",
                 def.Key, tenantId, batchId, result.StagingPurged, result.SourcePulled, stagingCount, result.DimRows);
                 def.Key, tenantId, batchId, result.StagingPurged, result.SourcePulled, stagingCount, result.DimRows);
@@ -156,7 +192,7 @@ public sealed class S0DimMaterializer : ITransient
         }
         }
         catch (Exception ex)
         catch (Exception ex)
         {
         {
-            result.Status = "FAILED";
+            result.Status = S0DimMaterializeResult.StatusFailed;
             result.Error = ex.Message;
             result.Error = ex.Message;
             _logger.LogError(ex, "[S0Dim] {Key} tenant={Tenant} batch={Batch} 物化失败(dim 未改动)",
             _logger.LogError(ex, "[S0Dim] {Key} tenant={Tenant} batch={Batch} 物化失败(dim 未改动)",
                 def.Key, tenantId, batchId);
                 def.Key, tenantId, batchId);

+ 45 - 8
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/S0Dim/S0DimReconciler.cs

@@ -50,6 +50,44 @@ public sealed class S0DimReconciler : ITransient
         return ps;
         return ps;
     }
     }
 
 
+    /// <summary>
+    /// 读取 source / dim 两侧的属性校验和读数(<c>count</c> + <c>checksum</c> 成对)。**纯只读,不写任何数据。**
+    ///
+    /// <para>两个调用点共用本方法,这是刻意的:</para>
+    /// <list type="number">
+    /// <item><see cref="ReconcileAsync"/> 的 Layer 3 —— dim 事务内的**写后**对账;</item>
+    /// <item><see cref="S0DimMaterializer"/> 的**写前**闸门 —— 两侧一致即整轮跳过。</item>
+    /// </list>
+    ///
+    /// <para>闸门的正确性依赖「写前判据 ⊆ 写后判据」:写后对账断言了 source == dim,
+    /// 故任何一轮 SUCCESS 之后两者必然相等;若下一轮开跑时**仍**相等,
+    /// 说明源侧自上轮以来没有任何会影响 dim 的变化。
+    /// 一旦两处各写一份读取逻辑,这条推理就会在某次单边修改后静默失效 ——
+    /// 所以只允许存在这一份。</para>
+    ///
+    /// <para>只用到 <c>@TenantId</c>(两条 SQL 的租户谓词),不涉及批次,
+    /// 因此可以在 purge / pull 之前、staging 尚未装载时安全调用。</para>
+    /// </summary>
+    public async Task<S0DimChecksumPair> ReadChecksumPairAsync(
+        S0DimDefinition def, long tenantId, CancellationToken ct = default)
+    {
+        if (tenantId <= 0) throw new InvalidOperationException($"[{def.Key}] 校验和读取拒绝无效租户:{tenantId}");
+
+        var ps = Params(def, tenantId, null);
+        var srcChk = (await _db.Ado.SqlQueryAsync<ChecksumRow>(S0DimSqlBuilder.BuildSourceChecksumSql(def), ps))
+            .FirstOrDefault() ?? new ChecksumRow();
+        var dimChk = (await _db.Ado.SqlQueryAsync<ChecksumRow>(S0DimSqlBuilder.BuildDimChecksumSql(def), ps))
+            .FirstOrDefault() ?? new ChecksumRow();
+
+        return new S0DimChecksumPair
+        {
+            SourceCount = srcChk.Cnt,
+            SourceChecksumValue = srcChk.Chk,
+            DimCount = dimChk.Cnt,
+            DimChecksumValue = dimChk.Chk
+        };
+    }
+
     /// <summary>
     /// <summary>
     /// 完整对账。<paramref name="batchId"/> 非空时按本批口径校验 staging 与 dim 纯度;
     /// 完整对账。<paramref name="batchId"/> 非空时按本批口径校验 staging 与 dim 纯度;
     /// 为空时按「当前状态」口径(用于独立的只读对账端点)。
     /// 为空时按「当前状态」口径(用于独立的只读对账端点)。
@@ -117,14 +155,13 @@ public sealed class S0DimReconciler : ITransient
                 $"stg\\dim={r.BizKeyDiff.StagingMinusDim} dim\\stg={r.BizKeyDiff.DimMinusStaging}");
                 $"stg\\dim={r.BizKeyDiff.StagingMinusDim} dim\\stg={r.BizKeyDiff.DimMinusStaging}");
 
 
         // ── Layer 3 · 属性校验和(source ↔ dim 端到端)
         // ── Layer 3 · 属性校验和(source ↔ dim 端到端)
-        var srcChk = (await _db.Ado.SqlQueryAsync<ChecksumRow>(S0DimSqlBuilder.BuildSourceChecksumSql(def), ps))
-            .FirstOrDefault() ?? new ChecksumRow();
-        var dimChk = (await _db.Ado.SqlQueryAsync<ChecksumRow>(S0DimSqlBuilder.BuildDimChecksumSql(def), ps))
-            .FirstOrDefault() ?? new ChecksumRow();
-        r.SourceChecksum = srcChk.Chk.ToString("F0");
-        r.DimChecksum = dimChk.Chk.ToString("F0");
-        if (srcChk.Cnt != dimChk.Cnt || srcChk.Chk != dimChk.Chk)
-            r.Failures.Add($"属性校验和不一致:source=({srcChk.Cnt},{r.SourceChecksum}) dim=({dimChk.Cnt},{r.DimChecksum})");
+        //    读取走 ReadChecksumPairAsync —— 与 S0DimMaterializer 的写前闸门是**同一条路径**,
+        //    不存在「写前一套算法、写后另一套」的漂移空间。
+        var chk = await ReadChecksumPairAsync(def, tenantId, ct);
+        r.SourceChecksum = chk.SourceChecksum;
+        r.DimChecksum = chk.DimChecksum;
+        if (!chk.IsMatch)
+            r.Failures.Add($"属性校验和不一致:source=({chk.SourceCount},{r.SourceChecksum}) dim=({chk.DimCount},{r.DimChecksum})");
 
 
         // ── Layer 4 · 结构断言
         // ── Layer 4 · 结构断言
         r.DimDuplicateBizKeys = await _db.Ado.GetIntAsync(S0DimSqlBuilder.BuildDimDuplicateBizKeySql(def), ps);
         r.DimDuplicateBizKeys = await _db.Ado.GetIntAsync(S0DimSqlBuilder.BuildDimDuplicateBizKeySql(def), ps);

+ 11 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/S0Dim/S0DimRefreshService.cs

@@ -76,11 +76,19 @@ public sealed class S0DimRefreshService : ITransient
             throw;
             throw;
         }
         }
 
 
-        result.Status = result.Items.Any(i => i.Status == "FAILED")
+        // 归类顺序:FAILED > 有告警 > 全部跳过 > SUCCESS。
+        // SKIPPED_NO_CHANGE 排在 SUCCESS 之前、告警之后:它是「非失败」,但**不得伪装成 SUCCESS** ——
+        // 否则「自动刷新是否真的在跑」将不可观测(全跳过与全刷新在 run log 里长得一模一样)。
+        // 只有**所有**维度都被写前闸门跳过才整轮记 SKIPPED_NO_CHANGE;只要有一个真刷过就是 SUCCESS。
+        // 被跳过的维度 Reconcile 为 null,故不会贡献告警,与上一档不冲突。
+        result.Status = result.Items.Any(i => i.Status == S0DimMaterializeResult.StatusFailed)
             ? "FAILED"
             ? "FAILED"
             : result.Items.Any(i => i.Reconcile?.Warnings.Count > 0)
             : result.Items.Any(i => i.Reconcile?.Warnings.Count > 0)
                 ? "SUCCESS_WITH_WARNING"
                 ? "SUCCESS_WITH_WARNING"
-                : "SUCCESS";
+                : result.Items.Count > 0
+                  && result.Items.All(i => i.Status == S0DimMaterializeResult.StatusSkippedNoChange)
+                    ? S0DimMaterializeResult.StatusSkippedNoChange
+                    : "SUCCESS";
         result.DurationMs = (int)(DateTime.Now - startedAt).TotalMilliseconds;
         result.DurationMs = (int)(DateTime.Now - startedAt).TotalMilliseconds;
 
 
         await CompleteRunLogAsync(result, startedAt);
         await CompleteRunLogAsync(result, startedAt);
@@ -156,7 +164,7 @@ public sealed class S0DimRefreshService : ITransient
 
 
     private async Task CompleteRunLogAsync(S0DimRefreshResult result, DateTime startedAt)
     private async Task CompleteRunLogAsync(S0DimRefreshResult result, DateTime startedAt)
     {
     {
-        var errors = result.Items.Where(i => i.Status == "FAILED")
+        var errors = result.Items.Where(i => i.Status == S0DimMaterializeResult.StatusFailed)
             .Select(i => $"{i.Key}: {i.Error}").ToList();
             .Select(i => $"{i.Key}: {i.Error}").ToList();
 
 
         await _db.Ado.ExecuteCommandAsync(
         await _db.Ado.ExecuteCommandAsync(

+ 88 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/S0Dim/S0DimResults.cs

@@ -1,5 +1,59 @@
+using System.Text.Json.Serialization;
+
 namespace Admin.NET.Plugin.AiDOP.DataPlatform.S0Dim;
 namespace Admin.NET.Plugin.AiDOP.DataPlatform.S0Dim;
 
 
+/// <summary>
+/// source / dim 两侧属性校验和的一次读数(<c>count</c> 与 <c>checksum</c> **成对**,缺一不可)。
+///
+/// <para>由 <see cref="S0DimReconciler.ReadChecksumPairAsync"/> 唯一产出,
+/// 供两处共用:① <c>ReconcileAsync</c> 的 Layer 3 写后对账;
+/// ② <see cref="S0DimMaterializer"/> 的写前闸门。两者共用同一读取路径,
+/// 保证「写前判等」与「写后对账」不会各跑一套口径。</para>
+/// </summary>
+public sealed class S0DimChecksumPair
+{
+    /// <summary>源侧参与校验和的行数(按租户)。</summary>
+    public int SourceCount { get; set; }
+
+    /// <summary>dim 侧参与校验和的行数(按租户)。</summary>
+    public int DimCount { get; set; }
+
+    /// <summary>
+    /// 源侧校验和原值。<c>DECIMAL(40,0)</c> 的精确整数,**不外化为 JSON 数字** ——
+    /// 20+ 位整数经 JSON/MySQL JSON 取出时会退化成 DOUBLE 丢精度,读者会看到一个错的数字。
+    /// 对外一律走 <see cref="SourceChecksum"/> 字符串。
+    /// </summary>
+    [JsonIgnore]
+    public decimal SourceChecksumValue { get; set; }
+
+    /// <inheritdoc cref="SourceChecksumValue"/>
+    [JsonIgnore]
+    public decimal DimChecksumValue { get; set; }
+
+    /// <summary>源侧校验和(十进制字符串,与 <see cref="S0DimReconcileResult.SourceChecksum"/> 同格式)。</summary>
+    public string SourceChecksum => SourceChecksumValue.ToString("F0");
+
+    /// <summary>dim 侧校验和(十进制字符串,与 <see cref="S0DimReconcileResult.DimChecksum"/> 同格式)。</summary>
+    public string DimChecksum => DimChecksumValue.ToString("F0");
+
+    /// <summary>
+    /// 两侧完全一致。<b>count 与 checksum 必须同时相等</b> —— 判据与
+    /// <c>S0DimReconciler</c> Layer 3 的失败条件逐字互补,不是「只比 checksum」。
+    /// </summary>
+    public bool IsMatch => IsMatchOf(SourceCount, SourceChecksumValue, DimCount, DimChecksumValue);
+
+    /// <summary>
+    /// 校验和判等的**唯一**判据(纯函数,可单测)。
+    ///
+    /// <para>🔴 <b>count 不可省略</b>:<c>SUM(RowHash)</c> 是可交换的加法,
+    /// 理论上不同行集可能求和相同;<c>S0DimSqlBuilder.BuildSourceChecksumSql</c> 的注释
+    /// 明确写了「必须与 COUNT 成对使用」。只比 checksum 会让闸门在极端情况下误判「无变化」
+    /// 而跳过一次本该执行的刷新。</para>
+    /// </summary>
+    public static bool IsMatchOf(int sourceCount, decimal sourceChecksum, int dimCount, decimal dimChecksum)
+        => sourceCount == dimCount && sourceChecksum == dimChecksum;
+}
+
 /// <summary>业务键集合三层等价的差集计数。全部为 0 才算通过。</summary>
 /// <summary>业务键集合三层等价的差集计数。全部为 0 才算通过。</summary>
 public sealed class S0DimBizKeyDiff
 public sealed class S0DimBizKeyDiff
 {
 {
@@ -106,6 +160,24 @@ public sealed class S0DimReconcileResult
 /// <summary>单个维度的物化结果。</summary>
 /// <summary>单个维度的物化结果。</summary>
 public sealed class S0DimMaterializeResult
 public sealed class S0DimMaterializeResult
 {
 {
+    /// <summary>本轮实际执行过刷新且写后对账通过。</summary>
+    public const string StatusSuccess = "SUCCESS";
+
+    /// <summary>本轮失败,dim 停在上一轮的完整快照。</summary>
+    public const string StatusFailed = "FAILED";
+
+    /// <summary>
+    /// 本轮**验证过** source 与 dim 完全一致,因此**没有执行**刷新。
+    ///
+    /// <para>属「非失败」,但**不是** <see cref="StatusSuccess"/>:
+    /// SUCCESS 表示「跑过并验证成功」,本状态表示「验证后确认无需跑」。
+    /// 两者混同会让「自动刷新是否真的在工作」不可观测。</para>
+    /// </summary>
+    public const string StatusSkippedNoChange = "SKIPPED_NO_CHANGE";
+
+    /// <summary>跳过原因:源与 dim 的 (count, checksum) 完全一致。</summary>
+    public const string SkipReasonNoChange = "NO_CHANGE";
+
     /// <summary>维度 Key。</summary>
     /// <summary>维度 Key。</summary>
     public string Key { get; set; } = "";
     public string Key { get; set; } = "";
 
 
@@ -127,11 +199,20 @@ public sealed class S0DimMaterializeResult
     /// <summary>写入 dim 的行数。</summary>
     /// <summary>写入 dim 的行数。</summary>
     public int DimRows { get; set; }
     public int DimRows { get; set; }
 
 
-    /// <summary>对账结果。</summary>
+    /// <summary>对账结果。跳过时为 null —— 本轮没有写入,也就没有写后对账。</summary>
     public S0DimReconcileResult? Reconcile { get; set; }
     public S0DimReconcileResult? Reconcile { get; set; }
 
 
-    /// <summary>SUCCESS / FAILED。</summary>
-    public string Status { get; set; } = "SUCCESS";
+    /// <summary>
+    /// 写前闸门读到的 source / dim 校验和读数。闸门执行过即非 null(无论是否跳过),
+    /// 外化进 <c>summary_json</c> 供事后复核「当时凭什么判定无变化」。
+    /// </summary>
+    public S0DimChecksumPair? Checksum { get; set; }
+
+    /// <summary>跳过原因(仅 <see cref="StatusSkippedNoChange"/> 时非 null)。</summary>
+    public string? SkipReason { get; set; }
+
+    /// <summary><see cref="StatusSuccess"/> / <see cref="StatusSkippedNoChange"/> / <see cref="StatusFailed"/>。</summary>
+    public string Status { get; set; } = StatusSuccess;
 
 
     /// <summary>失败信息。</summary>
     /// <summary>失败信息。</summary>
     public string? Error { get; set; }
     public string? Error { get; set; }
@@ -146,7 +227,10 @@ public sealed class S0DimRefreshResult
     /// <summary>租户。</summary>
     /// <summary>租户。</summary>
     public long TenantId { get; set; }
     public long TenantId { get; set; }
 
 
-    /// <summary>SUCCESS / SUCCESS_WITH_WARNING / FAILED。</summary>
+    /// <summary>
+    /// SUCCESS / SUCCESS_WITH_WARNING / SKIPPED_NO_CHANGE / FAILED / CANCELED。
+    /// <c>SKIPPED_NO_CHANGE</c> 仅在**全部**维度都被写前闸门跳过时出现。
+    /// </summary>
     public string Status { get; set; } = "SUCCESS";
     public string Status { get; set; } = "SUCCESS";
 
 
     /// <summary>逐维度结果。</summary>
     /// <summary>逐维度结果。</summary>

+ 12 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/Job/S0DimAutoRefreshJob.cs

@@ -35,6 +35,11 @@ namespace Admin.NET.Plugin.AiDOP.Job;
 /// <para><b>失败快照语义保持不变</b>:刷新失败 → 事务回滚 → 目标表停在上一轮完整快照,
 /// <para><b>失败快照语义保持不变</b>:刷新失败 → 事务回滚 → 目标表停在上一轮完整快照,
 /// **绝不清空**(见 <c>S0DimMaterializer</c> 的阶段 III 注释)。</para>
 /// **绝不清空**(见 <c>S0DimMaterializer</c> 的阶段 III 注释)。</para>
 ///
 ///
+/// <para><b>无变化即跳过</b>:<c>S0DimMaterializer</c> 的写前校验和闸门会在 purge / pull / replace
+/// 之前比对 source 与 dim 的 (count, checksum),一致则该组合记
+/// <c>SKIPPED_NO_CHANGE</c> 且不触碰任何数据。主数据变更频率低,小时级调度下**多数组合都会跳过**。
+/// 汇总里 <c>skipped</c> 与 <c>ok</c> / <c>failed</c> 分开计 —— 跳过不是失败,也不是「刷过一轮」。</para>
+///
 /// <para><b>并发</b>:<c>Concurrent = false</c> 由 Furion 保证同一作业不并行;
 /// <para><b>并发</b>:<c>Concurrent = false</c> 由 Furion 保证同一作业不并行;
 /// 本类内部对 (租户 × 维度) 组合**严格串行**,不叠加任何并行度。
 /// 本类内部对 (租户 × 维度) 组合**严格串行**,不叠加任何并行度。
 /// 这不是保守,是有实证约束的:<c>ISqlSugarClient</c> 是单例
 /// 这不是保守,是有实证约束的:<c>ISqlSugarClient</c> 是单例
@@ -84,7 +89,10 @@ public class S0DimAutoRefreshJob : IJob
             _logger.LogInformation("S0DimAutoRefreshJob 跳过已停用维度 {Keys}(mdp_entity.status != 1)",
             _logger.LogInformation("S0DimAutoRefreshJob 跳过已停用维度 {Keys}(mdp_entity.status != 1)",
                 string.Join(",", sc.DisabledKeys));
                 string.Join(",", sc.DisabledKeys));
 
 
-        int ok = 0, warned = 0, failed = 0;
+        // skipped 必须与 ok / failed 分开计:
+        // 它既不是失败(不能进 failed),也不是「刷过一轮」(混进 ok 会让
+        // 「自动刷新是否真的在工作」不可观测 —— 全跳过与全刷新在日志里将无法区分)。
+        int ok = 0, warned = 0, skipped = 0, failed = 0;
 
 
         foreach (var tenantId in sc.Tenants)
         foreach (var tenantId in sc.Tenants)
         {
         {
@@ -98,7 +106,8 @@ public class S0DimAutoRefreshJob : IJob
                     var result = await refresh.RefreshAsync(
                     var result = await refresh.RefreshAsync(
                         tenantId, key: def.Key, triggerType: "AUTO", ct: stoppingToken);
                         tenantId, key: def.Key, triggerType: "AUTO", ct: stoppingToken);
 
 
-                    if (result.Status == "FAILED") failed++;
+                    if (result.Status == S0DimMaterializeResult.StatusFailed) failed++;
+                    else if (result.Status == S0DimMaterializeResult.StatusSkippedNoChange) skipped++;
                     else if (result.Status == "SUCCESS_WITH_WARNING") warned++;
                     else if (result.Status == "SUCCESS_WITH_WARNING") warned++;
                     else ok++;
                     else ok++;
                 }
                 }
@@ -122,7 +131,7 @@ public class S0DimAutoRefreshJob : IJob
             tenants = sc.Tenants.Count,
             tenants = sc.Tenants.Count,
             definitions = sc.Definitions.Count,
             definitions = sc.Definitions.Count,
             combinations = sc.Tenants.Count * sc.Definitions.Count,
             combinations = sc.Tenants.Count * sc.Definitions.Count,
-            ok, warned, failed,
+            ok, warned, skipped, failed,
             skippedDisabled = sc.DisabledKeys.Count
             skippedDisabled = sc.DisabledKeys.Count
         }));
         }));
     }
     }

+ 31 - 24
server/Plugins/Admin.NET.Plugin.AiDOP/Supply/S3MdpSyncTransformService.cs

@@ -983,27 +983,18 @@ public class S3MdpSyncTransformService : ITransient
                 sync_time=VALUES(sync_time), update_time=CURRENT_TIMESTAMP
                 sync_time=VALUES(sync_time), update_time=CURRENT_TIMESTAMP
             """, batchId, now);
             """, batchId, now);
 
 
-        yield return Cmd(
-            """
-            INSERT INTO mdp_std_process_outsource_order
-            (tenant_id, factory_id, source_system, work_order, op_code, routing_code, supplier_code, po_no, po_line, order_qty, completed_qty, due_date, status, source_biz_key, sync_batch_id, sync_time)
-            SELECT tenant_id, COALESCE(NULLIF(factory_id,0),1), 'AIDOP',
-                   COALESCE(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.WorkOrd')), ''),
-                   CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.Op')) AS CHAR),
-                   COALESCE(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.RoutingCode')), JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.RouteCode'))),
-                   JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.SupplierCode')),
-                   NULL, NULL,
-                   COALESCE(CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.PackingQty')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.PackingQty')) AS DECIMAL(18,6)) END, 0),
-                   0,
-                   NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.UpdatedAt')), 'null'), ''),
-                   CASE WHEN JSON_EXTRACT(raw_data,'$.IsEnabled') IN (1, true) THEN 'OPEN' ELSE 'DISABLED' END,
-                   source_biz_key, @BatchId, @Now
-            FROM mdp_stg_work_order_material
-            WHERE source_table='RoutingOpDetail'
-            ON DUPLICATE KEY UPDATE routing_code=VALUES(routing_code), supplier_code=VALUES(supplier_code), order_qty=VALUES(order_qty),
-                due_date=VALUES(due_date), status=VALUES(status), sync_batch_id=VALUES(sync_batch_id), sync_time=VALUES(sync_time),
-                update_time=CURRENT_TIMESTAMP
-            """, batchId, now);
+        // ── 已移除:RoutingOpDetail → mdp_std_process_outsource_order 的 STD 物化 ──────────
+        //
+        // 这条 INSERT 的驱动集是 `WHERE source_table='RoutingOpDetail'`,**只**服务那一个来源,
+        // 因此随 S3_ROUTING_OUTSOURCE 入站注册一并移除(见文件末尾 S3MdpEntityConfig.All 的说明)。
+        //
+        // 它把 S0 主数据(标准工艺路线明细)当成委外交付事实物化:源侧没有 po_no / po_line,
+        // 只能硬编码成 NULL,而下游 dwd_process_outsource_delivery 的 uk_work_op_stat 含这两列 ——
+        // NULL-distinct 让 ODKU 永不命中,每轮全量追加,实测 3244 万行。
+        //
+        // mdp_std_process_outsource_order 的另一个写入方是 Supply/ProcessOutsourceOrderService.cs
+        // (PurOrdMaster Potype='PW' JOIN PurOrdDetail,字段齐全),**不在本文件内、不受影响**,
+        // 移除后它成为该表唯一的写入方。
     }
     }
 
 
     private IEnumerable<S3MdpSqlCommand> BuildDwdCommands(string batchId, DateTime now, bool retireStale)
     private IEnumerable<S3MdpSqlCommand> BuildDwdCommands(string batchId, DateTime now, bool retireStale)
@@ -1113,7 +1104,10 @@ public class S3MdpSyncTransformService : ITransient
             """
             """
             INSERT INTO dwd_process_outsource_delivery
             INSERT INTO dwd_process_outsource_delivery
             (tenant_id, factory_id, stat_date, work_order, op_code, routing_code, supplier_code, supplier_name, po_no, po_line, order_qty, completed_qty, remaining_qty, due_date, delivery_status, risk_level, source_system, calc_batch_id, calc_time)
             (tenant_id, factory_id, stat_date, work_order, op_code, routing_code, supplier_code, supplier_name, po_no, po_line, order_qty, completed_qty, remaining_qty, due_date, delivery_status, risk_level, source_system, calc_batch_id, calc_time)
-            SELECT o.tenant_id, o.factory_id, @StatDate, o.work_order, o.op_code, o.routing_code, o.supplier_code, s.supplier_name, o.po_no, IFNULL(o.po_line,''),
+            -- po_no 与 po_line 都必须 IFNULL 成空串:uk_work_op_stat 含这两列,
+            -- 而 MySQL 唯一索引是 NULL-distinct 的 —— 任一列留 NULL 都会让 ODKU 永不命中、
+            -- 每轮跑批全量追加(实测曾膨胀到 3244 万行)。两列保护必须对称,缺一即失效。
+            SELECT o.tenant_id, o.factory_id, @StatDate, o.work_order, o.op_code, o.routing_code, o.supplier_code, s.supplier_name, IFNULL(o.po_no,''), IFNULL(o.po_line,''),
                    IFNULL(o.order_qty,0), IFNULL(o.completed_qty,0), GREATEST(IFNULL(o.order_qty,0)-IFNULL(o.completed_qty,0),0),
                    IFNULL(o.order_qty,0), IFNULL(o.completed_qty,0), GREATEST(IFNULL(o.order_qty,0)-IFNULL(o.completed_qty,0),0),
                    o.due_date,
                    o.due_date,
                    CASE WHEN IFNULL(o.completed_qty,0) >= IFNULL(o.order_qty,0) AND IFNULL(o.order_qty,0) > 0 THEN 'COMPLETED'
                    CASE WHEN IFNULL(o.completed_qty,0) >= IFNULL(o.order_qty,0) AND IFNULL(o.order_qty,0) > 0 THEN 'COMPLETED'
@@ -1556,7 +1550,20 @@ internal sealed record S3MdpEntityConfig(
         new("S3_RECEIPT_DETAIL", "PurOrdRctDetail", "mdp_stg_receipt", "RecID", "CONCAT(IFNULL(s.`Domain`,''), ':', IFNULL(s.`Receiver`,''), ':', IFNULL(s.`Line`,''))"),
         new("S3_RECEIPT_DETAIL", "PurOrdRctDetail", "mdp_stg_receipt", "RecID", "CONCAT(IFNULL(s.`Domain`,''), ':', IFNULL(s.`Receiver`,''), ':', IFNULL(s.`Line`,''))"),
         new("S3_WORK_ORDER_MASTER", "WorkOrdMaster", "mdp_stg_work_order_material", "RecID", "CONCAT(IFNULL(s.`Domain`,''), ':', IFNULL(s.`WorkOrd`,''))"),
         new("S3_WORK_ORDER_MASTER", "WorkOrdMaster", "mdp_stg_work_order_material", "RecID", "CONCAT(IFNULL(s.`Domain`,''), ':', IFNULL(s.`WorkOrd`,''))"),
         new("S3_WORK_ORDER_DETAIL", "WorkOrdDetail", "mdp_stg_work_order_material", "RecID", "CONCAT(IFNULL(s.`Domain`,''), ':', IFNULL(s.`WorkOrd`,''), ':', IFNULL(s.`Op`,''), ':', IFNULL(s.`ItemNum`,''))"),
         new("S3_WORK_ORDER_DETAIL", "WorkOrdDetail", "mdp_stg_work_order_material", "RecID", "CONCAT(IFNULL(s.`Domain`,''), ':', IFNULL(s.`WorkOrd`,''), ':', IFNULL(s.`Op`,''), ':', IFNULL(s.`ItemNum`,''))"),
-        new("S3_WORK_ORDER_ROUTING", "WorkOrdRouting", "mdp_stg_work_order_material", "RecID", "CONCAT(IFNULL(s.`Domain`,''), ':', IFNULL(s.`WorkOrd`,''), ':', IFNULL(s.`OP`,''))"),
-        new("S3_ROUTING_OUTSOURCE", "RoutingOpDetail", "mdp_stg_work_order_material", "RecID", "CONCAT(IFNULL(s.`Domain`,''), ':', IFNULL(s.`RoutingCode`,''), ':', IFNULL(s.`Op`,''), ':', IFNULL(s.`SupplierCode`,''))")
+        new("S3_WORK_ORDER_ROUTING", "WorkOrdRouting", "mdp_stg_work_order_material", "RecID", "CONCAT(IFNULL(s.`Domain`,''), ':', IFNULL(s.`WorkOrd`,''), ':', IFNULL(s.`OP`,''))")
+
+        // ── 已移除:S3_ROUTING_OUTSOURCE(源表 RoutingOpDetail)──────────────────────────
+        //
+        // RoutingOpDetail 是「S0 标准工艺路线明细」,属**主数据**,已由 S0 维度层建模为
+        // bridge_item_routing_op(S0DimCatalog.cs),S3 这份属重复建模。
+        //
+        // 它没有 work_order / po_no / po_line / 订单量 / 完成量 / 交期,SupplierCode 100% 为空,
+        // 只有 0.48% 的行真的是委外工序;却被当成委外交付事实写进 mdp_std_process_outsource_order,
+        // 其中 po_no / po_line 只能硬编码为 NULL。而 dwd_process_outsource_delivery 的
+        // uk_work_op_stat 含这两列 —— MySQL 唯一索引的 NULL-distinct 语义使 ODKU 永不命中,
+        // 于是每轮跑批全量追加,实测膨胀至 3244 万行。
+        //
+        // 委外交付的唯一真实事实来自 PurOrdMaster(Potype='PW') JOIN PurOrdDetail,
+        // 由 Supply/ProcessOutsourceOrderService.cs 写入 std,字段齐全,不受本次移除影响。
     };
     };
 }
 }