소스 검색

feat(s8): 接入首个 STANDARD_DATASET 数据集 PURCHASE_DELIVERY 与采购交期规则

S8 第一条真实走数据中台的监控规则:dwd_supplier_delivery → PURCHASE_DELIVERY
Dataset → Provider → Canonical → 现有 TIMEOUT Evaluator,规则以 disabled 创建。

隔离边界(Phase 1 拍板):
- Security Boundary = Tenant,Provider 按 tenant_id 过滤并按该租户自身最新
  stat_date 收敛快照;
- Factory = Business Context,仍保留在 watch_rule / exception / 部门解析中,
  但不作为本数据集的过滤条件——dwd_supplier_delivery.factory_id 是写死的常量 1,
  与 S8 侧真实 SysOrg.Id 语义不同,强行相等匹配会让规则恒返 0 行。

能力声明只覆盖可信可用的机制:SupportsTimeout + 四个 canonical 字段。
刻意不声明数量类能力——remaining_qty 已证不可信(781/784 行恒等于 order_qty),
它与 order_qty 仅随 payload 落盘供排查,绝不参与判定,也不出现在 WHERE 中。

Provider 只做「作用域 → 快照收敛 → canonical 映射」,不判定、不预筛:
若先滤掉已完成行,「逾期但已完成不命中」就退化成 Provider 把它藏起来,
且该行不在 hits 里会被恢复对账误判为已恢复。

快照收敛用 ORDER BY stat_date DESC LIMIT 1 而非 MAX():idx_stat_date 不含
factory_id,聚合写法在大租户上实测 143ms,改反向索引扫描后 2.7ms。

due_at = COALESCE(need_date, due_date) 属 UAT_EXECUTION_MAPPING,业务语义未定,
故额外输出 due_at_source 逐行记录实际使用的日期基准,便于日后复盘。

Provider 刻意不实现 ITransient:Furion 自动扫描会按接口再注册一份,而 Registry
以 IEnumerable 注入,同一类型出现两次将触发「注册了多个 Provider」;因均为
Transient,该异常在首次解析时才发生,应用启动毫无异常——已补类型层守卫测试。

chore: bump version server 1.0.481
YY968XX 1 일 전
부모
커밋
188cafbae1

+ 249 - 0
server/Admin.NET.Test/S8/S8PurchaseDeliveryDatasetTests.cs

@@ -0,0 +1,249 @@
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers;
+using Xunit;
+
+namespace Admin.NET.Test.S8;
+
+/// <summary>
+/// S8-RULE01-PURCHASE-DELIVERY-A:PURCHASE_DELIVERY 是 S8 第一个真实业务数据集,
+/// 本文件锁定它在**目录层**的契约。锁定项与失败后果:
+///
+///   A. 数据集必须在 Catalog 中定义        → 否则规则连保存都过不了(dataset_not_defined),
+///                                            而这个失败只在创建规则时才暴露,届时已在配置界面上。
+///   B. TIMEOUT 能力必须机制层 + 字段层齐备 → 只写 SupportsTimeout 而漏 HasDueAt 的配置能存下来,
+///                                            运行期表现为"永不命中"的静默失效,最难排查。
+///   C. SHORTAGE / OUT_OF_RANGE 必须被拒   → remaining_qty 已证不可信(781/784 恒等于 order_qty);
+///                                            若声明数量能力,Enable Gate 会放行 SHORTAGE 规则,
+///                                            把不可信数据变成可启用能力。
+///   D. 目录与 Provider 的能力必须同源      → 各写一份字面量会漂移,而漂移只在启用那一刻才报
+///                                            dataset_capability_mismatch。
+///   E. 重复 DatasetCode 必须抛异常         → Catalog 是 AddTransient,重复登记的失败形态是
+///                                            "首次解析时 500",**不是启动崩溃**。启动日志干净 ≠ 目录健康。
+///
+/// 不接 DB、不接 DI、不触发任何真实规则。
+/// </summary>
+public class S8PurchaseDeliveryDatasetTests
+{
+    private const string PurchaseDelivery = S8BusinessDatasetDefinitions.PurchaseDeliveryCode;
+
+    private static IS8DatasetCatalog Catalog() =>
+        new S8DatasetCatalog(new IS8DatasetDefinitionSource[] { new S8BusinessDatasetDefinitions() });
+
+    private static AdoS8WatchRule StandardRule(string ruleType = "TIMEOUT") => new()
+    {
+        RuleCode = "UT_PURCHASE_DELIVERY",
+        RuleType = ruleType,
+        DataAccessMode = S8DataAccessMode.StandardDataset,
+        DatasetCode = PurchaseDelivery
+    };
+
+    // ============================================================
+    // A. 数据集定义
+    // ============================================================
+
+    [Fact]
+    public void T1_Catalog_Defines_PurchaseDelivery()
+    {
+        var catalog = Catalog();
+
+        Assert.True(catalog.IsDefined(PurchaseDelivery));
+
+        var definition = catalog.Find(PurchaseDelivery);
+        Assert.NotNull(definition);
+        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);
+    }
+
+    [Fact]
+    public void T1b_Catalog_DatasetCode_IsResolvedCaseInsensitively()
+    {
+        // Catalog 用 OrdinalIgnoreCase;规则里写小写不应变成"数据集不存在"。
+        Assert.True(Catalog().IsDefined("purchase_delivery"));
+    }
+
+    // ============================================================
+    // B/C. 能力声明:正反两面都必须覆盖
+    // ============================================================
+
+    [Fact]
+    public void T3_Capabilities_Satisfy_Timeout()
+    {
+        var capabilities = Catalog().Find(PurchaseDelivery).Capabilities;
+
+        Assert.True(capabilities.Supports("TIMEOUT"));
+        Assert.Empty(capabilities.MissingFieldsFor("TIMEOUT"));
+        Assert.True(capabilities.CanServe("TIMEOUT"));
+    }
+
+    [Fact]
+    public void T3b_Capabilities_Reject_ShortageAndOutOfRange()
+    {
+        var capabilities = Catalog().Find(PurchaseDelivery).Capabilities;
+
+        // 机制层就不支持,因此连字段层都不必看。
+        Assert.False(capabilities.Supports("SHORTAGE"));
+        Assert.False(capabilities.Supports("OUT_OF_RANGE"));
+        Assert.False(capabilities.CanServe("SHORTAGE"));
+        Assert.False(capabilities.CanServe("OUT_OF_RANGE"));
+    }
+
+    [Fact]
+    public void T3c_Capabilities_DoNotClaimQuantityFields()
+    {
+        // 物理表有 order_qty / remaining_qty,但 remaining_qty 已证不可信。
+        // Capability = "当前可可信用于机制判断",不是"表里有这一列"。
+        var capabilities = Catalog().Find(PurchaseDelivery).Capabilities;
+
+        Assert.False(capabilities.HasTargetQty);
+        Assert.False(capabilities.HasActualQty);
+        Assert.False(capabilities.HasMeasuredValue);
+    }
+
+    [Fact]
+    public void T3d_Definition_And_Provider_ShareSameCapabilityInstance()
+    {
+        // 同一实例而非同值:值相等只能证明"此刻没漂",同实例才能保证"不会漂"。
+        var definition = Catalog().Find(PurchaseDelivery);
+
+        Assert.Same(S8BusinessDatasetDefinitions.PurchaseDeliveryCapabilities, definition.Capabilities);
+    }
+
+    // ============================================================
+    // E. 重复登记
+    // ============================================================
+
+    [Fact]
+    public void DuplicateDatasetCode_ThrowsOnCatalogConstruction()
+    {
+        // 两个 source 给出同一个 code。注意:Catalog 注册为 AddTransient,
+        // 因此现实中这会表现为"首次解析该服务的请求 500",而不是应用启动失败。
+        var ex = Assert.Throws<InvalidOperationException>(() => new S8DatasetCatalog(
+            new IS8DatasetDefinitionSource[]
+            {
+                new S8BusinessDatasetDefinitions(),
+                new S8BusinessDatasetDefinitions()
+            }));
+
+        Assert.Contains(PurchaseDelivery, ex.Message);
+    }
+
+    [Fact]
+    public void Provider_MustNotSelfRegisterViaITransient()
+    {
+        // 实测事故(Batch A 运行态复现):Provider 同时带 ITransient 与 Startup 显式注册时,
+        // Furion 自动扫描会按接口再注册一份。Registry 以 IEnumerable<IS8MonitoringDataProvider>
+        // 注入,于是同一类型出现两次 → "数据集 PURCHASE_DELIVERY 注册了多个 Provider"。
+        // 因两者均为 Transient,应用启动毫无异常,直到第一个碰它的请求返回 500。
+        // 单元测试无法覆盖 DI 容器装配,因此在类型层面钉死这条约束。
+        Assert.DoesNotContain(
+            typeof(S8PurchaseDeliveryDataProvider).GetInterfaces(),
+            i => i.Name is "ITransient" or "IScoped" or "ISingleton");
+    }
+
+    // ============================================================
+    // F. Enable Gate:目录 + 注册表联合判定
+    // ============================================================
+
+    [Fact]
+    public void T2_Registry_Resolves_PurchaseDeliveryProvider()
+    {
+        var registry = new S8MonitoringDataProviderRegistry(
+            new IS8MonitoringDataProvider[] { new StubPurchaseDeliveryProvider() });
+
+        Assert.True(registry.IsRegistered(PurchaseDelivery));
+        // Gate ⑧ 会逐字比对 Provider 自报编码与目录定义,必须完全一致。
+        Assert.Equal(PurchaseDelivery, registry.Resolve(PurchaseDelivery).DatasetCode);
+    }
+
+    [Fact]
+    public void EnableGate_Passes_WhenCatalogAndProviderBothPresent()
+    {
+        var gate = new S8DatasetEnableGate(
+            Catalog(),
+            new S8MonitoringDataProviderRegistry(
+                new IS8MonitoringDataProvider[] { new StubPurchaseDeliveryProvider() }));
+
+        var result = gate.Check(StandardRule(), tenantId: 838257186181189, factoryId: 838257186320453);
+
+        Assert.True(result.Ok);
+        Assert.Null(result.ReasonCode);
+    }
+
+    [Fact]
+    public void EnableGate_Rejects_WhenProviderNotRegistered()
+    {
+        // 数据集已定义但 Provider 未上线:允许存草稿,不允许启用。
+        var gate = new S8DatasetEnableGate(
+            Catalog(),
+            new S8MonitoringDataProviderRegistry(Array.Empty<IS8MonitoringDataProvider>()));
+
+        var result = gate.Check(StandardRule(), 838257186181189, 838257186320453);
+
+        Assert.False(result.Ok);
+        Assert.Equal(S8DatasetReasonCode.ProviderNotRegistered, result.ReasonCode);
+    }
+
+    [Fact]
+    public void EnableGate_Rejects_ShortageRule_WithCapabilityMismatch()
+    {
+        // 目录层就能判定的问题必须先于 Provider 层报出:
+        // 报 provider_not_registered 会误导配置方以为"等实现就行",但这个数据集永远做不了 SHORTAGE。
+        var gate = new S8DatasetEnableGate(
+            Catalog(),
+            new S8MonitoringDataProviderRegistry(
+                new IS8MonitoringDataProvider[] { new StubPurchaseDeliveryProvider() }));
+
+        var result = gate.Check(StandardRule("SHORTAGE"), 838257186181189, 838257186320453);
+
+        Assert.False(result.Ok);
+        Assert.Equal(S8DatasetReasonCode.CapabilityMismatch, result.ReasonCode);
+        Assert.NotEqual(S8DatasetReasonCode.ProviderNotRegistered, result.ReasonCode);
+    }
+
+    [Fact]
+    public void DraftSave_Allowed_ForStandardRule_ReferencingPurchaseDelivery()
+    {
+        // 不抛异常本身即断言。
+        S8WatchRuleDataAccessValidator.ValidateForSave(StandardRule(), Catalog());
+    }
+
+    [Fact]
+    public void DraftSave_Rejected_WhenDatasetCodeMisspelled()
+    {
+        var rule = StandardRule();
+        rule.DatasetCode = "PURCHASE_DELIVERY_TYPO";
+
+        var ex = Assert.Throws<S8BizException>(() =>
+            S8WatchRuleDataAccessValidator.ValidateForSave(rule, Catalog()));
+        Assert.Contains(S8DatasetReasonCode.NotDefined, ex.Message);
+    }
+
+    [Fact]
+    public void StandardDatasetRule_DoesNotRequireDataSource()
+    {
+        // dataSourceId = 0 之所以合法,靠的就是这一条;若回归成 true,创建规则会因"数据源不存在"失败。
+        Assert.False(S8WatchRuleDataAccessValidator.RequiresDataSource(StandardRule()));
+    }
+
+    /// <summary>
+    /// 目录层测试用的最小 Provider:只回答编码与能力,不取数。
+    /// 取数与映射由 S8PurchaseDeliveryProviderMappingTests 覆盖。
+    /// </summary>
+    private sealed class StubPurchaseDeliveryProvider : IS8MonitoringDataProvider
+    {
+        public string DatasetCode => S8BusinessDatasetDefinitions.PurchaseDeliveryCode;
+        public S8DatasetCapabilities Capabilities => S8BusinessDatasetDefinitions.PurchaseDeliveryCapabilities;
+
+        public Task<S8MonitoringRowSet> LoadAsync(
+            S8MonitoringDataRequest request, CancellationToken cancellationToken = default)
+        {
+            request.EnsureValid();
+            return Task.FromResult(S8MonitoringRowSet.Empty);
+        }
+    }
+}

+ 494 - 0
server/Admin.NET.Test/S8/S8PurchaseDeliveryProviderMappingTests.cs

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

+ 3 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.480</AssemblyVersion>
-    <FileVersion>1.0.480</FileVersion>
-    <Version>1.0.480</Version>
+    <AssemblyVersion>1.0.481</AssemblyVersion>
+    <FileVersion>1.0.481</FileVersion>
+    <Version>1.0.481</Version>
   </PropertyGroup>
   </PropertyGroup>
 
 
   <ItemGroup>
   <ItemGroup>

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

@@ -0,0 +1,67 @@
+namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers;
+
+/// <summary>
+/// S8-RULE01-PURCHASE-DELIVERY-A:业务数据集定义的集中登记处。
+///
+/// <b>为什么集中一处而不是每个数据集一个 source 类</b>:
+/// <see cref="S8DatasetCatalog"/> 对重复 DatasetCode 抛 <c>InvalidOperationException</c>,
+/// 而它注册为 <c>AddTransient</c> —— 重复登记的失败形态是「首次解析该服务的那个请求 500」,
+/// <b>不是启动崩溃</b>。启动日志干净 ≠ 目录健康。集中在一个数组里,重复项人工一眼可查;
+/// 将来新增数据集只加一个元素,不必再改 <c>Startup.cs</c>。
+///
+/// <b>本类只回答「系统认知中存在哪些数据集、各自能力如何」</b>,不回答「谁供数」——
+/// 后者是 <see cref="S8MonitoringDataProviderRegistry"/> 的职责。二者分离才能表达
+/// 「数据集已定义、规则可存草稿、但 Provider 未上线因此不可启用」这个必要的中间状态。
+/// </summary>
+public sealed class S8BusinessDatasetDefinitions : IS8DatasetDefinitionSource
+{
+    /// <summary>采购交付(订单行级)数据集编码。Provider 与规则的 dataset_code 必须逐字引用本常量。</summary>
+    public const string PurchaseDeliveryCode = "PURCHASE_DELIVERY";
+
+    /// <summary>
+    /// 采购交付数据集的能力声明。<b>唯一来源</b>:
+    /// <see cref="S8PurchaseDeliveryDataProvider"/> 直接引用本字段,不另写一份字面量。
+    ///
+    /// 目录与 Provider 各写一份会漂移,而漂移只在 <see cref="S8DatasetEnableGate"/> 第 ⑨ 步
+    /// (运行期启用时)才被发现,报 <c>dataset_capability_mismatch</c>——那时已经晚了。
+    ///
+    /// <b>刻意不声明数量类能力</b>(SupportsShortage / HasTargetQty / HasActualQty):
+    /// <c>dwd_supplier_delivery</c> 物理上有 <c>order_qty</c> / <c>remaining_qty</c>,但字段级审计
+    /// 已证 <c>remaining_qty</c> 在 781/784 行恒等于 <c>order_qty</c>(收货量在源系统、STD、DWD 三层全断),
+    /// 不可作为机制判断依据。Capability 的语义是「当前可可信用于机制判断」,不是「表里有这一列」;
+    /// 声明 true 会让 Enable Gate 放行 SHORTAGE 规则,把不可信数据变成可启用能力。
+    /// 两列仍随 payload 落盘,仅供排查与后续数据治理观察。
+    /// </summary>
+    internal static readonly S8DatasetCapabilities PurchaseDeliveryCapabilities = new()
+    {
+        SupportsTimeout = true,
+        SupportsShortage = false,
+        SupportsOutOfRange = false,
+
+        HasSourceObjectId = true,
+        HasRelatedObjectCode = true,
+        HasDueAt = true,
+        HasStatus = true,
+
+        HasMeasuredValue = false,
+        HasTargetQty = false,
+        HasActualQty = false
+    };
+
+    public IEnumerable<S8DatasetDefinition> GetDefinitions() => new[]
+    {
+        new S8DatasetDefinition
+        {
+            DatasetCode = PurchaseDeliveryCode,
+            DisplayName = "采购交付(订单行级)",
+            Kind = S8DatasetKind.Object,
+            Status = S8DatasetStatus.Defined,
+            Capabilities = PurchaseDeliveryCapabilities,
+            Description =
+                "来源 dwd_supplier_delivery;粒度为采购订单行(po_no + po_line);" +
+                "按 Tenant 最新 stat_date 收敛(factory 不参与本数据集过滤,见 Provider 注释);" +
+                "due_at = COALESCE(need_date, due_date) 为 UAT_EXECUTION_MAPPING," +
+                "业务语义待生产确认,不等同于「正式承诺交期」。"
+        }
+    };
+}

+ 230 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/Providers/S8PurchaseDeliveryDataProvider.cs

@@ -0,0 +1,230 @@
+using Microsoft.Extensions.Logging;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers;
+
+/// <summary>
+/// S8-RULE01-PURCHASE-DELIVERY-A:<c>PURCHASE_DELIVERY</c> 数据集的取数 Provider。
+/// S8 第一个真实 STANDARD_DATASET Provider。
+///
+/// <b>职责边界</b>:只做「租户作用域 → 最新快照收敛 → DWD 行 → canonical 行」。
+/// 不判断是否异常、不判 severity、不建 Exception、不写 Detection、不决定 Recovery、
+/// 不读 <c>rule.Expression</c>、不读 HttpContext / UserManager、不回落 Legacy SQL、
+/// 不硬编码租户 / 工厂 / PO 号,也不修任何数据平台数据。
+/// 判定完全交给 <see cref="S8TimeoutRuleEvaluator"/>。
+///
+/// <b>为什么不在 SQL 里过滤 status / remaining_qty</b>:
+/// 若 Provider 先滤掉「已完成」行,那么「逾期但已完成不命中」这条契约就退化成
+/// 「Provider 把它藏起来了」,而不是「evaluator 判它不命中」——真正的判定逻辑测不到。
+/// 更要命的是:被滤掉的行不在 hits 里,<c>ReconcileRecoveriesForRuleAsync</c> 会把它当成
+/// 「不再命中」而正常触发 recovery,结果看似正确但恢复原因不可解释。
+/// 因此 Provider 返回当前快照的<b>全部</b> PO Line 事实,一行不滤。
+///
+/// <b>remaining_qty 仅作诊断</b>:字段级审计已证其不可信(781/784 行恒等于 order_qty),
+/// 绝不参与 Trigger,也绝不出现在 WHERE 中。
+/// </summary>
+/// <remarks>
+/// <b>刻意不实现 <c>ITransient</c></b>:Furion 的自动扫描会按接口再注册一次,而
+/// <see cref="S8MonitoringDataProviderRegistry"/> 是以 <c>IEnumerable&lt;IS8MonitoringDataProvider&gt;</c>
+/// 注入的——同一个类型会出现两份,注册表按"同 DatasetCode 重复注册"抛
+/// <c>InvalidOperationException</c>。且因两者均为 Transient,该异常在<b>首次解析时</b>才发生:
+/// 应用启动完全正常,直到第一个碰它的请求返回 500。
+/// (<c>S8DatasetCatalog</c> / <c>S8LegacySqlDataProvider</c> 同时带 ITransient 与显式注册却不出问题,
+/// 是因为它们按单一接口解析,后注册覆盖先注册,不走集合。)
+/// 本类只保留 <c>Startup.cs</c> 中的显式注册。
+/// </remarks>
+public sealed class S8PurchaseDeliveryDataProvider : IS8MonitoringDataProvider
+{
+    /// <summary>
+    /// 本 Provider 产出行对应的业务对象类型。
+    /// 仅用于 DedupKey 长度预算估算;运行期真正写入 hit 的是
+    /// <c>rule.SourceObjectType ?? rule.WatchObjectType</c>(见 <see cref="S8TimeoutRuleEvaluator"/>),
+    /// 由规则配置决定,本类不参与。两者应保持一致(规则配置为 PURCHASE_ORDER_LINE)。
+    /// </summary>
+    internal const string ExpectedSourceObjectType = "PURCHASE_ORDER_LINE";
+
+    /// <summary>
+    /// <c>ado_s8_exception.dedup_key</c> 的列长上限(<c>AdoS8Exception</c> 中 <c>Length = 128</c>)。
+    /// DedupKey 由 <c>S8TimeoutRuleEvaluator.BuildDedupKey</c> 拼成
+    /// <c>T{tenant}:F{factory}:R{ruleCode}:{objectType}:{objectId}</c>。
+    /// </summary>
+    internal const int DedupKeyMaxLength = 128;
+
+    /// <summary>
+    /// 取数 SQL。
+    ///
+    /// <b>作用域只按 tenant_id</b>(Phase 1 已拍板:Security Boundary = Tenant,Factory = Business Context)。
+    /// 刻意<b>不</b>加 <c>factory_id = @FactoryId</c>:<c>dwd_supplier_delivery.factory_id</c> 是写死的常量 1
+    /// (<c>S3MdpSyncTransformService</c> 的 <c>COALESCE(NULLIF(po.factory_id,0),1)</c> 与
+    /// <c>DeliveryScheduleService</c> 的字面量 1),与 S8 规则侧的真实 <c>SysOrg.Id</c> 语义不同,
+    /// 强行相等匹配会让每条规则恒返 0 行。客户运营模式为 1 Tenant = 1 Factory,
+    /// 因此租户过滤已构成完整的数据隔离边界。
+    ///
+    /// <b>快照收敛用 ORDER BY … DESC LIMIT 1 而非 MAX()</b>:<c>idx_stat_date</c> 为
+    /// <c>(tenant_id, stat_date)</c>,聚合写法在大租户上实测 143ms(扫 34,112 条索引项),
+    /// 改为反向索引扫描后 2.7ms(约 53×)。语义等价:无数据时同样返回 NULL → 外层 0 行。
+    ///
+    /// <c>po_no</c> / <c>po_line</c> 均为 NOT NULL,故只判空串;两者共同构成对象身份,
+    /// 任一为空会产生退化的 "#" 身份,必须排除。
+    /// </summary>
+    internal const string Sql =
+        """
+        SELECT
+            CONCAT(d.po_no, '#', d.po_line)                        AS source_object_id,
+            d.po_no                                                AS related_object_code,
+            COALESCE(NULLIF(d.supplier_name, ''), d.supplier_code) AS related_object_name,
+            COALESCE(d.need_date, d.due_date)                      AS due_at,
+            d.delivery_status                                      AS status,
+            CASE WHEN d.need_date IS NULL THEN 'due_date' ELSE 'need_date' END AS due_at_source,
+            d.po_no, d.po_line, d.po_type,
+            d.supplier_code, d.supplier_name,
+            d.item_code, d.item_name,
+            d.order_qty, d.remaining_qty,
+            d.need_date, d.due_date,
+            d.risk_level, d.stat_date
+        FROM dwd_supplier_delivery d
+        WHERE d.tenant_id = @TenantId
+          AND d.stat_date = (SELECT x.stat_date
+                             FROM dwd_supplier_delivery x
+                             WHERE x.tenant_id = @TenantId
+                             ORDER BY x.stat_date DESC
+                             LIMIT 1)
+          AND d.po_no <> ''
+          AND d.po_line <> ''
+        """;
+
+    private readonly ISqlSugarClient _db;
+    private readonly ILogger<S8PurchaseDeliveryDataProvider> _logger;
+
+    public S8PurchaseDeliveryDataProvider(
+        ISqlSugarClient db,
+        ILogger<S8PurchaseDeliveryDataProvider> logger)
+    {
+        _db = db;
+        _logger = logger;
+    }
+
+    public string DatasetCode => S8BusinessDatasetDefinitions.PurchaseDeliveryCode;
+
+    /// <summary>与 Catalog 引用同一实例,杜绝目录与实现的能力声明漂移。</summary>
+    public S8DatasetCapabilities Capabilities => S8BusinessDatasetDefinitions.PurchaseDeliveryCapabilities;
+
+    public async Task<S8MonitoringRowSet> LoadAsync(
+        S8MonitoringDataRequest request,
+        CancellationToken cancellationToken = default)
+    {
+        // 作用域必须由调用方显式给出;缺失时显式失败,绝不用默认值兜底
+        // (兜底会让跨租户读取以"看起来正常"的方式发生)。
+        request.EnsureValid();
+
+        var rows = await _db.Ado.SqlQueryAsync<PurchaseDeliveryRow>(
+            Sql,
+            new SugarParameter[] { new("@TenantId", request.TenantId) });
+
+        var budget = ResolveSourceObjectIdBudget(request);
+        var mapped = new List<S8MonitoringRow>(rows.Count);
+        var skipped = 0;
+
+        foreach (var row in rows)
+        {
+            var sourceObjectId = row.SourceObjectId ?? string.Empty;
+
+            // DedupKey 越界必须"响"而不是静默消失:数据库层的截断会让两个不同 PO Line
+            // 折叠成同一个异常,且没有任何痕迹。这里显式跳过并计数,收尾统一 warn。
+            if (sourceObjectId.Length > budget)
+            {
+                skipped++;
+                _logger.LogWarning(
+                    "s8_purchase_delivery_soid_over_dedup_budget rule={RuleCode} tenant={Tenant} " +
+                    "soid={Soid} length={Length} budget={Budget}",
+                    request.RuleCode, request.TenantId, sourceObjectId, sourceObjectId.Length, budget);
+                continue;
+            }
+
+            mapped.Add(S8MonitoringRow.FromValues(ToCanonicalValues(row)));
+        }
+
+        _logger.LogDebug(
+            "s8_purchase_delivery_loaded rule={RuleCode} tenant={Tenant} rows={Rows} skipped={Skipped}",
+            request.RuleCode, request.TenantId, mapped.Count, skipped);
+
+        return S8MonitoringRowSet.FromRows(mapped);
+    }
+
+    /// <summary>
+    /// 计算 <c>source_object_id</c> 在 DedupKey 中可用的字符预算。
+    ///
+    /// 预算<b>由 request 现算</b>而非写死常量:DedupKey 前缀长度随租户 / 工厂 / 规则编码变化,
+    /// 写死一个数字会在换规则或换租户时悄悄失效。
+    ///
+    /// 注意这个预算通常比 <c>source_object_id</c> 的列宽(64)更紧:
+    /// 15 位租户 + 15 位工厂 + 36 字符规则编码 + PURCHASE_ORDER_LINE 时前缀已占 92,只剩 36。
+    /// 落在 37–64 之间的键会过得了列校验、卡在 DedupKey 上,因此必须按本预算判定。
+    /// </summary>
+    internal static int ResolveSourceObjectIdBudget(S8MonitoringDataRequest request)
+    {
+        var prefixLength =
+            $"T{request.TenantId}:F{request.FactoryId}:R{request.RuleCode}:{ExpectedSourceObjectType}:".Length;
+        return Math.Max(0, DedupKeyMaxLength - prefixLength);
+    }
+
+    /// <summary>
+    /// DWD 行 → canonical 值。canonical 四列驱动 TIMEOUT 判定,其余列随 payload 落盘供追溯。
+    ///
+    /// <c>due_at_source</c> 必须保留:<c>due_at</c> 是 need_date / due_date 的混合映射
+    /// (实测该租户 18/36 行两者不同),若不逐行记录实际用了哪个基准,将来复盘无从下手。
+    /// </summary>
+    internal static Dictionary<string, object> ToCanonicalValues(PurchaseDeliveryRow row) => new(StringComparer.OrdinalIgnoreCase)
+    {
+        // canonical:evaluator 判定与身份
+        [S8CanonicalColumns.SourceObjectId] = row.SourceObjectId,
+        [S8CanonicalColumns.RelatedObjectCode] = row.RelatedObjectCode,
+        [S8CanonicalColumns.RelatedObjectName] = row.RelatedObjectName,
+        [S8CanonicalColumns.DueAt] = row.DueAt,
+        [S8CanonicalColumns.Status] = row.Status,
+
+        // 诊断 payload:不参与任何判定
+        ["due_at_source"] = row.DueAtSource,
+        ["po_no"] = row.PoNo,
+        ["po_line"] = row.PoLine,
+        ["po_type"] = row.PoType,
+        ["supplier_code"] = row.SupplierCode,
+        ["supplier_name"] = row.SupplierName,
+        ["item_code"] = row.ItemCode,
+        ["item_name"] = row.ItemName,
+        ["order_qty"] = row.OrderQty,
+        ["remaining_qty"] = row.RemainingQty,
+        ["need_date"] = row.NeedDate,
+        ["due_date"] = row.DueDate,
+        ["risk_level"] = row.RiskLevel,
+        ["stat_date"] = row.StatDate
+    };
+
+    /// <summary>
+    /// DWD 只读投影。刻意<b>不建 CodeFirst Entity</b>:<c>dwd_supplier_delivery</c> 由数据平台维护,
+    /// S8 侧建 Entity 会把它拉进结构同步范围,造成归属混乱。
+    /// </summary>
+    internal sealed class PurchaseDeliveryRow
+    {
+        public string SourceObjectId { get; set; }
+        public string RelatedObjectCode { get; set; }
+        public string RelatedObjectName { get; set; }
+        public DateTime? DueAt { get; set; }
+        public string Status { get; set; }
+        public string DueAtSource { get; set; }
+
+        public string PoNo { get; set; }
+        public string PoLine { get; set; }
+        public string PoType { get; set; }
+        public string SupplierCode { get; set; }
+        public string SupplierName { get; set; }
+        public string ItemCode { get; set; }
+        public string ItemName { get; set; }
+        public decimal? OrderQty { get; set; }
+        public decimal? RemainingQty { get; set; }
+        public DateTime? NeedDate { get; set; }
+        public DateTime? DueDate { get; set; }
+        public string RiskLevel { get; set; }
+        public DateTime? StatDate { get; set; }
+    }
+}

+ 8 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/Startup.cs

@@ -70,10 +70,16 @@ public class Startup : AppStartup
 
 
         // S8-DATASET-FOUNDATION-HARDENING-2:S8 取数底座的接口映射显式注册
         // S8-DATASET-FOUNDATION-HARDENING-2:S8 取数底座的接口映射显式注册
         // (与上方 IModuleRebuildLock 同一先例:本项目不依赖 ITransient 自动按接口注册)。
         // (与上方 IModuleRebuildLock 同一先例:本项目不依赖 ITransient 自动按接口注册)。
-        // IS8DatasetDefinitionSource / IS8MonitoringDataProvider 本批**刻意不注册任何实现**——
-        // Catalog 与 Registry 均能正确处理空集合,这是"尚未接生产数据集"的预期状态。
         services.AddTransient<Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.IS8DatasetCatalog, Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.S8DatasetCatalog>();
         services.AddTransient<Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.IS8DatasetCatalog, Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.S8DatasetCatalog>();
         services.AddTransient<Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.IS8LegacySqlDataProvider, Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.S8LegacySqlDataProvider>();
         services.AddTransient<Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.IS8LegacySqlDataProvider, Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.S8LegacySqlDataProvider>();
+
+        // S8-RULE01-PURCHASE-DELIVERY-A:接入第一个真实业务数据集 PURCHASE_DELIVERY。
+        // Catalog 有定义 + Registry 有实现,Enable Gate 才判 READY:
+        //   缺定义 → 规则连保存都过不了(dataset_not_defined);
+        //   缺实现 → 可存草稿但不可启用(dataset_provider_not_registered)。
+        // 两个接口都必须显式注册(同上,不依赖 ITransient 自动按接口注册)。
+        services.AddTransient<Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.IS8DatasetDefinitionSource, Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers.S8BusinessDatasetDefinitions>();
+        services.AddTransient<Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.IS8MonitoringDataProvider, Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers.S8PurchaseDeliveryDataProvider>();
     }
     }
 
 
     /// <summary>
     /// <summary>