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 Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace Admin.NET.Test.S8; /// /// S8-RULE01-PURCHASE-DELIVERY-CLOSURE-1:Rule 01 的**端到端链路**契约。 /// /// 与既有 S8PurchaseDeliveryProviderMappingTests 的分工(不是重复覆盖): /// 那个文件把 Provider 映射与 Evaluator 判定**分开**验证 —— canonical 映射直接构造行, /// 判定直接调 EvaluateRows。两端各自正确,不等于串起来正确: /// 中间还隔着 Catalog 查找、能力校验、Provider 解析、RowSet 传递。 /// 那一段目前没有任何用例覆盖,本文件补的就是它。 /// /// 链路: /// /// dataset_code /// → S8DatasetCatalog(真实 S8BusinessDatasetDefinitions,非 fake 定义) /// → S8MonitoringDataProviderRegistry /// → Provider.LoadAsync → S8MonitoringRowSet(canonical) /// → S8TimeoutRuleEvaluator → S8RuleHit /// /// /// Provider 用 stub 而非真 S8PurchaseDeliveryDataProvider:后者需要 ISqlSugarClient 与真库, /// 而本套件的硬约束是不接 DB。stub 只负责"吐出与真 Provider 相同形状的 canonical 行"—— /// 真 Provider 的 SQL→canonical 映射由 Mapping 套件用真实样本逐字段锁定, /// 两者合起来才构成完整证据。stub 刻意复用真实 Catalog 定义与真实能力声明, /// 因此 Catalog / Capability / Gateway 校验这几段走的都是**生产代码本身**。 /// /// 全程不出现:SQL expression、Legacy Provider、物理数据源配置、rule.Expression。 /// public class S8PurchaseDeliveryEndToEndTests { private const string PurchaseDelivery = S8BusinessDatasetDefinitions.PurchaseDeliveryCode; private const string RuleCode = "RULE_S4_PURCHASE_DELIVERY_DATE_DELAY"; private const long TenantId = 838257186181189L; private const long FactoryId = 838257186320453L; /// 评估基准时刻。固定值而非 DateTime.Now:判定结果必须可复现。 private static readonly DateTime EvaluatedAt = new(2026, 9, 6, 12, 0, 0); /// /// S8-RULE-GOVERNANCE-BATCH1:判定语义已迁至代码定义,evaluator 不再解析 params_json 的 A 类字段。 /// 本 helper 用 Rule 01 的**正式定义**构造生效规则,因此这些用例现在同时也在守护 /// 「定义与实际判定一致」——比原先直接喂一份手写 params_json 更接近真实运行。 /// private static S8EffectiveRule Effective(AdoS8WatchRule rule) => S8EffectiveRule.Resolve( rule, new S8RuleCatalog(new IS8RuleDefinitionSource[] { new S8PurchaseDeliveryRuleDefinitions() }) .GetRequired(S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryDateDelayCode)); private static AdoS8WatchRule Rule01() => new() { Id = 1329909460023L, RuleCode = RuleCode, SceneCode = "S4", WatchObjectType = "PURCHASE_ORDER_LINE", SourceObjectType = "PURCHASE_ORDER_LINE", Severity = "SERIOUS", RuleType = S8TimeoutRuleEvaluator.RuleTypeCode, DatasetCode = PurchaseDelivery, TenantId = TenantId, FactoryId = FactoryId, Enabled = false, ParamsJson = "{}" }; /// /// 吐出与真 Provider 同形状 canonical 行的 stub。 /// 能力声明取自 的同一实例, /// 保证 Gateway 的第 ⑨ 步(Provider 能力 vs 目录能力)走真实比对而非自证。 /// private sealed class StubPurchaseDeliveryProvider : IS8MonitoringDataProvider { private readonly IReadOnlyList<(string PoNo, string PoLine, DateTime? NeedDate, DateTime? DueDate, string Status)> _rows; public StubPurchaseDeliveryProvider( params (string PoNo, string PoLine, DateTime? NeedDate, DateTime? DueDate, string Status)[] rows) => _rows = rows; public string DatasetCode => PurchaseDelivery; public S8DatasetCapabilities Capabilities => S8BusinessDatasetDefinitions.PurchaseDeliveryCapabilities; public Task LoadAsync(S8MonitoringDataRequest request, CancellationToken cancellationToken = default) { // 与真 Provider 一致:作用域必须显式给出,缺失即失败。 request.EnsureValid(); var mapped = _rows.Select(r => S8MonitoringRow.FromValues( S8PurchaseDeliveryDataProvider.ToCanonicalValues( new S8PurchaseDeliveryDataProvider.PurchaseDeliveryRow { SourceObjectId = $"{r.PoNo}#{r.PoLine}", RelatedObjectCode = r.PoNo, RelatedObjectName = "珩睿", DueAt = r.NeedDate ?? r.DueDate, Status = r.Status, DueAtSource = r.NeedDate == null ? "due_date" : "need_date", PoNo = r.PoNo, PoLine = r.PoLine, NeedDate = r.NeedDate, DueDate = r.DueDate, StatDate = new DateTime(2026, 9, 6) }))).ToList(); return Task.FromResult(S8MonitoringRowSet.FromRows(mapped)); } } private static S8MonitoringDataGateway Gateway(IS8MonitoringDataProvider provider) => new(new S8MonitoringDataProviderRegistry(new[] { provider }), // 真实目录定义,不是测试 fake —— Catalog 里 PURCHASE_DELIVERY 的编码 / 能力 / // IsActive 若被改坏,本用例必须红。 new S8DatasetCatalog(new IS8DatasetDefinitionSource[] { new S8BusinessDatasetDefinitions() }), NullLogger.Instance); private static async Task> RunChainAsync(StubPurchaseDeliveryProvider provider) { var rule = Rule01(); var data = await Gateway(provider).LoadAsync( TenantId, rule, S8TimeoutRuleEvaluator.RuleTypeCode, maxRows: 1000); return S8TimeoutRuleEvaluator.EvaluateRows( data.RowSet, Effective(rule), TenantId, EvaluatedAt); } // ============================================================ // 三类判定:经完整链路,而非直接喂 EvaluateRows // ============================================================ /// Case A:未到期 → 不命中。真实样本 PO202608280001#1(due 2026-09-17 / OPEN)。 [Fact] public async Task CaseA_NotYetDue_ProducesNoHit() { var hits = await RunChainAsync(new StubPurchaseDeliveryProvider( ("PO202608280001", "1", new DateTime(2026, 9, 17), new DateTime(2026, 8, 9), "OPEN"))); Assert.Empty(hits); } /// Case B:已到期且未完成 → 命中。真实样本 PO202608260002#3(need 2026-08-17 / DELAYED)。 [Fact] public async Task CaseB_OverdueAndNotCompleted_ProducesHit() { var hits = await RunChainAsync(new StubPurchaseDeliveryProvider( ("PO202608260002", "3", new DateTime(2026, 8, 17, 4, 33, 7), new DateTime(2026, 9, 10), "DELAYED"))); var hit = Assert.Single(hits); Assert.Equal("PO202608260002#3", hit.SourceObjectId); Assert.Equal("PO202608260002", hit.RelatedObjectCode); Assert.Equal("PURCHASE_ORDER_LINE", hit.SourceObjectType); Assert.Equal(RuleCode, hit.SourceRuleCode); Assert.Equal("PURCHASE_DELIVERY_ABNORMAL", hit.ExceptionTypeCode); } /// Case C:已到期但已完成 → 不命中。判定必须由 evaluator 做出,Provider 不预先过滤。 [Fact] public async Task CaseC_OverdueButCompleted_ProducesNoHit() { var hits = await RunChainAsync(new StubPurchaseDeliveryProvider( ("PO202608260002", "1", new DateTime(2026, 8, 17, 4, 33, 7), new DateTime(2026, 9, 10), "COMPLETED"))); Assert.Empty(hits); } // ============================================================ // 混合快照:一次 tick 的真实形态 // ============================================================ /// /// 一个快照里三类行同时存在时,链路必须只挑出应命中的那一条。 /// 这条比三个单行用例更接近真实 tick —— 单行用例无法暴露"Provider 少返了行" /// 或"evaluator 把整批一起放过"这类整体性错误。 /// [Fact] public async Task MixedSnapshot_SelectsOnlyOverdueIncomplete() { var hits = await RunChainAsync(new StubPurchaseDeliveryProvider( ("PO-A", "1", new DateTime(2026, 8, 1), null, "DELAYED"), // 逾期未完成 → 命中 ("PO-A", "2", new DateTime(2026, 8, 1), null, "COMPLETED"), // 逾期已完成 → 不命中 ("PO-B", "1", new DateTime(2026, 12, 1), null, "OPEN"), // 未到期 → 不命中 ("PO-C", "1", new DateTime(2026, 8, 1), null, "PARTIAL"))); // 部分交付 → 命中(非终态) Assert.Equal(2, hits.Count); Assert.Equal(new[] { "PO-A#1", "PO-C#1" }, hits.Select(h => h.SourceObjectId).OrderBy(x => x).ToArray()); } // ============================================================ // 链路纯净性:不得出现任何 Legacy 概念 // ============================================================ /// /// 同一对象跨两次刷新,DedupKey 必须逐字不变 —— 否则同一条延期会在每个快照日 /// 重复建单,而不是刷新既有异常。这是 Rule 01 上线后最贵的错误形态。 /// [Fact] public async Task DedupKey_IsStableAcrossRefreshes() { var row = ("PO202608260002", "3", (DateTime?)new DateTime(2026, 8, 17, 4, 33, 7), (DateTime?)null, "DELAYED"); var first = await RunChainAsync(new StubPurchaseDeliveryProvider(row)); var second = await RunChainAsync(new StubPurchaseDeliveryProvider(row)); Assert.Equal(Assert.Single(first).DedupKey, Assert.Single(second).DedupKey); Assert.Contains(RuleCode, Assert.Single(first).DedupKey); Assert.Contains("PO202608260002#3", Assert.Single(first).DedupKey); } /// /// Rule 01 走完整链路时不得依赖任何 Legacy 字段。 /// 实体上这三个属性已被物理删除(1.0.487 同步删列),此处用反射钉住不会被加回来 —— /// 一旦有人恢复 Expression,"S8 不执行任意 SQL" 这条边界就重新出现缺口。 /// [Fact] public void Chain_CarriesNoLegacyConcepts() { var props = typeof(AdoS8WatchRule).GetProperties().Select(p => p.Name).ToArray(); Assert.DoesNotContain("Expression", props); Assert.DoesNotContain("DataSourceId", props); Assert.DoesNotContain("DataAccessMode", props); // Gateway 的依赖里不得出现任何能执行 SQL 的东西。 var deps = Assert.Single(typeof(S8MonitoringDataGateway).GetConstructors()) .GetParameters().Select(p => p.ParameterType.Name).ToArray(); Assert.DoesNotContain("IS8LegacySqlDataProvider", deps); Assert.DoesNotContain("ISqlSugarClient", deps); } /// /// 目录侧自证:Rule 01 依赖的 dataset 必须是 active 且声明支持 TIMEOUT。 /// 若有人把 PURCHASE_DELIVERY 停用或去掉 SupportsTimeout,链路会在 Gateway 处失败, /// 但失败原因埋在异常文本里;这条把它变成显式断言。 /// [Fact] public void Catalog_DeclaresPurchaseDeliveryReadyForTimeout() { var catalog = new S8DatasetCatalog(new IS8DatasetDefinitionSource[] { new S8BusinessDatasetDefinitions() }); var definition = catalog.Find(PurchaseDelivery); Assert.NotNull(definition); Assert.True(definition!.IsActive); Assert.True(definition.Capabilities.SupportsTimeout); Assert.Empty(definition.Capabilities.MissingFieldsFor(S8TimeoutRuleEvaluator.RuleTypeCode)); // 用户可见的是业务名,不是物理表名。 Assert.Equal("采购交付(订单行级)", definition.DisplayName); Assert.DoesNotContain("dwd_", definition.DisplayName); Assert.DoesNotContain("mdp_", definition.DisplayName); } }