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; /// /// 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),本文件锁定的是结构契约。 /// 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"} """); /// 真实 DWD 行样本(取自 UAT 租户 2026-09-05 快照)。 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.Instance); var ex = await Assert.ThrowsAsync(() => provider.LoadAsync(Request(tenantId: 0))); Assert.Equal("dataset_scope_invalid", ex.Reason); var ex2 = await Assert.ThrowsAsync(() => 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.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.Instance); var rule = NewRule(); rule.Expression = "SELECT SHOULD_NEVER_RUN"; var ex = await Assert.ThrowsAsync(() => 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 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 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 LoadAsync( S8MonitoringDataRequest request, CancellationToken cancellationToken = default) => throw new InvalidOperationException("dwd unavailable"); } }