瀏覽代碼

refactor(s8): Rule02 切换到数据中台订单 Authority

RULE_S1_ORDER_DELIVERY_DELAY_WARNING 的取数由业务源表直读改为读中台标准层:

  crm_seorderentry ⋈ crm_seorder  →  mdp_std_so

Provider 可执行代码中 crm_seorder / crm_seorderentry 引用为 0,且没有任何 JOIN ——
原先的「订单行 ⋈ 订单头」已在中台侧内化,mdp_std_so 本身就是这个连接的产物。

字段改用中台标准列名:客户要求交期 = plan_delivery_date,系统建议交期(ETA) =
capacity_date。刻意不碰 customer_request_date(源 rdate)与 promised_delivery_date
(源 date)—— 名字相近但是另外两个字段,与本规则判定无关。

业务行为一字未改:
  Hit          = DATEDIFF(ETA, 交期) > 0,UpperBound 0、容差 0
  CreateEligible = dueAt 为空 或 dueAt >= detectedAt,dueAt 仍取交期本身
  身份         = 单号#行号,dedup_key 逐字节不变
  仍不按延期天数预筛:DelayDays <= 0 的行必须留在结果集里,
                 「行还在但不越界」才是这条规则唯一合法的恢复形态

交期的 00:00:00 时间分量原样保留(中台侧逐行比对一致),
故「立案窗口在进入交期当天的零点关闭」这一既有行为不变。

刻意不写 source_table='...' 之类的判据:mdp_std_so 只有一个写入方、按契约只承载
订单行,该过滤恒真且实测加与不加结果逐行相同;写上它等于把源系统的表名重新塞回
S8,而本次中台化要消除的正是这种源表知识。

等价性(本地,两租户,连续三轮全量同步):
  rows_source=40 rows_mdp=40 compared=40 identical=40
  only_source=0 only_mdp=0 field_mismatch=0
  Hit / CreateEligible 交叉表两侧完全相同(28 / 3 / 7 / 2)
切源前 mdp 侧多出 14 行孤儿、其中 2 行会形成凭空异常,中台修复后降为 0。

切源后运行时(租户 838257186181189):
  Preview 候选 12 / 命中 3 / wouldCreate 0 / wouldMatchExisting 2
  —— SO202608240001#1 命中但交期已过,CreateEligible=false 故不立案,闸门经中台生效
  Run Now 后两条既有异常 hit 9→10、miss=0、recovered_at 仍为 NULL,
  dedup_key 与切源前逐字节相同,异常总数保持 2,无重复、无假恢复

测试侧同步改判:T5/T6/T7/T9 由断言源表列名改为断言中台标准列名,
断言的性质不变(要求真实 ETA、不得按时间或延期预筛、租户作用域、订单行粒度),
并新增「不得出现业务源表名、不得出现 JOIN」的正向约束。
YY968XX 1 天之前
父節點
當前提交
17f629204d

+ 18 - 15
server/Admin.NET.Test/S8/S8OrderDeliveryRule02Tests.cs

@@ -155,8 +155,8 @@ public class S8OrderDeliveryRule02Tests
 
         // ETA 必须来自源系统字段,且缺失即出局。
         // (「ETA 是否晚于交期」不在这里判 —— 那是 evaluator 的事,见 T6 / T15 / T16。)
-        Assert.Contains("e.sys_capacity_date IS NOT NULL", sql, StringComparison.Ordinal);
-        Assert.Contains("DATEDIFF(e.sys_capacity_date, e.plan_date)", sql, StringComparison.Ordinal);
+        Assert.Contains("s.capacity_date IS NOT NULL", sql, StringComparison.Ordinal);
+        Assert.Contains("DATEDIFF(s.capacity_date, s.plan_delivery_date)", sql, StringComparison.Ordinal);
 
         // 以下任何一个出现,都意味着有人在自造 ETA:
         //   DATE_ADD / DATE_SUB / INTERVAL → 用「交期前 N 天」冒充预测
@@ -194,11 +194,11 @@ public class S8OrderDeliveryRule02Tests
         Assert.DoesNotContain("CURDATE", sql, StringComparison.OrdinalIgnoreCase);
         Assert.DoesNotContain("CURRENT_DATE", sql, StringComparison.OrdinalIgnoreCase);
         Assert.DoesNotContain("@Now", sql, StringComparison.OrdinalIgnoreCase);
-        Assert.DoesNotContain("e.sys_capacity_date > e.plan_date", sql, StringComparison.Ordinal);
+        Assert.DoesNotContain("s.capacity_date > s.plan_delivery_date", sql, StringComparison.Ordinal);
 
         // 反面:取数仍必须要求 ETA 存在 —— 没有 ETA 就没有判定依据,
         // 这条过滤与生命周期无关,去掉它会让 350 条无 ETA 的行涌进来。
-        Assert.Contains("e.sys_capacity_date IS NOT NULL", sql, StringComparison.Ordinal);
+        Assert.Contains("s.capacity_date IS NOT NULL", sql, StringComparison.Ordinal);
     }
 
     [Fact]
@@ -206,10 +206,10 @@ public class S8OrderDeliveryRule02Tests
     {
         var sql = SqlWithoutComments();
 
-        Assert.Contains("e.tenant_id = @TenantId", sql, StringComparison.Ordinal);
-        Assert.Contains("o.closed = 0", sql, StringComparison.Ordinal);
-        Assert.Contains("o.IsDeleted = 0", sql, StringComparison.Ordinal);
-        Assert.Contains("e.IsDeleted = 0", sql, StringComparison.Ordinal);
+        Assert.Contains("s.tenant_id = @TenantId", sql, StringComparison.Ordinal);
+        // 中台标准层把源侧的 o.closed / o.IsDeleted / e.IsDeleted 归一为两个标准列。
+        Assert.Contains("IFNULL(s.closed, 0) = 0", sql, StringComparison.Ordinal);
+        Assert.Contains("IFNULL(s.deleted_flag, 0) = 0", sql, StringComparison.Ordinal);
 
         // 租户是唯一的安全边界(Phase 1 拍板)。补 factory_id 过滤会让规则恒返 0 行且不报错。
         Assert.DoesNotContain("factory_id", sql, StringComparison.OrdinalIgnoreCase);
@@ -232,13 +232,16 @@ public class S8OrderDeliveryRule02Tests
         var sql = SqlWithoutComments();
 
         // 身份 = 单号 + 行号。退化成单号会让同一张订单的多个风险行只留下一条异常。
-        Assert.Contains("CONCAT(e.bill_no, '#', e.entry_seq)", sql, StringComparison.Ordinal);
-        Assert.Contains("e.entry_seq IS NOT NULL", sql, StringComparison.Ordinal);
-        Assert.Contains("e.bill_no <> ''", sql, StringComparison.Ordinal);
-
-        // 明细挂在 crm_seorderentry 上;主表只用来取客户与关闭状态。
-        Assert.Contains("FROM crm_seorderentry e", sql, StringComparison.Ordinal);
-        Assert.Contains("JOIN crm_seorder o ON o.Id = e.seorder_id", sql, StringComparison.Ordinal);
+        Assert.Contains("CONCAT(s.order_no, '#', s.order_line)", sql, StringComparison.Ordinal);
+        Assert.Contains("s.order_line IS NOT NULL", sql, StringComparison.Ordinal);
+        Assert.Contains("s.order_no <> ''", sql, StringComparison.Ordinal);
+
+        // 数据来自中台标准层。原先的「明细 JOIN 主表」已在中台侧内化:
+        // mdp_std_so 本身就是订单行 ⋈ 订单头的产物,消费者不再需要认识这两张业务表。
+        Assert.Contains("FROM mdp_std_so s", sql, StringComparison.Ordinal);
+        Assert.DoesNotContain("crm_seorderentry", sql, StringComparison.Ordinal);
+        Assert.DoesNotContain("crm_seorder", sql, StringComparison.Ordinal);
+        Assert.DoesNotContain("JOIN", sql, StringComparison.OrdinalIgnoreCase);
     }
 
     [Fact]

+ 51 - 25
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/Providers/S8OrderDeliveryDataProvider.cs

@@ -46,34 +46,60 @@ public sealed class S8OrderDeliveryDataProvider : IS8MonitoringDataProvider
     /// 其中 <c>DelayDays &lt;= 0</c> 的行由 evaluator 判为不命中 —— 它们必须留在结果集里,
     /// 「行还在但不越界」才是这条规则唯一合法的恢复形态。</para>
     /// </summary>
+    /// <remarks>
+    /// <b>数据来源是数据中台标准层,不是业务源表。</b>本 Provider 不再读取
+    /// <c>crm_seorderentry</c> / <c>crm_seorder</c>;订单行的入站、租户归一与
+    /// 「源侧已删除的行要淘汰」都由 S1 的 <c>mdp_stg_so → mdp_std_so</c> 链路负责。
+    ///
+    /// <para><b>字段是中台的标准列名</b>:客户要求交期 = <c>plan_delivery_date</c>,
+    /// 系统建议交期(ETA) = <c>capacity_date</c>。<b>不要</b>被
+    /// <c>customer_request_date</c>(源 <c>rdate</c>)或 <c>promised_delivery_date</c>
+    /// (源 <c>date</c>)带偏 —— 那是另外两个字段,与本规则判定无关。</para>
+    ///
+    /// <para><b>谓词与切源前逐条等价</b>:<c>closed</c> / <c>deleted_flag</c> 对应原来的
+    /// <c>o.closed</c> / <c>o.IsDeleted</c> + <c>e.IsDeleted</c>;两个日期的 NOT NULL、
+    /// 单号非空、行号非空全部保留。原先的 <c>INNER JOIN crm_seorder</c> 在中台侧已内化:
+    /// <c>mdp_std_so</c> 本身就是「订单行 ⋈ 订单头」的产物。
+    /// 实测(本地,两租户)连续三轮全量同步:40 行逐行一致,
+    /// only_source = 0 / only_mdp = 0 / field_mismatch = 0,Hit 与 CreateEligible 交叉表完全相同。</para>
+    ///
+    /// <para><b>仍然不按延期天数预筛</b>:<c>DelayDays &lt;= 0</c> 的行必须留在结果集里,
+    /// 「行还在但不越界」才是这条规则唯一合法的恢复形态;在取数层滤掉它们会让
+    /// 「恢复」退化成「源行消失」。</para>
+    ///
+    /// <para><b>刻意不写 <c>source_table = '...'</c> 这类判据</b>:
+    /// <c>mdp_std_so</c> 只有一个写入方(S1 的标准层构建),按契约只承载订单行,
+    /// 该过滤今天恒真、实测加与不加结果逐行相同。写上它等于把源系统的表名
+    /// 重新塞回 S8 —— 本次中台化要消除的正是这种源表知识。
+    /// 若将来该表被扩展成多来源,应由中台侧提供正式的语义列(而不是让消费者认表名)。</para>
+    /// </remarks>
     internal const string Sql =
         """
         SELECT
-            CONCAT(e.bill_no, '#', e.entry_seq)                     AS SourceObjectId,
-            e.bill_no                                              AS RelatedObjectCode,
-            COALESCE(NULLIF(o.custom_name, ''), o.custom_no)       AS RelatedObjectName,
-            e.plan_date                                            AS DueAt,
-            DATEDIFF(e.sys_capacity_date, e.plan_date)             AS DelayDays,
-            e.bill_no          AS BillNo,
-            e.entry_seq        AS EntrySeq,
-            e.plan_date        AS PlanDate,
-            e.sys_capacity_date AS SysCapacityDate,
-            e.qty              AS Qty,
-            e.item_number      AS ItemNumber,
-            e.item_name        AS ItemName,
-            o.custom_no        AS CustomNo,
-            o.custom_name      AS CustomName,
-            o.bill_no          AS OrderBillNo
-        FROM crm_seorderentry e
-        JOIN crm_seorder o ON o.Id = e.seorder_id
-        WHERE e.tenant_id = @TenantId
-          AND e.sys_capacity_date IS NOT NULL
-          AND e.plan_date IS NOT NULL
-          AND o.closed = 0
-          AND o.IsDeleted = 0
-          AND e.IsDeleted = 0
-          AND e.bill_no <> ''
-          AND e.entry_seq IS NOT NULL
+            CONCAT(s.order_no, '#', s.order_line)                        AS SourceObjectId,
+            s.order_no                                                   AS RelatedObjectCode,
+            COALESCE(NULLIF(s.customer_name, ''), s.customer_no)         AS RelatedObjectName,
+            s.plan_delivery_date                                         AS DueAt,
+            DATEDIFF(s.capacity_date, s.plan_delivery_date)              AS DelayDays,
+            s.order_no            AS BillNo,
+            CASE WHEN s.order_line REGEXP '^-?[0-9]+$'
+                 THEN CONVERT(s.order_line, SIGNED) END                  AS EntrySeq,
+            s.plan_delivery_date  AS PlanDate,
+            s.capacity_date       AS SysCapacityDate,
+            s.order_qty           AS Qty,
+            s.item_code           AS ItemNumber,
+            s.item_name           AS ItemName,
+            s.customer_no         AS CustomNo,
+            s.customer_name       AS CustomName,
+            s.order_no            AS OrderBillNo
+        FROM mdp_std_so s
+        WHERE s.tenant_id = @TenantId
+          AND s.capacity_date IS NOT NULL
+          AND s.plan_delivery_date IS NOT NULL
+          AND IFNULL(s.closed, 0) = 0
+          AND IFNULL(s.deleted_flag, 0) = 0
+          AND s.order_no <> ''
+          AND s.order_line IS NOT NULL
         """;
 
     private readonly ISqlSugarClient _db;