|
|
@@ -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);
|
|
|
+ }
|
|
|
}
|