Эх сурвалжийг харах

test(s8): 对齐租户级规则执行契约

Batch 5 把规则执行主链收敛为租户级时改了 6 个契约签名,但 Admin.NET.Test
从未被编译到,于是这些调用点一直是坏的:EvaluateRows / BuildDedupKey /
S8DatasetEnableGate.Check / S8MonitoringDataGateway.LoadAsync /
S8WatchRuleDataAccessValidator.ValidateForEnable / S8MonitoringDataRequest.FactoryId。

漏检原因(已查清,本次不改 sln 拓扑):Admin.NET.Test 是解决方案里唯一缺
"Debug|Any CPU.Build.0" 映射的项目——只有 ActiveCfg 没有 Build.0,等于 VS 里
"生成"没勾。dotnet build 默认 Debug|Any CPU 故整个项目被跳过。实证:
-c Debug → 0 error,-c Release(该配置有 Build.0)→ 164 error。
今后该项目必须显式按 csproj 或 -c Release 验证,不能再拿 sln Debug 构建当全仓证据。

本次只改测试,不动产品代码。凡与新架构冲突的断言一律改写为反向守卫,
不删用例——这类保护删掉就等于把回归放行:

- Enable_Rejected_OnInvalidFactoryScope
  → Enable_NotRejected_ByFactoryScope_BecauseFactoryIsNotAScope
    工厂不再是门禁;同时锁死 Check 的所有重载不得再出现工厂形参。
    (invalid_factory_scope 常量仍在,但生产代码已无任何路径产生它。)
- BuildDedupKey_Differentiates_OnTenantFactoryRuleObject
  → BuildDedupKey_Differentiates_OnTenantRuleObject_NotOnFactory
    原断言含"同租户不同工厂 → 不同 key"。工厂段一旦回到 key 里,同一业务对象
    会在不同工厂元数据下被当成两个异常,且新旧 key 对不上,调度器会一边把旧异常
    当作已恢复、一边按新 key 重复建单。改为正面锁定四维身份 + 反向禁止 :F 段,
    并新增"同租户同对象单一身份""跨租户同来源不同身份"两条守卫。
- DataRequest_RejectsNonPositiveTenantOrFactory
  → DataRequest_RejectsNonPositiveTenant_ButHasNoFactoryGate
    保留"缺租户必须显式失败",把"工厂为 0 也拒绝"反过来——那正是 Batch 2 供给的
    factory_id=0 运行策略此前被静默挡死的那道门。
- T5b_EachMetricCode_ResolvesToExactlyOneEffectiveRow
    保留"每个 code 只留一条",把"工厂行优先级最高"改为"租户行优先"。
    本批之后租户覆盖行 factory_id 恒为 0,按工厂排序会与平台默认打平退化成任意序。

期望值同步:dedup key 去掉 :F 段(T{tenant}:R{rule}:{type}:{id})。

验证:Admin.NET.Test 编译 82 error → 0(net8.0 + net10.0 双目标 0 Error);
S8 159/159、全量(排除既有 WebDriver 环境项)201/201;
Plugin S8 377/377、Plugin 全量 1734 passed / 2 skipped;
sln Debug 与 Release 均 0 Error;前端 build 通过;vue-tsc 583 ≤ 基线 597。

不 bump 版本:仅改测试代码,非运行产物,与既有 test-only 提交 f66b6c098 同口径。
YY968XX 3 өдөр өмнө
parent
commit
54fe5e498a

+ 34 - 12
server/Admin.NET.Test/S8/S8DatasetCatalogAndGateTests.cs

@@ -145,7 +145,7 @@ public class S8DatasetCatalogAndGateTests
     public void Enable_Rejected_WhenProviderMissing_WithProviderReasonCode()
     {
         var gate = new S8DatasetEnableGate(Catalog(), EmptyRegistry());
-        var result = gate.Check(StandardRule(DefinedWithoutProvider), tenantId: 1, factoryId: 1);
+        var result = gate.Check(StandardRule(DefinedWithoutProvider), tenantId: 1);
 
         Assert.False(result.Ok);
         Assert.Equal(S8DatasetReasonCode.ProviderNotRegistered, result.ReasonCode);
@@ -159,7 +159,7 @@ public class S8DatasetCatalogAndGateTests
     public void UndefinedDataset_Reports_NotDefined_NotProviderMissing()
     {
         var gate = new S8DatasetEnableGate(Catalog(), EmptyRegistry());
-        var result = gate.Check(StandardRule("TOTALLY_UNKNOWN_DATASET"), 1, 1);
+        var result = gate.Check(StandardRule("TOTALLY_UNKNOWN_DATASET"), 1);
 
         Assert.False(result.Ok);
         Assert.Equal(S8DatasetReasonCode.NotDefined, result.ReasonCode);
@@ -185,7 +185,7 @@ public class S8DatasetCatalogAndGateTests
         var gate = new S8DatasetEnableGate(Catalog(), registry);
 
         // OUT_OF_RANGE-only 数据集挂 TIMEOUT 规则。
-        var result = gate.Check(StandardRule(OutOfRangeOnlyDataset, "TIMEOUT"), 1, 1);
+        var result = gate.Check(StandardRule(OutOfRangeOnlyDataset, "TIMEOUT"), 1);
 
         Assert.False(result.Ok);
         Assert.Equal(S8DatasetReasonCode.CapabilityMismatch, result.ReasonCode);
@@ -200,7 +200,7 @@ public class S8DatasetCatalogAndGateTests
     {
         // SupportsTimeout=true 但 HasDueAt=false:配置自相矛盾,必须识别。
         var gate = new S8DatasetEnableGate(Catalog(), EmptyRegistry());
-        var result = gate.Check(StandardRule(ContradictoryDataset), 1, 1);
+        var result = gate.Check(StandardRule(ContradictoryDataset), 1);
 
         Assert.False(result.Ok);
         Assert.Equal(S8DatasetReasonCode.MissingRequiredField, result.ReasonCode);
@@ -229,7 +229,7 @@ public class S8DatasetCatalogAndGateTests
     public void Enable_Rejected_WhenDatasetLifecycleDisabled()
     {
         var gate = new S8DatasetEnableGate(Catalog(), RegistryWithProvider());
-        var result = gate.Check(StandardRule(DefinedButDisabled), 1, 1);
+        var result = gate.Check(StandardRule(DefinedButDisabled), 1);
 
         Assert.False(result.Ok);
         Assert.Equal(S8DatasetReasonCode.NotReady, result.ReasonCode);
@@ -243,27 +243,49 @@ public class S8DatasetCatalogAndGateTests
     public void Enable_Rejected_OnInvalidTenantScope()
     {
         var gate = new S8DatasetEnableGate(Catalog(), RegistryWithProvider());
-        var result = gate.Check(StandardRule(DefinedWithProvider), tenantId: 0, factoryId: 1);
+        var result = gate.Check(StandardRule(DefinedWithProvider), tenantId: 0);
 
         Assert.False(result.Ok);
         Assert.Equal(S8DatasetReasonCode.InvalidTenantScope, result.ReasonCode);
     }
 
+    /// <summary>
+    /// S8-TENANT-ONLY-BATCH5 反向守卫(原 <c>Enable_Rejected_OnInvalidFactoryScope</c> 的继任者)。
+    ///
+    /// <para>原用例断言「factory 作用域非法 → 拒绝启用」。工厂不再是规则执行的作用域之后,
+    /// 这条断言必须**反过来**:Gate 里不得再有任何工厂形参,也不得再产生
+    /// <c>invalid_factory_scope</c>。删掉用例不行——那样等于把「工厂重新变回门禁」这件事放行了。</para>
+    ///
+    /// <para>Batch 2 供给出来的运行策略 <c>factory_id</c> 恒为 0;一旦有人把工厂门禁加回来,
+    /// 全部租户的规则都会在启用这一步被拒,且理由是一个业务上无意义的作用域错误。</para>
+    /// </summary>
     [Fact]
-    public void Enable_Rejected_OnInvalidFactoryScope()
+    public void Enable_NotRejected_ByFactoryScope_BecauseFactoryIsNotAScope()
     {
         var gate = new S8DatasetEnableGate(Catalog(), RegistryWithProvider());
-        var result = gate.Check(StandardRule(DefinedWithProvider), tenantId: 1, factoryId: 0);
 
-        Assert.False(result.Ok);
-        Assert.Equal(S8DatasetReasonCode.InvalidFactoryScope, result.ReasonCode);
+        // 只给租户即可通过:调用点连传工厂的地方都没有。
+        var result = gate.Check(StandardRule(DefinedWithProvider), tenantId: 1);
+        Assert.True(result.Ok);
+        Assert.Null(result.ReasonCode);
+
+        // Check 的两个重载都不得再出现工厂形参。
+        foreach (var m in typeof(S8DatasetEnableGate).GetMethods().Where(x => x.Name == "Check"))
+        {
+            Assert.DoesNotContain(m.GetParameters(), p =>
+                p.Name!.Contains("factory", StringComparison.OrdinalIgnoreCase));
+        }
+
+        // invalid_factory_scope 常量仍在(历史 reason code 不删),但生产代码不得再产生它。
+        // 该断言由 S8TenantOnlyMainChainTests 的源码扫描配合,此处只锁 Gate 自身行为。
+        Assert.NotEqual(S8DatasetReasonCode.InvalidFactoryScope, result.ReasonCode);
     }
 
     [Fact]
     public void Enable_Passes_WhenEverythingSatisfied()
     {
         var gate = new S8DatasetEnableGate(Catalog(), RegistryWithProvider());
-        var result = gate.Check(StandardRule(DefinedWithProvider), 797403760988229, 1);
+        var result = gate.Check(StandardRule(DefinedWithProvider), 797403760988229);
 
         Assert.True(result.Ok);
         Assert.Null(result.ReasonCode);
@@ -289,7 +311,7 @@ public class S8DatasetCatalogAndGateTests
         };
 
         var gate = new S8DatasetEnableGate(Catalog(), EmptyRegistry());
-        var result = gate.Check(orphan, 1, 1);
+        var result = gate.Check(orphan, 1);
 
         Assert.False(result.Ok);
         Assert.Equal(S8DatasetReasonCode.CodeMissing, result.ReasonCode);

+ 37 - 24
server/Admin.NET.Test/S8/S8MonitoringDataContractTests.cs

@@ -176,14 +176,14 @@ public class S8MonitoringDataContractTests
         var hits = S8TimeoutRuleEvaluator.EvaluateRows(
             rowSet,
             Effective("TIMEOUT", exceptionTypeCode: "DELIVERY_DELAY", completedStates: new[] { "COMPLETED" }),
-            tenantId: 11, factoryId: 22,
+            tenantId: 11,
             detectedAt: new DateTime(2026, 6, 1, 0, 0, 0));
 
         var hit = Assert.Single(hits);
         Assert.Equal("SO-OVERDUE", hit.SourceObjectId);
         Assert.Equal("ORDER", hit.SourceObjectType);
         Assert.Equal("DELIVERY_DELAY", hit.ExceptionTypeCode);
-        Assert.Equal("T11:F22:RUT_DATASET_RULE:ORDER:SO-OVERDUE", hit.DedupKey);
+        Assert.Equal("T11:RUT_DATASET_RULE:ORDER:SO-OVERDUE", hit.DedupKey);
     }
 
     [Fact]
@@ -201,7 +201,7 @@ public class S8MonitoringDataContractTests
         });
         var hits = S8TimeoutRuleEvaluator.EvaluateRows(
             rowSet, Effective("TIMEOUT", completedStates: new[] { "COMPLETED" }),
-            11, 22, new DateTime(2026, 6, 1));
+            11, new DateTime(2026, 6, 1));
 
         Assert.Empty(hits);
     }
@@ -230,7 +230,7 @@ public class S8MonitoringDataContractTests
         });
 
         var hits = S8TimeoutRuleEvaluator.EvaluateRows(
-            rowSet, Effective("TIMEOUT"), 1, 1, new DateTime(2026, 6, 1));
+            rowSet, Effective("TIMEOUT"), 1, new DateTime(2026, 6, 1));
 
         Assert.Empty(hits);
     }
@@ -249,7 +249,7 @@ public class S8MonitoringDataContractTests
         });
         var hits = S8OutOfRangeRuleEvaluator.EvaluateRows(
             rowSet, Effective("OUT_OF_RANGE", exceptionTypeCode: "QUALITY_OUT_OF_SPEC", upperBound: 100m),
-            11, 22, DateTime.Now);
+            11, DateTime.Now);
 
         var hit = Assert.Single(hits);
         Assert.Equal("ITEM-1", hit.SourceObjectId);
@@ -271,7 +271,7 @@ public class S8MonitoringDataContractTests
         });
         var hits = S8OutOfRangeRuleEvaluator.EvaluateRows(
             rowSet, Effective("OUT_OF_RANGE", upperBound: 100m, lowerBound: 10m),
-            1, 1, DateTime.Now);
+            1, DateTime.Now);
 
         Assert.Empty(hits);
     }
@@ -290,7 +290,7 @@ public class S8MonitoringDataContractTests
         });
         var hits = S8ShortageRuleEvaluator.EvaluateRows(
             rowSet, Effective("SHORTAGE", exceptionTypeCode: "MATERIAL_SHORTAGE"),
-            11, 22, DateTime.Now);
+            11, DateTime.Now);
 
         var hit = Assert.Single(hits);
         Assert.Equal("PO-1", hit.SourceObjectId);
@@ -313,7 +313,7 @@ public class S8MonitoringDataContractTests
             })
         });
         var hits = S8TimeoutRuleEvaluator.EvaluateRows(
-            rowSet, Effective("TIMEOUT"), 1, 1, new DateTime(2026, 6, 1));
+            rowSet, Effective("TIMEOUT"), 1, new DateTime(2026, 6, 1));
 
         Assert.Contains("extra_business_column", hits[0].SourcePayload);
         Assert.Contains("must-survive", hits[0].SourcePayload);
@@ -324,12 +324,11 @@ public class S8MonitoringDataContractTests
     // ============================================================
 
     [Fact]
-    public void DataRequest_CarriesExplicitTenantAndFactory()
+    public void DataRequest_CarriesExplicitTenant_AndHasNoFactoryDimension()
     {
         var request = new S8MonitoringDataRequest
         {
             TenantId = 797403760988229,
-            FactoryId = 1,
             DatasetCode = "UT_DATASET",
             RuleId = 1,
             RuleCode = "UT",
@@ -337,17 +336,32 @@ public class S8MonitoringDataContractTests
         };
 
         Assert.Equal(797403760988229, request.TenantId);
-        Assert.Equal(1, request.FactoryId);
+
+        // S8-TENANT-ONLY-BATCH5:取数作用域里不得再有工厂这一维。
+        // 唯一的 Provider 的 WHERE 从一开始就只有 tenant_id,工厂形参从来只是个
+        // 不影响结果、却能把零工厂/多工厂租户挡在门外的形式门槛。
+        Assert.Null(typeof(S8MonitoringDataRequest).GetProperty("FactoryId"));
     }
 
+    /// <summary>
+    /// 原用例 <c>DataRequest_RejectsNonPositiveTenantOrFactory</c> 的继任者。
+    ///
+    /// <para>租户缺失仍必须显式失败(禁止 Provider "猜"租户);
+    /// 而「工厂为 0 也拒绝」那一半必须**反过来**——工厂已不是作用域,
+    /// 不得再有任何取数请求因它被拒。这正是 Batch 2 供给出的
+    /// <c>factory_id = 0</c> 运行策略此前被静默挡死的那道门。</para>
+    /// </summary>
     [Fact]
-    public void DataRequest_RejectsNonPositiveTenantOrFactory()
+    public void DataRequest_RejectsNonPositiveTenant_ButHasNoFactoryGate()
     {
-        // 禁止 Provider 在缺少作用域时"猜"租户;缺失必须显式失败。
-        Assert.Throws<S8RuleEvaluatorException>(() =>
-            new S8MonitoringDataRequest { TenantId = 0, FactoryId = 1, DatasetCode = "D", RuleCode = "R", RuleType = "TIMEOUT" }.EnsureValid());
-        Assert.Throws<S8RuleEvaluatorException>(() =>
-            new S8MonitoringDataRequest { TenantId = 1, FactoryId = 0, DatasetCode = "D", RuleCode = "R", RuleType = "TIMEOUT" }.EnsureValid());
+        var ex = Assert.Throws<S8RuleEvaluatorException>(() =>
+            new S8MonitoringDataRequest { TenantId = 0, DatasetCode = "D", RuleCode = "R", RuleType = "TIMEOUT" }.EnsureValid());
+        Assert.Equal("dataset_scope_invalid", ex.Reason);
+        Assert.DoesNotContain("Factory", ex.Message, StringComparison.OrdinalIgnoreCase);
+
+        // 合法租户 → 通过;没有任何"工厂必须为正"的附加条件可以让它失败。
+        new S8MonitoringDataRequest { TenantId = 1, DatasetCode = "D", RuleCode = "R", RuleType = "TIMEOUT" }
+            .EnsureValid();
     }
 
     // ============================================================
@@ -365,7 +379,7 @@ public class S8MonitoringDataContractTests
             {
                 S8MonitoringRow.FromValues(new Dictionary<string, object>
                 {
-                    ["source_object_id"] = $"T{request.TenantId}-F{request.FactoryId}",
+                    ["source_object_id"] = $"T{request.TenantId}",
                     ["related_object_code"] = "R",
                     ["due_at"] = new DateTime(2026, 1, 1),
                     ["status"] = "RUNNING"
@@ -402,7 +416,7 @@ public class S8MonitoringDataContractTests
     }
 
     [Fact]
-    public async Task Provider_ReceivesTenantAndFactory_NoImplicitContext()
+    public async Task Provider_ReceivesTenant_NoImplicitContext()
     {
         var registry = new S8MonitoringDataProviderRegistry(new[] { new FakeDatasetProvider() });
         var provider = registry.Resolve("UT_FAKE_DATASET");
@@ -410,14 +424,14 @@ public class S8MonitoringDataContractTests
         var rowSet = await provider.LoadAsync(new S8MonitoringDataRequest
         {
             TenantId = 555,
-            FactoryId = 66,
             DatasetCode = "UT_FAKE_DATASET",
             RuleId = 1,
             RuleCode = "UT",
             RuleType = "TIMEOUT"
         });
 
-        Assert.Equal("T555-F66", rowSet.Rows[0].GetString("source_object_id"));
+        // 作用域必须来自请求本身,不得来自任何隐式上下文。
+        Assert.Equal("T555", rowSet.Rows[0].GetString("source_object_id"));
     }
 
     [Fact]
@@ -429,7 +443,6 @@ public class S8MonitoringDataContractTests
         var rowSet = await provider.LoadAsync(new S8MonitoringDataRequest
         {
             TenantId = 11,
-            FactoryId = 22,
             DatasetCode = "UT_FAKE_DATASET",
             RuleId = 1,
             RuleCode = "UT_DATASET_RULE",
@@ -437,9 +450,9 @@ public class S8MonitoringDataContractTests
         });
 
         var hits = S8TimeoutRuleEvaluator.EvaluateRows(
-            rowSet, Effective("TIMEOUT"), 11, 22, new DateTime(2026, 6, 1));
+            rowSet, Effective("TIMEOUT"), 11, new DateTime(2026, 6, 1));
 
         Assert.Single(hits);
-        Assert.Equal("T11:F22:RUT_DATASET_RULE:ORDER:T11-F22", hits[0].DedupKey);
+        Assert.Equal("T11:RUT_DATASET_RULE:ORDER:T11", hits[0].DedupKey);
     }
 }

+ 6 - 6
server/Admin.NET.Test/S8/S8MonitoringDataGatewayTests.cs

@@ -145,7 +145,7 @@ public class S8MonitoringDataGatewayTests
     public async Task ProviderThrows_IsClassifiedAsQueryFailed()
     {
         var ex = await Assert.ThrowsAsync<S8RuleEvaluatorException>(() =>
-            Gateway(new ThrowingProvider()).LoadAsync(1, 1, DatasetRule(), "TIMEOUT", 1000));
+            Gateway(new ThrowingProvider()).LoadAsync(1, DatasetRule(), "TIMEOUT", 1000));
 
         Assert.Equal(S8DatasetReasonCode.QueryFailed, ex.Reason);
     }
@@ -157,7 +157,7 @@ public class S8MonitoringDataGatewayTests
         rule.DatasetCode = "NOT_IN_CATALOG";
 
         var ex = await Assert.ThrowsAsync<S8RuleEvaluatorException>(() =>
-            Gateway(new OkProvider()).LoadAsync(1, 1, rule, "TIMEOUT", 1000));
+            Gateway(new OkProvider()).LoadAsync(1, rule, "TIMEOUT", 1000));
 
         Assert.Equal(S8DatasetReasonCode.NotDefined, ex.Reason);
     }
@@ -169,7 +169,7 @@ public class S8MonitoringDataGatewayTests
         rule.DatasetCode = null;
 
         var ex = await Assert.ThrowsAsync<S8RuleEvaluatorException>(() =>
-            Gateway(new OkProvider()).LoadAsync(1, 1, rule, "TIMEOUT", 1000));
+            Gateway(new OkProvider()).LoadAsync(1, rule, "TIMEOUT", 1000));
 
         Assert.Equal(S8DatasetReasonCode.CodeMissing, ex.Reason);
     }
@@ -178,7 +178,7 @@ public class S8MonitoringDataGatewayTests
     public async Task InvalidScope_Fails()
     {
         await Assert.ThrowsAsync<S8RuleEvaluatorException>(() =>
-            Gateway(new OkProvider()).LoadAsync(0, 1, DatasetRule(), "TIMEOUT", 1000));
+            Gateway(new OkProvider()).LoadAsync(0, DatasetRule(), "TIMEOUT", 1000));
     }
 
     // ============================================================
@@ -188,7 +188,7 @@ public class S8MonitoringDataGatewayTests
     [Fact]
     public async Task DatasetRule_LoadsCanonicalRows()
     {
-        var result = await Gateway(new OkProvider()).LoadAsync(11, 22, DatasetRule(), "TIMEOUT", 1000);
+        var result = await Gateway(new OkProvider()).LoadAsync(11, DatasetRule(), "TIMEOUT", 1000);
 
         Assert.Single(result.RowSet.Rows);
     }
@@ -197,7 +197,7 @@ public class S8MonitoringDataGatewayTests
     public async Task RowLimitGuard_Applies()
     {
         var ex = await Assert.ThrowsAsync<S8RuleEvaluatorException>(() =>
-            Gateway(new OkProvider()).LoadAsync(1, 1, DatasetRule(), "TIMEOUT", maxRows: 0));
+            Gateway(new OkProvider()).LoadAsync(1, DatasetRule(), "TIMEOUT", maxRows: 0));
 
         Assert.Equal("result_too_many_rows", ex.Reason);
     }

+ 3 - 3
server/Admin.NET.Test/S8/S8PurchaseDeliveryDatasetTests.cs

@@ -180,7 +180,7 @@ public class S8PurchaseDeliveryDatasetTests
             new S8MonitoringDataProviderRegistry(
                 new IS8MonitoringDataProvider[] { new StubPurchaseDeliveryProvider() }));
 
-        var result = gate.Check(StandardRule(), tenantId: 838257186181189, factoryId: 838257186320453);
+        var result = gate.Check(StandardRule(), tenantId: 838257186181189);
 
         Assert.True(result.Ok);
         Assert.Null(result.ReasonCode);
@@ -194,7 +194,7 @@ public class S8PurchaseDeliveryDatasetTests
             Catalog(),
             new S8MonitoringDataProviderRegistry(Array.Empty<IS8MonitoringDataProvider>()));
 
-        var result = gate.Check(StandardRule(), 838257186181189, 838257186320453);
+        var result = gate.Check(StandardRule(), 838257186181189);
 
         Assert.False(result.Ok);
         Assert.Equal(S8DatasetReasonCode.ProviderNotRegistered, result.ReasonCode);
@@ -210,7 +210,7 @@ public class S8PurchaseDeliveryDatasetTests
             new S8MonitoringDataProviderRegistry(
                 new IS8MonitoringDataProvider[] { new StubPurchaseDeliveryProvider() }));
 
-        var result = gate.Check(StandardRule("SHORTAGE"), 838257186181189, 838257186320453);
+        var result = gate.Check(StandardRule("SHORTAGE"), 838257186181189);
 
         Assert.False(result.Ok);
         Assert.Equal(S8DatasetReasonCode.CapabilityMismatch, result.ReasonCode);

+ 2 - 2
server/Admin.NET.Test/S8/S8PurchaseDeliveryEndToEndTests.cs

@@ -124,10 +124,10 @@ public class S8PurchaseDeliveryEndToEndTests
     {
         var rule = Rule01();
         var data = await Gateway(provider).LoadAsync(
-            TenantId, FactoryId, rule, S8TimeoutRuleEvaluator.RuleTypeCode, maxRows: 1000);
+            TenantId, rule, S8TimeoutRuleEvaluator.RuleTypeCode, maxRows: 1000);
 
         return S8TimeoutRuleEvaluator.EvaluateRows(
-            data.RowSet, Effective(rule), TenantId, FactoryId, EvaluatedAt);
+            data.RowSet, Effective(rule), TenantId, EvaluatedAt);
     }
 
     // ============================================================

+ 24 - 24
server/Admin.NET.Test/S8/S8PurchaseDeliveryProviderMappingTests.cs

@@ -32,10 +32,9 @@ public class S8PurchaseDeliveryProviderMappingTests
     private const string RuleCode = "RULE_S4_PURCHASE_DELIVERY_DATE_DELAY";
 
     private static S8MonitoringDataRequest Request(
-        long tenantId = 838257186181189, long factoryId = 838257186320453) => new()
+        long tenantId = 838257186181189) => new()
     {
         TenantId = tenantId,
-        FactoryId = factoryId,
         DatasetCode = PurchaseDelivery,
         RuleCode = RuleCode,
         RuleType = "TIMEOUT"
@@ -118,14 +117,14 @@ public class S8PurchaseDeliveryProviderMappingTests
     }
 
     [Fact]
-    public void T5b_FactoryId_DoesNotAlterQuery_ButTenantIdDoes()
+    public void T5b_FactoryIsNotAQueryDimension_ButTenantIdIs()
     {
-        // 同一租户下换工厂:SQL 与唯一参数都不受影响(工厂根本不进 SQL)
-        // 换租户:参数随之改变。二者共同证明"隔离维度只有租户"
-        var sameTenantOtherFactory = Request(factoryId: 999_999_999);
-        var otherTenant = Request(tenantId: 838257212780613);
+        // S8-TENANT-ONLY-BATCH5:原用例构造「同租户换工厂」的请求来证明工厂不影响取数
+        // 现在连构造都做不到了 —— 请求上没有工厂这一维,该结论由类型系统保证
+        // 保留断言的另一半:租户仍然是、且是唯一的取数作用域。
+        Assert.Null(typeof(S8MonitoringDataRequest).GetProperty("FactoryId"));
 
-        Assert.Equal(Request().TenantId, sameTenantOtherFactory.TenantId);
+        var otherTenant = Request(tenantId: 838257212780613);
         Assert.NotEqual(Request().TenantId, otherTenant.TenantId);
         // 工厂不在 SQL 中,因此不可能成为取数差异的来源。
         Assert.DoesNotContain("@FactoryId", S8PurchaseDeliveryDataProvider.Sql);
@@ -168,9 +167,11 @@ public class S8PurchaseDeliveryProviderMappingTests
             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);
+        // 原用例第二段断言「factoryId = 0 同样被拒」。工厂不再是作用域之后该断言必须反向:
+        // 合法租户下不存在任何以工厂为由的拒绝,请求校验必须直接通过。
+        // (这正是 Batch 2 供给的 factory_id = 0 运行策略此前过不去的那道门。)
+        Request().EnsureValid();
+        Assert.DoesNotContain("Factory", ex.Message, StringComparison.OrdinalIgnoreCase);
     }
 
     // ============================================================
@@ -211,7 +212,7 @@ public class S8PurchaseDeliveryProviderMappingTests
     {
         // 实测该租户最长 source_object_id 为 16 字符;此处按真实规则编码与作用域算完整 DedupKey。
         var key = S8TimeoutRuleEvaluator.BuildDedupKey(
-            838257186181189, 838257186320453, RuleCode, "PURCHASE_ORDER_LINE", "PO202608260002#4");
+            838257186181189, RuleCode, "PURCHASE_ORDER_LINE", "PO202608260002#4");
 
         Assert.True(key.Length <= S8PurchaseDeliveryDataProvider.DedupKeyMaxLength,
             $"DedupKey 长度 {key.Length} 超过 {S8PurchaseDeliveryDataProvider.DedupKeyMaxLength}:{key}");
@@ -230,7 +231,7 @@ public class S8PurchaseDeliveryProviderMappingTests
         // 预算 + 前缀必须正好等于上限,证明算式与 BuildDedupKey 的格式一致。
         var probe = new string('x', budget);
         var key = S8TimeoutRuleEvaluator.BuildDedupKey(
-            Request().TenantId, Request().FactoryId, RuleCode, "PURCHASE_ORDER_LINE", probe);
+            Request().TenantId, RuleCode, "PURCHASE_ORDER_LINE", probe);
         Assert.Equal(S8PurchaseDeliveryDataProvider.DedupKeyMaxLength, key.Length);
     }
 
@@ -241,7 +242,6 @@ public class S8PurchaseDeliveryProviderMappingTests
         var longRule = new S8MonitoringDataRequest
         {
             TenantId = Request().TenantId,
-            FactoryId = Request().FactoryId,
             DatasetCode = PurchaseDelivery,
             RuleCode = RuleCode + "_MUCH_LONGER_SUFFIX",
             RuleType = "TIMEOUT"
@@ -305,7 +305,7 @@ public class S8PurchaseDeliveryProviderMappingTests
             new DateTime(2026, 8, 17, 4, 33, 7), new DateTime(2026, 9, 10), "DELAYED"));
 
         var hits = S8TimeoutRuleEvaluator.EvaluateRows(
-            rowSet, Effective(NewRule()), 838257186181189, 838257186320453, new DateTime(2026, 9, 5));
+            rowSet, Effective(NewRule()), 838257186181189, new DateTime(2026, 9, 5));
 
         var hit = Assert.Single(hits);
         Assert.Equal("PO202608260002#3", hit.SourceObjectId);
@@ -321,7 +321,7 @@ public class S8PurchaseDeliveryProviderMappingTests
             new DateTime(2026, 9, 17), new DateTime(2026, 8, 9), "OPEN", supplierName: "怡祥"));
 
         var hits = S8TimeoutRuleEvaluator.EvaluateRows(
-            rowSet, Effective(NewRule()), 838257186181189, 838257186320453, new DateTime(2026, 9, 5));
+            rowSet, Effective(NewRule()), 838257186181189, new DateTime(2026, 9, 5));
 
         Assert.Empty(hits);
     }
@@ -336,7 +336,7 @@ public class S8PurchaseDeliveryProviderMappingTests
             new DateTime(2026, 8, 17, 4, 33, 7), new DateTime(2026, 9, 10), "COMPLETED"));
 
         var hits = S8TimeoutRuleEvaluator.EvaluateRows(
-            rowSet, Effective(NewRule()), 838257186181189, 838257186320453, new DateTime(2026, 9, 5));
+            rowSet, Effective(NewRule()), 838257186181189, new DateTime(2026, 9, 5));
 
         Assert.Empty(hits);
     }
@@ -351,7 +351,7 @@ public class S8PurchaseDeliveryProviderMappingTests
             Row("PO-CANCELLED", "1", new DateTime(2026, 8, 1), null, "CANCELLED"));
 
         var hits = S8TimeoutRuleEvaluator.EvaluateRows(
-            rowSet, Effective(NewRule()), 838257186181189, 838257186320453, new DateTime(2026, 9, 5));
+            rowSet, Effective(NewRule()), 838257186181189, new DateTime(2026, 9, 5));
 
         Assert.Empty(hits);
     }
@@ -363,7 +363,7 @@ public class S8PurchaseDeliveryProviderMappingTests
         var rowSet = RowSet(Row("PO-PARTIAL", "1", new DateTime(2026, 8, 1), null, "PARTIAL"));
 
         var hits = S8TimeoutRuleEvaluator.EvaluateRows(
-            rowSet, Effective(NewRule()), 838257186181189, 838257186320453, new DateTime(2026, 9, 5));
+            rowSet, Effective(NewRule()), 838257186181189, new DateTime(2026, 9, 5));
 
         Assert.Single(hits);
     }
@@ -380,9 +380,9 @@ public class S8PurchaseDeliveryProviderMappingTests
             new DateTime(2026, 8, 17, 4, 33, 7), new DateTime(2026, 9, 10), "COMPLETED"));
 
         var hitsBefore = S8TimeoutRuleEvaluator.EvaluateRows(
-            before, Effective(NewRule()), 838257186181189, 838257186320453, new DateTime(2026, 9, 5));
+            before, Effective(NewRule()), 838257186181189, new DateTime(2026, 9, 5));
         var hitsAfter = S8TimeoutRuleEvaluator.EvaluateRows(
-            after, Effective(NewRule()), 838257186181189, 838257186320453, new DateTime(2026, 9, 5));
+            after, Effective(NewRule()), 838257186181189, new DateTime(2026, 9, 5));
 
         var dedupKey = Assert.Single(hitsBefore).DedupKey;
         Assert.Empty(hitsAfter);
@@ -396,12 +396,12 @@ public class S8PurchaseDeliveryProviderMappingTests
             new DateTime(2026, 8, 17, 4, 33, 7), new DateTime(2026, 9, 10), "DELAYED"));
 
         var hit = Assert.Single(S8TimeoutRuleEvaluator.EvaluateRows(
-            rowSet, Effective(NewRule()), 838257186181189, 838257186320453, new DateTime(2026, 9, 5)));
+            rowSet, Effective(NewRule()), 838257186181189, 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",
+        Assert.Equal("T838257186181189:RRULE_S4_PURCHASE_DELIVERY_DATE_DELAY:PURCHASE_ORDER_LINE:PO202608260002#3",
             hit.DedupKey);
 
         // 诊断列必须随 payload 落盘:remaining_qty 供后续数据治理观察,
@@ -436,7 +436,7 @@ public class S8PurchaseDeliveryProviderMappingTests
             NullLogger<S8MonitoringDataGateway>.Instance);
 
         var ex = await Assert.ThrowsAsync<S8RuleEvaluatorException>(() =>
-            gateway.LoadAsync(838257186181189, 838257186320453, NewRule(), "TIMEOUT", 1000));
+            gateway.LoadAsync(838257186181189, NewRule(), "TIMEOUT", 1000));
 
         Assert.Equal(S8DatasetReasonCode.QueryFailed, ex.Reason);
     }

+ 15 - 3
server/Admin.NET.Test/S8/S8ResultKpiEnabledGateTests.cs

@@ -118,10 +118,21 @@ public class S8ResultKpiEnabledGateTests
         Assert.Equal(new[] { "PO_DELIVERY_RATE" }, CodesOf(result));
     }
 
+    /// <summary>
+    /// 覆盖解析必须"每个 code 只留一条",否则同一 KPI 会在卡片上重复出现 —— 这条不变。
+    ///
+    /// <para><b>S8-TENANT-ONLY-BATCH6:优先级判据由「工厂号降序」改为「租户行优先」。</b>
+    /// 原断言是「工厂行优先级最高」。本批之后租户覆盖行的 <c>factory_id</c> 恒为 0,
+    /// 按工厂排序会与平台默认打平并退化成任意序,可能让平台默认反超租户覆盖 ——
+    /// 表现为「配置页显示一种生效值、运行时用另一种」。故优先级只认「是不是租户行」。</para>
+    ///
+    /// <para>同租户内仅工厂元数据不同的两行属**歧义配置**:真库取证
+    /// <c>(tenant_id, metric_code)</c> 重复组为 0,该情形不存在;此处只断言
+    /// 「收敛为一条且必是租户行」,不再对工厂号做任何承诺。</para>
+    /// </summary>
     [Fact]
     public void T5b_EachMetricCode_ResolvesToExactlyOneEffectiveRow()
     {
-        // 覆盖解析必须"每个 code 只留一条",否则同一 KPI 会在卡片上重复出现。
         var result = S8MonitoringService.ResolveEnabledEffectiveResultKpis(new[]
         {
             Metric("WO_COMPLETION_RATE", enabled: true),
@@ -130,8 +141,9 @@ public class S8ResultKpiEnabledGateTests
         });
 
         Assert.Single(result);
-        // 工厂行优先级最高。
-        Assert.Equal(FactoryId, result[0].FactoryId);
+        // 胜出的必须是租户行,而不是平台默认行;工厂号不再参与优先级。
+        Assert.Equal(TenantId, result[0].TenantId);
+        Assert.NotEqual(0L, result[0].TenantId);
     }
 
     // ============================================================

+ 5 - 5
server/Admin.NET.Test/S8/S8RuleDefinitionContractTests.cs

@@ -205,7 +205,7 @@ public class S8RuleDefinitionContractTests
         var effective = S8EffectiveRule.Resolve(Rule(paramsJson: null), Catalog().GetRequired(Rule01));
 
         var hits = S8TimeoutRuleEvaluator.EvaluateRows(
-            OverdueRow(), effective, 838257186181189L, 838257186320453L, new DateTime(2026, 9, 6, 13, 4, 13));
+            OverdueRow(), effective, 838257186181189L, new DateTime(2026, 9, 6, 13, 4, 13));
 
         var hit = Assert.Single(hits);
         Assert.Equal("UATA-PO-001#1", hit.SourceObjectId);
@@ -232,8 +232,8 @@ public class S8RuleDefinitionContractTests
         var hostile = S8EffectiveRule.Resolve(Rule(HostileLegacyParamsJson), definition);
 
         var detectedAt = new DateTime(2026, 9, 6, 13, 4, 13);
-        var cleanHits = S8TimeoutRuleEvaluator.EvaluateRows(OverdueRow(), clean, 1, 1, detectedAt);
-        var hostileHits = S8TimeoutRuleEvaluator.EvaluateRows(OverdueRow(), hostile, 1, 1, detectedAt);
+        var cleanHits = S8TimeoutRuleEvaluator.EvaluateRows(OverdueRow(), clean, 1, detectedAt);
+        var hostileHits = S8TimeoutRuleEvaluator.EvaluateRows(OverdueRow(), hostile, 1, detectedAt);
 
         // 恶意 JSON 把 completedStates 写成 ["DELAYED"] —— 若它还有话语权,这一行就不该命中。
         Assert.Single(cleanHits);
@@ -252,7 +252,7 @@ public class S8RuleDefinitionContractTests
         var effective = S8EffectiveRule.Resolve(Rule(HostileLegacyParamsJson), Catalog().GetRequired(Rule01));
 
         var hit = Assert.Single(S8TimeoutRuleEvaluator.EvaluateRows(
-            OverdueRow(), effective, 838257186181189L, 838257186320453L, new DateTime(2026, 9, 6, 13, 4, 13)));
+            OverdueRow(), effective, 838257186181189L, new DateTime(2026, 9, 6, 13, 4, 13)));
 
         // 身份三件套全部来自定义:JSON 写的 EVIL-ID / EVIL_TYPE、DB 列写的 EVIL_OBJECT_TYPE 都不生效。
         Assert.Equal("UATA-PO-001#1", hit.SourceObjectId);
@@ -275,7 +275,7 @@ public class S8RuleDefinitionContractTests
 
         var effective = S8EffectiveRule.Resolve(Rule(HostileLegacyParamsJson), Catalog().GetRequired(Rule01));
         Assert.Empty(S8TimeoutRuleEvaluator.EvaluateRows(
-            rowSet, effective, 1, 1, new DateTime(2026, 9, 6, 13, 4, 13)));
+            rowSet, effective, 1, new DateTime(2026, 9, 6, 13, 4, 13)));
     }
 
     [Fact]

+ 55 - 12
server/Admin.NET.Test/S8/S8TimeoutEvaluatorTests.cs

@@ -141,33 +141,76 @@ public class S8TimeoutEvaluatorTests
         Assert.Equal(1, p.TriggerCountRequired);
     }
 
-    // ───────────────── dedup_key(本批未改,断言逐字保留) ─────────────────
+    // ───────────────── dedup_key(S8-TENANT-ONLY-BATCH5 后的身份口径) ─────────────────
 
     [Fact]
     public void BuildDedupKey_IsStable_SameInputsSameOutput()
     {
-        var k1 = S8TimeoutRuleEvaluator.BuildDedupKey(1, 1, "G01_TEST_TIMEOUT", "ORDER", "ORDER-TIMEOUT-01");
-        var k2 = S8TimeoutRuleEvaluator.BuildDedupKey(1, 1, "G01_TEST_TIMEOUT", "ORDER", "ORDER-TIMEOUT-01");
+        var k1 = S8TimeoutRuleEvaluator.BuildDedupKey(1, "G01_TEST_TIMEOUT", "ORDER", "ORDER-TIMEOUT-01");
+        var k2 = S8TimeoutRuleEvaluator.BuildDedupKey(1, "G01_TEST_TIMEOUT", "ORDER", "ORDER-TIMEOUT-01");
         Assert.Equal(k1, k2);
-        Assert.Equal("T1:F1:RG01_TEST_TIMEOUT:ORDER:ORDER-TIMEOUT-01", k1);
+        Assert.Equal("T1:RG01_TEST_TIMEOUT:ORDER:ORDER-TIMEOUT-01", k1);
     }
 
+    /// <summary>
+    /// 去重身份的四个维度:租户 / 规则 / 对象类型 / 对象 ID。
+    ///
+    /// <para><b>原用例名为 <c>..._OnTenantFactoryRuleObject</c>,其中一条断言是
+    /// 「同租户、不同工厂 → 不同 key」。</b>那条断言现在必须消失,而且不能靠删掉了事:
+    /// 工厂段一旦回到 key 里,同一个业务对象会在不同工厂元数据下被当成两个异常,
+    /// 而历史 key 与新 key 也会对不上,调度器会一边把旧异常当作"已恢复"、
+    /// 一边按新 key 重复建单。故此处改为**正面锁定四维身份 + 反向禁止工厂段**。</para>
+    /// </summary>
+    [Fact]
+    public void BuildDedupKey_Differentiates_OnTenantRuleObject_NotOnFactory()
+    {
+        var baseKey = S8TimeoutRuleEvaluator.BuildDedupKey(1, "R", "ORDER", "ID");
+
+        // 四个身份维度各自区分。
+        Assert.NotEqual(baseKey, S8TimeoutRuleEvaluator.BuildDedupKey(2, "R", "ORDER", "ID"));
+        Assert.NotEqual(baseKey, S8TimeoutRuleEvaluator.BuildDedupKey(1, "R2", "ORDER", "ID"));
+        Assert.NotEqual(baseKey, S8TimeoutRuleEvaluator.BuildDedupKey(1, "R", "TASK", "ID"));
+        Assert.NotEqual(baseKey, S8TimeoutRuleEvaluator.BuildDedupKey(1, "R", "ORDER", "ID2"));
+
+        // 工厂不再是身份的一部分:签名里根本没有该形参,key 里也不得出现 :F 段。
+        foreach (var m in typeof(S8TimeoutRuleEvaluator)
+                     .GetMethods(System.Reflection.BindingFlags.Static
+                                 | System.Reflection.BindingFlags.NonPublic
+                                 | System.Reflection.BindingFlags.Public)
+                     .Where(x => x.Name == "BuildDedupKey"))
+        {
+            Assert.DoesNotContain(m.GetParameters(), p =>
+                p.Name!.Contains("factory", StringComparison.OrdinalIgnoreCase));
+        }
+        Assert.DoesNotMatch(@":F\d", baseKey);
+    }
+
+    /// <summary>
+    /// 同一业务对象在同一租户内只有一个身份 —— 这正是「工厂元数据不参与去重」的可观测后果。
+    /// 两次调用之间没有任何工厂输入可供改变,故必然相等。
+    /// </summary>
+    [Fact]
+    public void BuildDedupKey_SameTenantSameObject_IsSingleIdentity()
+    {
+        Assert.Equal(
+            S8TimeoutRuleEvaluator.BuildDedupKey(838257186181189, "RULE_A", "PURCHASE_ORDER_LINE", "PO-1#1"),
+            S8TimeoutRuleEvaluator.BuildDedupKey(838257186181189, "RULE_A", "PURCHASE_ORDER_LINE", "PO-1#1"));
+    }
+
+    /// <summary>不同租户下的同一来源对象必须是两个身份,杜绝跨租户去重合并。</summary>
     [Fact]
-    public void BuildDedupKey_Differentiates_OnTenantFactoryRuleObject()
+    public void BuildDedupKey_DifferentTenantSameSource_AreDistinct()
     {
-        var baseKey = S8TimeoutRuleEvaluator.BuildDedupKey(1, 1, "R", "ORDER", "ID");
-        Assert.NotEqual(baseKey, S8TimeoutRuleEvaluator.BuildDedupKey(2, 1, "R", "ORDER", "ID"));
-        Assert.NotEqual(baseKey, S8TimeoutRuleEvaluator.BuildDedupKey(1, 2, "R", "ORDER", "ID"));
-        Assert.NotEqual(baseKey, S8TimeoutRuleEvaluator.BuildDedupKey(1, 1, "R2", "ORDER", "ID"));
-        Assert.NotEqual(baseKey, S8TimeoutRuleEvaluator.BuildDedupKey(1, 1, "R", "TASK", "ID"));
-        Assert.NotEqual(baseKey, S8TimeoutRuleEvaluator.BuildDedupKey(1, 1, "R", "ORDER", "ID2"));
+        Assert.NotEqual(
+            S8TimeoutRuleEvaluator.BuildDedupKey(838257186181189, "RULE_A", "PURCHASE_ORDER_LINE", "PO-1#1"),
+            S8TimeoutRuleEvaluator.BuildDedupKey(797403760988229, "RULE_A", "PURCHASE_ORDER_LINE", "PO-1#1"));
     }
 
     [Fact]
     public void BuildDedupKey_Length_FitsVarchar128()
     {
         // 实体定义 dedup_key VARCHAR(128)。本断言锁定常规标识符不溢出列长度。
-        var k = S8TimeoutRuleEvaluator.BuildDedupKey(9999999, 9999999, "VERY_LONG_RULE_CODE_FOR_PADDING_64____", "EXTREMELY_LONG_OBJECT_TYPE______", "EXTREMELY_LONG_OBJECT_ID________");
+        var k = S8TimeoutRuleEvaluator.BuildDedupKey(9999999, "VERY_LONG_RULE_CODE_FOR_PADDING_64____", "EXTREMELY_LONG_OBJECT_TYPE______", "EXTREMELY_LONG_OBJECT_ID________");
         Assert.True(k.Length <= 128, $"dedup_key length {k.Length} exceeds 128: {k}");
     }
 }

+ 3 - 3
server/Admin.NET.Test/S8/S8WatchRuleDataAccessValidatorTests.cs

@@ -110,7 +110,7 @@ public class S8WatchRuleDataAccessValidatorTests
         var ex = Assert.Throws<S8BizException>(() =>
             S8WatchRuleDataAccessValidator.ValidateForEnable(
                 Rule("TIMEOUT", DefinedNoProvider),
-                Gate(), 1, 1));
+                Gate(), 1));
         Assert.Contains(S8DatasetReasonCode.ProviderNotRegistered, ex.Message);
     }
 
@@ -121,7 +121,7 @@ public class S8WatchRuleDataAccessValidatorTests
         var ex = Assert.Throws<S8BizException>(() =>
             S8WatchRuleDataAccessValidator.ValidateForEnable(
                 Rule("OUT_OF_RANGE", ReadyDataset),
-                Gate(), 1, 1));
+                Gate(), 1));
         Assert.Contains(S8DatasetReasonCode.CapabilityMismatch, ex.Message);
     }
 
@@ -131,7 +131,7 @@ public class S8WatchRuleDataAccessValidatorTests
         // 标准数据集规则不需要用户填写任何 SQL。
         var rule = Rule("TIMEOUT", ReadyDataset);
         S8WatchRuleDataAccessValidator.ValidateForSave(rule, Catalog());
-        S8WatchRuleDataAccessValidator.ValidateForEnable(rule, Gate(), 1, 1);
+        S8WatchRuleDataAccessValidator.ValidateForEnable(rule, Gate(), 1);
     }
 
 }