Przeglądaj źródła

fix(s8): 对齐 Rule02 预演与首次立案资格口径

预演会系统性高估「将新建异常」。本地 Runtime 实测到分叉:

  预演   命中 3 · 将新建 3
  真跑   hits=3 · created=2   (1 条 create_not_eligible)

差的那一条是已过客户交期、风险已经兑现的历史订单行。它是真实命中
(因而保护既有异常不被误判恢复),但不该再开一个新案子。
管理员正是拿「将新建」这个数字决定要不要启用,报大了比不报更糟。

根因:WouldCreateCount 只判「有没有同 dedup_key 的活动异常」,
没有跟上 S8-RULE-LIFECYCLE-CREATE-GATE-1 引入的 hit.CreateEligible ——
即调度器 ProcessSingleRuleAsync 建单前的那道闸门。

修法只补这一项缺失契约:

- WouldCreateCount 追加 h.CreateEligible 过滤
- WouldMatchExistingCount 刻意不加:闸门在调度器里位于「既有异常刷新」之后,
  跨过到期日的既有预警 CreateEligible 已是 false 但照样会被刷新以保持 active;
  这里跟着过滤会把「保持 active」误报成「什么都不会发生」

未改数据集、evaluator、调度器、DTO schema,无 RuleCode 特判,
已有的去重 / 既有异常 / 零写入语义原样保留。

两个计数抽成 internal static 纯函数,只为让这条最容易悄悄漂移的算术能被直接断言 ——
原先它是 RunAsync 里的内联 lambda,而 RunAsync 需要 DB + Gateway,
于是整个服务里唯一测不到的部分恰好就是它。

版本:origin/master 已占用 1.0.507,本批 rebase 后统一取 1.0.508;无 migration。

测试:预演契约 13/13(新增 P1-P8);Rule02 35/35;
Admin.NET.Test S8 194/194;插件 2019/2021(2 项既有 skip)。
YY968XX 2 dni temu
rodzic
commit
ee733f6bd6

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

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

+ 152 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8WatchRulePreviewContractTests.cs

@@ -2,6 +2,7 @@ using System.Reflection;
 using Microsoft.AspNetCore.Mvc;
 using Admin.NET.Plugin.AiDOP.Controllers.S8;
 using Admin.NET.Plugin.AiDOP.Service.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
 using Xunit;
 
 namespace Admin.NET.Plugin.AiDOP.Tests.S8;
@@ -120,4 +121,155 @@ public class S8WatchRulePreviewContractTests
 
         Assert.Equal(typeof(long?), typeof(S8WatchRulePreviewHit).GetProperty("ExistingActiveExceptionId")!.PropertyType);
     }
+
+    // ============================================================
+    // S8-RULE-LIFECYCLE-CREATE-GATE-1:预演与首次立案资格的口径对齐
+    //
+    // 修的是一个已在本地 Runtime 实测到的分叉:
+    //   预演   命中 3 · 将新建 3
+    //   真跑   hits=3 · created=2   (1 条 create_not_eligible)
+    // 差的那一条是已过客户交期、风险已经兑现的历史行 —— 它是真实命中
+    // (因而保护既有异常不被误判恢复),但不该再开一个新案子。
+    // 管理员正是拿「将新建」这个数字决定要不要启用,报大了比不报更糟。
+    // ============================================================
+
+    private static S8RuleHit Hit(string dedupKey, bool createEligible) => new()
+    {
+        SourceRuleCode = "RULE_UNDER_TEST",
+        SourceObjectType = "SALES_ORDER_LINE",
+        SourceObjectId = dedupKey,
+        RelatedObjectCode = dedupKey,
+        DedupKey = dedupKey,
+        CreateEligible = createEligible
+    };
+
+    private static IReadOnlyDictionary<string, long> Active(params string[] dedupKeys) =>
+        dedupKeys.ToDictionary(k => k, k => (long)k.GetHashCode(), StringComparer.Ordinal);
+
+    private static readonly IReadOnlyDictionary<string, long> NoneActive =
+        new Dictionary<string, long>(StringComparer.Ordinal);
+
+    /// <summary>P1:CreateEligible=false 的命中<b>仍然是命中</b>。</summary>
+    [Fact]
+    public void P1_IneligibleHit_StillCountsAsHit()
+    {
+        var hits = new[] { Hit("A", true), Hit("B", true), Hit("C", false) };
+
+        // HitCount 直接取 hits.Count(见 RunAsync)——这里锁的是"不得在命中侧做任何过滤"。
+        // 一旦有人为了让数字好看而把 ineligible 从 hits 里剔掉,恢复判定会立刻失去保护,
+        // 假恢复原地复活。
+        Assert.Equal(3, hits.Length);
+        Assert.Contains(hits, h => !h.CreateEligible);
+    }
+
+    /// <summary>P2:CreateEligible=false 不计入 WouldCreate。</summary>
+    [Fact]
+    public void P2_IneligibleHit_IsNotCountedAsWouldCreate()
+    {
+        var hits = new[] { Hit("A", true), Hit("B", true), Hit("C", false) };
+
+        Assert.Equal(2, S8WatchRulePreviewService.CountWouldCreate(hits, NoneActive));
+    }
+
+    /// <summary>P3:CreateEligible=true 仍按原有去重口径计入。</summary>
+    [Fact]
+    public void P3_EligibleHits_KeepExistingDedupSemantics()
+    {
+        var hits = new[] { Hit("A", true), Hit("B", true) };
+
+        Assert.Equal(2, S8WatchRulePreviewService.CountWouldCreate(hits, NoneActive));
+
+        // A 已有活动异常 → 它会被刷新而不是新建(既有语义,未改动)。
+        Assert.Equal(1, S8WatchRulePreviewService.CountWouldCreate(hits, Active("A")));
+        Assert.Equal(1, S8WatchRulePreviewService.CountWouldMatchExisting(hits, Active("A")));
+    }
+
+    /// <summary>
+    /// P4:未声明闸门的规则(Rule 01 即是)行为逐字不变。
+    /// <c>CreateEligible</c> 默认 true,因此新过滤对它恒真、不改变任何计数。
+    /// </summary>
+    [Fact]
+    public void P4_RulesWithoutTheGate_AreUnaffected()
+    {
+        Assert.True(new S8RuleHit().CreateEligible);
+
+        var rule01Style = new[] { new S8RuleHit { DedupKey = "P1" }, new S8RuleHit { DedupKey = "P2" } };
+
+        Assert.Equal(2, S8WatchRulePreviewService.CountWouldCreate(rule01Style, NoneActive));
+        Assert.Equal(1, S8WatchRulePreviewService.CountWouldCreate(rule01Style, Active("P1")));
+        Assert.Equal(1, S8WatchRulePreviewService.CountWouldMatchExisting(rule01Style, Active("P1")));
+    }
+
+    /// <summary>
+    /// P5:预演仍然零写入。本批只改了两个计数表达式,不得因此引入任何写能力。
+    /// (与本文件开头的依赖/方法名断言互补:那两条从依赖面证明,这条从源码面证明。)
+    /// </summary>
+    [Fact]
+    public void P5_PreviewRemainsWriteFree()
+    {
+        var code = PreviewSource();
+
+        foreach (var write in new[]
+                 { "Insertable", "Updateable", "Deleteable", "ExecuteCommand", "SaveChanges" })
+            Assert.DoesNotContain(write, code, StringComparison.Ordinal);
+    }
+
+    /// <summary>P6:不得出现 RuleCode 特判 —— 闸门是通用能力,不是 Rule 02 的特例。</summary>
+    [Fact]
+    public void P6_NoRuleCodeSpecialCasing()
+    {
+        var code = PreviewSource();
+
+        Assert.DoesNotContain("RULE_S1_ORDER_DELIVERY_DELAY_WARNING", code, StringComparison.Ordinal);
+        Assert.DoesNotContain("RULE_S4_PURCHASE_DELIVERY", code, StringComparison.Ordinal);
+        Assert.DoesNotContain("CreateOnlyBeforeDueAt", code, StringComparison.Ordinal);
+    }
+
+    /// <summary>
+    /// P7:Rule 02 本地实测形态 —— 2 条未到期 + 1 条已过交期,均无既有异常。
+    /// 期望 Hits=3 / WouldCreate=2,与真跑的 <c>hits=3 created=2</c> 逐字一致。
+    /// </summary>
+    [Fact]
+    public void P7_Rule02LocalShape_HitsThree_WouldCreateTwo()
+    {
+        var hits = new[]
+        {
+            Hit("T838257186181189:R…:SALES_ORDER_LINE:SO202609030003#1", true),   // 未到期
+            Hit("T838257186181189:R…:SALES_ORDER_LINE:SO202609030004#1", true),   // 未到期
+            Hit("T838257186181189:R…:SALES_ORDER_LINE:SO202608240001#1", false)   // 已过交期
+        };
+
+        Assert.Equal(3, hits.Length);
+        Assert.Equal(2, S8WatchRulePreviewService.CountWouldCreate(hits, NoneActive));
+        Assert.Equal(0, S8WatchRulePreviewService.CountWouldMatchExisting(hits, NoneActive));
+    }
+
+    /// <summary>
+    /// P8:跨过交期的既有预警必须仍报「会刷新」。
+    /// 刷新分支<b>不</b>受闸门影响 —— 闸门在调度器里位于刷新之后。
+    /// 若这里也过滤,预演会把「保持 active」误报成「什么都不会发生」。
+    /// </summary>
+    [Fact]
+    public void P8_IneligibleHitWithExistingException_StillReportsRefresh()
+    {
+        var hits = new[] { Hit("C", false) };
+
+        Assert.Equal(0, S8WatchRulePreviewService.CountWouldCreate(hits, Active("C")));
+        Assert.Equal(1, S8WatchRulePreviewService.CountWouldMatchExisting(hits, Active("C")));
+    }
+
+    private static string PreviewSource()
+    {
+        var path = Path.GetFullPath(Path.Combine(
+            AppContext.BaseDirectory,
+            "../../../../Admin.NET.Plugin.AiDOP/Service/S8/S8WatchRulePreviewService.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);
+            }));
+    }
 }

+ 32 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchRulePreviewService.cs

@@ -122,8 +122,8 @@ public class S8WatchRulePreviewService : ITransient
             // 那需要 preview 自己复刻一遍 evaluator 的判定分支,一旦两边逻辑漂移,
             // 预演就会给出与真实运行不同的解释。宁可少报,不可假报。
             NoHitRows = Math.Max(0, candidateRows - hits.Count),
-            WouldCreateCount = hits.Count(h => !activeByDedupKey.ContainsKey(h.DedupKey ?? string.Empty)),
-            WouldMatchExistingCount = hits.Count(h => activeByDedupKey.ContainsKey(h.DedupKey ?? string.Empty)),
+            WouldCreateCount = CountWouldCreate(hits, activeByDedupKey),
+            WouldMatchExistingCount = CountWouldMatchExisting(hits, activeByDedupKey),
             Hits = hits.Select(h => new S8WatchRulePreviewHit
             {
                 SourceObjectType = h.SourceObjectType,
@@ -139,6 +139,36 @@ public class S8WatchRulePreviewService : ITransient
             }).ToList()
         };
     }
+
+    /// <summary>
+    /// S8-RULE-LIFECYCLE-CREATE-GATE-1:「若此刻真的执行,会<b>新建</b>几条」。
+    ///
+    /// <para>判据必须与调度器 <c>ProcessSingleRuleAsync</c> 的建单条件<b>同源</b> ——
+    /// 那里除了去重,还有一道 <see cref="S8RuleHit.CreateEligible"/> 闸门。
+    /// 少判这一项,预演就会系统性高估:Rule 02 本地实测中预演报「将新建 3」,
+    /// 而真跑 <c>created=2</c>,差的正是那条已过交期、风险已兑现的历史行。
+    /// 管理员正是拿这个数字决定要不要启用,<b>报大了比不报更糟</b>。</para>
+    ///
+    /// <para>抽成纯函数只为一件事:让这条口径能被直接断言。原先它是
+    /// <c>RunAsync</c> 里的一个内联 lambda,而 <c>RunAsync</c> 需要 DB + Gateway,
+    /// 于是这条最容易悄悄漂移的算术反而是整个服务里唯一测不到的部分。</para>
+    /// </summary>
+    internal static int CountWouldCreate(
+        IReadOnlyCollection<S8RuleHit> hits, IReadOnlyDictionary<string, long> activeByDedupKey) =>
+        hits.Count(h => h.CreateEligible && !activeByDedupKey.ContainsKey(h.DedupKey ?? string.Empty));
+
+    /// <summary>
+    /// 「若此刻真的执行,会<b>刷新既有</b>几条」。
+    ///
+    /// <para><b>刻意不加 <see cref="S8RuleHit.CreateEligible"/> 过滤</b>:闸门在调度器里位于
+    /// 「既有异常刷新」<b>之后</b>。一条跨过到期日的既有预警 <c>CreateEligible</c> 已经是 false,
+    /// 但它照样会被刷新以保持 active —— 这正是假恢复修复的核心。这里跟着加过滤,
+    /// 预演就会把「保持 active」误报成「什么都不会发生」,等于用另一种方式
+    /// 把那条风险从管理员视野里抹掉。</para>
+    /// </summary>
+    internal static int CountWouldMatchExisting(
+        IReadOnlyCollection<S8RuleHit> hits, IReadOnlyDictionary<string, long> activeByDedupKey) =>
+        hits.Count(h => activeByDedupKey.ContainsKey(h.DedupKey ?? string.Empty));
 }
 
 /// <summary>预演结果。纯读,不代表任何已发生的副作用。</summary>