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; /// /// S8-RULE02-ORDER-DELIVERY-WARNING-1:订单交付延期预警的结构契约。 /// /// 本文件锁定的每一条,都对应一种「代码照样跑、结论却是错的」的失效形态: /// /// ETA 必须来自源系统——一旦有人用「交期前 N 天」「历史均值」「已完工数量倒推」 /// 补一个 ETA,规则会从"预测"退化成"临期提醒",而输出格式完全一样,看不出来。 /// 只判未到期——去掉 plan_date >= NOW() 会让 385 条历史逾期单一次性涌进来, /// 且永远不会恢复。这不是"多报几条",是这条规则从预警变成了积压清单。 /// 身份是订单行——退化成订单号会让同单多行只剩一条异常,其余行静默消失。 /// DedupKey 预算——超预算的键被数据库截断后,两个订单行折叠成一条异常,无任何报错。 /// SQL 别名映射——本仓 EnableUnderLine = false,别名写错会静默映射为 null, /// 行数照常返回但命中恒为 0(Rule 01 真实踩过一次)。 /// /// /// 不接 DB、不接 DI。真库行为已由实施前只读核验覆盖(390 行 / 40 行有 ETA / 命中 2 条), /// 本文件锁定的是结构。 /// 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); /// /// 构造一行订单行事实。 是 Provider 在 SQL 里算好的 /// DATEDIFF(sys_capacity_date, plan_date),此处显式传入以便逐值锁定判定边界。 /// 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)))); /// 去掉块注释与行注释后的 SQL —— 断言只应看真正会执行的文本。 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 必须来自源系统字段,且缺失即出局。 // (「ETA 是否晚于交期」不在这里判 —— 那是 evaluator 的事,见 T6 / T15 / T16。) 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 天」冒充预测 // 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_MustNotFilterByTimeOrByDelay_OrFalseRecoveryComesBack() { var sql = SqlWithoutComments(); // ⚠️ 本仓最容易被"好心改回去"的一条。 // // 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("s.capacity_date > s.plan_delivery_date", sql, StringComparison.Ordinal); // 反面:取数仍必须要求 ETA 存在 —— 没有 ETA 就没有判定依据, // 这条过滤与生命周期无关,去掉它会让 350 条无 ETA 的行涌进来。 Assert.Contains("s.capacity_date IS NOT NULL", sql, StringComparison.Ordinal); } [Fact] public void T7_Sql_ScopesByTenant_AndExcludesClosedOrDeletedOrders() { var sql = SqlWithoutComments(); 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); } [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(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] 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(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); } } // ============================================================ // F. 生命周期:CREATE → KEEP ACTIVE → RECOVER // // 这一组是 S8-RULE-LIFECYCLE-CREATE-GATE-1 的核心回归。它守护的不是 // 「测试能过」,而是一条已经被系统识别出来的延期风险, // 不会因为客户交期真正到达反而被标记成"已恢复"。 // ============================================================ private const string LifecycleBillNo = "SO-LIFECYCLE-001"; private const int LifecycleEntrySeq = 1; /// 三个周期共用同一业务键;到期日与 ETA 按周期变化。 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(); 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); } }