Selaa lähdekoodia

feat(s8): 钉死 PURCHASE_DELIVERY 业务语义并补齐 Rule 01 端到端链路契约

Rule 01 的 Provider / Catalog / Evaluator / Rule 配置在基座批次已落地。本批做的是
把「业务语义靠什么成立」从注释里的待确认状态,变成有血缘证据、有回归护栏的事实。

一、due_at 语义完成血缘取证(Dataset Description 升级)
原描述把 due_at 标为 UAT_EXECUTION_MAPPING / 业务语义待生产确认。追 transform 后确认:
  · dwd.need_date := COALESCE(交付计划 MIN(need_date), 采购订单 need_date)
  · dwd.due_date  := 采购订单 due_date 原样
  · delivery_status 的 DELAYED 判据 := COALESCE(ds.need_date, po.need_date, po.due_date) < now
Provider 的 COALESCE(need_date, due_date) 展开后与该判据**逐字等价**,不是另起口径。
实测佐证:UAT 租户最新快照 36 行,evaluator 命中 28 条,与中台自标 DELAYED 28 条完全一致。

但只证到这里。need_date 是否等同于「与供应商约定的承诺交期」仍未证——需追至源系统
PurOrdDetail.NeedDate 并由业务确认。故 Description 改为同时钉住两面,新增
UNPROVEN_CONTRACT_SEMANTICS 标记;对应断言由「锁 UAT_EXECUTION_MAPPING」拆成
「锁 同源等价 + 锁 未证标记」。只留其一都会误导:前者让人以为语义已全部拍板,
后者丢掉本批建立的证据。

二、补齐端到端链路测试(S8PurchaseDeliveryEndToEndTests,7 例)
既有 Mapping 套件把 Provider 映射与 Evaluator 判定**分开**验证,中间的 Catalog 查找、
能力校验、Provider 解析、RowSet 传递没有任何用例覆盖。本文件走完整链路:
  dataset_code → 真实 S8DatasetCatalog → Registry → Provider → canonical → TIMEOUT → hit
  · Case A 未到期不命中 / Case B 逾期未完成命中 / Case C 逾期已完成不命中
  · 混合快照只挑出应命中的两条(单行用例查不出「少返行」「整批放过」这类整体性错误)
  · DedupKey 跨刷新逐字不变(这是上线后最贵的错误形态:每个快照日重复建单)
  · 链路不携带任何 Legacy 概念(实体三列不得复活 + Gateway 依赖不含可执行 SQL 的组件)
  · Catalog 自证 PURCHASE_DELIVERY 处于 active 且支持 TIMEOUT,显示名为业务名不含物理表名
Provider 用 stub 而非真实类:真实类需 ISqlSugarClient 与真库,而本套件不接 DB;
其 SQL→canonical 映射已由 Mapping 套件用真实样本逐字段锁定,两者合起来才是完整证据。
stub 复用真实 Catalog 定义与真实能力声明,故 Catalog / Capability / Gateway 三段走生产代码。

三、本批复核但未改动的既有结论(重新取证后确认原实现正确)
  · status 域 = COMPLETED/DELAYED/OPEN/CLOSED/CANCELLED/PARTIAL(三写入方合并)。
    completedStates 取 COMPLETED/CLOSED/CANCELLED 三个终态有据:CLOSED=RestQty<=0、
    CANCELLED=源状态 C;PARTIAL 刻意排除(部分交付未终结,应继续告警)。
    我一度以为 CLOSED/CANCELLED 是猜测残留、准备改参数,展开三个写入方后确认原配置正确,
    遂不改——记此一笔以免后人重走弯路。
  · 粒度 = PO+Line,实测 36 行身份全唯一、跨 21 个快照日每身份每日恰 1 行,无需 GROUP BY。
  · 身份跨租户碰撞 0;DedupKey 最长 105 ≤ 列宽 128。
  · factory_id 在全表恒为 1(transform 的 COALESCE(NULLIF(po.factory_id,0),1) 兜底常量),
    故仍按已拍板口径只以 tenant 作隔离边界。

不改 Rule 01 参数、不改 enabled、不新增 migration、未触碰冻结的 1.0.486/487。
YY968XX 1 viikko sitten
vanhempi
commit
9c92987e4a

+ 15 - 2
server/Admin.NET.Test/S8/S8PurchaseDeliveryDatasetTests.cs

@@ -54,8 +54,21 @@ public class S8PurchaseDeliveryDatasetTests
         Assert.Equal(S8DatasetKind.Object, definition.Kind);
         Assert.Equal(S8DatasetStatus.Defined, definition.Status);
         Assert.True(definition.IsActive);
-        // Description 必须写明 UAT 映射身份,避免后人把 due_at 当成生产业务合同。
-        Assert.Contains("UAT_EXECUTION_MAPPING", definition.Description);
+        // Description 必须同时钉住「已证」与「未证」两面,否则 due_at 会被误当成交付合同依据。
+        //
+        // S8-RULE01-PURCHASE-DELIVERY-CLOSURE-1:原断言锁的是 UAT_EXECUTION_MAPPING 标记,
+        // 语义是「due_at 是 UAT 期的执行映射,业务语义待确认」。本批完成血缘取证后,
+        // 该标记只对了一半,故拆成两条分别断言:
+        //
+        //   ① 已证:Provider 的 COALESCE(need_date, due_date) 与中台自身 DELAYED 判据
+        //      COALESCE(ds.need_date, po.need_date, po.due_date) 逐字等价
+        //      (S3MdpSyncTransformService 的 dwd_supplier_delivery INSERT)。
+        //   ② 仍未证:need_date 是否等同于「与供应商约定的承诺交期」——
+        //      这要追到源系统 PurOrdDetail.NeedDate 并由业务确认。
+        //
+        // 只留 ① 会让后人以为语义已经全部拍板;只留 ② 则丢掉了本批真正建立的证据。
+        Assert.Contains("同源等价", definition.Description);
+        Assert.Contains("UNPROVEN_CONTRACT_SEMANTICS", definition.Description);
     }
 
     [Fact]

+ 252 - 0
server/Admin.NET.Test/S8/S8PurchaseDeliveryEndToEndTests.cs

@@ -0,0 +1,252 @@
+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-CLOSURE-1:Rule 01 的**端到端链路**契约。
+///
+/// <b>与既有 S8PurchaseDeliveryProviderMappingTests 的分工</b>(不是重复覆盖):
+/// 那个文件把 Provider 映射与 Evaluator 判定**分开**验证 —— canonical 映射直接构造行,
+/// 判定直接调 <c>EvaluateRows</c>。两端各自正确,不等于串起来正确:
+/// 中间还隔着 Catalog 查找、能力校验、Provider 解析、RowSet 传递。
+/// 那一段目前没有任何用例覆盖,本文件补的就是它。
+///
+/// 链路:
+/// <code>
+///   dataset_code
+///     → S8DatasetCatalog(真实 S8BusinessDatasetDefinitions,非 fake 定义)
+///     → S8MonitoringDataProviderRegistry
+///     → Provider.LoadAsync → S8MonitoringRowSet(canonical)
+///     → S8TimeoutRuleEvaluator → S8RuleHit
+/// </code>
+///
+/// <b>Provider 用 stub 而非真 S8PurchaseDeliveryDataProvider</b>:后者需要 ISqlSugarClient 与真库,
+/// 而本套件的硬约束是不接 DB。stub 只负责"吐出与真 Provider 相同形状的 canonical 行"——
+/// 真 Provider 的 SQL→canonical 映射由 Mapping 套件用真实样本逐字段锁定,
+/// 两者合起来才构成完整证据。stub 刻意复用真实 Catalog 定义与真实能力声明,
+/// 因此 Catalog / Capability / Gateway 校验这几段走的都是**生产代码本身**。
+///
+/// 全程不出现:SQL expression、Legacy Provider、物理数据源配置、rule.Expression。
+/// </summary>
+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;
+
+    /// <summary>评估基准时刻。固定值而非 DateTime.Now:判定结果必须可复现。</summary>
+    private static readonly DateTime EvaluatedAt = new(2026, 9, 6, 12, 0, 0);
+
+    /// <summary>Rule 01 在库中的真实 params_json(逐字,含三个终态)。</summary>
+    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"}
+        """);
+
+    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 = "{}"
+    };
+
+    /// <summary>
+    /// 吐出与真 Provider 同形状 canonical 行的 stub。
+    /// 能力声明取自 <see cref="S8BusinessDatasetDefinitions"/> 的同一实例,
+    /// 保证 Gateway 的第 ⑨ 步(Provider 能力 vs 目录能力)走真实比对而非自证。
+    /// </summary>
+    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<S8MonitoringRowSet> 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<S8MonitoringDataGateway>.Instance);
+
+    private static async Task<List<S8RuleHit>> RunChainAsync(StubPurchaseDeliveryProvider provider)
+    {
+        var rule = Rule01();
+        var data = await Gateway(provider).LoadAsync(
+            TenantId, FactoryId, rule, S8TimeoutRuleEvaluator.RuleTypeCode, maxRows: 1000);
+
+        return S8TimeoutRuleEvaluator.EvaluateRows(
+            data.RowSet, Params(), rule, TenantId, FactoryId, EvaluatedAt);
+    }
+
+    // ============================================================
+    // 三类判定:经完整链路,而非直接喂 EvaluateRows
+    // ============================================================
+
+    /// <summary>Case A:未到期 → 不命中。真实样本 PO202608280001#1(due 2026-09-17 / OPEN)。</summary>
+    [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);
+    }
+
+    /// <summary>Case B:已到期且未完成 → 命中。真实样本 PO202608260002#3(need 2026-08-17 / DELAYED)。</summary>
+    [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);
+    }
+
+    /// <summary>Case C:已到期但已完成 → 不命中。判定必须由 evaluator 做出,Provider 不预先过滤。</summary>
+    [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 的真实形态
+    // ============================================================
+
+    /// <summary>
+    /// 一个快照里三类行同时存在时,链路必须只挑出应命中的那一条。
+    /// 这条比三个单行用例更接近真实 tick —— 单行用例无法暴露"Provider 少返了行"
+    /// 或"evaluator 把整批一起放过"这类整体性错误。
+    /// </summary>
+    [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 概念
+    // ============================================================
+
+    /// <summary>
+    /// 同一对象跨两次刷新,DedupKey 必须逐字不变 —— 否则同一条延期会在每个快照日
+    /// 重复建单,而不是刷新既有异常。这是 Rule 01 上线后最贵的错误形态。
+    /// </summary>
+    [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);
+    }
+
+    /// <summary>
+    /// Rule 01 走完整链路时不得依赖任何 Legacy 字段。
+    /// 实体上这三个属性已被物理删除(1.0.487 同步删列),此处用反射钉住不会被加回来 ——
+    /// 一旦有人恢复 Expression,"S8 不执行任意 SQL" 这条边界就重新出现缺口。
+    /// </summary>
+    [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);
+    }
+
+    /// <summary>
+    /// 目录侧自证:Rule 01 依赖的 dataset 必须是 active 且声明支持 TIMEOUT。
+    /// 若有人把 PURCHASE_DELIVERY 停用或去掉 SupportsTimeout,链路会在 Gateway 处失败,
+    /// 但失败原因埋在异常文本里;这条把它变成显式断言。
+    /// </summary>
+    [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);
+    }
+}

+ 18 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/Providers/S8BusinessDatasetDefinitions.cs

@@ -57,11 +57,26 @@ public sealed class S8BusinessDatasetDefinitions : IS8DatasetDefinitionSource
             Kind = S8DatasetKind.Object,
             Status = S8DatasetStatus.Defined,
             Capabilities = PurchaseDeliveryCapabilities,
+            // S8-RULE01-PURCHASE-DELIVERY-CLOSURE-1:due_at 的业务语义已完成血缘取证,
+            // 从「UAT_EXECUTION_MAPPING / 待生产确认」升级为「与中台自身逾期判据同源」。
+            //
+            // 证据链(S3MdpSyncTransformService.cs 的 dwd_supplier_delivery INSERT):
+            //   · 落库 need_date := COALESCE(ds.need_date, po.need_date)
+            //     其中 ds = mdp_std_delivery_schedule 按 (tenant,factory,po_no,po_line) 聚合的 MIN(need_date)
+            //   · 落库 due_date  := po.due_date 原样
+            //   · delivery_status 的 DELAYED 判据 := COALESCE(ds.need_date, po.need_date, po.due_date) < now
+            // 因此 Provider 的 COALESCE(need_date, due_date) 展开后与该判据**逐字等价**,
+            // 不是另起一套口径。实测佐证:UAT 租户最新快照 36 行中,evaluator 判定命中 28 条,
+            // 与中台自行标记的 DELAYED 28 条完全一致(COMPLETED 7 / OPEN 1 均正确不命中)。
             Description =
-                "来源 dwd_supplier_delivery;粒度为采购订单行(po_no + po_line);" +
+                "来源 dwd_supplier_delivery;粒度为采购订单行(po_no + po_line,实测每快照日唯一);" +
                 "按 Tenant 最新 stat_date 收敛(factory 不参与本数据集过滤,见 Provider 注释);" +
-                "due_at = COALESCE(need_date, due_date) 为 UAT_EXECUTION_MAPPING," +
-                "业务语义待生产确认,不等同于「正式承诺交期」。"
+                "due_at = COALESCE(need_date, due_date),已证与数据中台自身的 DELAYED 判据 " +
+                "COALESCE(交付计划 need_date, 采购订单 need_date, 采购订单 due_date) 同源等价;" +
+                "status 域 = COMPLETED / DELAYED / OPEN / CLOSED / CANCELLED / PARTIAL(三个写入方合并)。" +
+                "⚠️ UNPROVEN_CONTRACT_SEMANTICS:上述只证明了 S8 与中台口径一致," +
+                "并未证明 need_date 等同于与供应商约定的承诺交期 —— 该问题需追至源系统 " +
+                "PurOrdDetail.NeedDate 并由业务确认,在此之前 due_at 不得被当作交付合同依据。"
         }
     };
 }