|
|
@@ -0,0 +1,494 @@
|
|
|
+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 Microsoft.Extensions.Logging.Abstractions;
|
|
|
+using Xunit;
|
|
|
+
|
|
|
+namespace Admin.NET.Test.S8;
|
|
|
+
|
|
|
+/// <summary>
|
|
|
+/// S8-RULE01-PURCHASE-DELIVERY-A:PURCHASE_DELIVERY 的取数作用域、canonical 映射与 TIMEOUT 判定契约。
|
|
|
+/// 锁定项与失败后果:
|
|
|
+///
|
|
|
+/// A. 作用域只能按 tenant → Phase 1 拍板:Security Boundary = Tenant。若有人日后往 SQL 里补回
|
|
|
+/// factory_id 过滤,规则会恒返 0 行且不报错(dwd_supplier_delivery.factory_id
|
|
|
+/// 是写死的常量 1,与 S8 侧真实 SysOrg.Id 永不相等)——静默失效,最难发现。
|
|
|
+/// B. 快照按 tenant 各自收敛 → 用全局 MAX(stat_date) 会让落后一天的租户整租户静默丢数据。
|
|
|
+/// C. 身份稳定且唯一 → source_object_id 进 DedupKey,格式一变历史异常与新异常直接断代。
|
|
|
+/// D. DedupKey 预算守卫 → 预算比 source_object_id 列宽(64)更紧;落在预算与列宽之间的键
|
|
|
+/// 会过得了列校验、卡在 DedupKey 上,数据库静默截断会把两个 PO Line 折叠成一条异常。
|
|
|
+/// E. due_at 双分支 → need_date 为 NULL 时回落 due_date;两者实测 18/36 行不同,不是等价列。
|
|
|
+/// F. 判定归 evaluator → Provider 不滤 status,"逾期但已完成不命中"必须由 evaluator 判出来。
|
|
|
+/// G. STANDARD 绝不回落 → 取数失败偷偷执行 rule.Expression 会让迁移失败被静默隐藏:
|
|
|
+/// 规则看起来在正常跑,实际跑的还是旧 SQL。
|
|
|
+///
|
|
|
+/// 不接 DB、不接 DI、不触发任何真实规则。SQL 在真实库上的行为已由实施前的只读核验覆盖
|
|
|
+/// (4 租户身份唯一、跨租户交集 0、外层走 idx_stat_date),本文件锁定的是结构契约。
|
|
|
+/// </summary>
|
|
|
+public class S8PurchaseDeliveryProviderMappingTests
|
|
|
+{
|
|
|
+ private const string PurchaseDelivery = S8BusinessDatasetDefinitions.PurchaseDeliveryCode;
|
|
|
+ private const string RuleCode = "RULE_S4_PURCHASE_DELIVERY_DATE_DELAY";
|
|
|
+
|
|
|
+ private static S8MonitoringDataRequest Request(
|
|
|
+ long tenantId = 838257186181189, long factoryId = 838257186320453) => new()
|
|
|
+ {
|
|
|
+ TenantId = tenantId,
|
|
|
+ FactoryId = factoryId,
|
|
|
+ DatasetCode = PurchaseDelivery,
|
|
|
+ RuleCode = RuleCode,
|
|
|
+ RuleType = "TIMEOUT"
|
|
|
+ };
|
|
|
+
|
|
|
+ private static AdoS8WatchRule NewRule() => new()
|
|
|
+ {
|
|
|
+ Id = 900101,
|
|
|
+ RuleCode = RuleCode,
|
|
|
+ SceneCode = "S4",
|
|
|
+ WatchObjectType = "PURCHASE_ORDER_LINE",
|
|
|
+ SourceObjectType = "PURCHASE_ORDER_LINE",
|
|
|
+ Severity = "SERIOUS",
|
|
|
+ RuleType = "TIMEOUT",
|
|
|
+ DataAccessMode = S8DataAccessMode.StandardDataset,
|
|
|
+ DatasetCode = PurchaseDelivery
|
|
|
+ };
|
|
|
+
|
|
|
+ private static S8TimeoutParams Params() => S8TimeoutParams.Parse(
|
|
|
+ """
|
|
|
+ {"dueAtField":"due_at","statusField":"status",
|
|
|
+ "completedStates":["COMPLETED","CLOSED","CANCELLED"],
|
|
|
+ "objectCodeField":"related_object_code","objectIdField":"source_object_id",
|
|
|
+ "graceMinutes":0,"exceptionTypeCode":"PURCHASE_DELIVERY_ABNORMAL"}
|
|
|
+ """);
|
|
|
+
|
|
|
+ /// <summary>真实 DWD 行样本(取自 UAT 租户 2026-09-05 快照)。</summary>
|
|
|
+ private static S8PurchaseDeliveryDataProvider.PurchaseDeliveryRow Row(
|
|
|
+ string poNo, string poLine, DateTime? needDate, DateTime? dueDate, string status,
|
|
|
+ string supplierName = "珩睿", string supplierCode = "10003232") => new()
|
|
|
+ {
|
|
|
+ SourceObjectId = $"{poNo}#{poLine}",
|
|
|
+ RelatedObjectCode = poNo,
|
|
|
+ RelatedObjectName = string.IsNullOrEmpty(supplierName) ? supplierCode : supplierName,
|
|
|
+ DueAt = needDate ?? dueDate,
|
|
|
+ Status = status,
|
|
|
+ DueAtSource = needDate == null ? "due_date" : "need_date",
|
|
|
+ PoNo = poNo,
|
|
|
+ PoLine = poLine,
|
|
|
+ PoType = "po",
|
|
|
+ SupplierCode = supplierCode,
|
|
|
+ SupplierName = supplierName,
|
|
|
+ ItemCode = "ITM-001",
|
|
|
+ ItemName = "物料",
|
|
|
+ OrderQty = 3000m,
|
|
|
+ RemainingQty = 3000m,
|
|
|
+ NeedDate = needDate,
|
|
|
+ DueDate = dueDate,
|
|
|
+ RiskLevel = "HIGH",
|
|
|
+ StatDate = new DateTime(2026, 9, 5)
|
|
|
+ };
|
|
|
+
|
|
|
+ private static S8MonitoringRowSet RowSet(params S8PurchaseDeliveryDataProvider.PurchaseDeliveryRow[] rows) =>
|
|
|
+ S8MonitoringRowSet.FromRows(
|
|
|
+ rows.Select(r => S8MonitoringRow.FromValues(S8PurchaseDeliveryDataProvider.ToCanonicalValues(r))));
|
|
|
+
|
|
|
+ // ============================================================
|
|
|
+ // A/B. 作用域与快照收敛(SQL 结构契约)
|
|
|
+ // ============================================================
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void T4_Sql_FiltersByTenant_InBothOuterQueryAndSnapshotSubquery()
|
|
|
+ {
|
|
|
+ // 外层与快照子查询都必须带租户,否则会拿到别的租户的最新快照日期。
|
|
|
+ var sql = S8PurchaseDeliveryDataProvider.Sql;
|
|
|
+
|
|
|
+ Assert.Contains("d.tenant_id = @TenantId", sql);
|
|
|
+ Assert.Contains("x.tenant_id = @TenantId", sql);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void T5_Sql_DoesNotUseFactoryAsDatasetFilter()
|
|
|
+ {
|
|
|
+ // Phase 1 拍板:Factory 是业务上下文,不是本数据集的过滤条件。
|
|
|
+ // 这条断言是防回归的主锁——补回 factory 过滤会让规则恒返 0 行且不报任何错。
|
|
|
+ Assert.DoesNotContain("factory_id", S8PurchaseDeliveryDataProvider.Sql);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void T5b_FactoryId_DoesNotAlterQuery_ButTenantIdDoes()
|
|
|
+ {
|
|
|
+ // 同一租户下换工厂:SQL 与唯一参数都不受影响(工厂根本不进 SQL)。
|
|
|
+ // 换租户:参数随之改变。二者共同证明"隔离维度只有租户"。
|
|
|
+ var sameTenantOtherFactory = Request(factoryId: 999_999_999);
|
|
|
+ var otherTenant = Request(tenantId: 838257212780613);
|
|
|
+
|
|
|
+ Assert.Equal(Request().TenantId, sameTenantOtherFactory.TenantId);
|
|
|
+ Assert.NotEqual(Request().TenantId, otherTenant.TenantId);
|
|
|
+ // 工厂不在 SQL 中,因此不可能成为取数差异的来源。
|
|
|
+ Assert.DoesNotContain("@FactoryId", S8PurchaseDeliveryDataProvider.Sql);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void T6_Sql_ConvergesSnapshot_PerTenantLatest_NotGlobalMax()
|
|
|
+ {
|
|
|
+ var sql = S8PurchaseDeliveryDataProvider.Sql;
|
|
|
+
|
|
|
+ // 反向索引扫描而非聚合:大租户上实测 143ms → 2.7ms。
|
|
|
+ Assert.Contains("ORDER BY x.stat_date DESC", sql);
|
|
|
+ Assert.Contains("LIMIT 1", sql);
|
|
|
+ Assert.DoesNotContain("MAX(", sql);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void Sql_ExcludesDegenerateIdentity_AndDoesNotFilterOnBusinessState()
|
|
|
+ {
|
|
|
+ var sql = S8PurchaseDeliveryDataProvider.Sql;
|
|
|
+
|
|
|
+ // 身份任一段为空会产生退化的 "#",必须排除。
|
|
|
+ Assert.Contains("d.po_no <> ''", sql);
|
|
|
+ Assert.Contains("d.po_line <> ''", sql);
|
|
|
+
|
|
|
+ // 判定归 evaluator:Provider 不得按状态或不可信的 remaining_qty 预筛。
|
|
|
+ Assert.DoesNotContain("remaining_qty >", sql);
|
|
|
+ Assert.DoesNotContain("delivery_status <>", sql);
|
|
|
+ Assert.DoesNotContain("delivery_status !=", sql);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public async Task LoadAsync_ValidatesScope_BeforeTouchingDatabase()
|
|
|
+ {
|
|
|
+ // 故意注入 null 数据库:若作用域校验不是第一步,这里会得到 NullReferenceException。
|
|
|
+ var provider = new S8PurchaseDeliveryDataProvider(
|
|
|
+ null, NullLogger<S8PurchaseDeliveryDataProvider>.Instance);
|
|
|
+
|
|
|
+ var ex = await Assert.ThrowsAsync<S8RuleEvaluatorException>(() =>
|
|
|
+ provider.LoadAsync(Request(tenantId: 0)));
|
|
|
+ Assert.Equal("dataset_scope_invalid", ex.Reason);
|
|
|
+
|
|
|
+ var ex2 = await Assert.ThrowsAsync<S8RuleEvaluatorException>(() =>
|
|
|
+ provider.LoadAsync(Request(factoryId: 0)));
|
|
|
+ Assert.Equal("dataset_scope_invalid", ex2.Reason);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ============================================================
|
|
|
+ // C/D. 身份与 DedupKey 预算
|
|
|
+ // ============================================================
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void T7_Identity_IsStableComposite_UniquePerLine()
|
|
|
+ {
|
|
|
+ var line3 = Row("PO202608260002", "3", new DateTime(2026, 8, 17, 4, 33, 7), new DateTime(2026, 9, 10), "DELAYED");
|
|
|
+ var line4 = Row("PO202608260002", "4", new DateTime(2026, 8, 17), new DateTime(2026, 9, 10), "DELAYED");
|
|
|
+
|
|
|
+ var v3 = S8PurchaseDeliveryDataProvider.ToCanonicalValues(line3);
|
|
|
+ var v4 = S8PurchaseDeliveryDataProvider.ToCanonicalValues(line4);
|
|
|
+
|
|
|
+ Assert.Equal("PO202608260002#3", v3[S8CanonicalColumns.SourceObjectId]);
|
|
|
+ Assert.Equal("PO202608260002", v3[S8CanonicalColumns.RelatedObjectCode]);
|
|
|
+ // 同单不同行必须是不同对象,否则同一采购单只会产出一条异常。
|
|
|
+ Assert.NotEqual(v3[S8CanonicalColumns.SourceObjectId], v4[S8CanonicalColumns.SourceObjectId]);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void T7b_Identity_IsInvariantAcrossSnapshots()
|
|
|
+ {
|
|
|
+ // 身份只由 po_no + po_line 决定,与 stat_date 无关;否则每天换一次身份,
|
|
|
+ // 昨天的异常今天会被判恢复、并新建一条。
|
|
|
+ var day1 = Row("PO202608260002", "3", new DateTime(2026, 8, 17), new DateTime(2026, 9, 10), "DELAYED");
|
|
|
+ var day2 = Row("PO202608260002", "3", new DateTime(2026, 8, 17), new DateTime(2026, 9, 10), "DELAYED");
|
|
|
+ day2.StatDate = new DateTime(2026, 9, 6);
|
|
|
+
|
|
|
+ Assert.Equal(
|
|
|
+ S8PurchaseDeliveryDataProvider.ToCanonicalValues(day1)[S8CanonicalColumns.SourceObjectId],
|
|
|
+ S8PurchaseDeliveryDataProvider.ToCanonicalValues(day2)[S8CanonicalColumns.SourceObjectId]);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void T7c_DedupKey_StaysWithinColumnLimit_ForRealWorstCase()
|
|
|
+ {
|
|
|
+ // 实测该租户最长 source_object_id 为 16 字符;此处按真实规则编码与作用域算完整 DedupKey。
|
|
|
+ var key = S8TimeoutRuleEvaluator.BuildDedupKey(
|
|
|
+ 838257186181189, 838257186320453, RuleCode, "PURCHASE_ORDER_LINE", "PO202608260002#4");
|
|
|
+
|
|
|
+ Assert.True(key.Length <= S8PurchaseDeliveryDataProvider.DedupKeyMaxLength,
|
|
|
+ $"DedupKey 长度 {key.Length} 超过 {S8PurchaseDeliveryDataProvider.DedupKeyMaxLength}:{key}");
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void T7d_Budget_IsTighterThanSourceObjectIdColumnWidth()
|
|
|
+ {
|
|
|
+ // 关键认知:预算(128 − 前缀)比 source_object_id 的列宽 64 更紧。
|
|
|
+ // 落在预算与 64 之间的键会过得了列校验、卡在 DedupKey 上。
|
|
|
+ var budget = S8PurchaseDeliveryDataProvider.ResolveSourceObjectIdBudget(Request());
|
|
|
+
|
|
|
+ Assert.True(budget > 0);
|
|
|
+ Assert.True(budget < 64, $"预期预算紧于列宽 64,实际 {budget}");
|
|
|
+
|
|
|
+ // 预算 + 前缀必须正好等于上限,证明算式与 BuildDedupKey 的格式一致。
|
|
|
+ var probe = new string('x', budget);
|
|
|
+ var key = S8TimeoutRuleEvaluator.BuildDedupKey(
|
|
|
+ Request().TenantId, Request().FactoryId, RuleCode, "PURCHASE_ORDER_LINE", probe);
|
|
|
+ Assert.Equal(S8PurchaseDeliveryDataProvider.DedupKeyMaxLength, key.Length);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void T7e_Budget_ShrinksWhenRuleCodeGrows()
|
|
|
+ {
|
|
|
+ // 预算按 request 现算而非写死;换更长的规则编码必须自动收紧。
|
|
|
+ var longRule = new S8MonitoringDataRequest
|
|
|
+ {
|
|
|
+ TenantId = Request().TenantId,
|
|
|
+ FactoryId = Request().FactoryId,
|
|
|
+ DatasetCode = PurchaseDelivery,
|
|
|
+ RuleCode = RuleCode + "_MUCH_LONGER_SUFFIX",
|
|
|
+ RuleType = "TIMEOUT"
|
|
|
+ };
|
|
|
+
|
|
|
+ Assert.True(
|
|
|
+ S8PurchaseDeliveryDataProvider.ResolveSourceObjectIdBudget(longRule)
|
|
|
+ < S8PurchaseDeliveryDataProvider.ResolveSourceObjectIdBudget(Request()));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ============================================================
|
|
|
+ // E. Canonical 映射
|
|
|
+ // ============================================================
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void T8_Canonical_Mapping_IsComplete_WhenNeedDatePresent()
|
|
|
+ {
|
|
|
+ var values = S8PurchaseDeliveryDataProvider.ToCanonicalValues(
|
|
|
+ Row("PO202608260002", "3", new DateTime(2026, 8, 17, 4, 33, 7), new DateTime(2026, 9, 10), "DELAYED"));
|
|
|
+
|
|
|
+ Assert.Equal("PO202608260002#3", values[S8CanonicalColumns.SourceObjectId]);
|
|
|
+ Assert.Equal("PO202608260002", values[S8CanonicalColumns.RelatedObjectCode]);
|
|
|
+ Assert.Equal(new DateTime(2026, 8, 17, 4, 33, 7), values[S8CanonicalColumns.DueAt]);
|
|
|
+ Assert.Equal("DELAYED", values[S8CanonicalColumns.Status]);
|
|
|
+ Assert.Equal("need_date", values["due_at_source"]);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void T8b_DueAt_FallsBackToDueDate_WhenNeedDateIsNull()
|
|
|
+ {
|
|
|
+ // 真实样本 PW202608280001#1:need_date 为 NULL,是唯一能验回落分支的行。
|
|
|
+ var values = S8PurchaseDeliveryDataProvider.ToCanonicalValues(
|
|
|
+ Row("PW202608280001", "1", null, new DateTime(2026, 8, 29), "DELAYED", supplierName: "怡祥"));
|
|
|
+
|
|
|
+ Assert.Equal(new DateTime(2026, 8, 29), values[S8CanonicalColumns.DueAt]);
|
|
|
+ // due_at 是两种业务日期的混合,必须逐行自证用的是哪个基准。
|
|
|
+ Assert.Equal("due_date", values["due_at_source"]);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void T8c_Canonical_DoesNotFabricateBusinessJudgements()
|
|
|
+ {
|
|
|
+ // Provider 不得构造 is_delivery_complete / is_active_obligation / severity ——
|
|
|
+ // 上一轮数据契约审计已判这三项 NOT_PROVEN,凭空构造等于把中台缺口埋进 S8。
|
|
|
+ var values = S8PurchaseDeliveryDataProvider.ToCanonicalValues(
|
|
|
+ Row("PO202608260002", "1", new DateTime(2026, 8, 17), new DateTime(2026, 9, 10), "COMPLETED"));
|
|
|
+
|
|
|
+ Assert.DoesNotContain("is_delivery_complete", values.Keys);
|
|
|
+ Assert.DoesNotContain("is_active_obligation", values.Keys);
|
|
|
+ Assert.DoesNotContain("severity", values.Keys);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ============================================================
|
|
|
+ // F. TIMEOUT 判定(由 evaluator 判,不由 Provider 预筛)
|
|
|
+ // ============================================================
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void T9_Timeout_Hits_WhenOverdueAndNotCompleted()
|
|
|
+ {
|
|
|
+ var rowSet = RowSet(Row("PO202608260002", "3",
|
|
|
+ new DateTime(2026, 8, 17, 4, 33, 7), new DateTime(2026, 9, 10), "DELAYED"));
|
|
|
+
|
|
|
+ var hits = S8TimeoutRuleEvaluator.EvaluateRows(
|
|
|
+ rowSet, Params(), NewRule(), 838257186181189, 838257186320453, 0, new DateTime(2026, 9, 5));
|
|
|
+
|
|
|
+ var hit = Assert.Single(hits);
|
|
|
+ Assert.Equal("PO202608260002#3", hit.SourceObjectId);
|
|
|
+ Assert.Equal("PO202608260002", hit.RelatedObjectCode);
|
|
|
+ Assert.Equal("PURCHASE_ORDER_LINE", hit.SourceObjectType);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void T10_Timeout_NoHit_WhenNotYetDue()
|
|
|
+ {
|
|
|
+ // 真实样本 PO202608280001#1:due 2026-09-17,状态 OPEN。
|
|
|
+ var rowSet = RowSet(Row("PO202608280001", "1",
|
|
|
+ new DateTime(2026, 9, 17), new DateTime(2026, 8, 9), "OPEN", supplierName: "怡祥"));
|
|
|
+
|
|
|
+ var hits = S8TimeoutRuleEvaluator.EvaluateRows(
|
|
|
+ rowSet, Params(), NewRule(), 838257186181189, 838257186320453, 0, new DateTime(2026, 9, 5));
|
|
|
+
|
|
|
+ Assert.Empty(hits);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void T11_Timeout_NoHit_WhenOverdueButCompleted()
|
|
|
+ {
|
|
|
+ // 真实样本 PO202608260002#1:已逾期但 COMPLETED。
|
|
|
+ // 这一条必须由 evaluator 判出来 —— 若 Provider 在 SQL 里滤掉已完成行,
|
|
|
+ // 本测试会"通过"但测的是过滤而非判定,且该行不在 hits 里会被误判为恢复。
|
|
|
+ var rowSet = RowSet(Row("PO202608260002", "1",
|
|
|
+ new DateTime(2026, 8, 17, 4, 33, 7), new DateTime(2026, 9, 10), "COMPLETED"));
|
|
|
+
|
|
|
+ var hits = S8TimeoutRuleEvaluator.EvaluateRows(
|
|
|
+ rowSet, Params(), NewRule(), 838257186181189, 838257186320453, 0, new DateTime(2026, 9, 5));
|
|
|
+
|
|
|
+ Assert.Empty(hits);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void T11b_Timeout_NoHit_ForClosedAndCancelled()
|
|
|
+ {
|
|
|
+ // dwd_supplier_delivery 有三个写入方,词表不一致:批量转换产 COMPLETED/DELAYED/OPEN,
|
|
|
+ // 增量写入方产 CANCELLED/CLOSED/PARTIAL。当前库中后两值 0 行,这是零行为变更的安全余量。
|
|
|
+ var rowSet = RowSet(
|
|
|
+ Row("PO-CLOSED", "1", new DateTime(2026, 8, 1), null, "CLOSED"),
|
|
|
+ Row("PO-CANCELLED", "1", new DateTime(2026, 8, 1), null, "CANCELLED"));
|
|
|
+
|
|
|
+ var hits = S8TimeoutRuleEvaluator.EvaluateRows(
|
|
|
+ rowSet, Params(), NewRule(), 838257186181189, 838257186320453, 0, new DateTime(2026, 9, 5));
|
|
|
+
|
|
|
+ Assert.Empty(hits);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void T11c_Timeout_Hits_ForPartial_BecausePartialIsNotTerminal()
|
|
|
+ {
|
|
|
+ // PARTIAL 刻意不列入 completedStates:部分收货不是终态,仍需继续监控。
|
|
|
+ var rowSet = RowSet(Row("PO-PARTIAL", "1", new DateTime(2026, 8, 1), null, "PARTIAL"));
|
|
|
+
|
|
|
+ var hits = S8TimeoutRuleEvaluator.EvaluateRows(
|
|
|
+ rowSet, Params(), NewRule(), 838257186181189, 838257186320453, 0, new DateTime(2026, 9, 5));
|
|
|
+
|
|
|
+ Assert.Single(hits);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void T13_Recovery_Contract_HitSetShrinksWhenStatusBecomesCompleted()
|
|
|
+ {
|
|
|
+ // 恢复判定的输入是"本轮 hits 的 dedup_key 集合"(见 ReconcileRecoveriesForRuleAsync)。
|
|
|
+ // 这里证明:同一对象在状态转为 COMPLETED 后不再出现在 hits 中,
|
|
|
+ // 从而进入恢复候选。真实 recovered_at 写入属运行态,由 Batch B 验证。
|
|
|
+ var before = RowSet(Row("PO202608260002", "2",
|
|
|
+ new DateTime(2026, 8, 17, 4, 33, 7), new DateTime(2026, 9, 10), "DELAYED"));
|
|
|
+ var after = RowSet(Row("PO202608260002", "2",
|
|
|
+ new DateTime(2026, 8, 17, 4, 33, 7), new DateTime(2026, 9, 10), "COMPLETED"));
|
|
|
+
|
|
|
+ var hitsBefore = S8TimeoutRuleEvaluator.EvaluateRows(
|
|
|
+ before, Params(), NewRule(), 838257186181189, 838257186320453, 0, new DateTime(2026, 9, 5));
|
|
|
+ var hitsAfter = S8TimeoutRuleEvaluator.EvaluateRows(
|
|
|
+ after, Params(), NewRule(), 838257186181189, 838257186320453, 0, new DateTime(2026, 9, 5));
|
|
|
+
|
|
|
+ var dedupKey = Assert.Single(hitsBefore).DedupKey;
|
|
|
+ Assert.Empty(hitsAfter);
|
|
|
+ Assert.DoesNotContain(dedupKey, hitsAfter.Select(h => h.DedupKey));
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public void T14_Hit_CarriesExceptionTypeAndDiagnosticPayload()
|
|
|
+ {
|
|
|
+ var rowSet = RowSet(Row("PO202608260002", "3",
|
|
|
+ new DateTime(2026, 8, 17, 4, 33, 7), new DateTime(2026, 9, 10), "DELAYED"));
|
|
|
+
|
|
|
+ var hit = Assert.Single(S8TimeoutRuleEvaluator.EvaluateRows(
|
|
|
+ rowSet, Params(), NewRule(), 838257186181189, 838257186320453, 0, new DateTime(2026, 9, 5)));
|
|
|
+
|
|
|
+ Assert.Equal("PURCHASE_DELIVERY_ABNORMAL", hit.ExceptionTypeCode);
|
|
|
+ Assert.Equal("PURCHASE_ORDER_LINE", hit.SourceObjectType);
|
|
|
+ Assert.Equal("PO202608260002#3", hit.SourceObjectId);
|
|
|
+ Assert.Equal("T838257186181189:F838257186320453:RRULE_S4_PURCHASE_DELIVERY_DATE_DELAY:PURCHASE_ORDER_LINE:PO202608260002#3",
|
|
|
+ hit.DedupKey);
|
|
|
+
|
|
|
+ // 诊断列必须随 payload 落盘:remaining_qty 供后续数据治理观察,
|
|
|
+ // due_at_source 用于追溯每行实际使用了 need_date 还是 due_date。
|
|
|
+ Assert.Contains("remaining_qty", hit.SourcePayload);
|
|
|
+ Assert.Contains("due_at_source", hit.SourcePayload);
|
|
|
+ Assert.Contains("stat_date", hit.SourcePayload);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ============================================================
|
|
|
+ // G. STANDARD_DATASET 绝不回落 Legacy
|
|
|
+ // ============================================================
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public async Task T12_StandardDataset_NeverFallsBackToLegacySql()
|
|
|
+ {
|
|
|
+ var legacy = new SpyLegacy();
|
|
|
+ var gateway = new S8MonitoringDataGateway(
|
|
|
+ legacy,
|
|
|
+ new S8MonitoringDataProviderRegistry(new IS8MonitoringDataProvider[] { new EmptyPurchaseDeliveryProvider() }),
|
|
|
+ new S8DatasetCatalog(new IS8DatasetDefinitionSource[] { new S8BusinessDatasetDefinitions() }),
|
|
|
+ NullLogger<S8MonitoringDataGateway>.Instance);
|
|
|
+
|
|
|
+ var rule = NewRule();
|
|
|
+ // 历史遗留字段里即使躺着可执行 SQL,STANDARD_DATASET 路径也绝不能碰它。
|
|
|
+ rule.Expression = "DROP TABLE ado_s8_exception; SELECT SHOULD_NEVER_RUN";
|
|
|
+ rule.DataSourceId = 99;
|
|
|
+
|
|
|
+ var result = await gateway.LoadAsync(838257186181189, 838257186320453, rule, "TIMEOUT", 60, 1000);
|
|
|
+
|
|
|
+ Assert.Equal(S8DataAccessMode.StandardDataset, result.DataAccessMode);
|
|
|
+ Assert.Equal(0, legacy.CallCount);
|
|
|
+ Assert.Null(legacy.LastExpression);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public async Task T12b_ProviderFailure_SurfacesAsQueryFailed_WithoutLegacyFallback()
|
|
|
+ {
|
|
|
+ var legacy = new SpyLegacy();
|
|
|
+ var gateway = new S8MonitoringDataGateway(
|
|
|
+ legacy,
|
|
|
+ new S8MonitoringDataProviderRegistry(new IS8MonitoringDataProvider[] { new ThrowingPurchaseDeliveryProvider() }),
|
|
|
+ new S8DatasetCatalog(new IS8DatasetDefinitionSource[] { new S8BusinessDatasetDefinitions() }),
|
|
|
+ NullLogger<S8MonitoringDataGateway>.Instance);
|
|
|
+
|
|
|
+ var rule = NewRule();
|
|
|
+ rule.Expression = "SELECT SHOULD_NEVER_RUN";
|
|
|
+
|
|
|
+ var ex = await Assert.ThrowsAsync<S8RuleEvaluatorException>(() =>
|
|
|
+ gateway.LoadAsync(838257186181189, 838257186320453, rule, "TIMEOUT", 60, 1000));
|
|
|
+
|
|
|
+ Assert.Equal(S8DatasetReasonCode.QueryFailed, ex.Reason);
|
|
|
+ Assert.Equal(0, legacy.CallCount); // ← 本测试的核心断言
|
|
|
+ }
|
|
|
+
|
|
|
+ private sealed class SpyLegacy : IS8LegacySqlDataProvider
|
|
|
+ {
|
|
|
+ public int CallCount { get; private set; }
|
|
|
+ public string LastExpression { get; private set; }
|
|
|
+
|
|
|
+ public Task<S8MonitoringDataResult> LoadAsync(
|
|
|
+ long tenantId, long factoryId, AdoS8WatchRule rule, string ruleType,
|
|
|
+ int timeoutSeconds, CancellationToken cancellationToken = default)
|
|
|
+ {
|
|
|
+ CallCount++;
|
|
|
+ LastExpression = rule.Expression;
|
|
|
+ return Task.FromResult(new S8MonitoringDataResult { RowSet = S8MonitoringRowSet.Empty });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private sealed class EmptyPurchaseDeliveryProvider : IS8MonitoringDataProvider
|
|
|
+ {
|
|
|
+ public string DatasetCode => PurchaseDelivery;
|
|
|
+ public S8DatasetCapabilities Capabilities => S8BusinessDatasetDefinitions.PurchaseDeliveryCapabilities;
|
|
|
+
|
|
|
+ public Task<S8MonitoringRowSet> LoadAsync(
|
|
|
+ S8MonitoringDataRequest request, CancellationToken cancellationToken = default)
|
|
|
+ {
|
|
|
+ request.EnsureValid();
|
|
|
+ return Task.FromResult(S8MonitoringRowSet.Empty);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private sealed class ThrowingPurchaseDeliveryProvider : IS8MonitoringDataProvider
|
|
|
+ {
|
|
|
+ public string DatasetCode => PurchaseDelivery;
|
|
|
+ public S8DatasetCapabilities Capabilities => S8BusinessDatasetDefinitions.PurchaseDeliveryCapabilities;
|
|
|
+
|
|
|
+ public Task<S8MonitoringRowSet> LoadAsync(
|
|
|
+ S8MonitoringDataRequest request, CancellationToken cancellationToken = default) =>
|
|
|
+ throw new InvalidOperationException("dwd unavailable");
|
|
|
+ }
|
|
|
+}
|