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