Переглянути джерело

fix(s8): 修复 Rule02 跨过交期即假恢复,拆出通用首次立案闸门

风险兑现被当成风险解除。Rule02 的数据集带着 plan_date >= NOW(),
订单一旦跨过客户交期,源行就从结果集里消失;而 S8 判恢复的唯一依据是
「dedup_key 还在不在本轮 hits 里」,它无法区分「行不在了」与「行还在但不再越界」,
于是把一条 ETA 一天没改、仍晚于交期的预警标成「已恢复」,并派发恢复通知。
真库实测复现:两条命中在 2026-10-01 与 2026-10-08 两轮均返回 0 行。

根因不是 SQL 写错,是 Runtime 只有一个二值「本轮是否命中」,
却同时承担了三件事:能不能建单、要不要保持 active、算不算恢复。

修法是把「未到期」从风险条件降级为立案条件,并做成通用能力:

- S8RuleDefinition.CreateOnlyBeforeDueAt(默认 false)
  预警型规则声明「只在到期前允许首次立案」
- S8RuleHit.CreateEligible(默认 true)
  false = 真实命中但不开新案子;绝不等于 miss,必须留在 hits 里
- OUT_OF_RANGE evaluator 按声明用 canonical due_at 计算,到期日缺失时放行
- 调度器在「既有异常刷新之后、首次建单之前」加一道闸门,结果码 create_not_eligible

顺序是本机制的要害,两边都不能挪:挪到刷新之前,跨期的既有预警连
last_detected_at 都停止推进;挪到建单之后,历史风险会被倒成一批"预警"。
恢复侧一行未改 —— ReconcileRecoveriesForRuleAsync 在 hit 循环之前执行、
拿的是完整 hits,CreateEligible=false 的命中天然保护了对应异常。

Rule02 数据集回归「只讲业务事实」:去掉 plan_date >= NOW(),
也去掉 sys_capacity_date > plan_date,是否延期交给 evaluator 判 ——
让恢复的正确形态是「行还在但不命中」,而不是「行消失」。

真库只读复核(40 行有 ETA):dataset 40 → hits 9 → 可立案 2 / 不可立案 7 /
在结果集但不命中 31。可立案的 2 条与修复前完全一致,历史 7 条一条不建。

Rule01 零影响:未声明闸门 → CreateEligible 恒 true → 新分支永不进入,
并由 T30/T31/T32 逐条证明而非"我没改它"。

无 migration、无 schema 变更。
测试:Rule02 35/35;Admin.NET.Test S8 194/194;插件 1994/1996(2 项既有 skip)。

Known Gap(本批未扩大范围):订单 closed / IsDeleted 的生命周期归属
(RECOVER / CLOSE / CANCEL)仍无业务确权,标记 UNRESOLVED;
真库当前该组合命中数为 0,暂不阻塞。
YY968XX 2 днів тому
батько
коміт
7a0091ad53

+ 310 - 8
server/Admin.NET.Test/S8/S8OrderDeliveryRule02Tests.cs

@@ -154,8 +154,9 @@ public class S8OrderDeliveryRule02Tests
         var sql = SqlWithoutComments();
 
         // ETA 必须来自源系统字段,且缺失即出局。
+        // (「ETA 是否晚于交期」不在这里判 —— 那是 evaluator 的事,见 T6 / T15 / T16。)
         Assert.Contains("e.sys_capacity_date IS NOT NULL", sql, StringComparison.Ordinal);
-        Assert.Contains("e.sys_capacity_date > e.plan_date", sql, StringComparison.Ordinal);
+        Assert.Contains("DATEDIFF(e.sys_capacity_date, e.plan_date)", sql, StringComparison.Ordinal);
 
         // 以下任何一个出现,都意味着有人在自造 ETA:
         //   DATE_ADD / DATE_SUB / INTERVAL → 用「交期前 N 天」冒充预测
@@ -173,17 +174,31 @@ public class S8OrderDeliveryRule02Tests
     }
 
     [Fact]
-    public void T6_Sql_OnlyLooksAtOrdersThatHaveNotComeDueYet()
+    public void T6_Sql_MustNotFilterByTimeOrByDelay_OrFalseRecoveryComesBack()
     {
         var sql = SqlWithoutComments();
 
-        // 这一条是「预警」与「逾期清单」的分界。去掉它,真库 385 条历史逾期单会一次涌入,
-        // 且因为永远不会再变成"未逾期",也永远不会恢复。
-        Assert.Contains("e.plan_date >= NOW()", sql, StringComparison.Ordinal);
-
-        // 时间基准必须与被比较的日期同源(都在库里)。混用应用时钟会在跨时区部署时
-        // 把"今天到期"判成"已过期",而本规则的价值恰恰建立在"还没到期"上。
+        // ⚠️ 本仓最容易被"好心改回去"的一条。
+        //
+        // S8 的恢复判定唯一依据是「dedup_key 还在不在本轮 hits 里」
+        // (ReconcileRecoveriesForRuleAsync),它无法区分「行不在结果集里」与
+        // 「行还在但不越界」。所以任何被 SQL 滤掉的行 = 风险已解除。
+        //
+        // · 加回 plan_date >= NOW() → 订单跨过客户交期时行消失 → 风险**兑现**的那一刻
+        //   被标成"已恢复"。这是 2026-09-07 实测复现并修掉的缺陷。
+        // · 加回 sys_capacity_date > plan_date → ETA 修回时行同样消失,恢复结论碰巧对、
+        //   理由是错的:正确的恢复形态必须是"行还在但不再命中"。
+        //
+        // 「只在到期前立案」由规则定义的 CreateOnlyBeforeDueAt 表达,不在取数层。
+        Assert.DoesNotContain("NOW()", sql, StringComparison.OrdinalIgnoreCase);
+        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);
+
+        // 反面:取数仍必须要求 ETA 存在 —— 没有 ETA 就没有判定依据,
+        // 这条过滤与生命周期无关,去掉它会让 350 条无 ETA 的行涌进来。
+        Assert.Contains("e.sys_capacity_date IS NOT NULL", sql, StringComparison.Ordinal);
     }
 
     [Fact]
@@ -441,4 +456,291 @@ public class S8OrderDeliveryRule02Tests
             Assert.DoesNotContain(leak, Definition.DedupIdentitySummary!, StringComparison.OrdinalIgnoreCase);
         }
     }
+
+    // ============================================================
+    // F. 生命周期:CREATE → KEEP ACTIVE → RECOVER
+    //
+    // 这一组是 S8-RULE-LIFECYCLE-CREATE-GATE-1 的核心回归。它守护的不是
+    // 「测试能过」,而是一条已经被系统识别出来的延期风险,
+    // 不会因为客户交期真正到达反而被标记成"已恢复"。
+    // ============================================================
+
+    private const string LifecycleBillNo = "SO-LIFECYCLE-001";
+    private const int LifecycleEntrySeq = 1;
+
+    /// <summary>三个周期共用同一业务键;到期日与 ETA 按周期变化。</summary>
+    private static S8MonitoringRowSet LifecycleRow(DateTime planDate, DateTime sysCapacityDate) =>
+        RowSet(Row(LifecycleBillNo, LifecycleEntrySeq, planDate, sysCapacityDate,
+            (decimal)(sysCapacityDate - planDate).TotalDays));
+
+    [Fact]
+    public void T22_Cycle1_FutureDueDate_Hits_AndIsCreateEligible()
+    {
+        // 客户交期 10-31 尚未到达,系统建议交期 11-12 —— 晚 12 天。
+        var hits = S8OutOfRangeRuleEvaluator.EvaluateRows(
+            LifecycleRow(new DateTime(2026, 10, 31), new DateTime(2026, 11, 12)),
+            Effective(NewRule()), TenantId, detectedAt: new DateTime(2026, 10, 1));
+
+        var hit = Assert.Single(hits);
+        Assert.True(hit.CreateEligible, "交期未到的风险必须允许首次立案,否则这条规则永远建不出单");
+    }
+
+    [Fact]
+    public void T23_Cycle2_AfterDueDate_StillHits_ButIsNotCreateEligible()
+    {
+        // 时间走到 11-01:客户交期(10-31)已经过去,而系统建议交期(11-12)一天没改。
+        // 风险不但没解除,是**兑现**了。
+        var hits = S8OutOfRangeRuleEvaluator.EvaluateRows(
+            LifecycleRow(new DateTime(2026, 10, 31), new DateTime(2026, 11, 12)),
+            Effective(NewRule()), TenantId, detectedAt: new DateTime(2026, 11, 1));
+
+        var hit = Assert.Single(hits);
+
+        // ① 仍然是命中 —— 这一条是整个修复的支点。
+        //    只要它还在 hits 里,ReconcileRecoveriesForRuleAsync 就会因为
+        //    hitDedupKeys.Contains(dedupKey) 而直接 continue,既有异常不累计 miss、不被恢复。
+        //    把它从 hits 里剔除(哪怕理由是"反正不建单")= 假恢复原地复活。
+        Assert.NotEmpty(hits);
+
+        // ② 但不允许再开一个新案子。
+        Assert.False(hit.CreateEligible);
+    }
+
+    [Fact]
+    public void T24_Cycle1And2_ShareTheSameDedupKey_SoTheSameExceptionIsMatched()
+    {
+        // 跨交期前后必须是同一个 dedup_key —— 否则 Cycle 2 既找不到既有异常去 refresh,
+        // 也保护不了它,等于换一种方式把它放掉。
+        var before = Assert.Single(S8OutOfRangeRuleEvaluator.EvaluateRows(
+            LifecycleRow(new DateTime(2026, 10, 31), new DateTime(2026, 11, 12)),
+            Effective(NewRule()), TenantId, new DateTime(2026, 10, 1)));
+
+        var after = Assert.Single(S8OutOfRangeRuleEvaluator.EvaluateRows(
+            LifecycleRow(new DateTime(2026, 10, 31), new DateTime(2026, 11, 12)),
+            Effective(NewRule()), TenantId, new DateTime(2026, 11, 1)));
+
+        Assert.Equal(before.DedupKey, after.DedupKey);
+        Assert.Equal(before.SourceObjectId, after.SourceObjectId);
+        Assert.NotEqual(before.CreateEligible, after.CreateEligible);
+    }
+
+    [Fact]
+    public void T25_Cycle3_EtaPulledBackWithinDueDate_StopsHitting()
+    {
+        // 重新评审把建议交期拉回到 10-31(不晚于要求交期)→ 不再命中。
+        // 行仍在数据集里(SQL 不滤延期条件),是 evaluator 判它不越界 ——
+        // 这是本规则唯一合法的恢复形态:连续 RecoverCountRequired 轮未命中后写 recovered_at。
+        var hits = S8OutOfRangeRuleEvaluator.EvaluateRows(
+            LifecycleRow(new DateTime(2026, 10, 31), new DateTime(2026, 10, 31)),
+            Effective(NewRule()), TenantId, detectedAt: new DateTime(2026, 11, 1));
+
+        Assert.Empty(hits);
+    }
+
+    [Fact]
+    public void T26_FullCycle_Create_KeepActive_Recover()
+    {
+        var rule = Effective(NewRule());
+        var due = new DateTime(2026, 10, 31);
+        var lateEta = new DateTime(2026, 11, 12);
+
+        // Cycle 1 —— 交期未到,ETA 晚 → 命中且可立案
+        var c1 = S8OutOfRangeRuleEvaluator.EvaluateRows(
+            LifecycleRow(due, lateEta), rule, TenantId, new DateTime(2026, 10, 1));
+        // Cycle 2 —— 交期已过,ETA 仍晚 → 仍命中,但不可立案(保持 active)
+        var c2 = S8OutOfRangeRuleEvaluator.EvaluateRows(
+            LifecycleRow(due, lateEta), rule, TenantId, new DateTime(2026, 11, 1));
+        // Cycle 3 —— ETA 拉回交期以内 → 不再命中(合法恢复)
+        var c3 = S8OutOfRangeRuleEvaluator.EvaluateRows(
+            LifecycleRow(due, due), rule, TenantId, new DateTime(2026, 11, 8));
+
+        Assert.True(Assert.Single(c1).CreateEligible);
+        Assert.False(Assert.Single(c2).CreateEligible);
+        Assert.Empty(c3);
+
+        // 关键否定断言:Cycle 2 不得表现为"不再命中"。
+        Assert.Contains(Assert.Single(c1).DedupKey, c2.Select(h => h.DedupKey));
+    }
+
+    [Fact]
+    public void T27_HistoricalRowsHit_ButNoneOfThemMayOpenANewCase()
+    {
+        // 真库形态的缩影:2 条未到期 + 7 条已过期,全部 ETA 晚。
+        // 取消 SQL 时间过滤后 9 条都会命中;允许立案的必须只有那 2 条。
+        // 用 fixture 表达数量关系,不绑定真实单号。
+        const int futureCount = 2;
+        const int historicalCount = 7;
+        var detectedAt = new DateTime(2026, 9, 7);
+        var rows = new List<S8OrderDeliveryDataProvider.OrderDeliveryRow>();
+
+        for (var i = 0; i < futureCount; i++)
+            rows.Add(Row($"SO-FUTURE-{i}", 1, detectedAt.AddDays(23), detectedAt.AddDays(35), 12m));
+        for (var i = 0; i < historicalCount; i++)
+            rows.Add(Row($"SO-PAST-{i}", 1, detectedAt.AddDays(-30 - i), detectedAt.AddDays(-25 - i), 5m));
+
+        var hits = S8OutOfRangeRuleEvaluator.EvaluateRows(
+            RowSet(rows.ToArray()), Effective(NewRule()), TenantId, detectedAt);
+
+        Assert.Equal(futureCount + historicalCount, hits.Count);
+        Assert.Equal(futureCount, hits.Count(h => h.CreateEligible));
+        Assert.Equal(historicalCount, hits.Count(h => !h.CreateEligible));
+    }
+
+    [Fact]
+    public void T28_MissingDueDate_FailsOpen_SoTheRuleNeverGoesSilent()
+    {
+        // 到期日缺失时闸门放行。闸门的职责是"到期后别开新案子",
+        // 不是"没有到期日就别报警"—— 缺列时静默拒绝建单会让规则表现为
+        // 「每轮都命中、一条异常也建不出来」,正是本仓反复在防的静默失败。
+        var row = Row("SO-NO-DUE", 1, new DateTime(2026, 10, 31), new DateTime(2026, 11, 12), 12m);
+        var values = S8OrderDeliveryDataProvider.ToCanonicalValues(row);
+        values[S8CanonicalColumns.DueAt] = null!;
+
+        var hit = Assert.Single(S8OutOfRangeRuleEvaluator.EvaluateRows(
+            S8MonitoringRowSet.FromRows(new[] { S8MonitoringRow.FromValues(values) }),
+            Effective(NewRule()), TenantId, new DateTime(2026, 11, 1)));
+
+        Assert.True(hit.CreateEligible);
+    }
+
+    [Fact]
+    public void T29_Rule02_DeclaresTheCreateWindow_AndItsDatasetCanCarryDueAt()
+    {
+        Assert.True(Definition.CreateOnlyBeforeDueAt);
+
+        // 闸门读的是 canonical due_at,因此数据集必须声明带得出到期日。
+        var dataset = Assert.Single(new S8OrderDeliveryDatasetDefinitions().GetDefinitions());
+        Assert.True(dataset.Capabilities.HasDueAt);
+    }
+
+    // ============================================================
+    // G. Rule01 回归 + 通用契约默认值
+    // ============================================================
+
+    [Fact]
+    public void T30_Rule01_DoesNotDeclareTheGate_SoItsBehaviourIsByteForByteUnchanged()
+    {
+        var rule01 = Catalog.GetRequired(S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryDateDelayCode);
+        Assert.False(rule01.CreateOnlyBeforeDueAt);
+    }
+
+    [Fact]
+    public void T31_CreateEligible_DefaultsToTrue_OnAFreshHit()
+    {
+        // 默认 true 是"未声明规则行为不变"的唯一保证。
+        // 一旦有人把默认值改成 false,所有未声明闸门的规则会当场停止建单,
+        // 且表现为"命中很多、一条也没建"——最难定位的一类静默失败。
+        Assert.True(new S8RuleHit().CreateEligible);
+    }
+
+    [Fact]
+    public void T32_TimeoutEvaluator_LeavesEveryHitCreateEligible()
+    {
+        // Rule01 走 TIMEOUT,其 evaluator 不参与闸门。逐条证明而不是靠"我没改它"。
+        var rule01Row = new AdoS8WatchRule
+        {
+            Id = 900101,
+            RuleCode = S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryDateDelayCode,
+            SceneCode = "S4",
+            WatchObjectType = "PURCHASE_ORDER_LINE",
+            SourceObjectType = "PURCHASE_ORDER_LINE",
+            Severity = "SERIOUS",
+            RuleType = S8TimeoutRuleEvaluator.RuleTypeCode,
+            DatasetCode = S8BusinessDatasetDefinitions.PurchaseDeliveryCode
+        };
+        var effective = S8EffectiveRule.Resolve(
+            rule01Row, Catalog.GetRequired(S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryDateDelayCode));
+
+        var overdue = new S8PurchaseDeliveryDataProvider.PurchaseDeliveryRow
+        {
+            SourceObjectId = "PO-REG-1#1",
+            RelatedObjectCode = "PO-REG-1",
+            RelatedObjectName = "供应商",
+            DueAt = new DateTime(2026, 8, 1),
+            Status = "DELAYED",
+            DueAtSource = "need_date"
+        };
+
+        var hits = S8TimeoutRuleEvaluator.EvaluateRows(
+            S8MonitoringRowSet.FromRows(new[] {
+                S8MonitoringRow.FromValues(S8PurchaseDeliveryDataProvider.ToCanonicalValues(overdue)) }),
+            effective, TenantId, new DateTime(2026, 9, 5));
+
+        Assert.NotEmpty(hits);
+        Assert.All(hits, h => Assert.True(h.CreateEligible));
+    }
+
+    // ============================================================
+    // H. 调度器顺序守卫
+    //
+    // 闸门的正确性完全取决于它在 ProcessSingleRuleAsync 里的位置。
+    // 该方法是私有的、依赖 4 个 repository,本仓对它既有的守护
+    // (S8SystemRecoveredTimelineTests)同样采用源码文本断言,此处沿用同一标准。
+    // ============================================================
+
+    private static string SchedulerCode()
+    {
+        var path = Path.GetFullPath(Path.Combine(
+            AppContext.BaseDirectory,
+            "../../../../Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchSchedulerService.cs"));
+        Assert.True(File.Exists(path), $"调度器源码路径需同步更新:{path}");
+
+        // 去掉注释行:本节断言的是**执行顺序**,不能被文档里的同名字样带偏。
+        return string.Join('\n', File.ReadAllLines(path)
+            .Where(l =>
+            {
+                var t = l.TrimStart();
+                return !t.StartsWith("///", StringComparison.Ordinal)
+                       && !t.StartsWith("//", StringComparison.Ordinal);
+            }));
+    }
+
+    [Fact]
+    public void T33_RecoveryReconcile_RunsBeforeTheHitLoop()
+    {
+        var code = SchedulerCode();
+
+        var reconcileIdx = code.IndexOf("ReconcileRecoveriesForRuleAsync(tenantId, rule, ruleType, hits, runId)",
+            StringComparison.Ordinal);
+        var loopIdx = code.IndexOf("foreach (var hit in hits)", StringComparison.Ordinal);
+
+        Assert.True(reconcileIdx > 0 && loopIdx > 0);
+        Assert.True(reconcileIdx < loopIdx,
+            "恢复判定必须在 hit 循环之前、且拿到完整 hits —— 否则 CreateEligible=false 的命中" +
+            "保护不了既有异常,假恢复会原地复活");
+    }
+
+    [Fact]
+    public void T34_CreateGate_SitsAfterRefresh_AndBeforeCreate()
+    {
+        var code = SchedulerCode();
+
+        var refreshIdx = code.IndexOf("await RefreshDetectionAsync(matchedId, hit);", StringComparison.Ordinal);
+        var backfillIdx = code.IndexOf("await BackfillLegacyExceptionAsync(compatId, hit);", StringComparison.Ordinal);
+        var gateIdx = code.IndexOf("if (!hit.CreateEligible)", StringComparison.Ordinal);
+        var createIdx = code.IndexOf("await _reportDispatcher.CreateFromHitAsync(tenantId, hit);", StringComparison.Ordinal);
+
+        Assert.True(refreshIdx > 0 && backfillIdx > 0 && gateIdx > 0 && createIdx > 0);
+
+        // 在刷新之后:跨过交期的既有预警 CreateEligible 变 false,但必须照常 refresh 才能保持 active。
+        Assert.True(refreshIdx < gateIdx, "闸门必须在既有异常刷新之后,否则跨期预警会停止刷新");
+        Assert.True(backfillIdx < gateIdx, "闸门必须在 legacy 回填之后,那同样是保活而非建单");
+
+        // 在建单之前:历史风险行不得被倒成一批"预警"。
+        Assert.True(gateIdx < createIdx, "闸门必须在首次建单之前");
+
+        // 结果原因必须是可解释的独立值,不能混进 failed 家族。
+        Assert.Contains("\"create_not_eligible\"", code, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void T35_CreateNotEligible_IsNotCountedAsFailure()
+    {
+        var code = SchedulerCode();
+
+        // Failed 统计只认这四个 reason;create_not_eligible 不在其中,
+        // 否则规则每轮都会显示"有失败",而它其实工作得完全正确。
+        var failedLine = code.Split('\n').First(l => l.Contains("Failed = results.Count(", StringComparison.Ordinal));
+        Assert.DoesNotContain("create_not_eligible", failedLine, StringComparison.Ordinal);
+    }
 }

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

@@ -6,16 +6,24 @@ 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>SQL 只负责「哪些订单行值得看」,不负责「哪些算异常」</b>
+/// (与 <see cref="S8PurchaseDeliveryDataProvider"/> 同一条纪律):
+/// 过滤只到「有 ETA + 单据还活着 + 身份完整」为止,是否延期一律交给 evaluator 判。</para>
 ///
-/// <para><b>时间比较用 <c>NOW()</c>(数据库时区)而不是应用侧 <c>DateTime.Now</c></b>:
-/// <c>plan_date</c> 与 <c>sys_capacity_date</c> 都是库里的日期,比较双方应当同源。
-/// 混用应用时钟会在跨时区部署时把「今天到期」判成「已过期」,
-/// 而这条规则的整个价值恰恰建立在「还没到期」这个前提上。</para>
+/// <para><b>⚠️ 绝不要把时间条件或延期条件加回 WHERE。</b>
+/// S8 的恢复判定唯一依据是「dedup_key 还在不在本轮 hits 里」
+/// (<c>S8WatchSchedulerService.ReconcileRecoveriesForRuleAsync</c>),
+/// 它<b>无法区分</b>「这一行不在结果集里」与「这一行还在但不再越界」。
+/// 因此任何被 SQL 滤掉的行都等价于「风险已解除」:
+/// <list type="bullet">
+///   <item>加回 <c>plan_date &gt;= NOW()</c> → 订单跨过客户交期时行消失 →
+///     风险<b>兑现</b>的那一刻异常被标成「已恢复」。这正是 2026-09-07 实测复现并修掉的缺陷
+///     (真库两条命中,跨期后数据集返回 0 行,而 ETA 一天未改、仍晚于交期)。</item>
+///   <item>加回 <c>sys_capacity_date &gt; plan_date</c> → ETA 修回交期以内时行同样消失,
+///     恢复结论虽然碰巧正确,理由却是错的;下次判据变化就不成立了。</item>
+/// </list>
+/// 「只在到期前立案」这条业务约束由规则定义的
+/// <c>CreateOnlyBeforeDueAt</c> 表达,<b>不在这里</b>。</para>
 ///
 /// <para><b>不做任何日期加减补偿</b>:不加 +24h、不补 23:59:59、不提前 N 天。
 /// 那些都是在没有业务依据的情况下改判据。</para>
@@ -34,8 +42,9 @@ public sealed class S8OrderDeliveryDataProvider : IS8MonitoringDataProvider
     /// 用 <c>DATEDIFF</c> 而不是时间戳差 —— 两个字段在业务上都是「哪一天交」,
     /// 精确到秒的差值没有业务含义,反而会让 12.0 天和 11.6 天表现不同。</para>
     ///
-    /// <para><c>e.plan_date &gt;= NOW()</c> 是本规则与「已逾期」规则的分界线,
-    /// 也是它敢叫「预警」的唯一依据:客户交期还没到,风险已经算出来了。</para>
+    /// <para>结果集 = <b>全部有 ETA 且单据还活着的订单行</b>,含已过交期的。
+    /// 其中 <c>DelayDays &lt;= 0</c> 的行由 evaluator 判为不命中 —— 它们必须留在结果集里,
+    /// 「行还在但不越界」才是这条规则唯一合法的恢复形态。</para>
     /// </summary>
     internal const string Sql =
         """
@@ -60,8 +69,6 @@ public sealed class S8OrderDeliveryDataProvider : IS8MonitoringDataProvider
         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

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

@@ -63,8 +63,8 @@ public sealed class S8OrderDeliveryDatasetDefinitions : IS8DatasetDefinitionSour
                 "measured_value = DATEDIFF(sys_capacity_date, plan_date),即系统建议交期晚于客户要求交期的天数;" +
                 "plan_date(客户要求交期)实测 100% 非空,是唯一可靠的 DueDate;" +
                 "sys_capacity_date(系统建议交期)由订单评审资源检查写入,实测覆盖 40/390。" +
-                "结果集含「尚未到期、未关闭、未删除、且已有 ETA」的行 —— " +
-                "已过期的行不属于本数据集(那是事后延期,不是提前预警)。"
+                "结果集含「未关闭、未删除、且已有 ETA」的全部订单,包含已过交期的 —— " +
+                "是否延期由规则判定,是否允许首次立案由规则的立案窗口声明,都不在取数层。"
         }
     };
 }

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

@@ -62,11 +62,31 @@ public sealed class S8OrderDeliveryRuleDefinitions : IS8RuleDefinitionSource
 
             ExceptionTypeCode = OrderDeliveryDelayWarningExceptionTypeCode,
 
+            // ────────────────────────────────────────────────────────────────
+            // S8-RULE-LIFECYCLE-CREATE-GATE-1:「未到期」是<b>立案</b>条件,不是<b>风险</b>条件。
+            //
+            // 三条语义各归各位,不再挤在一个「本轮是否命中」里:
+            //   立案:ETA 晚于交期  且  交期尚未到达   ← 只有这一条要求"未到期"
+            //   持续:ETA 晚于交期                    ← 跨过交期照样成立,异常保持 active
+            //   恢复:ETA 回到交期以内                ← 唯一合法的恢复
+            //
+            // 修的是一个已实测复现的假恢复:时间跨过客户交期时,风险其实是**兑现**了,
+            // 而旧实现(数据集里带 plan_date >= NOW())会让这一行从结果集消失,
+            // 恢复判定看到的与"风险解除"完全一样,于是把它标成已恢复。
+            //
+            // 代价是数据集会持续返回已过交期的风险行(真库当前 7 条)。它们照常命中、
+            // 照常保护既有异常不被误判恢复,只是拿不到首次建单资格 —— 那些风险早已兑现,
+            // 不是预警;接手它们的应当是将来的「订单交期已延误」规则,不是本规则。
+            // ────────────────────────────────────────────────────────────────
+            CreateOnlyBeforeDueAt = true,
+
             // 面向业务管理员的只读说明。刻意不出现表名 / 列名 / DATEDIFF ——
             // 配置页要回答的是「这条规则按什么判」,不是「它怎么实现的」。
             JudgementSummary =
-                "系统按产能测算出的建议交期晚于客户要求的交期,且客户交期尚未到达时预警;"
+                "系统按产能测算出的建议交期晚于客户要求的交期,且客户交期尚未到达时,提报预警;"
                 + "晚出来的天数即预警的严重程度依据。"
+                + "预警提出后,即使客户交期已经过去,只要建议交期仍然晚于要求交期,预警继续有效;"
+                + "只有重新评审把建议交期拉回到要求交期以内,才算风险解除。"
                 + "订单已关闭、或该订单行还没有系统建议交期时,不参与判定。",
             DedupIdentitySummary = "销售订单号 + 订单行号",
 

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

@@ -87,6 +87,32 @@ public sealed class S8RuleDefinition
     /// <summary>OUT_OF_RANGE 判定语义。<see cref="RuleType"/> = OUT_OF_RANGE 时必填。</summary>
     public S8OutOfRangeSemantics? OutOfRange { get; init; }
 
+    /// <summary>
+    /// S8-RULE-LIFECYCLE-CREATE-GATE-1:<b>预警型规则</b>声明 —— 只在到期日之前允许<b>首次立案</b>。
+    ///
+    /// <para><b>它修的是什么</b>:Runtime 此前只有一个二值概念「本轮是否命中」,
+    /// 而它同时承担了三件事:能不能建单、要不要保持 active、算不算恢复。
+    /// 对「事前预警」类规则这会得出一个荒谬的结论 —— 风险<b>兑现</b>的那一刻
+    /// (时间跨过到期日),如果数据集把过期行滤掉,dedup_key 就从 hits 里消失,
+    /// 恢复判定(<c>ReconcileRecoveriesForRuleAsync</c>)看到的与「风险解除」<b>完全一样</b>,
+    /// 于是把它标成已恢复。Rule 02 上已实测复现(真库两条命中,跨过交期后数据集返回 0 行,
+    /// 而 ETA 一天没改、仍晚于交期)。</para>
+    ///
+    /// <para><b>正确的拆法</b>:把「未到期」从<b>风险存在条件</b>降级为<b>首次立案条件</b>。
+    /// 数据集照常返回全部风险事实(过期的也返回),evaluator 照常判命中,
+    /// 只是过期行产出的命中带 <c>CreateEligible = false</c>:
+    /// 它<b>仍在 hits 里</b>(因而保护既有异常不被误判恢复),只是不用来开新案子。</para>
+    ///
+    /// <para><b>为什么不是「过滤掉过期行」</b>:那正是缺陷本身。被过滤的行不在 hits 里,
+    /// 与「不再命中」不可区分 —— 这条注释存在的意义就是防止有人日后为了
+    /// 「少建几条历史异常」把时间条件加回数据集。</para>
+    ///
+    /// <para><b>默认 <c>false</c>,因此未声明的规则行为逐字不变</b>(Rule 01 即是)。
+    /// 判定基准是 canonical <see cref="S8CanonicalColumns.DueAt"/> 列,
+    /// 故声明本项的规则其数据集必须 <c>HasDueAt = true</c>。</para>
+    /// </summary>
+    public bool CreateOnlyBeforeDueAt { get; init; }
+
     /// <summary>本规则开放给租户调整的运行参数白名单与取值域。</summary>
     public S8RuleParameterPolicy Parameters { get; init; } = new();
 }

+ 19 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/IS8RuleEvaluator.cs

@@ -45,6 +45,25 @@ public sealed class S8RuleHit
     public long? OccurrenceDeptId { get; set; }
     public long? ResponsibleDeptId { get; set; }
 
+    /// <summary>
+    /// S8-RULE-LIFECYCLE-CREATE-GATE-1:本条命中是否可用于<b>首次建单</b>。
+    ///
+    /// <para><c>true</c>(默认)= 与既有行为完全一致。
+    /// <c>false</c> = <b>这是一条真实命中</b>,只是不适合开一个新案子。</para>
+    ///
+    /// <para><b>false 绝不等于 MISS。</b>它必须照常出现在 <c>hits</c> 里,
+    /// 因为恢复判定(<c>ReconcileRecoveriesForRuleAsync</c>)唯一的判据就是
+    /// 「dedup_key 在不在本轮 hits 集合内」。一旦把它从 hits 里剔除,
+    /// 已有异常会开始累计 miss 并最终被标成"已恢复" ——
+    /// 那正是本机制要消灭的假恢复,只是换了个地方发生。</para>
+    ///
+    /// <para>由各 evaluator 按 <see cref="Definitions.S8RuleDefinition.CreateOnlyBeforeDueAt"/>
+    /// 等声明计算;未声明的规则恒为 <c>true</c>。调度器在
+    /// <b>「已有异常刷新」之后、「首次建单」之前</b>消费它(顺序不可调换,
+    /// 否则跨过到期日的既有预警会刷新不到)。</para>
+    /// </summary>
+    public bool CreateEligible { get; set; } = true;
+
     /// <summary>
     /// 命中归属模块码(S1-S7)。evaluator 可显式提供以覆盖 scene 默认派生;
     /// 留 null 时建单方按严格 S1-S7 派生:hit.SceneCode(仅 S1-S7)→ exception_type.scene_code。

+ 15 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8OutOfRangeRuleEvaluator.cs

@@ -149,8 +149,23 @@ public class S8OutOfRangeRuleEvaluator : IS8RuleEvaluator, ITransient
 
             var dedupKey = BuildDedupKey(tenantId, definition.RuleCode, sourceObjectType, sourceObjectId);
 
+            // S8-RULE-LIFECYCLE-CREATE-GATE-1:预警型规则的首次立案闸门。
+            //
+            // 只对显式声明 CreateOnlyBeforeDueAt 的规则生效;其余规则恒 true,行为逐字不变。
+            //
+            // 到期日缺失时判 true 而不是 false:闸门的职责是"到期后别开新案子",
+            // 不是"没有到期日就别报警"。缺列时静默拒绝建单会让一条规则表现为
+            // 「每轮都命中、一条异常也建不出来」——正是本仓反复在防的那类静默失败。
+            var createEligible = true;
+            if (definition.CreateOnlyBeforeDueAt)
+            {
+                var dueAt = row.GetDateTime(S8CanonicalColumns.DueAt);
+                createEligible = dueAt == null || dueAt.Value >= detectedAt;
+            }
+
             hits.Add(new S8RuleHit
             {
+                CreateEligible = createEligible,
                 SourceRuleId = rule.Id,
                 SourceRuleCode = definition.RuleCode,
                 SourceObjectType = sourceObjectType,

+ 25 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchSchedulerService.cs

@@ -1118,6 +1118,31 @@ public class S8WatchSchedulerService : ITransient
                 }
             }
 
+            // S8-RULE-LIFECYCLE-CREATE-GATE-1:首次立案闸门。
+            //
+            // 位置是本机制的全部要害,两边都不能挪:
+            //
+            // · 必须在**已有异常刷新之后**(上面两个 continue 分支)——
+            //   一条预警在跨过到期日之后 CreateEligible 会变成 false,但它对应的异常
+            //   仍需正常 refresh 才能保持 active。闸门挪到刷新之前,跨期的既有预警
+            //   会连 last_detected_at 都停止推进,等于换一种方式把它放掉。
+            //
+            // · 必须在**首次建单之前**(下面的 exception_type / 抗抖 / CreateFromHitAsync)——
+            //   否则历史风险行会在规则首次启用时被一次性倒成一批"预警",
+            //   而它们的风险早已兑现,不是预警。
+            //
+            // 注意这条 hit 已经被 ReconcileRecoveriesForRuleAsync 消费过了
+            // (它在 foreach 之前执行、拿的是完整 hits 列表),因此 CreateEligible=false
+            // 的命中天然保护了对应异常不被误判恢复 —— 恢复侧一行都不用改。
+            if (!hit.CreateEligible)
+            {
+                _logger.LogInformation(
+                    "create_not_eligible ruleCode={RuleCode} dedupKey={DedupKey} reason=hit_outside_create_window",
+                    rule.RuleCode, hit.DedupKey);
+                results.Add(BuildSkipResult(rule, "create_not_eligible", null, hit));
+                continue;
+            }
+
             bool typeExists;
             try
             {