Prechádzať zdrojové kódy

feat(s8): 新增 Rule02 订单交付延期预警(OUT_OF_RANGE,默认关闭)

S8 第二条真实自动规则。判据取源系统既有的两个日期:
系统按产能测算的建议交期(sys_capacity_date)晚于客户要求交期(plan_date),
且客户交期尚未到达 —— 差值即延期天数,落 measured_value,上限 0。

ETA 全部来自源系统,不做任何推算:
不用「交期前 N 天」、不用历史均值、不用订单数量倒推,
也不用 WorkOrdMaster.QtyCompleted(真库 267 行全为 0)。
没有 sys_capacity_date 的订单行直接不进结果集 —— 那表示还没做过评审资源检查,
没有 ETA 证据,不能靠"离交期还有几天"凑一个预警出来。

复用既有 runtime,未新增 evaluator:
- 数据集 ORDER_DELIVERY_RISK:只声明 SupportsOutOfRange
- Provider 走订单行粒度,身份 = bill_no + '#' + entry_seq,带 dedup_key 预算守卫
- 规则走 S8OutOfRangeRuleEvaluator(仓内第一条 OUT_OF_RANGE 规则,evaluator 一行未改)
- 四条就绪门禁全开:数据集无部门列必须配兜底部门;处理/复核/升级池按异常实际去向逐条确认
  (SLA 240 分钟 → 必进超时升级扫描;复核候选只取该规则复核池,空池会卡死在 IN_PROGRESS)

异常类型 ORDER_DELIVERY_DELAY_WARNING 已在种子中存在,本批不新建、无 migration。
规则由 provisioning 落库时 Enabled=false,需管理员配齐责任池与部门后方可启用。

真库只读 dry-run:390 行订单行中 40 行有 ETA,命中 2 条(均属租户 838257186181189,各晚 12 天)。

已知边界(本批不处理):
- 覆盖面 10.3%,只有走过评审资源检查的订单行有 ETA
- 订单行跨过客户交期后会退出数据集并被判为"恢复",其后的事后逾期属
  ORDER_DUE_DATE_DELAY 的范围,本批明确不实现

测试:新增 21 条契约(Admin.NET.Test/S8);S8 全量 180/180、插件 1994/1996(2 项既有 skip)通过。
YY968XX 2 dní pred
rodič
commit
fcea666e77

+ 444 - 0
server/Admin.NET.Test/S8/S8OrderDeliveryRule02Tests.cs

@@ -0,0 +1,444 @@
+using System.Reflection;
+using System.Text.RegularExpressions;
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions;
+using Xunit;
+
+namespace Admin.NET.Test.S8;
+
+/// <summary>
+/// S8-RULE02-ORDER-DELIVERY-WARNING-1:订单交付延期预警的结构契约。
+///
+/// <para>本文件锁定的每一条,都对应一种「代码照样跑、结论却是错的」的失效形态:</para>
+/// <list type="bullet">
+///   <item><b>ETA 必须来自源系统</b>——一旦有人用「交期前 N 天」「历史均值」「已完工数量倒推」
+///     补一个 ETA,规则会从"预测"退化成"临期提醒",而输出格式完全一样,看不出来。</item>
+///   <item><b>只判未到期</b>——去掉 <c>plan_date &gt;= NOW()</c> 会让 385 条历史逾期单一次性涌进来,
+///     且永远不会恢复。这不是"多报几条",是这条规则从预警变成了积压清单。</item>
+///   <item><b>身份是订单行</b>——退化成订单号会让同单多行只剩一条异常,其余行静默消失。</item>
+///   <item><b>DedupKey 预算</b>——超预算的键被数据库截断后,两个订单行折叠成一条异常,无任何报错。</item>
+///   <item><b>SQL 别名映射</b>——本仓 <c>EnableUnderLine = false</c>,别名写错会静默映射为 null,
+///     行数照常返回但命中恒为 0(Rule 01 真实踩过一次)。</item>
+/// </list>
+///
+/// <para>不接 DB、不接 DI。真库行为已由实施前只读核验覆盖(390 行 / 40 行有 ETA / 命中 2 条),
+/// 本文件锁定的是结构。</para>
+/// </summary>
+public class S8OrderDeliveryRule02Tests
+{
+    private const long TenantId = 838257186181189;
+    private const string RuleCode = S8OrderDeliveryRuleDefinitions.OrderDeliveryDelayWarningCode;
+
+    private static readonly S8RuleCatalog Catalog =
+        new(new IS8RuleDefinitionSource[]
+        {
+            new S8PurchaseDeliveryRuleDefinitions(),
+            new S8OrderDeliveryRuleDefinitions()
+        });
+
+    private static S8RuleDefinition Definition => Catalog.GetRequired(RuleCode);
+
+    private static AdoS8WatchRule NewRule() => new()
+    {
+        Id = 900201,
+        RuleCode = RuleCode,
+        SceneCode = "S1",
+        WatchObjectType = S8OrderDeliveryRuleDefinitions.SalesOrderLineObjectType,
+        SourceObjectType = S8OrderDeliveryRuleDefinitions.SalesOrderLineObjectType,
+        Severity = "FOLLOW",
+        RuleType = S8OutOfRangeRuleEvaluator.RuleTypeCode,
+        DatasetCode = S8OrderDeliveryDatasetDefinitions.OrderDeliveryRiskCode
+    };
+
+    private static S8EffectiveRule Effective(AdoS8WatchRule rule) =>
+        S8EffectiveRule.Resolve(rule, Definition);
+
+    /// <summary>
+    /// 构造一行订单行事实。<paramref name="delayDays"/> 是 Provider 在 SQL 里算好的
+    /// <c>DATEDIFF(sys_capacity_date, plan_date)</c>,此处显式传入以便逐值锁定判定边界。
+    /// </summary>
+    private static S8OrderDeliveryDataProvider.OrderDeliveryRow Row(
+        string billNo, int entrySeq, DateTime planDate, DateTime? sysCapacityDate,
+        decimal? delayDays, decimal qty = 1000m, string itemName = "集控备机",
+        string customName = "内部客户") => new()
+    {
+        SourceObjectId = $"{billNo}#{entrySeq}",
+        RelatedObjectCode = billNo,
+        RelatedObjectName = customName,
+        DueAt = planDate,
+        DelayDays = delayDays,
+        BillNo = billNo,
+        EntrySeq = entrySeq,
+        PlanDate = planDate,
+        SysCapacityDate = sysCapacityDate,
+        Qty = qty,
+        ItemNumber = "IT-0001",
+        ItemName = itemName,
+        CustomNo = "C-0001",
+        CustomName = customName,
+        OrderBillNo = billNo
+    };
+
+    private static S8MonitoringRowSet RowSet(params S8OrderDeliveryDataProvider.OrderDeliveryRow[] rows) =>
+        S8MonitoringRowSet.FromRows(
+            rows.Select(r => S8MonitoringRow.FromValues(S8OrderDeliveryDataProvider.ToCanonicalValues(r))));
+
+    /// <summary>去掉块注释与行注释后的 SQL —— 断言只应看真正会执行的文本。</summary>
+    private static string SqlWithoutComments() =>
+        Regex.Replace(
+            Regex.Replace(S8OrderDeliveryDataProvider.Sql, @"/\*.*?\*/", " ", RegexOptions.Singleline),
+            @"--[^\n]*", " ");
+
+    // ============================================================
+    // A. 数据集定义与 Provider 的一致性
+    // ============================================================
+
+    [Fact]
+    public void T1_Dataset_IsDeclared_WithOutOfRangeCapabilityOnly()
+    {
+        var definition = Assert.Single(new S8OrderDeliveryDatasetDefinitions().GetDefinitions());
+
+        Assert.Equal(S8OrderDeliveryDatasetDefinitions.OrderDeliveryRiskCode, definition.DatasetCode);
+        Assert.Equal(S8DatasetStatus.Defined, definition.Status);
+
+        var c = definition.Capabilities;
+        Assert.True(c.SupportsOutOfRange);
+        Assert.True(c.HasMeasuredValue);
+        Assert.True(c.HasSourceObjectId);
+        Assert.True(c.HasRelatedObjectCode);
+
+        // TIMEOUT / SHORTAGE 必须关闭:本数据集不表达「到期日已过」也不表达「数量缺口」。
+        // 多声明一项能力会让配置页把跑不通的规则类型列成可选,用户配完才在运行期失败。
+        Assert.False(c.SupportsTimeout);
+        Assert.False(c.SupportsShortage);
+    }
+
+    [Fact]
+    public void T2_Provider_And_Catalog_ShareTheSameCapabilityInstance()
+    {
+        var provider = new S8OrderDeliveryDataProvider(null!, null!);
+        var definition = Assert.Single(new S8OrderDeliveryDatasetDefinitions().GetDefinitions());
+
+        // 同一实例而非"值相等":两处各写一份能力声明迟早会漂移,
+        // 而漂移的表现是 Enable Gate 与 evaluator 对同一数据集有不同认知。
+        Assert.Same(definition.Capabilities, provider.Capabilities);
+        Assert.Equal(definition.DatasetCode, provider.DatasetCode);
+    }
+
+    [Fact]
+    public void T3_RuleDefinition_PointsAtTheRegisteredDataset()
+    {
+        Assert.Equal(S8OrderDeliveryDatasetDefinitions.OrderDeliveryRiskCode, Definition.DatasetCode);
+        Assert.Equal(new S8OrderDeliveryDataProvider(null!, null!).DatasetCode, Definition.DatasetCode);
+    }
+
+    [Fact]
+    public void T4_RuleCatalog_AcceptsBothRules_WithoutCodeCollision()
+    {
+        // Catalog 构造期即做完整性校验 + 重复 rule_code 检测;能构造出来本身就是断言。
+        Assert.True(Catalog.IsDefined(RuleCode));
+        Assert.True(Catalog.IsDefined(S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryDateDelayCode));
+        Assert.Equal(2, Catalog.Definitions.Count);
+    }
+
+    // ============================================================
+    // B. SQL 契约:ETA 来源、时间窗、身份粒度
+    // ============================================================
+
+    [Fact]
+    public void T5_Sql_RequiresRealEta_AndNeverSynthesizesOne()
+    {
+        var sql = SqlWithoutComments();
+
+        // ETA 必须来自源系统字段,且缺失即出局。
+        Assert.Contains("e.sys_capacity_date IS NOT NULL", sql, StringComparison.Ordinal);
+        Assert.Contains("e.sys_capacity_date > e.plan_date", sql, StringComparison.Ordinal);
+
+        // 以下任何一个出现,都意味着有人在自造 ETA:
+        //   DATE_ADD / DATE_SUB / INTERVAL → 用「交期前 N 天」冒充预测
+        //   AVG                            → 用历史均值造 ETA
+        //   QtyCompleted / work_ord        → 用工单完工量倒推(该列真库 267 行全为 0)
+        //   deliver_count                  → 已发数量,真库 100% 为空
+        foreach (var forbidden in new[]
+                 {
+                     "DATE_ADD", "DATE_SUB", "INTERVAL", "AVG(",
+                     "QtyCompleted", "qty_completed", "work_ord", "WorkOrd", "deliver_count"
+                 })
+        {
+            Assert.DoesNotContain(forbidden, sql, StringComparison.OrdinalIgnoreCase);
+        }
+    }
+
+    [Fact]
+    public void T6_Sql_OnlyLooksAtOrdersThatHaveNotComeDueYet()
+    {
+        var sql = SqlWithoutComments();
+
+        // 这一条是「预警」与「逾期清单」的分界。去掉它,真库 385 条历史逾期单会一次涌入,
+        // 且因为永远不会再变成"未逾期",也永远不会恢复。
+        Assert.Contains("e.plan_date >= NOW()", sql, StringComparison.Ordinal);
+
+        // 时间基准必须与被比较的日期同源(都在库里)。混用应用时钟会在跨时区部署时
+        // 把"今天到期"判成"已过期",而本规则的价值恰恰建立在"还没到期"上。
+        Assert.DoesNotContain("@Now", sql, StringComparison.OrdinalIgnoreCase);
+    }
+
+    [Fact]
+    public void T7_Sql_ScopesByTenant_AndExcludesClosedOrDeletedOrders()
+    {
+        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);
+
+        // 租户是唯一的安全边界(Phase 1 拍板)。补 factory_id 过滤会让规则恒返 0 行且不报错。
+        Assert.DoesNotContain("factory_id", sql, StringComparison.OrdinalIgnoreCase);
+    }
+
+    [Fact]
+    public void T8_Sql_IsReadOnly()
+    {
+        var sql = SqlWithoutComments();
+
+        // 必须按整词匹配:软删列名 IsDeleted 内含 "Delete",子串匹配会把一条只读 SQL 判成写操作。
+        foreach (var write in new[] { "INSERT", "UPDATE", "DELETE", "REPLACE", "TRUNCATE", "ALTER", "DROP" })
+            Assert.False(Regex.IsMatch(sql, $@"\b{write}\b", RegexOptions.IgnoreCase),
+                $"取数 SQL 中出现写操作关键字 {write};本 Provider 只允许 SELECT");
+    }
+
+    [Fact]
+    public void T9_Sql_UsesOrderLineGrain_NotOrderGrain()
+    {
+        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);
+    }
+
+    [Fact]
+    public void T10_Sql_DoesNotReadProgress_BecauseItIsNotCompletionRate()
+    {
+        // crm_seorderentry.progress 是字符串状态码(0=再评审 1=新建 2=评审 3=确认),
+        // 不是完成百分比。把它当进度用会得到一个看起来合理、实际无意义的判据。
+        Assert.DoesNotContain("progress", SqlWithoutComments(), StringComparison.OrdinalIgnoreCase);
+    }
+
+    // ============================================================
+    // C. SQL 别名 ↔ POCO 映射(EnableUnderLine = false 的静默失败防线)
+    // ============================================================
+
+    [Fact]
+    public void T11_EverySelectAlias_MapsToRowProperty_AndViceVersa()
+    {
+        var aliases = Regex
+            .Matches(S8OrderDeliveryDataProvider.Sql, @"\bAS\s+([A-Za-z_][A-Za-z0-9_]*)", RegexOptions.IgnoreCase)
+            .Select(m => m.Groups[1].Value)
+            .ToArray();
+
+        var props = typeof(S8OrderDeliveryDataProvider.OrderDeliveryRow)
+            .GetProperties(BindingFlags.Public | BindingFlags.Instance)
+            .Select(p => p.Name)
+            .ToArray();
+
+        Assert.NotEmpty(aliases);
+
+        // Ordinal 比较:SqlSugar 的名字匹配不该靠"大概能对上"来赌。
+        var unmapped = aliases.Where(a => !props.Contains(a, StringComparer.Ordinal)).ToArray();
+        Assert.True(unmapped.Length == 0,
+            "以下 SQL 别名在 OrderDeliveryRow 上找不到同名属性,SqlSugar 会静默映射为 null:\n  "
+            + string.Join("\n  ", unmapped));
+
+        var uncovered = props.Where(p => !aliases.Contains(p, StringComparer.Ordinal)).ToArray();
+        Assert.True(uncovered.Length == 0,
+            "以下属性没有任何 SQL 别名覆盖,取数后恒为 null:\n  " + string.Join("\n  ", uncovered));
+    }
+
+    [Fact]
+    public void T12_CanonicalMapping_PutsDelayDaysIntoMeasuredValue()
+    {
+        var values = S8OrderDeliveryDataProvider.ToCanonicalValues(
+            Row("SO202609030003", 1, new DateTime(2026, 9, 30), new DateTime(2026, 10, 12), 12m));
+
+        // measured_value 是判定输入;放错列 = evaluator 拿不到值 = 命中恒为 0。
+        Assert.Equal(12m, Assert.IsType<decimal>(values[S8CanonicalColumns.MeasuredValue]));
+        Assert.Equal("SO202609030003#1", values[S8CanonicalColumns.SourceObjectId]);
+        Assert.Equal("SO202609030003", values[S8CanonicalColumns.RelatedObjectCode]);
+
+        // due_at 随行输出供展示与排序,但不参与判定(数据集能力里 SupportsTimeout=false)。
+        Assert.Equal(new DateTime(2026, 9, 30), values[S8CanonicalColumns.DueAt]);
+
+        // 两个原始日期必须一并落 payload:延期天数是差值,脱离两端就无法复盘。
+        Assert.Equal(new DateTime(2026, 9, 30), values["plan_date"]);
+        Assert.Equal(new DateTime(2026, 10, 12), values["sys_capacity_date"]);
+    }
+
+    [Fact]
+    public void T13_DedupKeyBudget_LeavesRoomForRealOrderLineIds()
+    {
+        var request = new S8MonitoringDataRequest
+        {
+            TenantId = TenantId,
+            DatasetCode = S8OrderDeliveryDatasetDefinitions.OrderDeliveryRiskCode,
+            RuleCode = RuleCode,
+            RuleType = S8OutOfRangeRuleEvaluator.RuleTypeCode
+        };
+
+        var budget = S8OrderDeliveryDataProvider.ResolveSourceObjectIdBudget(request);
+
+        // 预算随租户 / 规则编码变化,故现算而不是写死。真实身份形如 SO202609030003#1(16 字符)。
+        Assert.True(budget >= "SO202609030003#1".Length,
+            $"dedup_key 预算 {budget} 已经容不下真实订单行标识,Provider 会开始静默跳过行");
+
+        var expectedPrefix =
+            $"T{TenantId}:R{RuleCode}:{S8OrderDeliveryRuleDefinitions.SalesOrderLineObjectType}:";
+        Assert.Equal(
+            S8OrderDeliveryDataProvider.DedupKeyMaxLength - expectedPrefix.Length, budget);
+    }
+
+    // ============================================================
+    // D. 判定:越界方向、边界值、身份
+    // ============================================================
+
+    [Fact]
+    public void T14_Hits_WhenSystemEtaIsLaterThanCustomerDueDate()
+    {
+        // 真库 dry-run 的两条命中之一:SO202609030003#1,要求 09-30,系统建议 10-12,晚 12 天。
+        var hits = S8OutOfRangeRuleEvaluator.EvaluateRows(
+            RowSet(Row("SO202609030003", 1, new DateTime(2026, 9, 30), new DateTime(2026, 10, 12), 12m)),
+            Effective(NewRule()), TenantId, new DateTime(2026, 9, 7));
+
+        var hit = Assert.Single(hits);
+        Assert.Equal("ORDER_DELIVERY_DELAY_WARNING", hit.ExceptionTypeCode);
+        Assert.Equal("SALES_ORDER_LINE", hit.SourceObjectType);
+        Assert.Equal("SO202609030003#1", hit.SourceObjectId);
+        Assert.Equal("SO202609030003", hit.RelatedObjectCode);
+        Assert.Equal("S1", hit.SceneCode);
+        Assert.Equal("FOLLOW", hit.Severity);
+        Assert.Equal(
+            $"T{TenantId}:R{RuleCode}:SALES_ORDER_LINE:SO202609030003#1", hit.DedupKey);
+
+        // 方向与偏差必须落 payload:业务看到的"晚几天"就是这个 deviation。
+        Assert.Contains("\"direction\":\"ABOVE_UPPER\"", hit.SourcePayload, StringComparison.Ordinal);
+        Assert.Contains("\"deviation\":12", hit.SourcePayload, StringComparison.Ordinal);
+        Assert.Contains("delay_days", hit.SourcePayload, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void T15_NoHit_WhenEtaEqualsDueDate()
+    {
+        // 上限为 0、容差为 0:正好赶上不算延期。这是边界,写错一个符号就会把
+        // 全部"刚好按期"的订单报成异常。
+        var hits = S8OutOfRangeRuleEvaluator.EvaluateRows(
+            RowSet(Row("SO-ONTIME", 1, new DateTime(2026, 9, 30), new DateTime(2026, 9, 30), 0m)),
+            Effective(NewRule()), TenantId, new DateTime(2026, 9, 7));
+
+        Assert.Empty(hits);
+    }
+
+    [Fact]
+    public void T16_NoHit_WhenEtaIsEarlierThanDueDate()
+    {
+        // 提前完成是好事。LowerBound 未设 → 不存在"太早也算异常"这条判据。
+        var hits = S8OutOfRangeRuleEvaluator.EvaluateRows(
+            RowSet(Row("SO-EARLY", 1, new DateTime(2026, 9, 30), new DateTime(2026, 9, 20), -10m)),
+            Effective(NewRule()), TenantId, new DateTime(2026, 9, 7));
+
+        Assert.Empty(hits);
+        Assert.Null(Definition.OutOfRange!.LowerBound);
+    }
+
+    [Fact]
+    public void T17_NoHit_WhenEtaIsMissing()
+    {
+        // 没有 ETA = 没有证据。SQL 已经滤掉这类行,此处再锁一次判定层:
+        // 即便将来有人放宽 SQL,evaluator 也不会凭空判它延期。
+        var hits = S8OutOfRangeRuleEvaluator.EvaluateRows(
+            RowSet(Row("SO-NO-ETA", 1, new DateTime(2026, 9, 30), null, null)),
+            Effective(NewRule()), TenantId, new DateTime(2026, 9, 7));
+
+        Assert.Empty(hits);
+    }
+
+    [Fact]
+    public void T18_EachOrderLine_GetsItsOwnException()
+    {
+        // 真库 dry-run 的两条命中分属两张订单;这里用同一张单的两行,
+        // 锁住"同单多行不会被折叠成一条"。
+        var hits = S8OutOfRangeRuleEvaluator.EvaluateRows(
+            RowSet(
+                Row("SO202609030004", 1, new DateTime(2026, 9, 30), new DateTime(2026, 10, 12), 12m),
+                Row("SO202609030004", 2, new DateTime(2026, 10, 15), new DateTime(2026, 10, 20), 5m)),
+            Effective(NewRule()), TenantId, new DateTime(2026, 9, 7));
+
+        Assert.Equal(2, hits.Count);
+        Assert.Equal(2, hits.Select(h => h.DedupKey).Distinct(StringComparer.Ordinal).Count());
+        Assert.Contains(hits, h => h.SourceObjectId == "SO202609030004#1");
+        Assert.Contains(hits, h => h.SourceObjectId == "SO202609030004#2");
+    }
+
+    // ============================================================
+    // E. 规则定义契约
+    // ============================================================
+
+    [Fact]
+    public void T19_Rule_IsOutOfRange_WithZeroUpperBoundAndNoInventedTolerance()
+    {
+        Assert.Equal(S8OutOfRangeRuleEvaluator.RuleTypeCode, Definition.RuleType);
+
+        var semantics = Definition.OutOfRange!;
+        Assert.Equal(0m, semantics.UpperBound);
+
+        // 容差非 0 意味着"晚 N 天以内不报"——本批没有任何业务依据支持这样一个数字,
+        // 凭空填值会静默过滤掉真实风险。要放宽必须由业务给出天数。
+        Assert.Equal(0m, semantics.ToleranceAbs);
+        Assert.Equal(0m, semantics.ToleranceRatio);
+
+        // 行内上下限列未启用:上限是"不延期"这个定义本身,不随行变化。
+        Assert.Null(semantics.UpperBoundColumn);
+        Assert.Null(semantics.LowerBoundColumn);
+    }
+
+    [Fact]
+    public void T20_Rule_DeclaresAllFourReadinessGates()
+    {
+        var policy = Definition.Parameters!;
+
+        // 每一条都对应一种运行期静默断裂,理由见规则定义处的注释。
+        Assert.True(policy.RequiresDepartmentDefaultsForEnable);  // 数据集无部门列 → 建单必失败
+        Assert.True(policy.RequiresHandlerPoolForEnable);         // 无人认领
+        Assert.True(policy.RequiresVerification);                 // 复核候选为空,卡在 IN_PROGRESS
+        Assert.True(policy.SupportsTimeoutEscalation);            // SLA 240 分钟 → 必进升级扫描
+
+        // 预警不是事故:客户交期尚未到达,仍有处置窗口。与异常类型自身默认严重度一致。
+        Assert.Equal("FOLLOW", policy.SeverityDefault);
+    }
+
+    [Fact]
+    public void T21_Rule_CarriesBusinessFacingCopy_WithoutImplementationLeakage()
+    {
+        // 配置页展示的是"这条规则按什么判",不是"它怎么实现的"。
+        Assert.False(string.IsNullOrWhiteSpace(Definition.JudgementSummary));
+        Assert.False(string.IsNullOrWhiteSpace(Definition.DedupIdentitySummary));
+
+        foreach (var leak in new[]
+                 {
+                     "crm_seorderentry", "crm_seorder", "DATEDIFF",
+                     "sys_capacity_date", "plan_date", "measured_value", "SQL"
+                 })
+        {
+            Assert.DoesNotContain(leak, Definition.JudgementSummary!, StringComparison.OrdinalIgnoreCase);
+            Assert.DoesNotContain(leak, Definition.DedupIdentitySummary!, StringComparison.OrdinalIgnoreCase);
+        }
+    }
+}

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

@@ -0,0 +1,180 @@
+using Microsoft.Extensions.Logging;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers;
+
+/// <summary>
+/// S8-RULE02-ORDER-DELIVERY-WARNING-1:S1 订单交付风险取数。
+///
+/// <para><b>命中判据全部下推到 SQL</b>(而不是取全量再在内存里筛):
+/// 订单行表当前 390 行,其中 385 行早已过期、只有 40 行有 ETA。
+/// 把「尚未到期 + 有 ETA + 未关闭」这三个条件留在 SQL 里,
+/// evaluator 拿到的就只有真正的候选行,既省一次全表搬运,
+/// 也让「为什么这一行没被判」在 SQL 层就能解释清楚。</para>
+///
+/// <para><b>时间比较用 <c>NOW()</c>(数据库时区)而不是应用侧 <c>DateTime.Now</c></b>:
+/// <c>plan_date</c> 与 <c>sys_capacity_date</c> 都是库里的日期,比较双方应当同源。
+/// 混用应用时钟会在跨时区部署时把「今天到期」判成「已过期」,
+/// 而这条规则的整个价值恰恰建立在「还没到期」这个前提上。</para>
+///
+/// <para><b>不做任何日期加减补偿</b>:不加 +24h、不补 23:59:59、不提前 N 天。
+/// 那些都是在没有业务依据的情况下改判据。</para>
+/// </summary>
+public sealed class S8OrderDeliveryDataProvider : IS8MonitoringDataProvider
+{
+    internal const string ExpectedSourceObjectType = "SALES_ORDER_LINE";
+
+    /// <summary>与 <c>S8PurchaseDeliveryDataProvider</c> 同一口径:dedup_key 列宽上限。</summary>
+    internal const int DedupKeyMaxLength = 128;
+
+    /// <summary>
+    /// 取数 SQL。
+    ///
+    /// <para><c>DelayDays</c> 就是 <c>measured_value</c>:系统建议交期比客户要求交期晚几天。
+    /// 用 <c>DATEDIFF</c> 而不是时间戳差 —— 两个字段在业务上都是「哪一天交」,
+    /// 精确到秒的差值没有业务含义,反而会让 12.0 天和 11.6 天表现不同。</para>
+    ///
+    /// <para><c>e.plan_date &gt;= NOW()</c> 是本规则与「已逾期」规则的分界线,
+    /// 也是它敢叫「预警」的唯一依据:客户交期还没到,风险已经算出来了。</para>
+    /// </summary>
+    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 e.sys_capacity_date > e.plan_date
+          AND e.plan_date >= NOW()
+          AND o.closed = 0
+          AND o.IsDeleted = 0
+          AND e.IsDeleted = 0
+          AND e.bill_no <> ''
+          AND e.entry_seq IS NOT NULL
+        """;
+
+    private readonly ISqlSugarClient _db;
+    private readonly ILogger<S8OrderDeliveryDataProvider> _logger;
+
+    public S8OrderDeliveryDataProvider(
+        ISqlSugarClient db,
+        ILogger<S8OrderDeliveryDataProvider> logger)
+    {
+        _db = db;
+        _logger = logger;
+    }
+
+    public string DatasetCode => S8OrderDeliveryDatasetDefinitions.OrderDeliveryRiskCode;
+
+    public S8DatasetCapabilities Capabilities =>
+        S8OrderDeliveryDatasetDefinitions.OrderDeliveryRiskCapabilities;
+
+    public async Task<S8MonitoringRowSet> LoadAsync(
+        S8MonitoringDataRequest request,
+        CancellationToken cancellationToken = default)
+    {
+        request.EnsureValid();
+
+        var rows = await _db.Ado.SqlQueryAsync<OrderDeliveryRow>(
+            Sql,
+            new SugarParameter[] { new("@TenantId", request.TenantId) });
+
+        var budget = ResolveSourceObjectIdBudget(request);
+        var mapped = new List<S8MonitoringRow>(rows.Count);
+        var skipped = 0;
+
+        foreach (var row in rows)
+        {
+            var sourceObjectId = row.SourceObjectId ?? string.Empty;
+            if (sourceObjectId.Length > budget)
+            {
+                // 超长的 source_object_id 会让 dedup_key 被截断 —— 截断后两个不同订单行
+                // 可能算出同一个键,于是第二行永远建不出异常且没有任何报错。
+                // 宁可跳过并留证,也不要制造一个静默丢单的去重键。
+                skipped++;
+                _logger.LogWarning(
+                    "s8_order_delivery_soid_over_dedup_budget rule={RuleCode} tenant={Tenant} " +
+                    "soid={Soid} length={Length} budget={Budget}",
+                    request.RuleCode, request.TenantId, sourceObjectId, sourceObjectId.Length, budget);
+                continue;
+            }
+
+            mapped.Add(S8MonitoringRow.FromValues(ToCanonicalValues(row)));
+        }
+
+        _logger.LogDebug(
+            "s8_order_delivery_loaded rule={RuleCode} tenant={Tenant} rows={Rows} skipped={Skipped}",
+            request.RuleCode, request.TenantId, mapped.Count, skipped);
+
+        return S8MonitoringRowSet.FromRows(mapped);
+    }
+
+    /// <summary>dedup_key 前缀之外还能留给 source_object_id 的长度。与采购交付 Provider 同一算法。</summary>
+    internal static int ResolveSourceObjectIdBudget(S8MonitoringDataRequest request)
+    {
+        var prefixLength =
+            $"T{request.TenantId}:R{request.RuleCode}:{ExpectedSourceObjectType}:".Length;
+        return Math.Max(0, DedupKeyMaxLength - prefixLength);
+    }
+
+    /// <summary>
+    /// 行 → canonical 列 + 展示字段。
+    /// <para>canonical 的 <c>measured_value</c> 是 <c>DelayDays</c>;
+    /// <c>due_at</c> 放客户要求交期,只用于展示与排序,<b>不参与判定</b>。</para>
+    /// </summary>
+    internal static Dictionary<string, object> ToCanonicalValues(OrderDeliveryRow row) => new(StringComparer.OrdinalIgnoreCase)
+    {
+        [S8CanonicalColumns.SourceObjectId] = row.SourceObjectId,
+        [S8CanonicalColumns.RelatedObjectCode] = row.RelatedObjectCode,
+        [S8CanonicalColumns.RelatedObjectName] = row.RelatedObjectName,
+        [S8CanonicalColumns.DueAt] = row.DueAt,
+        [S8CanonicalColumns.MeasuredValue] = row.DelayDays,
+        ["bill_no"] = row.BillNo,
+        ["entry_seq"] = row.EntrySeq,
+        ["plan_date"] = row.PlanDate,
+        ["sys_capacity_date"] = row.SysCapacityDate,
+        ["delay_days"] = row.DelayDays,
+        ["qty"] = row.Qty,
+        ["item_number"] = row.ItemNumber,
+        ["item_name"] = row.ItemName,
+        ["custom_no"] = row.CustomNo,
+        ["custom_name"] = row.CustomName,
+        ["order_bill_no"] = row.OrderBillNo
+    };
+
+    /// <summary>SQL 投影行。字段名与 SELECT 别名一一对应。</summary>
+    internal sealed class OrderDeliveryRow
+    {
+        public string SourceObjectId { get; set; } = string.Empty;
+        public string RelatedObjectCode { get; set; } = string.Empty;
+        public string? RelatedObjectName { get; set; }
+        public DateTime? DueAt { get; set; }
+        public decimal? DelayDays { get; set; }
+        public string? BillNo { get; set; }
+        public int? EntrySeq { get; set; }
+        public DateTime? PlanDate { get; set; }
+        public DateTime? SysCapacityDate { get; set; }
+        public decimal? Qty { get; set; }
+        public string? ItemNumber { get; set; }
+        public string? ItemName { get; set; }
+        public string? CustomNo { get; set; }
+        public string? CustomName { get; set; }
+        public string? OrderBillNo { get; set; }
+    }
+}

+ 70 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/Providers/S8OrderDeliveryDatasetDefinitions.cs

@@ -0,0 +1,70 @@
+namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers;
+
+/// <summary>
+/// S8-RULE02-ORDER-DELIVERY-WARNING-1:S1 销售订单行的<b>交付延期风险</b>数据集。
+///
+/// <para><b>它衡量的是什么</b>:把「客户要求交期」与「系统按产能算出来的建议交期」放在同一行上,
+/// 二者之差(天)即 <c>measured_value</c>。差值为正 = 系统自己认为这一行会晚。</para>
+///
+/// <para><b>ETA 不是本数据集算的。</b><c>sys_capacity_date</c> 由 S1 的
+/// <c>OrderReviewOrchestrationService</c> 在订单评审做完资源检查后写入(来源是齐套时间),
+/// 本 Provider 只把它取出来做差。<b>没有这个字段的订单行一律不进结果集</b> ——
+/// 那表示该行还没有过可用的资源检查,没有 ETA 证据,不能靠"离交期还有几天"凑一个预警出来。</para>
+///
+/// <para><b>粒度是订单行</b>:真实交期(<c>plan_date</c>)与数量(<c>qty</c>)都挂在
+/// <c>crm_seorderentry</c> 上,主表 <c>crm_seorder</c> 没有交期字段。
+/// 一张订单可能有多行、各行交期不同,按主单聚合会把风险行淹掉。</para>
+///
+/// <para><b>已知能力边界(勿当缺陷"修")</b>:真库 390 行订单行中只有 40 行有
+/// <c>sys_capacity_date</c>(10.3%)—— 只有走过评审资源检查的订单才有 ETA。
+/// 这是覆盖面窄,不是数据质量问题;<b>不得</b>为了提高覆盖率改用
+/// <c>plan_date - N 天</c>、<c>WorkOrdMaster.QtyCompleted</c>(实测 267 行全为 0)
+/// 或任何自造 ETA 来补 —— 那会把"临期提醒"伪装成"延期预测"。</para>
+/// </summary>
+public sealed class S8OrderDeliveryDatasetDefinitions : IS8DatasetDefinitionSource
+{
+    /// <summary>数据集编码。与 <see cref="S8OrderDeliveryDataProvider.DatasetCode"/> 必须一致。</summary>
+    public const string OrderDeliveryRiskCode = "ORDER_DELIVERY_RISK";
+
+    /// <summary>
+    /// 能力声明。
+    /// <para><b>只开 OutOfRange</b>:本数据集的判据是「两个日期之差越界」,
+    /// 不是「到期日已过」(那是 TIMEOUT),也不是「数量缺口」(那是 SHORTAGE)。
+    /// 多开一项能力会让配置页把不成立的规则类型也列成可选。</para>
+    /// <para><c>HasDueAt = true</c> 仍然为真 —— 客户要求交期会随行输出,
+    /// 供异常展示与排序使用,但它<b>不</b>参与命中判定。</para>
+    /// </summary>
+    internal static readonly S8DatasetCapabilities OrderDeliveryRiskCapabilities = new()
+    {
+        SupportsTimeout = false,
+        SupportsShortage = false,
+        SupportsOutOfRange = true,
+        HasSourceObjectId = true,
+        HasRelatedObjectCode = true,
+        HasDueAt = true,
+        HasStatus = false,
+        HasMeasuredValue = true,
+        HasTargetQty = false,
+        HasActualQty = false
+    };
+
+    public IEnumerable<S8DatasetDefinition> GetDefinitions() => new[]
+    {
+        new S8DatasetDefinition
+        {
+            DatasetCode = OrderDeliveryRiskCode,
+            DisplayName = "订单交付风险(订单行级)",
+            Kind = S8DatasetKind.Object,
+            Status = S8DatasetStatus.Defined,
+            Capabilities = OrderDeliveryRiskCapabilities,
+            Description =
+                "来源 crm_seorderentry(订单行)左连 crm_seorder(主单);粒度为订单行" +
+                "(bill_no + entry_seq,实测 390/390 唯一);" +
+                "measured_value = DATEDIFF(sys_capacity_date, plan_date),即系统建议交期晚于客户要求交期的天数;" +
+                "plan_date(客户要求交期)实测 100% 非空,是唯一可靠的 DueDate;" +
+                "sys_capacity_date(系统建议交期)由订单评审资源检查写入,实测覆盖 40/390。" +
+                "结果集只含「尚未到期、未关闭、未删除、且已有 ETA」的行 —— " +
+                "已过期的行不属于本数据集(那是事后延期,不是提前预警)。"
+        }
+    };
+}

+ 153 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/Definitions/S8OrderDeliveryRuleDefinitions.cs

@@ -0,0 +1,153 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions;
+
+/// <summary>
+/// S8-RULE02-ORDER-DELIVERY-WARNING-1:订单交付延期<b>预警</b>规则定义。
+///
+/// <para><b>与 Rule 01 的本质区别</b>:Rule 01(采购交期延误)判的是「到期日已过而事情没做完」——
+/// 事后。本规则判的是「客户交期还没到,但系统自己算出来的建议交期已经晚于它」——事前。
+/// 前者用 TIMEOUT(时间轴过了没有),后者用 OUT_OF_RANGE(两个日期之差越了界)。</para>
+///
+/// <para><b>为什么是 OUT_OF_RANGE 而不是新造一个 evaluator</b>:本规则的判据展开后就是
+/// 「<c>measured_value</c>(延期天数)&gt; 上限 0」,正是 OUT_OF_RANGE 的既有语义。
+/// 仓内此前没有任何 OUT_OF_RANGE 规则定义(见 <see cref="S8OutOfRangeRuleEvaluator"/> 类注释),
+/// <b>本规则是第一条</b>;evaluator 本身一行未改。</para>
+/// </summary>
+public sealed class S8OrderDeliveryRuleDefinitions : IS8RuleDefinitionSource
+{
+    /// <summary>
+    /// Rule 02 编码。命名沿用 Rule 01 的 <c>RULE_{模块}_{业务}</c> 格式。
+    /// <b>参与 dedup_key,全链身份</b>:任何地方引用必须用本常量,不写字面量。
+    /// </summary>
+    public const string OrderDeliveryDelayWarningCode = "RULE_S1_ORDER_DELIVERY_DELAY_WARNING";
+
+    /// <summary>
+    /// 建单使用的异常类型。<b>已存在于种子</b>
+    /// (<c>S8ExceptionTypeSeedData</c>:订单交付延期预警 / scene S1 / SLA 240 分钟 / 默认 FOLLOW),
+    /// 因此本批<b>不新建异常类型、不写 migration</b>。
+    /// </summary>
+    public const string OrderDeliveryDelayWarningExceptionTypeCode = "ORDER_DELIVERY_DELAY_WARNING";
+
+    /// <summary>
+    /// 源对象类型。<b>参与 dedup_key</b>。
+    /// <para>是「订单行」而不是「订单」:交期与数量都挂在行上,同一张订单的不同行交期可以不同。
+    /// 用订单号做身份会让同单多行只留下一条异常,其余行的风险被静默吞掉。</para>
+    /// </summary>
+    public const string SalesOrderLineObjectType = "SALES_ORDER_LINE";
+
+    public IEnumerable<S8RuleDefinition> GetDefinitions() => new[]
+    {
+        new S8RuleDefinition
+        {
+            RuleCode = OrderDeliveryDelayWarningCode,
+            DisplayName = "订单交付延期预警",
+            Description =
+                "订单行的系统建议交期晚于客户要求交期,且客户交期尚未到达时提报预警。" +
+                "延期天数由系统建议交期与客户要求交期相减得到,不做任何推算;" +
+                "尚未做过评审资源检查(没有系统建议交期)的订单行不参与判定。",
+
+            DatasetCode = S8OrderDeliveryDatasetDefinitions.OrderDeliveryRiskCode,
+            RuleType = S8OutOfRangeRuleEvaluator.RuleTypeCode,
+            RuleMechanism = "DATE",
+            SourceObjectType = SalesOrderLineObjectType,
+
+            SceneCode = S8ModuleCode.S1,
+            StageCode = S8ModuleCode.S1,
+
+            // 风险在「评审/排产/测算」环节产生:系统建议交期正是该环节的资源检查结果。
+            OrderFlowCode = "ORDER_REVIEW_PLAN_CALC",
+
+            ExceptionTypeCode = OrderDeliveryDelayWarningExceptionTypeCode,
+
+            // 面向业务管理员的只读说明。刻意不出现表名 / 列名 / DATEDIFF ——
+            // 配置页要回答的是「这条规则按什么判」,不是「它怎么实现的」。
+            JudgementSummary =
+                "系统按产能测算出的建议交期晚于客户要求的交期,且客户交期尚未到达时预警;"
+                + "晚出来的天数即预警的严重程度依据。"
+                + "订单已关闭、或该订单行还没有系统建议交期时,不参与判定。",
+            DedupIdentitySummary = "销售订单号 + 订单行号",
+
+            // ────────────────────────────────────────────────────────────────
+            // 判定语义。
+            //
+            // measured_value = 系统建议交期 - 客户要求交期(天)。由 Provider 用
+            // DATEDIFF 算好后放在 canonical 的 measured_value 列,evaluator 只做越界比较。
+            //
+            // 上限 0 的含义:只要建议交期比要求交期晚哪怕一天就预警。
+            // 这不是拍脑袋定的阈值 —— 0 是「不延期」这件事本身的定义。
+            // 真正可调的宽容度留给 ToleranceAbs(见下),管理员要"晚 3 天以内不报"时
+            // 改的是容差,而不是重新定义什么叫延期。
+            //
+            // 不设 LowerBound:建议交期<b>早于</b>要求交期是好事,不构成异常。
+            // ────────────────────────────────────────────────────────────────
+            OutOfRange = new S8OutOfRangeSemantics
+            {
+                MeasuredValueColumn = S8CanonicalColumns.MeasuredValue,
+
+                // 去重身份 = bill_no + '#' + entry_seq,由 Provider 拼好放进 source_object_id。
+                // 真库证据:crm_seorderentry 390 行,(bill_no, entry_seq) 重复组为 0。
+                SourceObjectIdColumn = S8CanonicalColumns.SourceObjectId,
+                RelatedObjectCodeColumn = S8CanonicalColumns.RelatedObjectCode,
+
+                UpperBound = 0m,
+
+                // ToleranceAbs = 0:本批没有任何业务依据说「晚几天以内可以不报」。
+                // 凭空填一个 3 会在没有拍板的情况下把一批真实风险静默过滤掉。
+                // 需要宽容度时由业务给出天数,再改这一处。
+                ToleranceAbs = 0m,
+
+                // ToleranceRatio 对本规则无意义:上限为 0 时 evaluator 会跳过比例判断
+                // (避免除零),写非 0 值只会造成"配了但不生效"的错觉。
+                ToleranceRatio = 0m
+            },
+
+            Parameters = new S8RuleParameterPolicy
+            {
+                // 数据是「天」粒度,且只在订单评审时变化。5 分钟一轮(Rule 01 的默认)
+                // 对本规则没有任何信息增益,只是徒增数据库负载。
+                PollIntervalSecondsDefault = 3600,
+
+                TriggerCountRequiredDefault = 1,
+
+                // 订单重新评审时系统建议交期会被改写,期间可能短暂缺失。
+                // 连续两轮不命中才判恢复,避免一次重评审把预警冲掉又建回来。
+                RecoverCountRequiredDefault = 2,
+
+                // 宽限分钟是 TIMEOUT 的概念,OUT_OF_RANGE evaluator 根本不读它。
+                // 保持 0,不给管理员一个改了不生效的旋钮。
+                GraceMinutesDefault = 0,
+
+                // 预警不是事故:客户交期还没到,还有处置窗口。
+                // 与异常类型自身的默认严重度(FOLLOW)一致。
+                SeverityDefault = S8SeverityCode.Follow,
+
+                AllowsDepartmentDefaults = true,
+
+                // S8-RULE-READINESS-1:本规则的数据集<b>没有部门列</b> ——
+                // crm_seorderentry / crm_seorder 都不带发生部门或责任部门,
+                // 命中行带不出 occurrence / responsible,建单时只能取这两个运行参数。
+                // 不配就是「启用成功、调度成功、异常一条建不出来」的静默失败。
+                RequiresDepartmentDefaultsForEnable = true,
+
+                // 以下三条不是照抄 Rule 01,是按本规则异常的实际去向逐条确认的:
+                //
+                // · 处理池:异常带 source_rule_code,认领 / 派单走规则处理池。空池 =
+                //   预警源源不断建出来、躺在列表里没人认领,而调度器每轮都报成功。
+                //
+                // · 复核池:S8VerifierEligibility 对「有来源规则」的异常,检验人候选<b>只</b>取
+                //   该规则的复核池。空池 → 候选列表为空 → 处理人点不了「提交复核」,
+                //   单据永远停在 IN_PROGRESS。
+                //
+                // · 升级池:本异常类型的 SLA 为 240 分钟 → 建单即写 sla_deadline →
+                //   必然进入 S8TimeoutAutoEscalationService 的扫描范围。空池时该服务只会记一行
+                //   reason=escalation_pool_empty 然后跳过,超时无人知晓。
+                RequiresHandlerPoolForEnable = true,
+                RequiresVerification = true,
+                SupportsTimeoutEscalation = true
+            }
+        }
+    };
+}

+ 7 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Startup.cs

@@ -81,6 +81,12 @@ public class Startup : AppStartup
         services.AddTransient<Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.IS8DatasetDefinitionSource, Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers.S8BusinessDatasetDefinitions>();
         services.AddTransient<Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.IS8MonitoringDataProvider, Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers.S8PurchaseDeliveryDataProvider>();
 
+        // S8-RULE02-ORDER-DELIVERY-WARNING-1:第二个真实业务数据集 ORDER_DELIVERY_RISK。
+        // 与上面同理,定义与实现两个接口都要显式注册;两者的 DatasetCode 必须逐字一致,
+        // 否则 Enable Gate 会以 dataset_provider_not_registered 挡住启用。
+        services.AddTransient<Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.IS8DatasetDefinitionSource, Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers.S8OrderDeliveryDatasetDefinitions>();
+        services.AddTransient<Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.IS8MonitoringDataProvider, Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers.S8OrderDeliveryDataProvider>();
+
         // S8-RULE-GOVERNANCE-BATCH1:规则定义目录。业务语义从此只存在于代码里,
         // ado_s8_watch_rule 上的同名列降级为 provisioning 维护的只读投影。
         //
@@ -88,6 +94,7 @@ public class Startup : AppStartup
         // 而 Transient 的失败形态是「首次解析该服务的那个请求 500」,启动日志干净但目录其实是坏的。
         // Singleton + Configure 里强制解析一次 = 定义写错就起不来,见下方 Configure。
         services.AddSingleton<Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions.IS8RuleDefinitionSource, Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions.S8PurchaseDeliveryRuleDefinitions>();
+        services.AddSingleton<Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions.IS8RuleDefinitionSource, Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions.S8OrderDeliveryRuleDefinitions>();
         services.AddSingleton<Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions.IS8RuleCatalog, Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions.S8RuleCatalog>();
 
         // S8-RULE-GOVERNANCE-BATCH2:代码定义 → 每租户运行策略的供给服务。