Jelajahi Sumber

refactor(s8): 将规则执行主链收敛为租户级作用域

S8 此前存在"伪 Factory Scope":取数从一开始就是租户级的——唯一的
PURCHASE_DELIVERY Provider 的 WHERE 只有 tenant_id——但规则归属、调度作用域、
dedup 身份、抗抖状态唯一键却统统按工厂切。两个后果同时存在:

- 零工厂 / 多工厂租户被形式门槛拒之门外,而放不放行对取出的数据毫无影响
  (S8MonitoringDataRequest 要求 FactoryId>0、EnableGate 报 invalid_factory_scope);
- Batch 2 供给出来的 factory_id=0 运行策略永远不被调度,且不报任何错——
  ListEnabledScopesAsync 带 AND r.factory_id > 0,页面显示已启用、实际从不执行。

代码侧收敛为 Tenant → Rule:

- 取数契约:S8MonitoringDataRequest 去掉 FactoryId 与其校验;Gateway 只传租户,
  rule.FactoryId 降为日志 metadata。Provider SQL 一个字未改——它本来就只按租户。
- Enable Gate:删除第 ⑩ 步的工厂检查,只留租户;重载签名同步收窄。
- Scheduler:ListEnabledScopesAsync → ListEnabledTenantsAsync(DISTINCT tenant_id);
  RunDispatchTick / ResetExpiredLeases / PickReadyRules / RunSingleRule /
  ProcessSingleRule / ProcessRulesByType / ReconcileRecoveries /
  HasRecentRefreshedDetectionLog 等全链去掉 factoryId 形参与谓词。
  归属复核与 rule_scope_mismatch 保留,只是少一维。
- Rule lookup:LoadScopedAsync / ListAsync 只按租户绑行,否则 factory_id=0 的
  供给行做任何配置操作都会 404。
- Evaluator:EvaluateAsync 形参去掉 factoryId;判定语义仍来自代码定义,不回退 params_json。
- DedupKey:T{t}:F{f}:R... → T{t}:R...。租户隔离由首段前缀与各查询的 tenant_id
  谓词双重保证;工厂段既不提供隔离,又让同一业务对象在不同工厂元数据下被当成两个异常。
- 异常类型存在性检查去掉工厂条件:那是一次 AnyAsync,少一个条件只会更宽松,
  否则 factory_id=0 的规则只能匹配平台默认行、找不到就整条 exception_type_missing。
  t.TenantId==0 是平台默认哨兵(S8ConfigScope),不是工厂语义,保留。
- Preview 与调度器同源:活动异常按 (租户, dedup_key) 查,两边分叉预演就不再可信。

DB 迁移 1.0.493 与代码是一个不可分割的单元。只改代码不迁数据,旧 active exception
仍是含 F 段的键而新 detection 产出无 F 段的键,调度器会认为旧异常"不再命中"从而开始
recovery,同时按新键再建一条重复异常。脚本按 preflight → 归档 → 改写 → 复验 →
重建唯一键的顺序,全部 DDL 由 information_schema 守卫,不依赖事务回滚。

改写只针对严格匹配 ^T{数字}:F{数字}:R 的行,不做无界 REPLACE(':F','')——那会误伤
任何恰好含 ":F" 的业务对象标识。碰撞门禁用具名 CHECK 约束实现(执行器拒绝含自定义
语句分隔符指令的脚本,且是全文子串匹配,故用不了存储过程 + SIGNAL),约束名即失败
原因,会进 sys_db_migration_log.error_message。碰撞明细另落诊断表,便于人工治理。

本地隔离库实测:改写 exception 33 / detection_log 155 / detection_state 28 行,
1:1 精确(逐行比对 mismatched=0),68 行非本批格式的 exception 零触碰,行数守恒,
Rule 01 在 exception / detection_state / detection_log 三处身份一致,
重复执行退出码 0 且归档不被覆盖。

首次执行时踩到一个真实缺陷并已修正:REGEXP_REPLACE 的反向引用在 MySQL 里必须写 $1,
写成 \1 会被吞成字面量 "1",租户前缀整段丢失。由归档表完整还原后重跑通过——
这也顺带验证了归档确实可用。

共享库全程只读:无 1.0.488~493 迁移记录、无新索引、无新表,
detection_state 唯一键仍含 factory_id,Rule 01 enabled=0 未变。

不做:DROP 任何 factory_id 列、Exception/TaskFlow/OperatorBinding/Notification/
MasterData 的作用域清理(Batch 6)、S8ConfigScope 的 factory_id=0 平台默认哨兵。

已知过渡态:factory_id=0 的规则建出的异常,其 exception.factory_id 也是 0,
而 S8ExceptionService 的列表查询仍按工厂过滤,因此在 Batch 6 之前这类异常不会
出现在异常中心列表里。新供给规则默认停用,正式部署要等 Tenant-only 全部完成。

chore: bump version server 1.0.493
YY968XX 1 bulan lalu
induk
melakukan
712bb6f751
25 mengubah file dengan 959 tambahan dan 187 penghapusan
  1. 6 3
      server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj
  2. 250 0
      server/Admin.NET.Web.Entry/UpdateScripts/1.0.493.sql
  3. 2 1
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8LegacySymbolsRemovedTests.cs
  4. 64 22
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8SchedulerScopeGuardTests.cs
  5. 11 2
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8TenantIsolationContractTests.cs
  6. 403 0
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8TenantOnlyMainChainTests.cs
  7. 3 1
      server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8WatchRuleWriteGuardTests.cs
  8. 1 1
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigWatchRulesController.cs
  9. 1 1
      server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8WatchDebugController.cs
  10. 7 3
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8RuleDetectionState.cs
  11. 7 0
      server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8WatchRule.cs
  12. 11 12
      server/Plugins/Admin.NET.Plugin.AiDOP/Job/S8WatchSchedulerJob.cs
  13. 3 2
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/IS8MonitoringDataProvider.cs
  14. 3 2
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/Providers/S8PurchaseDeliveryDataProvider.cs
  15. 12 11
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/S8DatasetEnableGate.cs
  16. 5 6
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/S8MonitoringDataGateway.cs
  17. 9 10
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/S8MonitoringDataRequest.cs
  18. 2 2
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/S8WatchRuleDataAccessValidator.cs
  19. 5 1
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/IS8RuleEvaluator.cs
  20. 20 8
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8OutOfRangeRuleEvaluator.cs
  21. 20 8
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8ShortageRuleEvaluator.cs
  22. 20 8
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8TimeoutRuleEvaluator.cs
  23. 7 5
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchRulePreviewService.cs
  24. 17 11
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchRuleService.cs
  25. 70 67
      server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchSchedulerService.cs

+ 6 - 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.492</AssemblyVersion>
-    <FileVersion>1.0.492</FileVersion>
-    <Version>1.0.492</Version>
+    <AssemblyVersion>1.0.493</AssemblyVersion>
+    <FileVersion>1.0.493</FileVersion>
+    <Version>1.0.493</Version>
   </PropertyGroup>
   </PropertyGroup>
 
 
   <ItemGroup>
   <ItemGroup>
@@ -682,6 +682,9 @@
     <None Update="UpdateScripts\1.0.487.sql">
     <None Update="UpdateScripts\1.0.487.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
     </None>
+    <None Update="UpdateScripts\1.0.493.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
     <None Update="UpdateScripts\UAT-PLACEHOLDER-MENU-HIDE.ops.sql">
     <None Update="UpdateScripts\UAT-PLACEHOLDER-MENU-HIDE.ops.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
     </None>

+ 250 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.493.sql

@@ -0,0 +1,250 @@
+-- S8-TENANT-ONLY-BATCH5:把 S8 规则执行主链的身份从 (Tenant, Factory) 收敛为 Tenant。
+--
+-- 本脚本与同批代码是**一个不可分割的单元**。单独跑任何一边都会出事:
+--   · 只改代码不迁数据 → 旧 active exception 仍是 T{t}:F{f}:R... 而新 detection 产出 T{t}:R...,
+--     调度器会认为旧异常"不再命中"从而开始 recovery 计数,同时按新键再建一条重复异常;
+--   · 只迁数据不改代码 → 每次 tick 生成的仍是含 F 段的键,与库里刚改完的键对不上,同样重复建单。
+--
+-- 变更(DDL 部分在 MySQL 下会自动提交,脚本按"每步可独立重跑"设计,不依赖事务回滚):
+--   ① preflight:去掉 :F{factory} 段后是否产生键碰撞 —— 有则整脚本中止,不自动挑一条
+--   ② 归档受影响行的最小恢复字段
+--   ③ 重写 dedup_key:仅限严格匹配 ^T{数字}:F{数字}:R 的行
+--   ④ 复验:重写后 collision 必须为 0
+--   ⑤ ado_s8_rule_detection_state 唯一键 (tenant,factory,rule_code,dedup_key) → (tenant,rule_code,dedup_key)
+--   ⑥ ado_s8_watch_rule 新增 UNIQUE(tenant_id, rule_code)
+--
+-- 明确**不做**:
+--   · 不 DROP 任何 factory_id 列(本批做的是"移除隔离语义",不是 schema 美化)
+--   · 不动 ado_s8_exception 的 uk(tenant,factory,exception_code)(异常侧属 Batch 6)
+--   · 不动 ado_s8_exception_bak_1_0_487(那是 1.0.487 的还原点,重写它等于毁掉备份)
+--   · 不动 mdp_entity.dedup_key_path(数据中台的同名概念,与 S8 规则去重无关)
+--
+-- 迁移前实测(2026-09-06,本地隔离克隆 aidopdev_s8_local,非沿用历史数字):
+--   旧格式行数    : exception 33 · detection_log 155 · detection_state 28
+--   非旧格式行数  : exception 68(不得触碰)· detection_log 0 · detection_state 0
+--   去 F 段后碰撞: exception 活动单 0 · detection_state 0
+--   watch_rule (tenant_id, rule_code) 重复组: 0
+
+-- ============================================================================
+-- ① PREFLIGHT:碰撞门禁
+--
+-- 去掉 F 段后若出现「同租户 + 同规则 + 同对象」的多个旧键映射到同一个新键,
+-- 说明历史上确实靠工厂区分过两条不同的异常。那种情况下自动挑一条留下会静默丢数据,
+-- 因此直接让脚本失败、交人工治理。
+-- ============================================================================
+
+SET @collision_exception := (
+  SELECT COUNT(*) FROM (
+    SELECT tenant_id, REGEXP_REPLACE(dedup_key, '^(T[0-9]+):F[0-9]+:', '$1:') nk, COUNT(*) c
+    FROM ado_s8_exception
+    WHERE dedup_key REGEXP '^T[0-9]+:F[0-9]+:R' AND is_deleted = 0 AND status <> 'CLOSED'
+    GROUP BY 1, 2 HAVING c > 1) z);
+
+SET @collision_state := (
+  SELECT COUNT(*) FROM (
+    SELECT tenant_id, rule_code, REGEXP_REPLACE(dedup_key, '^(T[0-9]+):F[0-9]+:', '$1:') nk, COUNT(*) c
+    FROM ado_s8_rule_detection_state
+    WHERE dedup_key REGEXP '^T[0-9]+:F[0-9]+:R'
+    GROUP BY 1, 2, 3 HAVING c > 1) z);
+
+SET @dup_watch_rule := (
+  SELECT COUNT(*) FROM (
+    SELECT tenant_id, rule_code, COUNT(*) c FROM ado_s8_watch_rule GROUP BY 1, 2 HAVING c > 1) z);
+
+-- 门禁实现说明:
+--   AutoVersionUpdate 明确拒绝含自定义语句分隔符指令的脚本("当前执行器不支持"),
+--   而且它是对全文做子串匹配 —— 连注释里出现那个关键字都会被拦,
+--   因此本文件通篇不出现该词。既然用不了存储过程 + SIGNAL,
+--   改用**具名 CHECK 约束**:约束名本身携带失败原因,
+--   MySQL 的报错文案会带上它("Check constraint 'xxx' is violated"),
+--   而该文案会被 AutoVersionUpdate 写进 sys_db_migration_log.error_message,
+--   于是运维在日志里直接看得到"为什么被挡",不必回头翻脚本。
+--   本版本随即被记为 Failed 并阻止应用启动 —— 迁移没过这件事不可能被忽略。
+
+-- 先把碰撞明细落一张诊断表:被挡住时运维需要知道**具体哪几个键**撞了,
+-- 只给一个计数会让人无从下手。
+DROP TABLE IF EXISTS ado_s8_tenant_only_collision_1_0_493;
+CREATE TABLE ado_s8_tenant_only_collision_1_0_493 (
+  source_table VARCHAR(64) NOT NULL,
+  tenant_id BIGINT NULL,
+  rule_code VARCHAR(64) NULL,
+  new_dedup_key VARCHAR(256) NULL,
+  row_count INT NULL
+) COMMENT 'S8-TENANT-ONLY-BATCH5 去 F 段后的 dedup_key 碰撞明细(为空表示无碰撞)';
+
+INSERT INTO ado_s8_tenant_only_collision_1_0_493 (source_table, tenant_id, rule_code, new_dedup_key, row_count)
+SELECT 'ado_s8_exception', tenant_id, source_rule_code,
+       REGEXP_REPLACE(dedup_key, '^(T[0-9]+):F[0-9]+:', '$1:'), COUNT(*)
+FROM ado_s8_exception
+WHERE dedup_key REGEXP '^T[0-9]+:F[0-9]+:R' AND is_deleted = 0 AND status <> 'CLOSED'
+GROUP BY 1, 2, 3, 4 HAVING COUNT(*) > 1;
+
+INSERT INTO ado_s8_tenant_only_collision_1_0_493 (source_table, tenant_id, rule_code, new_dedup_key, row_count)
+SELECT 'ado_s8_rule_detection_state', tenant_id, rule_code,
+       REGEXP_REPLACE(dedup_key, '^(T[0-9]+):F[0-9]+:', '$1:'), COUNT(*)
+FROM ado_s8_rule_detection_state
+WHERE dedup_key REGEXP '^T[0-9]+:F[0-9]+:R'
+GROUP BY 1, 2, 3, 4 HAVING COUNT(*) > 1;
+
+INSERT INTO ado_s8_tenant_only_collision_1_0_493 (source_table, tenant_id, rule_code, new_dedup_key, row_count)
+SELECT 'ado_s8_watch_rule_duplicate_identity', tenant_id, rule_code, NULL, COUNT(*)
+FROM ado_s8_watch_rule GROUP BY 1, 2, 3, 4 HAVING COUNT(*) > 1;
+
+-- 门禁本体:probe 非 0 即违反 CHECK,脚本在**任何写入之前**失败。
+DROP TABLE IF EXISTS tmp_s8_493_preflight_guard;
+CREATE TABLE tmp_s8_493_preflight_guard (
+  probe INT NOT NULL,
+  CONSTRAINT s8_tenant_only_BLOCKED_see_collision_table_1_0_493 CHECK (probe = 0)
+) COMMENT 'S8-TENANT-ONLY-BATCH5 preflight 门禁;成功后自动删除';
+
+INSERT INTO tmp_s8_493_preflight_guard (probe)
+VALUES (@collision_exception + @collision_state + @dup_watch_rule);
+
+DROP TABLE IF EXISTS tmp_s8_493_preflight_guard;
+
+-- ============================================================================
+-- ② 归档
+--
+-- 只存恢复所需的最小字段,不整表复制:受影响的是 dedup_key 这一列,
+-- 备份整张 exception 表既慢又没有额外价值。
+-- INSERT IGNORE + 主键:脚本重跑时不会把已改写后的值再存一遍覆盖原值。
+-- ============================================================================
+
+CREATE TABLE IF NOT EXISTS ado_s8_exception_dedup_bak_1_0_493 (
+  id BIGINT NOT NULL PRIMARY KEY,
+  tenant_id BIGINT NULL,
+  factory_id BIGINT NULL,
+  source_rule_code VARCHAR(64) NULL,
+  dedup_key VARCHAR(128) NULL
+) COMMENT 'S8-TENANT-ONLY-BATCH5 dedup_key 迁移前快照';
+
+CREATE TABLE IF NOT EXISTS ado_s8_detection_log_dedup_bak_1_0_493 (
+  id BIGINT NOT NULL PRIMARY KEY,
+  tenant_id BIGINT NULL,
+  factory_id BIGINT NULL,
+  rule_code VARCHAR(64) NULL,
+  dedup_key VARCHAR(256) NULL
+) COMMENT 'S8-TENANT-ONLY-BATCH5 dedup_key 迁移前快照';
+
+CREATE TABLE IF NOT EXISTS ado_s8_rule_detection_state_dedup_bak_1_0_493 (
+  id BIGINT NOT NULL PRIMARY KEY,
+  tenant_id BIGINT NULL,
+  factory_id BIGINT NULL,
+  rule_code VARCHAR(64) NULL,
+  dedup_key VARCHAR(128) NULL
+) COMMENT 'S8-TENANT-ONLY-BATCH5 dedup_key 迁移前快照';
+
+INSERT IGNORE INTO ado_s8_exception_dedup_bak_1_0_493 (id, tenant_id, factory_id, source_rule_code, dedup_key)
+SELECT id, tenant_id, factory_id, source_rule_code, dedup_key
+FROM ado_s8_exception WHERE dedup_key REGEXP '^T[0-9]+:F[0-9]+:R';
+
+INSERT IGNORE INTO ado_s8_detection_log_dedup_bak_1_0_493 (id, tenant_id, factory_id, rule_code, dedup_key)
+SELECT id, tenant_id, factory_id, rule_code, dedup_key
+FROM ado_s8_detection_log WHERE dedup_key REGEXP '^T[0-9]+:F[0-9]+:R';
+
+INSERT IGNORE INTO ado_s8_rule_detection_state_dedup_bak_1_0_493 (id, tenant_id, factory_id, rule_code, dedup_key)
+SELECT id, tenant_id, factory_id, rule_code, dedup_key
+FROM ado_s8_rule_detection_state WHERE dedup_key REGEXP '^T[0-9]+:F[0-9]+:R';
+
+-- ============================================================================
+-- ③ 重写 dedup_key
+--
+-- 只改严格匹配 ^T{数字}:F{数字}:R 的行,且只去掉 :F{数字} 这一段 ——
+-- ⚠️ 反向引用必须写 $1:MySQL 的 REGEXP_REPLACE 用 ICU 语法,
+--    写成 \1 不会被当作分组引用,而是被吞成字面量 "1" —— 租户前缀会整段丢失,
+--    得到 "1:RRULE_..." 这种既不是旧格式也不是新格式的坏键。
+--    本地隔离库首次执行时实测踩到过,已由归档表还原后修正。
+-- 不做全表 REPLACE(':F', ''):那会误伤任何恰好含 ":F" 的对象标识(例如 PO 号里带 :F)。
+-- 正则锚定行首 + 数字类,把改写面收敛到确实由旧 BuildDedupKey 生成的键。
+-- 幂等:改写后的行不再匹配该正则,重跑即 0 行受影响。
+-- ============================================================================
+
+UPDATE ado_s8_exception
+SET dedup_key = REGEXP_REPLACE(dedup_key, '^(T[0-9]+):F[0-9]+:', '$1:')
+WHERE dedup_key REGEXP '^T[0-9]+:F[0-9]+:R';
+
+UPDATE ado_s8_detection_log
+SET dedup_key = REGEXP_REPLACE(dedup_key, '^(T[0-9]+):F[0-9]+:', '$1:')
+WHERE dedup_key REGEXP '^T[0-9]+:F[0-9]+:R';
+
+UPDATE ado_s8_rule_detection_state
+SET dedup_key = REGEXP_REPLACE(dedup_key, '^(T[0-9]+):F[0-9]+:', '$1:')
+WHERE dedup_key REGEXP '^T[0-9]+:F[0-9]+:R';
+
+-- ============================================================================
+-- ④ 复验:重写后不得残留旧格式、不得出现碰撞
+-- ============================================================================
+
+SET @left_over := (
+  (SELECT COUNT(*) FROM ado_s8_exception WHERE dedup_key REGEXP '^T[0-9]+:F[0-9]+:R')
+  + (SELECT COUNT(*) FROM ado_s8_detection_log WHERE dedup_key REGEXP '^T[0-9]+:F[0-9]+:R')
+  + (SELECT COUNT(*) FROM ado_s8_rule_detection_state WHERE dedup_key REGEXP '^T[0-9]+:F[0-9]+:R'));
+
+SET @post_collision := (
+  SELECT COUNT(*) FROM (
+    SELECT tenant_id, rule_code, dedup_key, COUNT(*) c
+    FROM ado_s8_rule_detection_state GROUP BY 1, 2, 3 HAVING c > 1) z);
+
+-- 与 preflight 同一机制:约束名即失败原因。
+DROP TABLE IF EXISTS tmp_s8_493_postcheck_guard;
+CREATE TABLE tmp_s8_493_postcheck_guard (
+  probe INT NOT NULL,
+  CONSTRAINT s8_tenant_only_POSTCHECK_leftover_or_collision_1_0_493 CHECK (probe = 0)
+) COMMENT 'S8-TENANT-ONLY-BATCH5 改写后复验门禁;成功后自动删除';
+
+INSERT INTO tmp_s8_493_postcheck_guard (probe) VALUES (@left_over + @post_collision);
+
+DROP TABLE IF EXISTS tmp_s8_493_postcheck_guard;
+
+-- ============================================================================
+-- ⑤ ado_s8_rule_detection_state 唯一键去掉 factory_id
+--
+-- 必须与代码同批:upsert 的 WHERE 已改为 (tenant, rule_code, dedup_key),
+-- 而旧唯一键仍允许同键在不同 factory 下存两行 —— 抗抖计数会分裂,
+-- trigger/recover 阈值静默失效。
+-- 用 information_schema 判存在性:MySQL 无 DROP INDEX IF EXISTS。
+-- ============================================================================
+
+SET @has_old_uk := (SELECT COUNT(*) FROM information_schema.STATISTICS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ado_s8_rule_detection_state'
+    AND INDEX_NAME = 'uk_s8_rule_detection_state_dedup');
+SET @sql := IF(@has_old_uk > 0,
+  'ALTER TABLE ado_s8_rule_detection_state DROP INDEX uk_s8_rule_detection_state_dedup', 'DO 0');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @has_new_uk := (SELECT COUNT(*) FROM information_schema.STATISTICS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ado_s8_rule_detection_state'
+    AND INDEX_NAME = 'uk_s8_rule_detection_state_dedup');
+SET @sql := IF(@has_new_uk = 0,
+  'ALTER TABLE ado_s8_rule_detection_state ADD UNIQUE INDEX uk_s8_rule_detection_state_dedup (tenant_id, rule_code, dedup_key)', 'DO 0');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- 辅助索引同步去掉 factory_id(非唯一,纯查询效率)。
+SET @has_idx := (SELECT COUNT(*) FROM information_schema.STATISTICS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ado_s8_rule_detection_state'
+    AND INDEX_NAME = 'idx_s8_rule_detection_state_rule' AND COLUMN_NAME = 'factory_id');
+SET @sql := IF(@has_idx > 0,
+  'ALTER TABLE ado_s8_rule_detection_state DROP INDEX idx_s8_rule_detection_state_rule', 'DO 0');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+SET @has_idx2 := (SELECT COUNT(*) FROM information_schema.STATISTICS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ado_s8_rule_detection_state'
+    AND INDEX_NAME = 'idx_s8_rule_detection_state_rule');
+SET @sql := IF(@has_idx2 = 0,
+  'ALTER TABLE ado_s8_rule_detection_state ADD INDEX idx_s8_rule_detection_state_rule (tenant_id, rule_code)', 'DO 0');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
+
+-- ============================================================================
+-- ⑥ ado_s8_watch_rule 新增 UNIQUE(tenant_id, rule_code)
+--
+-- Batch 2 把运行策略身份定为 (TenantId, RuleCode),但当时只有应用层 AnyAsync 兜底,
+-- 因此只保证"顺序幂等",两个实例同时供给理论上仍可能建出重复行。
+-- 本索引把那个缺口在 DB 层封死。preflight 已确认无重复行。
+-- ============================================================================
+
+SET @has_rule_uk := (SELECT COUNT(*) FROM information_schema.STATISTICS
+  WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'ado_s8_watch_rule'
+    AND INDEX_NAME = 'uk_s8_watch_rule_tenant_code');
+SET @sql := IF(@has_rule_uk = 0,
+  'ALTER TABLE ado_s8_watch_rule ADD UNIQUE INDEX uk_s8_watch_rule_tenant_code (tenant_id, rule_code)', 'DO 0');
+PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;

+ 2 - 1
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8LegacySymbolsRemovedTests.cs

@@ -55,7 +55,8 @@ public class S8LegacySymbolsRemovedTests
         var names = evaluate.GetParameters().Select(p => p.Name).ToArray();
         var names = evaluate.GetParameters().Select(p => p.Name).ToArray();
 
 
         Assert.DoesNotContain("alertRules", names);
         Assert.DoesNotContain("alertRules", names);
-        Assert.Equal(new[] { "tenantId", "factoryId", "rule", "cancellationToken" }, names);
+        // S8-TENANT-ONLY-BATCH5:factoryId 已退出判定作用域。
+        Assert.Equal(new[] { "tenantId", "rule", "cancellationToken" }, names);
     }
     }
 
 
     /// <summary>
     /// <summary>

+ 64 - 22
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8SchedulerScopeGuardTests.cs

@@ -48,18 +48,26 @@ public class S8SchedulerScopeGuardTests
 
 
     /// <summary>
     /// <summary>
     /// 缺口 1:执行入口必须按本次 tick 的 scope 绑行,并对归属不符 fail-fast。
     /// 缺口 1:执行入口必须按本次 tick 的 scope 绑行,并对归属不符 fail-fast。
+    ///
+    /// <para><b>S8-TENANT-ONLY-BATCH5</b>:作用域收敛为租户。绑行与归属复核**必须仍在**
+    /// —— 去掉的只是工厂那一维,不是这道防线本身。同时反向断言工厂谓词不得回归,
+    /// 否则 Batch 2 供给出来的 factory_id=0 的规则会再次被静默排除在执行之外。</para>
     /// </summary>
     /// </summary>
     [Fact]
     [Fact]
-    public void RunSingleRule_BindsRuleToTickScope_AndFailsFastOnMismatch()
+    public void RunSingleRule_BindsRuleToTenantScope_AndFailsFastOnMismatch()
     {
     {
         var code = CodeOnly(SchedulerSrc);
         var code = CodeOnly(SchedulerSrc);
 
 
-        // 装载规则时必须同时带 TenantId / FactoryId 谓词,禁止裸 Id 装载。
-        Assert.Contains("x.Id == lease.RuleId && x.TenantId == tenantId && x.FactoryId == factoryId", code);
+        // 装载规则时必须带 TenantId 谓词,禁止裸 Id 装载。
+        Assert.Contains("x.Id == lease.RuleId && x.TenantId == tenantId", code);
 
 
         // 归属复核 + 明确的失败原因码;不得静默继续执行。
         // 归属复核 + 明确的失败原因码;不得静默继续执行。
         Assert.Contains("rule_scope_mismatch", code);
         Assert.Contains("rule_scope_mismatch", code);
-        Assert.Contains("rule.TenantId != tenantId || rule.FactoryId != factoryId", code);
+        Assert.Contains("rule.TenantId != tenantId", code);
+
+        // 反向:工厂不得重新进入规则身份判定。
+        Assert.DoesNotContain("x.FactoryId == factoryId", code);
+        Assert.DoesNotContain("rule.FactoryId != factoryId", code);
     }
     }
 
 
     /// <summary>
     /// <summary>
@@ -113,35 +121,69 @@ public class S8SchedulerScopeGuardTests
     }
     }
 
 
     /// <summary>
     /// <summary>
-    /// 遍历入口本身保持「平台后台任务」语义:按租户枚举 + 只取 Status=1 的租户,
-    /// 但每个 scope 必须带 tenant_id / factory_id 两个维度(不能只按租户)。
+    /// 遍历入口保持「平台后台任务」语义:按租户枚举 + 只取 Status=1 的租户。
+    ///
+    /// <para><b>S8-TENANT-ONLY-BATCH5</b>:遍历维度由 (tenant, factory) 改为 tenant。
+    /// 特别断言 <c>AND r.factory_id &gt; 0</c> **不得回归** —— 它会让 Batch 2 供给出来的
+    /// factory_id=0 的运行策略永远不被调度,且不报任何错:页面显示已启用,实际从不执行。</para>
     /// </summary>
     /// </summary>
     [Fact]
     [Fact]
-    public void EnabledScopeEnumeration_IsTenantAndFactoryPaired()
+    public void EnabledScopeEnumeration_IsTenantOnly()
     {
     {
         var code = CodeOnly(SchedulerSrc);
         var code = CodeOnly(SchedulerSrc);
-        Assert.Contains("SELECT DISTINCT r.tenant_id AS TenantId, r.factory_id AS FactoryId", code);
+        Assert.Contains("SELECT DISTINCT r.tenant_id", code);
         Assert.Contains("INNER JOIN SysTenant t ON t.Id = r.tenant_id AND t.Status = 1", code);
         Assert.Contains("INNER JOIN SysTenant t ON t.Id = r.tenant_id AND t.Status = 1", code);
         Assert.Contains("AND r.tenant_id > 0", code);
         Assert.Contains("AND r.tenant_id > 0", code);
-        Assert.Contains("AND r.factory_id > 0", code);
+
+        Assert.DoesNotContain("r.factory_id AS FactoryId", code);
+        Assert.DoesNotContain("AND r.factory_id > 0", code);
+    }
+
+    /// <summary>
+    /// 下游写入口必须继续以**显式租户**承接,不允许退回从 rule / 客户端推导。
+    ///
+    /// <para><b>S8-TENANT-ONLY-BATCH5</b>:第二个 long 形参(factoryId)已移除。
+    /// 这里改为断言"第一个形参是 tenantId 且形参里不再出现 factoryId" ——
+    /// 显式作用域这条要求没有放松,只是维度少了一个。</para>
+    /// </summary>
+    [Theory]
+    [InlineData(nameof(S8WatchSchedulerService.PickReadyRulesAsync))]
+    [InlineData(nameof(S8WatchSchedulerService.RunSingleRuleAsync))]
+    [InlineData(nameof(S8WatchSchedulerService.ResetExpiredLeasesAsync))]
+    [InlineData(nameof(S8WatchSchedulerService.RunDispatchTickAsync))]
+    [InlineData(nameof(S8WatchSchedulerService.OnRuleCompletedAsync))]
+    public void DownstreamWriters_KeepExplicitTenantScope(string method)
+    {
+        var m = typeof(S8WatchSchedulerService).GetMethod(method);
+        Assert.NotNull(m);
+
+        var ps = m!.GetParameters();
+        Assert.Equal("tenantId", ps[0].Name);
+        Assert.Equal(typeof(long), ps[0].ParameterType);
+
+        Assert.DoesNotContain(ps, p => p.Name == "factoryId");
     }
     }
 
 
     /// <summary>
     /// <summary>
-    /// 下游写入口必须继续以显式 (tenantId, factoryId) 承接,不允许退回从 rule / 客户端推导。
+    /// S8-TENANT-ONLY-BATCH5:dedup_key 不得再含工厂段。
+    ///
+    /// <para>这条是本批最关键的防回归点:一旦有人把 <c>:F{factory}</c> 加回去,
+    /// 历史异常与新命中会分裂成两套身份 —— 旧异常开始 recovery、新键再建一条重复单,
+    /// 而且这个后果只会在生产跑一段时间之后才显形。</para>
     /// </summary>
     /// </summary>
     [Fact]
     [Fact]
-    public void DownstreamWriters_KeepExplicitScopeParameters()
+    public void DedupKey_CarriesNoFactorySegment()
     {
     {
-        var pick = typeof(S8WatchSchedulerService).GetMethod(
-            nameof(S8WatchSchedulerService.PickReadyRulesAsync));
-        Assert.NotNull(pick);
-        Assert.Equal(typeof(long), pick!.GetParameters()[0].ParameterType);
-        Assert.Equal(typeof(long), pick.GetParameters()[1].ParameterType);
-
-        var run = typeof(S8WatchSchedulerService).GetMethod(
-            nameof(S8WatchSchedulerService.RunSingleRuleAsync));
-        Assert.NotNull(run);
-        Assert.Equal(typeof(long), run!.GetParameters()[0].ParameterType);
-        Assert.Equal(typeof(long), run.GetParameters()[1].ParameterType);
+        foreach (var src in new[]
+                 {
+                     "Service/S8/Rules/S8TimeoutRuleEvaluator.cs",
+                     "Service/S8/Rules/S8ShortageRuleEvaluator.cs",
+                     "Service/S8/Rules/S8OutOfRangeRuleEvaluator.cs",
+                 })
+        {
+            var code = CodeOnly(src);
+            Assert.Contains("$\"T{tenantId}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}\"", code);
+            Assert.DoesNotContain(":F{factoryId}", code);
+        }
     }
     }
 }
 }

+ 11 - 2
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8TenantIsolationContractTests.cs

@@ -24,11 +24,20 @@ public class S8TenantIsolationContractTests
             [typeof(long), typeof(long), typeof(S8RuleHit)]));
             [typeof(long), typeof(long), typeof(S8RuleHit)]));
     }
     }
 
 
+    /// <summary>
+    /// S8-TENANT-ONLY-BATCH5:规则调度的遍历维度已从 (租户, 工厂) 收敛为租户。
+    /// 断言的是「新形态存在 + 旧形态不存在」——只断言新方法在,挡不住有人把旧方法加回来。
+    ///
+    /// <para><c>S8ActiveFlowWatchService.ListActiveScopesAsync</c> 仍是 (租户, 工厂):
+    /// 它属于异常侧,归 Batch 6,本批刻意不动。</para>
+    /// </summary>
     [Fact]
     [Fact]
-    public void SchedulerServices_DiscoverValidTenantScopes()
+    public void SchedulerDiscoversTenants_NotTenantFactoryPairs()
     {
     {
         Assert.NotNull(typeof(S8WatchSchedulerService).GetMethod(
         Assert.NotNull(typeof(S8WatchSchedulerService).GetMethod(
-            nameof(S8WatchSchedulerService.ListEnabledScopesAsync)));
+            nameof(S8WatchSchedulerService.ListEnabledTenantsAsync)));
+        Assert.Null(typeof(S8WatchSchedulerService).GetMethod("ListEnabledScopesAsync"));
+
         Assert.NotNull(typeof(S8ActiveFlowWatchService).GetMethod(
         Assert.NotNull(typeof(S8ActiveFlowWatchService).GetMethod(
             nameof(S8ActiveFlowWatchService.ListActiveScopesAsync)));
             nameof(S8ActiveFlowWatchService.ListActiveScopesAsync)));
     }
     }

+ 403 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8TenantOnlyMainChainTests.cs

@@ -0,0 +1,403 @@
+using System.Reflection;
+using System.Text.RegularExpressions;
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using SqlSugar;
+using Admin.NET.Plugin.AiDOP.Service.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S8;
+
+/// <summary>
+/// S8-TENANT-ONLY-BATCH5:规则执行主链收敛为租户级作用域。
+///
+/// <para><b>本批要消灭的是"伪 Factory Scope"</b>:取数从一开始就是租户级的
+/// (唯一 Provider 的 WHERE 只有 <c>tenant_id</c>),但规则归属、调度作用域、
+/// dedup 身份、抗抖状态唯一键却统统按工厂切。结果是两件事同时发生:</para>
+/// <list type="bullet">
+///   <item>零工厂 / 多工厂租户被形式门槛拒之门外,而放不放行对取出的数据毫无影响;</item>
+///   <item>Batch 2 供给出来的 <c>factory_id = 0</c> 运行策略永远不被调度,且不报任何错
+///         —— 页面显示已启用,实际从不执行。</item>
+/// </list>
+///
+/// <para>本文件的多数断言是**反向的**("不得出现 factory"),因为这类回归的特征是
+/// 悄悄加回一个谓词就让一批规则静默失效,正向断言抓不到。</para>
+/// </summary>
+public class S8TenantOnlyMainChainTests
+{
+    private const long TenantA = 838257186181189L;
+    private const long TenantB = 838257212780613L;
+    private const string Rule01 = S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryDateDelayCode;
+
+    private static S8RuleDefinition Definition() =>
+        new S8RuleCatalog(new IS8RuleDefinitionSource[] { new S8PurchaseDeliveryRuleDefinitions() })
+            .GetRequired(Rule01);
+
+    // ───────────────── T1:取数请求契约 ─────────────────
+
+    [Fact]
+    public void T1_MonitoringDataRequest_HasNoFactoryScope()
+    {
+        Assert.Null(typeof(S8MonitoringDataRequest).GetProperty("FactoryId"));
+
+        // 只缺租户才失败;工厂已不是作用域的一部分,无从缺失。
+        var req = new S8MonitoringDataRequest
+        {
+            TenantId = TenantA, DatasetCode = "D", RuleCode = Rule01, RuleType = "TIMEOUT"
+        };
+        req.EnsureValid();   // 不抛即通过
+
+        var invalid = new S8MonitoringDataRequest
+        {
+            TenantId = 0, DatasetCode = "D", RuleCode = Rule01, RuleType = "TIMEOUT"
+        };
+        var ex = Assert.Throws<S8RuleEvaluatorException>(() => invalid.EnsureValid());
+        Assert.Equal("dataset_scope_invalid", ex.Reason);
+        Assert.DoesNotContain("FactoryId", ex.Message, StringComparison.Ordinal);
+    }
+
+    // ───────────────── T2 / T4 / T6 / T16:FactoryId = 0 不再阻塞 ─────────────────
+
+    /// <summary>
+    /// Enable Gate 不得再因 <c>factory_id &lt;= 0</c> 拒绝规则。
+    /// 这条直接决定 Batch 2 供给出来的运行策略能不能被启用。
+    /// </summary>
+    [Fact]
+    public void T2_EnableGate_TakesTenantOnly_AndHasNoFactoryOverload()
+    {
+        var gate = typeof(S8DatasetEnableGate);
+
+        Assert.NotNull(gate.GetMethod("Check", new[] { typeof(AdoS8WatchRule), typeof(long) }));
+        Assert.Null(gate.GetMethod("Check", new[] { typeof(AdoS8WatchRule), typeof(long), typeof(long) }));
+
+        Assert.NotNull(gate.GetMethod("EnsureCanEnable", new[] { typeof(AdoS8WatchRule), typeof(long) }));
+        Assert.Null(gate.GetMethod("EnsureCanEnable", new[] { typeof(AdoS8WatchRule), typeof(long), typeof(long) }));
+    }
+
+    [Fact]
+    public void T2_EnableGate_SourceHasNoInvalidFactoryScopeCheck()
+    {
+        var code = CodeOnly("Service/S8/Rules/DataAccess/S8DatasetEnableGate.cs");
+        Assert.DoesNotContain("InvalidFactoryScope", code);
+        Assert.DoesNotContain("factoryId <= 0", code);
+
+        // 反向对照:租户门槛必须还在,否则是把门整个拆了而不是收敛。
+        Assert.Contains("tenantId <= 0", code);
+        Assert.Contains("InvalidTenantScope", code);
+    }
+
+    /// <summary>
+    /// T4 / T16:Batch 2 供给出来的行 <c>factory_id = 0</c>。
+    /// 调度候选查询若还带工厂谓词,这些行会被静默排除 —— 断言源码里不存在该谓词。
+    /// </summary>
+    [Fact]
+    public void T4_T16_ProvisionedFactoryZeroRule_IsNotExcludedByScheduler()
+    {
+        Assert.Equal(0L, S8RuleProvisioningService.CompatibilityFactoryId);
+
+        var code = CodeOnly("Service/S8/S8WatchSchedulerService.cs");
+        Assert.DoesNotContain("AND r.factory_id > 0", code);
+        Assert.DoesNotContain("x.FactoryId == factoryId", code);
+    }
+
+    // ───────────────── T3 / T5 / T6:Tenant → Rule ─────────────────
+
+    [Fact]
+    public void T3_Scheduler_EnumeratesTenants_ReturningPlainTenantIds()
+    {
+        var m = typeof(S8WatchSchedulerService).GetMethod("ListEnabledTenantsAsync");
+        Assert.NotNull(m);
+        Assert.Equal(typeof(Task<List<long>>), m!.ReturnType);
+
+        Assert.Null(typeof(S8WatchSchedulerService).GetMethod("ListEnabledScopesAsync"));
+    }
+
+    /// <summary>
+    /// T5 / T6:0 / 1 / N 个工厂的租户都只产生**一次**调度。
+    ///
+    /// <para>工厂数量根本不是调度遍历的输入 —— 这正是要断言的事实:
+    /// 遍历返回的是租户 id 列表,一个租户在里面只可能出现一次(SQL 的 DISTINCT tenant_id),
+    /// 因此无论它有几个工厂,规则都只跑一遍。</para>
+    /// </summary>
+    [Theory]
+    [InlineData(0)]
+    [InlineData(1)]
+    [InlineData(3)]
+    public void T5_T6_TenantIsDispatchedOnce_RegardlessOfFactoryCount(int factoryCount)
+    {
+        _ = factoryCount;   // 不是输入,故意不用
+
+        var code = CodeOnly("Service/S8/S8WatchSchedulerService.cs");
+        Assert.Contains("SELECT DISTINCT r.tenant_id", code);
+        Assert.DoesNotContain("DISTINCT r.tenant_id AS TenantId, r.factory_id", code);
+
+        // Job 侧按租户遍历,不再按 (租户, 工厂) 对。
+        var job = CodeOnly("Job/S8WatchSchedulerJob.cs");
+        Assert.Contains("foreach (var tenantId in tenantIds)", job);
+        Assert.Contains("RunDispatchTickAsync(tenantId, BatchSize, lockedBy)", job);
+    }
+
+    // ───────────────── T7:Evaluator 契约 ─────────────────
+
+    [Fact]
+    public void T7_EvaluatorContract_HasNoFactoryScope()
+    {
+        var names = typeof(IS8RuleEvaluator).GetMethod(nameof(IS8RuleEvaluator.EvaluateAsync))!
+            .GetParameters().Select(p => p.Name).ToArray();
+        Assert.Equal(new[] { "tenantId", "rule", "cancellationToken" }, names);
+
+        foreach (var impl in new[]
+                 { typeof(S8TimeoutRuleEvaluator), typeof(S8ShortageRuleEvaluator), typeof(S8OutOfRangeRuleEvaluator) })
+        {
+            var ps = impl.GetMethod("EvaluateAsync")!.GetParameters().Select(p => p.Name).ToArray();
+            Assert.DoesNotContain("factoryId", ps);
+        }
+    }
+
+    // ───────────────── T8 / T9 / T10:DedupKey vNext ─────────────────
+
+    [Fact]
+    public void T8_DedupKey_ContainsNoFactorySegment()
+    {
+        var key = S8TimeoutRuleEvaluator.BuildDedupKey(TenantA, Rule01, "PURCHASE_ORDER_LINE", "UATA-PO-001#1");
+
+        Assert.Equal($"T{TenantA}:R{Rule01}:PURCHASE_ORDER_LINE:UATA-PO-001#1", key);
+        Assert.DoesNotMatch(new Regex("^T[0-9]+:F[0-9]+:"), key);
+
+        // 租户前缀必须完整保留 —— 迁移脚本首版曾把它写掉过(MySQL 反向引用要用 $1 而非 \\1)。
+        Assert.StartsWith($"T{TenantA}:", key, StringComparison.Ordinal);
+    }
+
+    /// <summary>
+    /// T9:同租户 / 同规则 / 同对象,无论工厂元数据是什么,dedup 身份必须相同。
+    /// 三个 evaluator 的公式必须逐字一致,否则跨类型的历史迁移会出现两套格式。
+    /// </summary>
+    [Fact]
+    public void T9_SameTenantRuleObject_YieldsSameDedup_AcrossEvaluators()
+    {
+        var t = S8TimeoutRuleEvaluator.BuildDedupKey(TenantA, "R", "ORDER", "ID");
+        var s = S8ShortageRuleEvaluator.BuildDedupKey(TenantA, "R", "ORDER", "ID");
+        var o = S8OutOfRangeRuleEvaluator.BuildDedupKey(TenantA, "R", "ORDER", "ID");
+
+        Assert.Equal(t, s);
+        Assert.Equal(t, o);
+    }
+
+    [Fact]
+    public void T10_DifferentTenant_SameObject_YieldsDifferentDedup()
+    {
+        var a = S8TimeoutRuleEvaluator.BuildDedupKey(TenantA, "R", "ORDER", "ID");
+        var b = S8TimeoutRuleEvaluator.BuildDedupKey(TenantB, "R", "ORDER", "ID");
+
+        Assert.NotEqual(a, b);
+        Assert.StartsWith($"T{TenantA}:", a, StringComparison.Ordinal);
+        Assert.StartsWith($"T{TenantB}:", b, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void T8_DedupKey_StillFitsVarchar128()
+    {
+        // 去掉 F 段后前缀更短,余量只会变大;本断言防的是将来又往键里塞东西。
+        var k = S8TimeoutRuleEvaluator.BuildDedupKey(
+            9999999999999999L, "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}");
+    }
+
+    // ───────────────── T11:DetectionState identity ─────────────────
+
+    [Fact]
+    public void T11_DetectionStateUniqueKey_IsTenantRuleDedup()
+    {
+        var uk = typeof(AdoS8RuleDetectionState)
+            .GetCustomAttributes<SugarIndexAttribute>()
+            .Single(a => a.IndexName == "uk_s8_rule_detection_state_dedup");
+
+        Assert.True(uk.IsUnique);
+        Assert.Equal(new[] { "TenantId", "RuleCode", "DedupKey" }, uk.IndexFields.Keys.ToArray());
+        Assert.DoesNotContain("FactoryId", uk.IndexFields.Keys);
+    }
+
+    [Fact]
+    public void T11_DetectionStateUpsert_LooksUpWithoutFactory()
+    {
+        var code = CodeOnly("Service/S8/S8WatchSchedulerService.cs");
+
+        // 抗抖状态的 upsert / 累计必须只按 (tenant, rule_code, dedup_key)。
+        Assert.Contains("x.RuleCode == rule.RuleCode", code);
+        Assert.DoesNotContain("x.FactoryId == factoryId", code);
+    }
+
+    // ───────────────── T14 / T15:Rule scope ─────────────────
+
+    [Fact]
+    public void T14_WatchRuleLookup_IsTenantOnly()
+    {
+        var code = CodeOnly("Service/S8/S8WatchRuleService.cs");
+
+        Assert.Contains("x.Id == id && x.TenantId == scope.TenantId", code);
+        Assert.DoesNotContain("x.FactoryId == scope.FactoryId", code);
+    }
+
+    [Fact]
+    public void T15_WatchRuleUniqueIdentity_IsTenantAndRuleCode()
+    {
+        var uk = typeof(AdoS8WatchRule)
+            .GetCustomAttributes<SugarIndexAttribute>()
+            .Single(a => a.IndexName == "uk_s8_watch_rule_tenant_code");
+
+        Assert.True(uk.IsUnique);
+        Assert.Equal(new[] { "TenantId", "RuleCode" }, uk.IndexFields.Keys.ToArray());
+    }
+
+    // ───────────────── T13:Preview 与真跑同源 ─────────────────
+
+    /// <summary>
+    /// 预演必须与调度器用同一套作用域与 dedup 口径。
+    /// 两边一旦分叉,预演给出的"会新建还是会刷新"就不再可信 —— 而那正是预演唯一的价值。
+    /// </summary>
+    [Fact]
+    public void T13_Preview_UsesSameTenantOnlyScopeAsScheduler()
+    {
+        var code = CodeOnly("Service/S8/S8WatchRulePreviewService.cs");
+
+        Assert.Contains("_gateway.LoadAsync(scope.TenantId, rule, ruleType, maxRows)", code);
+        Assert.Contains("evaluator.EvaluateAsync(scope.TenantId, rule)", code);
+        Assert.DoesNotContain("x.FactoryId == scope.FactoryId", code);
+    }
+
+    // ───────────────── T17–T22:迁移脚本自身的守卫 ─────────────────
+
+    /// <summary>
+    /// 迁移脚本必须自带碰撞门禁:去掉 F 段后若两个旧键映射到同一个新键,
+    /// 自动挑一条会静默丢数据,必须让脚本失败。
+    /// </summary>
+    [Fact]
+    public void T17_Migration_HasCollisionPreflightGate()
+    {
+        var sql = MigrationSql();
+
+        Assert.Contains("@collision_exception", sql);
+        Assert.Contains("@collision_state", sql);
+        Assert.Contains("@dup_watch_rule", sql);
+
+        // 门禁靠具名 CHECK 约束;约束名即失败原因,会出现在 error_message 里。
+        Assert.Contains("s8_tenant_only_BLOCKED_see_collision_table_1_0_493", sql);
+        Assert.Contains("CHECK (probe = 0)", sql);
+    }
+
+    /// <summary>
+    /// T18:改写必须用 <c>$1</c> 反向引用。
+    ///
+    /// <para>MySQL 的 <c>REGEXP_REPLACE</c> 用 ICU 语法,<c>\\1</c> 不是分组引用而会被吞成字面量 "1"
+    /// —— 租户前缀整段丢失,得到既不是旧格式也不是新格式的坏键。
+    /// 本地隔离库首次执行时实测踩到过,本断言防它复发。</para>
+    /// </summary>
+    [Fact]
+    public void T18_Migration_UsesDollarBackreference_NotBackslash()
+    {
+        var sql = MigrationSql();
+
+        Assert.Contains("'$1:'", sql);
+        Assert.DoesNotContain("'\\\\1:'", sql);
+    }
+
+    /// <summary>T19:改写面必须锚定旧格式,不得做无界字符串替换。</summary>
+    [Fact]
+    public void T19_Migration_RewritesOnlyAnchoredLegacyFormat()
+    {
+        var sql = MigrationSql();
+
+        Assert.Contains("'^T[0-9]+:F[0-9]+:R'", sql);
+        // 无界替换会误伤任何恰好含 ":F" 的业务对象标识。
+        Assert.DoesNotContain("REPLACE(dedup_key, ':F'", sql);
+
+        // 归档表必须先于改写建立,否则失败后无从恢复。
+        Assert.True(sql.IndexOf("_dedup_bak_1_0_493", StringComparison.Ordinal)
+                    < sql.IndexOf("UPDATE ado_s8_exception", StringComparison.Ordinal),
+            "归档必须发生在改写之前");
+    }
+
+    [Fact]
+    public void T20_T21_Migration_RebuildsTenantOnlyUniqueKeys()
+    {
+        var sql = MigrationSql();
+
+        Assert.Contains("uk_s8_rule_detection_state_dedup (tenant_id, rule_code, dedup_key)", sql);
+        Assert.Contains("uk_s8_watch_rule_tenant_code (tenant_id, rule_code)", sql);
+    }
+
+    /// <summary>
+    /// T22:重复执行安全。改写谓词自带幂等(改完就不再匹配);
+    /// DDL 全部由 information_schema 存在性守卫,不依赖事务回滚
+    /// —— MySQL 的 DDL 多数自动提交,假设能回滚是危险的。
+    /// </summary>
+    [Fact]
+    public void T22_Migration_IsRerunSafe()
+    {
+        var sql = MigrationSql();
+
+        Assert.Contains("information_schema.STATISTICS", sql);
+        Assert.Contains("INSERT IGNORE INTO ado_s8_exception_dedup_bak_1_0_493", sql);
+        Assert.Contains("CREATE TABLE IF NOT EXISTS ado_s8_exception_dedup_bak_1_0_493", sql);
+
+        // 执行器不支持自定义语句分隔符指令,脚本通篇不得出现该关键字(它做的是全文子串匹配)。
+        Assert.DoesNotContain("DELIMITER", sql, StringComparison.OrdinalIgnoreCase);
+    }
+
+    /// <summary>迁移刻意不碰的对象:1.0.487 的还原点、异常表的工厂唯一键、以及任何 factory_id 列。</summary>
+    [Fact]
+    public void Migration_DoesNotTouchOutOfScopeObjects()
+    {
+        // 只看可执行 SQL:脚本注释里会**明确列出"不动哪些对象"**,
+        // 那正是我们想要的留档,不该被当成触碰。
+        var sql = ExecutableSql(MigrationSql());
+
+        Assert.DoesNotContain("ado_s8_exception_bak_1_0_487", sql);
+        Assert.DoesNotContain("uk_ado_s8_exception_tenant_factory_code", sql);
+        Assert.DoesNotContain("DROP COLUMN", sql);
+        Assert.DoesNotContain("mdp_entity", sql);
+    }
+
+    /// <summary>去掉 <c>--</c> 注释行,只留可执行语句。</summary>
+    private static string ExecutableSql(string sql) =>
+        string.Join('\n', sql.Split('\n').Where(l => !l.TrimStart().StartsWith("--", StringComparison.Ordinal)));
+
+    // ───────────────── helpers ─────────────────
+
+    private static string MigrationSql()
+    {
+        var dir = new DirectoryInfo(AppContext.BaseDirectory);
+        while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Web.Entry")))
+            dir = dir.Parent;
+        Assert.NotNull(dir);
+
+        var path = Path.Combine(dir!.FullName, "Admin.NET.Web.Entry", "UpdateScripts", "1.0.493.sql");
+        Assert.True(File.Exists(path), $"迁移脚本不存在,路径需同步更新:{path}");
+        return File.ReadAllText(path);
+    }
+
+    private static string CodeOnly(string relative)
+    {
+        var dir = new DirectoryInfo(AppContext.BaseDirectory);
+        while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Plugin.AiDOP")))
+            dir = dir.Parent;
+        Assert.NotNull(dir);
+
+        var full = Path.Combine(dir!.FullName, "Admin.NET.Plugin.AiDOP",
+            relative.Replace('/', Path.DirectorySeparatorChar));
+        Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
+
+        // 只取可执行代码行:注释里为留档会复述旧写法,不应算违规。
+        return string.Join('\n', File.ReadAllLines(full)
+            .Where(l =>
+            {
+                var t = l.TrimStart();
+                return !t.StartsWith("///", StringComparison.Ordinal)
+                       && !t.StartsWith("//", StringComparison.Ordinal);
+            }));
+    }
+}

+ 3 - 1
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8WatchRuleWriteGuardTests.cs

@@ -75,7 +75,9 @@ public class S8WatchRuleWriteGuardTests
     public void NarrowWritePathsAndReadPath_ArePreserved()
     public void NarrowWritePathsAndReadPath_ArePreserved()
     {
     {
         var t = typeof(S8WatchRuleService);
         var t = typeof(S8WatchRuleService);
-        Assert.NotNull(t.GetMethod("ListAsync", new[] { typeof(long), typeof(long) }));
+        // S8-TENANT-ONLY-BATCH5:列表按租户取,不再按 (租户, 工厂)。
+        Assert.NotNull(t.GetMethod("ListAsync", new[] { typeof(long) }));
+        Assert.Null(t.GetMethod("ListAsync", new[] { typeof(long), typeof(long) }));
         // S8-RULE-GOVERNANCE-BATCH1:整块 params 入口已被强类型白名单入口取代(G1 修复)。
         // S8-RULE-GOVERNANCE-BATCH1:整块 params 入口已被强类型白名单入口取代(G1 修复)。
         // 断言旧签名**不存在**,否则那条"缺字段即置 NULL"的路径就还活着。
         // 断言旧签名**不存在**,否则那条"缺字段即置 NULL"的路径就还活着。
         Assert.Null(t.GetMethods().FirstOrDefault(m => m.Name == "UpdateParamsAsync"));
         Assert.Null(t.GetMethods().FirstOrDefault(m => m.Name == "UpdateParamsAsync"));

+ 1 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8ConfigWatchRulesController.cs

@@ -37,7 +37,7 @@ public class AdoS8ConfigWatchRulesController : ControllerBase
         // Compatibility-only. Security scope is resolved server-side.
         // Compatibility-only. Security scope is resolved server-side.
         _ = tenantId; _ = factoryId;
         _ = tenantId; _ = factoryId;
         var scope = await _scope.ResolveAsync();
         var scope = await _scope.ResolveAsync();
-        return Ok(await _svc.ListAsync(scope.TenantId, scope.FactoryId));
+        return Ok(await _svc.ListAsync(scope.TenantId));
     }
     }
 
 
     // ================================================================================
     // ================================================================================

+ 1 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/S8/AdoS8WatchDebugController.cs

@@ -34,7 +34,7 @@ public class AdoS8WatchDebugController : ControllerBase
     {
     {
         if (!_debugEndpointEnabled) return NotFound();
         if (!_debugEndpointEnabled) return NotFound();
 
 
-        var results = await _svc.CreateExceptionsAsync(tenantId, factoryId);
+        var results = await _svc.CreateExceptionsAsync(tenantId);
         return Ok(new
         return Ok(new
         {
         {
             count = results.Count,
             count = results.Count,

+ 7 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8RuleDetectionState.cs

@@ -4,19 +4,19 @@ namespace Admin.NET.Plugin.AiDOP.Entity.S8;
 /// S8 规则检测抗抖累计状态(建单前阶段)。
 /// S8 规则检测抗抖累计状态(建单前阶段)。
 /// 用途:在"持续 N 次命中才建单 / 持续 N 次未命中才标 recovered"路径上,
 /// 用途:在"持续 N 次命中才建单 / 持续 N 次未命中才标 recovered"路径上,
 /// 承载每条 (rule_code, dedup_key) 在还未建单时的累计计数。
 /// 承载每条 (rule_code, dedup_key) 在还未建单时的累计计数。
-/// 本表无软删字段;唯一记录由 (tenant_id, factory_id, rule_code, dedup_key) 决定。
+/// 本表无软删字段;唯一记录由 (tenant_id, rule_code, dedup_key) 决定。
+/// S8-TENANT-ONLY-BATCH5:factory_id 已退出唯一键。它此前把同一 (tenant, rule, dedup)
+/// 按工厂切成多行,抗抖计数因此可能分裂在两行上、trigger/recover 阈值静默失效。
 /// 已建单后的抗抖累计落在 ado_s8_exception.consecutive_hit_count / consecutive_miss_count。
 /// 已建单后的抗抖累计落在 ado_s8_exception.consecutive_hit_count / consecutive_miss_count。
 /// </summary>
 /// </summary>
 [SugarTable("ado_s8_rule_detection_state", "S8 规则检测抗抖状态")]
 [SugarTable("ado_s8_rule_detection_state", "S8 规则检测抗抖状态")]
 [SugarIndex("uk_s8_rule_detection_state_dedup",
 [SugarIndex("uk_s8_rule_detection_state_dedup",
     nameof(TenantId), OrderByType.Asc,
     nameof(TenantId), OrderByType.Asc,
-    nameof(FactoryId), OrderByType.Asc,
     nameof(RuleCode), OrderByType.Asc,
     nameof(RuleCode), OrderByType.Asc,
     nameof(DedupKey), OrderByType.Asc,
     nameof(DedupKey), OrderByType.Asc,
     IsUnique = true)]
     IsUnique = true)]
 [SugarIndex("idx_s8_rule_detection_state_rule",
 [SugarIndex("idx_s8_rule_detection_state_rule",
     nameof(TenantId), OrderByType.Asc,
     nameof(TenantId), OrderByType.Asc,
-    nameof(FactoryId), OrderByType.Asc,
     nameof(RuleCode), OrderByType.Asc)]
     nameof(RuleCode), OrderByType.Asc)]
 [SugarIndex("idx_s8_rule_detection_state_active_exception",
 [SugarIndex("idx_s8_rule_detection_state_active_exception",
     nameof(ActiveExceptionId), OrderByType.Asc)]
     nameof(ActiveExceptionId), OrderByType.Asc)]
@@ -28,6 +28,10 @@ public class AdoS8RuleDetectionState
     [SugarColumn(ColumnName = "tenant_id", ColumnDataType = "bigint")]
     [SugarColumn(ColumnName = "tenant_id", ColumnDataType = "bigint")]
     public long TenantId { get; set; }
     public long TenantId { get; set; }
 
 
+    /// <summary>
+    /// 历史兼容列。S8-TENANT-ONLY-BATCH5 起**不再参与唯一键与任何查询谓词**,
+    /// 仅作元数据保留(写入时取规则行的 factory_id)。列不删除,等后续 schema 清理。
+    /// </summary>
     [SugarColumn(ColumnName = "factory_id", ColumnDataType = "bigint")]
     [SugarColumn(ColumnName = "factory_id", ColumnDataType = "bigint")]
     public long FactoryId { get; set; }
     public long FactoryId { get; set; }
 
 

+ 7 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/S8/AdoS8WatchRule.cs

@@ -6,6 +6,9 @@ namespace Admin.NET.Plugin.AiDOP.Entity.S8;
 [SugarIndex("idx_s8_watch_rule_dispatch", nameof(Enabled), OrderByType.Asc, nameof(PausedUntil), OrderByType.Asc, nameof(NextRunAt), OrderByType.Asc)]
 [SugarIndex("idx_s8_watch_rule_dispatch", nameof(Enabled), OrderByType.Asc, nameof(PausedUntil), OrderByType.Asc, nameof(NextRunAt), OrderByType.Asc)]
 [SugarIndex("idx_s8_watch_rule_lock_until", nameof(LockUntil), OrderByType.Asc)]
 [SugarIndex("idx_s8_watch_rule_lock_until", nameof(LockUntil), OrderByType.Asc)]
 [SugarIndex("idx_s8_watch_rule_rule_code", nameof(RuleCode), OrderByType.Asc)]
 [SugarIndex("idx_s8_watch_rule_rule_code", nameof(RuleCode), OrderByType.Asc)]
+// S8-TENANT-ONLY-BATCH5:运行策略的逻辑唯一键在 Batch 2 已定为 (TenantId, RuleCode),
+// 此前只有应用层 AnyAsync 保证。加 DB 兜底后,并发供给不再可能建出同码重复行。
+[SugarIndex("uk_s8_watch_rule_tenant_code", nameof(TenantId), OrderByType.Asc, nameof(RuleCode), OrderByType.Asc, IsUnique = true)]
 public class AdoS8WatchRule
 public class AdoS8WatchRule
 {
 {
     [SugarColumn(ColumnName = "id", IsPrimaryKey = true, IsIdentity = true, ColumnDataType = "bigint")]
     [SugarColumn(ColumnName = "id", IsPrimaryKey = true, IsIdentity = true, ColumnDataType = "bigint")]
@@ -14,6 +17,10 @@ public class AdoS8WatchRule
     [SugarColumn(ColumnName = "tenant_id", ColumnDataType = "bigint")]
     [SugarColumn(ColumnName = "tenant_id", ColumnDataType = "bigint")]
     public long TenantId { get; set; }
     public long TenantId { get; set; }
 
 
+    /// <summary>
+    /// 历史兼容列。S8-TENANT-ONLY-BATCH5 起**不再参与规则身份、调度作用域与任何查询谓词**,
+    /// 仅作元数据保留(Batch 2 供给出来的新行恒为 0)。列不删除,等后续 schema 清理。
+    /// </summary>
     [SugarColumn(ColumnName = "factory_id", ColumnDataType = "bigint")]
     [SugarColumn(ColumnName = "factory_id", ColumnDataType = "bigint")]
     public long FactoryId { get; set; }
     public long FactoryId { get; set; }
 
 

+ 11 - 12
server/Plugins/Admin.NET.Plugin.AiDOP/Job/S8WatchSchedulerJob.cs

@@ -10,7 +10,9 @@ namespace Admin.NET.Plugin.AiDOP.Job;
 
 
 /// <summary>
 /// <summary>
 /// G-08:S8 自动监控主链定时调度作业。
 /// G-08:S8 自动监控主链定时调度作业。
-/// 固定租户 / 工厂上下文下,按周期触发一次 G-01 主链(<see cref="S8WatchSchedulerService.CreateExceptionsAsync"/>)。
+/// 按租户遍历,周期触发规则调度主链。
+/// S8-TENANT-ONLY-BATCH5:遍历维度由 (租户, 工厂) 改为租户 —— 运行策略每租户一条,
+/// 按工厂重复 tick 只会让多次 tick 抢同一把租约。
 /// 本轮仅完成"正式定时触发接入";不在调度层追加业务逻辑、不改写主链入参语义、不做真实 SQL / 认证链路 / SLA 治理。
 /// 本轮仅完成"正式定时触发接入";不在调度层追加业务逻辑、不改写主链入参语义、不做真实 SQL / 认证链路 / SLA 治理。
 /// 周期由 [Period] 属性固化为默认值(30 分钟);运行期调整走 Admin.NET 作业管理,不走配置中心。
 /// 周期由 [Period] 属性固化为默认值(30 分钟);运行期调整走 Admin.NET 作业管理,不走配置中心。
 /// 开关:默认通过 Admin.NET 作业管理的触发器状态控制;本作业构造不自动启动。
 /// 开关:默认通过 Admin.NET 作业管理的触发器状态控制;本作业构造不自动启动。
@@ -127,24 +129,23 @@ public class S8WatchSchedulerJob : IJob
         try
         try
         {
         {
             var lockedBy = BuildLockedBy();
             var lockedBy = BuildLockedBy();
-            var scopes = await scheduler.ListEnabledScopesAsync();
+            var tenantIds = await scheduler.ListEnabledTenantsAsync();
             if (Interlocked.Exchange(ref _firstActivationLogged, 1) == 0)
             if (Interlocked.Exchange(ref _firstActivationLogged, 1) == 0)
             {
             {
                 _logger.LogInformation(
                 _logger.LogInformation(
-                    "S8WatchSchedulerJob 首次激活:IntervalMs={IntervalMs} TickIntervalMs={TickInterval} BatchSize={Batch} ScopeCount={ScopeCount} LockedBy={LockedBy}",
-                    IntervalMs, tickIntervalMs, BatchSize, scopes.Count, lockedBy);
+                    "S8WatchSchedulerJob 首次激活:IntervalMs={IntervalMs} TickIntervalMs={TickInterval} BatchSize={Batch} TenantCount={TenantCount} LockedBy={LockedBy}",
+                    IntervalMs, tickIntervalMs, BatchSize, tenantIds.Count, lockedBy);
             }
             }
 
 
-            foreach (var tenantScope in scopes)
+            foreach (var tenantId in tenantIds)
             {
             {
-                var summary = await scheduler.RunDispatchTickAsync(
-                    tenantScope.TenantId, tenantScope.FactoryId, BatchSize, lockedBy);
+                var summary = await scheduler.RunDispatchTickAsync(tenantId, BatchSize, lockedBy);
                 // S8-SCHEDULER-P0-BLEEDING-STOP-CONFIG-1:完全空跑(6 项摘要全 0)降为 LogDebug;
                 // S8-SCHEDULER-P0-BLEEDING-STOP-CONFIG-1:完全空跑(6 项摘要全 0)降为 LogDebug;
                 // 任何非零字段(picked/failed/perRuleFailed/created/refreshed/leaseReleased)保持 LogInformation。
                 // 任何非零字段(picked/failed/perRuleFailed/created/refreshed/leaseReleased)保持 LogInformation。
                 if (IsEmptyTick(summary))
                 if (IsEmptyTick(summary))
-                    _logger.LogDebug(BuildTraceLine(triggeredAt, "success", summary, null, tenantScope.TenantId, tenantScope.FactoryId));
+                    _logger.LogDebug(BuildTraceLine(triggeredAt, "success", summary, null, tenantId));
                 else
                 else
-                    _logger.LogInformation(BuildTraceLine(triggeredAt, "success", summary, null, tenantScope.TenantId, tenantScope.FactoryId));
+                    _logger.LogInformation(BuildTraceLine(triggeredAt, "success", summary, null, tenantId));
             }
             }
         }
         }
         catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
         catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
@@ -177,8 +178,7 @@ public class S8WatchSchedulerJob : IJob
         string result,
         string result,
         S8DispatchTickResult? summary,
         S8DispatchTickResult? summary,
         string? failureSummary,
         string? failureSummary,
-        long? tenantId = null,
-        long? factoryId = null)
+        long? tenantId = null)
     {
     {
         var payload = new
         var payload = new
         {
         {
@@ -186,7 +186,6 @@ public class S8WatchSchedulerJob : IJob
             triggeredAt = triggeredAt.ToString("yyyy-MM-dd HH:mm:ss"),
             triggeredAt = triggeredAt.ToString("yyyy-MM-dd HH:mm:ss"),
             result,
             result,
             tenantId,
             tenantId,
-            factoryId,
             tickId = summary?.TickId,
             tickId = summary?.TickId,
             runId = summary?.RunId,
             runId = summary?.RunId,
             picked = summary?.Picked ?? 0,
             picked = summary?.Picked ?? 0,

+ 3 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/IS8MonitoringDataProvider.cs

@@ -9,7 +9,8 @@ namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
 ///     待其提供 freshness 接口后由本层消费,不在 S8 内造替代实现;
 ///     待其提供 freshness 接口后由本层消费,不在 S8 内造替代实现;
 ///   · 不管理源系统连接串。
 ///   · 不管理源系统连接串。
 ///
 ///
-/// Provider 只回答一件事:给定租户 / 工厂作用域,返回该数据集当前的 canonical 行。
+/// Provider 只回答一件事:给定**租户**作用域,返回该数据集当前的 canonical 行。
+/// S8-TENANT-ONLY-BATCH5:Factory 已退出取数作用域 —— 它从未参与任何 Provider 的过滤条件。
 /// </summary>
 /// </summary>
 public interface IS8MonitoringDataProvider
 public interface IS8MonitoringDataProvider
 {
 {
@@ -22,7 +23,7 @@ public interface IS8MonitoringDataProvider
     /// <summary>
     /// <summary>
     /// 按作用域取 canonical 行。
     /// 按作用域取 canonical 行。
     /// 实现必须先调用 <see cref="S8MonitoringDataRequest.EnsureValid"/>,
     /// 实现必须先调用 <see cref="S8MonitoringDataRequest.EnsureValid"/>,
-    /// 且只能使用请求参数中的 TenantId / FactoryId 作为作用域,不得隐式取值。
+    /// 且只能使用请求参数中的 TenantId 作为作用域,不得隐式取值。
     /// </summary>
     /// </summary>
     Task<S8MonitoringRowSet> LoadAsync(
     Task<S8MonitoringRowSet> LoadAsync(
         S8MonitoringDataRequest request,
         S8MonitoringDataRequest request,

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

@@ -183,13 +183,14 @@ public sealed class S8PurchaseDeliveryDataProvider : IS8MonitoringDataProvider
     /// 写死一个数字会在换规则或换租户时悄悄失效。
     /// 写死一个数字会在换规则或换租户时悄悄失效。
     ///
     ///
     /// 注意这个预算通常比 <c>source_object_id</c> 的列宽(64)更紧:
     /// 注意这个预算通常比 <c>source_object_id</c> 的列宽(64)更紧:
-    /// 15 位租户 + 15 位工厂 + 36 字符规则编码 + PURCHASE_ORDER_LINE 时前缀已占 92,只剩 36。
+    /// S8-TENANT-ONLY-BATCH5 去掉 <c>:F{factory}</c> 段后,
+    /// 15 位租户 + 36 字符规则编码 + PURCHASE_ORDER_LINE 时前缀约占 76,余量比之前宽松了 16 字符。
     /// 落在 37–64 之间的键会过得了列校验、卡在 DedupKey 上,因此必须按本预算判定。
     /// 落在 37–64 之间的键会过得了列校验、卡在 DedupKey 上,因此必须按本预算判定。
     /// </summary>
     /// </summary>
     internal static int ResolveSourceObjectIdBudget(S8MonitoringDataRequest request)
     internal static int ResolveSourceObjectIdBudget(S8MonitoringDataRequest request)
     {
     {
         var prefixLength =
         var prefixLength =
-            $"T{request.TenantId}:F{request.FactoryId}:R{request.RuleCode}:{ExpectedSourceObjectType}:".Length;
+            $"T{request.TenantId}:R{request.RuleCode}:{ExpectedSourceObjectType}:".Length;
         return Math.Max(0, DedupKeyMaxLength - prefixLength);
         return Math.Max(0, DedupKeyMaxLength - prefixLength);
     }
     }
 
 

+ 12 - 11
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/S8DatasetEnableGate.cs

@@ -36,7 +36,10 @@ public sealed class S8DatasetEnableCheck
 ///   7. Provider 是否注册          → dataset_provider_not_registered
 ///   7. Provider 是否注册          → dataset_provider_not_registered
 ///   8. Provider 编码是否一致      → dataset_provider_code_mismatch
 ///   8. Provider 编码是否一致      → dataset_provider_code_mismatch
 ///   9. Provider 能力是否同样满足   → dataset_capability_mismatch / dataset_missing_required_field
 ///   9. Provider 能力是否同样满足   → dataset_capability_mismatch / dataset_missing_required_field
-///  10. 租户 / 工厂作用域          → invalid_tenant_scope / invalid_factory_scope
+///  10. 租户作用域                → invalid_tenant_scope
+///
+/// S8-TENANT-ONLY-BATCH5:第 ⑩ 步的工厂检查已删除。它此前会因 factoryId &lt;= 0 拒绝启用,
+/// 而 Batch 2 供给出来的运行策略行 factory_id 恒为 0 —— 那些规则本来永远启用不了。
 ///
 ///
 /// S8-STANDARD-DATASET-HARD-CUTOVER-1:取数路径唯一,Gate 不再有"先判模式"这一步。
 /// S8-STANDARD-DATASET-HARD-CUTOVER-1:取数路径唯一,Gate 不再有"先判模式"这一步。
 /// </summary>
 /// </summary>
@@ -56,14 +59,14 @@ public class S8DatasetEnableGate : ITransient
     /// 它读的是 <c>rule.DatasetCode</c> / <c>rule.RuleType</c>,而这两列现在只是
     /// 它读的是 <c>rule.DatasetCode</c> / <c>rule.RuleType</c>,而这两列现在只是
     /// provisioning 维护的投影,可能与代码定义不一致。保留是为了既有校验路径与测试不受影响。
     /// provisioning 维护的投影,可能与代码定义不一致。保留是为了既有校验路径与测试不受影响。
     /// </summary>
     /// </summary>
-    public S8DatasetEnableCheck Check(AdoS8WatchRule rule, long tenantId, long factoryId) =>
-        Check(rule.DatasetCode, rule.RuleType, rule.RuleCode, tenantId, factoryId);
+    public S8DatasetEnableCheck Check(AdoS8WatchRule rule, long tenantId) =>
+        Check(rule.DatasetCode, rule.RuleType, rule.RuleCode, tenantId);
 
 
     /// <summary>
     /// <summary>
     /// 按**显式的数据集编码与规则类型**检查。启用路径应使用本重载并传入代码定义的值,
     /// 按**显式的数据集编码与规则类型**检查。启用路径应使用本重载并传入代码定义的值,
     /// 这样「能不能启用」的判定就不受任何人手工改 DB 投影列的影响。
     /// 这样「能不能启用」的判定就不受任何人手工改 DB 投影列的影响。
     /// </summary>
     /// </summary>
-    public S8DatasetEnableCheck Check(string? datasetCode, string? ruleType, string? ruleCode, long tenantId, long factoryId)
+    public S8DatasetEnableCheck Check(string? datasetCode, string? ruleType, string? ruleCode, long tenantId)
     {
     {
         // ① dataset_code
         // ① dataset_code
         if (string.IsNullOrWhiteSpace(datasetCode))
         if (string.IsNullOrWhiteSpace(datasetCode))
@@ -128,23 +131,21 @@ public class S8DatasetEnableGate : ITransient
                 S8DatasetReasonCode.MissingRequiredField,
                 S8DatasetReasonCode.MissingRequiredField,
                 $"Provider 实现缺少 {ruleType} 必需字段:{string.Join(", ", providerMissing)}");
                 $"Provider 实现缺少 {ruleType} 必需字段:{string.Join(", ", providerMissing)}");
 
 
-        // ⑩ 作用域
+        // ⑩ 作用域。只认 Tenant —— Factory 不是 S8 的隔离边界。
         if (tenantId <= 0)
         if (tenantId <= 0)
             return S8DatasetEnableCheck.Fail(S8DatasetReasonCode.InvalidTenantScope, "租户作用域非法");
             return S8DatasetEnableCheck.Fail(S8DatasetReasonCode.InvalidTenantScope, "租户作用域非法");
-        if (factoryId <= 0)
-            return S8DatasetEnableCheck.Fail(S8DatasetReasonCode.InvalidFactoryScope, "工厂作用域非法");
 
 
         return S8DatasetEnableCheck.Pass();
         return S8DatasetEnableCheck.Pass();
     }
     }
 
 
     /// <summary>检查并在失败时抛 <see cref="S8BizException"/>(配置类错误,API 层直接回给调用方)。</summary>
     /// <summary>检查并在失败时抛 <see cref="S8BizException"/>(配置类错误,API 层直接回给调用方)。</summary>
-    public void EnsureCanEnable(AdoS8WatchRule rule, long tenantId, long factoryId) =>
-        EnsureCanEnable(rule.DatasetCode, rule.RuleType, rule.RuleCode, tenantId, factoryId);
+    public void EnsureCanEnable(AdoS8WatchRule rule, long tenantId) =>
+        EnsureCanEnable(rule.DatasetCode, rule.RuleType, rule.RuleCode, tenantId);
 
 
     /// <summary>按显式数据集编码与规则类型检查并在失败时抛 <see cref="S8BizException"/>。启用路径用这一个。</summary>
     /// <summary>按显式数据集编码与规则类型检查并在失败时抛 <see cref="S8BizException"/>。启用路径用这一个。</summary>
-    public void EnsureCanEnable(string? datasetCode, string? ruleType, string? ruleCode, long tenantId, long factoryId)
+    public void EnsureCanEnable(string? datasetCode, string? ruleType, string? ruleCode, long tenantId)
     {
     {
-        var result = Check(datasetCode, ruleType, ruleCode, tenantId, factoryId);
+        var result = Check(datasetCode, ruleType, ruleCode, tenantId);
         if (!result.Ok)
         if (!result.Ok)
             throw new S8BizException($"[{result.ReasonCode}] {result.Message}");
             throw new S8BizException($"[{result.ReasonCode}] {result.Message}");
     }
     }

+ 5 - 6
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/S8MonitoringDataGateway.cs

@@ -38,13 +38,12 @@ public class S8MonitoringDataGateway : ITransient
     /// </summary>
     /// </summary>
     public async Task<S8MonitoringDataResult> LoadAsync(
     public async Task<S8MonitoringDataResult> LoadAsync(
         long tenantId,
         long tenantId,
-        long factoryId,
         AdoS8WatchRule rule,
         AdoS8WatchRule rule,
         string ruleType,
         string ruleType,
         int maxRows,
         int maxRows,
         CancellationToken cancellationToken = default)
         CancellationToken cancellationToken = default)
     {
     {
-        var result = await LoadFromDatasetAsync(tenantId, factoryId, rule, ruleType, cancellationToken);
+        var result = await LoadFromDatasetAsync(tenantId, rule, ruleType, cancellationToken);
 
 
         S8EvaluatorGuard.EnsureRowCountWithinLimit(result.RowSet.Rows.Count, maxRows, ruleType, rule.RuleCode);
         S8EvaluatorGuard.EnsureRowCountWithinLimit(result.RowSet.Rows.Count, maxRows, ruleType, rule.RuleCode);
         return result;
         return result;
@@ -52,7 +51,6 @@ public class S8MonitoringDataGateway : ITransient
 
 
     private async Task<S8MonitoringDataResult> LoadFromDatasetAsync(
     private async Task<S8MonitoringDataResult> LoadFromDatasetAsync(
         long tenantId,
         long tenantId,
-        long factoryId,
         AdoS8WatchRule rule,
         AdoS8WatchRule rule,
         string ruleType,
         string ruleType,
         CancellationToken cancellationToken)
         CancellationToken cancellationToken)
@@ -96,7 +94,6 @@ public class S8MonitoringDataGateway : ITransient
         var request = new S8MonitoringDataRequest
         var request = new S8MonitoringDataRequest
         {
         {
             TenantId = tenantId,
             TenantId = tenantId,
-            FactoryId = factoryId,
             DatasetCode = definition.DatasetCode,
             DatasetCode = definition.DatasetCode,
             RuleId = rule.Id,
             RuleId = rule.Id,
             RuleCode = rule.RuleCode,
             RuleCode = rule.RuleCode,
@@ -121,9 +118,11 @@ public class S8MonitoringDataGateway : ITransient
                 ex);
                 ex);
         }
         }
 
 
+        // rule.FactoryId 仅作日志 metadata:它不再是取数作用域的一部分,
+        // 保留是为了排查历史数据时还能对上号。
         _logger.LogDebug(
         _logger.LogDebug(
-            "s8_dataset_loaded dataset={Dataset} rule={Rule} tenant={Tenant} factory={Factory} rows={Rows}",
-            definition.DatasetCode, rule.RuleCode, tenantId, factoryId, rowSet.Rows.Count);
+            "s8_dataset_loaded dataset={Dataset} rule={Rule} tenant={Tenant} factoryMeta={FactoryMeta} rows={Rows}",
+            definition.DatasetCode, rule.RuleCode, tenantId, rule.FactoryId, rowSet.Rows.Count);
 
 
         return new S8MonitoringDataResult { RowSet = rowSet };
         return new S8MonitoringDataResult { RowSet = rowSet };
     }
     }

+ 9 - 10
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/S8MonitoringDataRequest.cs

@@ -3,18 +3,22 @@ namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
 /// <summary>
 /// <summary>
 /// 向 Provider 取数的请求上下文。
 /// 向 Provider 取数的请求上下文。
 ///
 ///
-/// 硬性设计约束:<see cref="TenantId"/> / <see cref="FactoryId"/> **必须由调用方显式传入**。
+/// 硬性设计约束:<see cref="TenantId"/> **必须由调用方显式传入**。
 /// Provider 实现禁止从 HttpContext / 当前登录用户 / 全局单租户假设推导作用域,
 /// Provider 实现禁止从 HttpContext / 当前登录用户 / 全局单租户假设推导作用域,
 /// 也禁止把租户写成 SQL 字面量 —— 这两种做法正是既有 LEGACY_SQL 规则里的已知问题形态。
 /// 也禁止把租户写成 SQL 字面量 —— 这两种做法正是既有 LEGACY_SQL 规则里的已知问题形态。
+///
+/// <para><b>S8-TENANT-ONLY-BATCH5:Factory 已退出取数作用域。</b>
+/// 它此前是必填项(<c>FactoryId &lt;= 0</c> 直接 <c>dataset_scope_invalid</c>),
+/// 但**没有任何 Provider 真的用它过滤** —— 唯一的 PURCHASE_DELIVERY Provider 的 WHERE
+/// 从一开始就只有 <c>tenant_id</c>。于是它是一道纯粹的形式门槛:
+/// 零工厂 / 多工厂租户会在这里被拒,而放行与否对取出的数据没有任何影响。
+/// 现在作用域只认 Tenant,与真实数据边界一致。</para>
 /// </summary>
 /// </summary>
 public sealed class S8MonitoringDataRequest
 public sealed class S8MonitoringDataRequest
 {
 {
     /// <summary>租户作用域。必填且必须 &gt; 0。</summary>
     /// <summary>租户作用域。必填且必须 &gt; 0。</summary>
     public long TenantId { get; init; }
     public long TenantId { get; init; }
 
 
-    /// <summary>工厂作用域。必填且必须 &gt; 0。</summary>
-    public long FactoryId { get; init; }
-
     /// <summary>数据集编码(STANDARD_DATASET 模式)。</summary>
     /// <summary>数据集编码(STANDARD_DATASET 模式)。</summary>
     public string DatasetCode { get; init; } = string.Empty;
     public string DatasetCode { get; init; } = string.Empty;
 
 
@@ -28,7 +32,7 @@ public sealed class S8MonitoringDataRequest
     public string RuleType { get; init; } = string.Empty;
     public string RuleType { get; init; } = string.Empty;
 
 
     /// <summary>
     /// <summary>
-    /// 作用域校验。缺失租户 / 工厂时显式失败,不允许 Provider 用默认值兜底
+    /// 作用域校验。缺失租户时显式失败,不允许 Provider 用默认值兜底
     /// (兜底会让跨租户读取以"看起来正常"的方式发生)。
     /// (兜底会让跨租户读取以"看起来正常"的方式发生)。
     /// </summary>
     /// </summary>
     public void EnsureValid()
     public void EnsureValid()
@@ -37,10 +41,5 @@ public sealed class S8MonitoringDataRequest
             throw new S8RuleEvaluatorException(
             throw new S8RuleEvaluatorException(
                 "dataset_scope_invalid",
                 "dataset_scope_invalid",
                 $"数据集取数缺少有效 TenantId(rule={RuleCode}, dataset={DatasetCode})");
                 $"数据集取数缺少有效 TenantId(rule={RuleCode}, dataset={DatasetCode})");
-
-        if (FactoryId <= 0)
-            throw new S8RuleEvaluatorException(
-                "dataset_scope_invalid",
-                $"数据集取数缺少有效 FactoryId(rule={RuleCode}, dataset={DatasetCode})");
     }
     }
 }
 }

+ 2 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/S8WatchRuleDataAccessValidator.cs

@@ -41,6 +41,6 @@ public static class S8WatchRuleDataAccessValidator
     /// 这里是「Create 时带 enabled=true」与「UpdateParams 由停用切启用」两条路径的**共同必经点**。
     /// 这里是「Create 时带 enabled=true」与「UpdateParams 由停用切启用」两条路径的**共同必经点**。
     /// </summary>
     /// </summary>
     public static void ValidateForEnable(
     public static void ValidateForEnable(
-        AdoS8WatchRule rule, S8DatasetEnableGate gate, long tenantId, long factoryId) =>
-        gate.EnsureCanEnable(rule, tenantId, factoryId);
+        AdoS8WatchRule rule, S8DatasetEnableGate gate, long tenantId) =>
+        gate.EnsureCanEnable(rule, tenantId);
 }
 }

+ 5 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/IS8RuleEvaluator.cs

@@ -13,9 +13,13 @@ public interface IS8RuleEvaluator
     string RuleType { get; }
     string RuleType { get; }
 
 
     /// <summary>评估一条 WatchRule,产出 0..N 条命中。不做去重、不建单。</summary>
     /// <summary>评估一条 WatchRule,产出 0..N 条命中。不做去重、不建单。</summary>
+    /// <remarks>
+    /// S8-TENANT-ONLY-BATCH5:形参不再含 factoryId。判定作用域只认 Tenant ——
+    /// 工厂从未参与任何 Provider 的过滤,却曾经决定 dedup 身份与规则归属,
+    /// 那是"伪 Factory Scope":取数是租户级的,身份却按工厂切。
+    /// </remarks>
     Task<List<S8RuleHit>> EvaluateAsync(
     Task<List<S8RuleHit>> EvaluateAsync(
         long tenantId,
         long tenantId,
-        long factoryId,
         AdoS8WatchRule rule,
         AdoS8WatchRule rule,
         CancellationToken cancellationToken = default);
         CancellationToken cancellationToken = default);
 }
 }

+ 20 - 8
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8OutOfRangeRuleEvaluator.cs

@@ -46,7 +46,6 @@ public class S8OutOfRangeRuleEvaluator : IS8RuleEvaluator, ITransient
 
 
     public async Task<List<S8RuleHit>> EvaluateAsync(
     public async Task<List<S8RuleHit>> EvaluateAsync(
         long tenantId,
         long tenantId,
-        long factoryId,
         AdoS8WatchRule rule,
         AdoS8WatchRule rule,
         CancellationToken cancellationToken = default)
         CancellationToken cancellationToken = default)
     {
     {
@@ -57,9 +56,9 @@ public class S8OutOfRangeRuleEvaluator : IS8RuleEvaluator, ITransient
         var maxRows = S8EvaluatorGuard.ResolveMaxRows(_logger);
         var maxRows = S8EvaluatorGuard.ResolveMaxRows(_logger);
 
 
         var data = await _dataGateway.LoadAsync(
         var data = await _dataGateway.LoadAsync(
-            tenantId, factoryId, rule, RuleTypeCode, maxRows, cancellationToken);
+            tenantId, rule, RuleTypeCode, maxRows, cancellationToken);
 
 
-        return EvaluateRows(data.RowSet, effective, tenantId, factoryId, DateTime.Now);
+        return EvaluateRows(data.RowSet, effective, tenantId, DateTime.Now);
     }
     }
 
 
     /// <summary>解析生效规则:定义必须存在、类型必须匹配、OUT_OF_RANGE 语义必须齐备。</summary>
     /// <summary>解析生效规则:定义必须存在、类型必须匹配、OUT_OF_RANGE 语义必须齐备。</summary>
@@ -91,7 +90,6 @@ public class S8OutOfRangeRuleEvaluator : IS8RuleEvaluator, ITransient
         S8MonitoringRowSet rowSet,
         S8MonitoringRowSet rowSet,
         S8EffectiveRule effective,
         S8EffectiveRule effective,
         long tenantId,
         long tenantId,
-        long factoryId,
         DateTime detectedAt)
         DateTime detectedAt)
     {
     {
         var definition = effective.Definition;
         var definition = effective.Definition;
@@ -149,7 +147,7 @@ public class S8OutOfRangeRuleEvaluator : IS8RuleEvaluator, ITransient
 
 
             var sourceObjectId = row.GetString(objectIdColumn) ?? relatedObjectCode;
             var sourceObjectId = row.GetString(objectIdColumn) ?? relatedObjectCode;
 
 
-            var dedupKey = BuildDedupKey(tenantId, factoryId, definition.RuleCode, sourceObjectType, sourceObjectId);
+            var dedupKey = BuildDedupKey(tenantId, definition.RuleCode, sourceObjectType, sourceObjectId);
 
 
             hits.Add(new S8RuleHit
             hits.Add(new S8RuleHit
             {
             {
@@ -173,9 +171,23 @@ public class S8OutOfRangeRuleEvaluator : IS8RuleEvaluator, ITransient
         return hits;
         return hits;
     }
     }
 
 
-    /// <summary>R3 OUT_OF_RANGE dedup_key:与 TIMEOUT/SHORTAGE 同形 T{t}:F{f}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}。</summary>
-    internal static string BuildDedupKey(long tenantId, long factoryId, string ruleCode, string sourceObjectType, string sourceObjectId) =>
-        $"T{tenantId}:F{factoryId}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}";
+    /// <summary>
+    /// 构造 dedup_key:<c>T{tenant}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}</c>。
+    ///
+    /// <para><b>S8-TENANT-ONLY-BATCH5:去掉了 <c>:F{factory}</c> 段。</b>
+    /// 租户隔离由首段 <c>T{tenant}</c> 与各查询的 <c>tenant_id</c> 谓词双重保证;
+    /// 工厂段既不提供隔离,又让同一个业务对象在不同工厂元数据下被当成两个异常。</para>
+    ///
+    /// <para>真库证据(迁移前取证):<c>dwd_supplier_delivery</c> 全表 39854 行 / 4 租户 / 97 个快照,
+    /// <c>(tenant_id, stat_date, po_no, po_line)</c> 重复组为 0,跨工厂碰撞组为 0。
+    /// 若将来某个规则的 SourceObjectId 在租户内不唯一,唯一性应由该规则的
+    /// <b>SourceObjectId contract</b> 负责(例如把工厂编号并进对象标识),
+    /// 而不是把工厂重新变回隔离维度。</para>
+    ///
+    /// internal 暴露供测试。
+    /// </summary>
+    internal static string BuildDedupKey(long tenantId, string ruleCode, string sourceObjectType, string sourceObjectId) =>
+        $"T{tenantId}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}";
 
 
     private static string BuildTitle(string sourceObjectType, string sourceObjectId, decimal measured, decimal? lower, decimal? upper, string direction)
     private static string BuildTitle(string sourceObjectType, string sourceObjectId, decimal measured, decimal? lower, decimal? upper, string direction)
     {
     {

+ 20 - 8
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8ShortageRuleEvaluator.cs

@@ -46,7 +46,6 @@ public class S8ShortageRuleEvaluator : IS8RuleEvaluator, ITransient
 
 
     public async Task<List<S8RuleHit>> EvaluateAsync(
     public async Task<List<S8RuleHit>> EvaluateAsync(
         long tenantId,
         long tenantId,
-        long factoryId,
         AdoS8WatchRule rule,
         AdoS8WatchRule rule,
         CancellationToken cancellationToken = default)
         CancellationToken cancellationToken = default)
     {
     {
@@ -57,9 +56,9 @@ public class S8ShortageRuleEvaluator : IS8RuleEvaluator, ITransient
         var maxRows = S8EvaluatorGuard.ResolveMaxRows(_logger);
         var maxRows = S8EvaluatorGuard.ResolveMaxRows(_logger);
 
 
         var data = await _dataGateway.LoadAsync(
         var data = await _dataGateway.LoadAsync(
-            tenantId, factoryId, rule, RuleTypeCode, maxRows, cancellationToken);
+            tenantId, rule, RuleTypeCode, maxRows, cancellationToken);
 
 
-        return EvaluateRows(data.RowSet, effective, tenantId, factoryId, DateTime.Now);
+        return EvaluateRows(data.RowSet, effective, tenantId, DateTime.Now);
     }
     }
 
 
     /// <summary>解析生效规则:定义必须存在、类型必须匹配、SHORTAGE 语义必须齐备。</summary>
     /// <summary>解析生效规则:定义必须存在、类型必须匹配、SHORTAGE 语义必须齐备。</summary>
@@ -91,7 +90,6 @@ public class S8ShortageRuleEvaluator : IS8RuleEvaluator, ITransient
         S8MonitoringRowSet rowSet,
         S8MonitoringRowSet rowSet,
         S8EffectiveRule effective,
         S8EffectiveRule effective,
         long tenantId,
         long tenantId,
-        long factoryId,
         DateTime detectedAt)
         DateTime detectedAt)
     {
     {
         var definition = effective.Definition;
         var definition = effective.Definition;
@@ -121,7 +119,7 @@ public class S8ShortageRuleEvaluator : IS8RuleEvaluator, ITransient
 
 
             var sourceObjectId = row.GetString(semantics.SourceObjectIdColumn) ?? relatedObjectCode;
             var sourceObjectId = row.GetString(semantics.SourceObjectIdColumn) ?? relatedObjectCode;
 
 
-            var dedupKey = BuildDedupKey(tenantId, factoryId, definition.RuleCode, sourceObjectType, sourceObjectId);
+            var dedupKey = BuildDedupKey(tenantId, definition.RuleCode, sourceObjectType, sourceObjectId);
 
 
             hits.Add(new S8RuleHit
             hits.Add(new S8RuleHit
             {
             {
@@ -145,9 +143,23 @@ public class S8ShortageRuleEvaluator : IS8RuleEvaluator, ITransient
         return hits;
         return hits;
     }
     }
 
 
-    /// <summary>构造 R3 dedup_key 稳定字符串:T{tenant}:F{factory}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}。internal 暴露供测试。</summary>
-    internal static string BuildDedupKey(long tenantId, long factoryId, string ruleCode, string sourceObjectType, string sourceObjectId) =>
-        $"T{tenantId}:F{factoryId}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}";
+    /// <summary>
+    /// 构造 dedup_key:<c>T{tenant}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}</c>。
+    ///
+    /// <para><b>S8-TENANT-ONLY-BATCH5:去掉了 <c>:F{factory}</c> 段。</b>
+    /// 租户隔离由首段 <c>T{tenant}</c> 与各查询的 <c>tenant_id</c> 谓词双重保证;
+    /// 工厂段既不提供隔离,又让同一个业务对象在不同工厂元数据下被当成两个异常。</para>
+    ///
+    /// <para>真库证据(迁移前取证):<c>dwd_supplier_delivery</c> 全表 39854 行 / 4 租户 / 97 个快照,
+    /// <c>(tenant_id, stat_date, po_no, po_line)</c> 重复组为 0,跨工厂碰撞组为 0。
+    /// 若将来某个规则的 SourceObjectId 在租户内不唯一,唯一性应由该规则的
+    /// <b>SourceObjectId contract</b> 负责(例如把工厂编号并进对象标识),
+    /// 而不是把工厂重新变回隔离维度。</para>
+    ///
+    /// internal 暴露供测试。
+    /// </summary>
+    internal static string BuildDedupKey(long tenantId, string ruleCode, string sourceObjectType, string sourceObjectId) =>
+        $"T{tenantId}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}";
 
 
     private static string BuildPayload(S8MonitoringRow row, string sourceObjectType, string sourceObjectId,
     private static string BuildPayload(S8MonitoringRow row, string sourceObjectType, string sourceObjectId,
         decimal target, decimal actual, decimal shortage, decimal ratio, S8RuleDefinition definition)
         decimal target, decimal actual, decimal shortage, decimal ratio, S8RuleDefinition definition)

+ 20 - 8
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8TimeoutRuleEvaluator.cs

@@ -46,7 +46,6 @@ public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient
 
 
     public async Task<List<S8RuleHit>> EvaluateAsync(
     public async Task<List<S8RuleHit>> EvaluateAsync(
         long tenantId,
         long tenantId,
-        long factoryId,
         AdoS8WatchRule rule,
         AdoS8WatchRule rule,
         CancellationToken cancellationToken = default)
         CancellationToken cancellationToken = default)
     {
     {
@@ -64,9 +63,9 @@ public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient
         var maxRows = S8EvaluatorGuard.ResolveMaxRows(_logger);
         var maxRows = S8EvaluatorGuard.ResolveMaxRows(_logger);
 
 
         var data = await _dataGateway.LoadAsync(
         var data = await _dataGateway.LoadAsync(
-            tenantId, factoryId, rule, RuleTypeCode, maxRows, cancellationToken);
+            tenantId, rule, RuleTypeCode, maxRows, cancellationToken);
 
 
-        return EvaluateRows(data.RowSet, effective, tenantId, factoryId, DateTime.Now);
+        return EvaluateRows(data.RowSet, effective, tenantId, DateTime.Now);
     }
     }
 
 
     /// <summary>
     /// <summary>
@@ -101,7 +100,6 @@ public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient
         S8MonitoringRowSet rowSet,
         S8MonitoringRowSet rowSet,
         S8EffectiveRule effective,
         S8EffectiveRule effective,
         long tenantId,
         long tenantId,
-        long factoryId,
         DateTime detectedAt)
         DateTime detectedAt)
     {
     {
         var definition = effective.Definition;
         var definition = effective.Definition;
@@ -137,7 +135,7 @@ public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient
 
 
             var sourceObjectId = row.GetString(objectIdColumn) ?? relatedObjectCode;
             var sourceObjectId = row.GetString(objectIdColumn) ?? relatedObjectCode;
 
 
-            var dedupKey = BuildDedupKey(tenantId, factoryId, definition.RuleCode, sourceObjectType, sourceObjectId);
+            var dedupKey = BuildDedupKey(tenantId, definition.RuleCode, sourceObjectType, sourceObjectId);
 
 
             hits.Add(new S8RuleHit
             hits.Add(new S8RuleHit
             {
             {
@@ -161,9 +159,23 @@ public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient
         return hits;
         return hits;
     }
     }
 
 
-    /// <summary>构造 R2 dedup_key 稳定字符串:T{tenant}:F{factory}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}。internal 暴露供测试。</summary>
-    internal static string BuildDedupKey(long tenantId, long factoryId, string ruleCode, string sourceObjectType, string sourceObjectId) =>
-        $"T{tenantId}:F{factoryId}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}";
+    /// <summary>
+    /// 构造 dedup_key:<c>T{tenant}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}</c>。
+    ///
+    /// <para><b>S8-TENANT-ONLY-BATCH5:去掉了 <c>:F{factory}</c> 段。</b>
+    /// 租户隔离由首段 <c>T{tenant}</c> 与各查询的 <c>tenant_id</c> 谓词双重保证;
+    /// 工厂段既不提供隔离,又让同一个业务对象在不同工厂元数据下被当成两个异常。</para>
+    ///
+    /// <para>真库证据(迁移前取证):<c>dwd_supplier_delivery</c> 全表 39854 行 / 4 租户 / 97 个快照,
+    /// <c>(tenant_id, stat_date, po_no, po_line)</c> 重复组为 0,跨工厂碰撞组为 0。
+    /// 若将来某个规则的 SourceObjectId 在租户内不唯一,唯一性应由该规则的
+    /// <b>SourceObjectId contract</b> 负责(例如把工厂编号并进对象标识),
+    /// 而不是把工厂重新变回隔离维度。</para>
+    ///
+    /// internal 暴露供测试。
+    /// </summary>
+    internal static string BuildDedupKey(long tenantId, string ruleCode, string sourceObjectType, string sourceObjectId) =>
+        $"T{tenantId}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}";
 
 
     private static string BuildPayload(
     private static string BuildPayload(
         S8MonitoringRow row, string sourceObjectType, string sourceObjectId, DateTime dueAt, string status,
         S8MonitoringRow row, string sourceObjectType, string sourceObjectId, DateTime dueAt, string status,

+ 7 - 5
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchRulePreviewService.cs

@@ -65,7 +65,7 @@ public class S8WatchRulePreviewService : ITransient
     public async Task<S8WatchRulePreviewResult> PreviewAsync(long id, S8TrustedScope scope)
     public async Task<S8WatchRulePreviewResult> PreviewAsync(long id, S8TrustedScope scope)
     {
     {
         var rule = await _ruleRep.AsQueryable()
         var rule = await _ruleRep.AsQueryable()
-            .Where(x => x.Id == id && x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId)
+            .Where(x => x.Id == id && x.TenantId == scope.TenantId)
             .FirstAsync() ?? throw new S8NotFoundException();
             .FirstAsync() ?? throw new S8NotFoundException();
 
 
         // S8-RULE-GOVERNANCE-BATCH1:预演必须与真实运行走同一份语义来源。
         // S8-RULE-GOVERNANCE-BATCH1:预演必须与真实运行走同一份语义来源。
@@ -85,10 +85,10 @@ public class S8WatchRulePreviewService : ITransient
         // 与生产同一条取数链。evaluator 内部会再次经 Gateway 取数,
         // 与生产同一条取数链。evaluator 内部会再次经 Gateway 取数,
         // 这里先单独取一次仅为拿到 candidateRows(判定前的行数),用于解释「筛掉了多少」。
         // 这里先单独取一次仅为拿到 candidateRows(判定前的行数),用于解释「筛掉了多少」。
         var maxRows = S8EvaluatorGuard.ResolveMaxRows(null);
         var maxRows = S8EvaluatorGuard.ResolveMaxRows(null);
-        var data = await _gateway.LoadAsync(scope.TenantId, scope.FactoryId, rule, ruleType, maxRows);
+        var data = await _gateway.LoadAsync(scope.TenantId, rule, ruleType, maxRows);
         var candidateRows = data.RowSet.Rows.Count;
         var candidateRows = data.RowSet.Rows.Count;
 
 
-        var hits = await evaluator.EvaluateAsync(scope.TenantId, scope.FactoryId, rule);
+        var hits = await evaluator.EvaluateAsync(scope.TenantId, rule);
 
 
         // 逐条回答「若真的建单,是新建还是命中既有活动异常」。
         // 逐条回答「若真的建单,是新建还是命中既有活动异常」。
         // 与 S8WatchSchedulerService 的去重口径一致:同 dedup_key 且未 CLOSED 未删即视为活动。
         // 与 S8WatchSchedulerService 的去重口径一致:同 dedup_key 且未 CLOSED 未删即视为活动。
@@ -96,7 +96,10 @@ public class S8WatchRulePreviewService : ITransient
         var activeByDedupKey = dedupKeys.Count == 0
         var activeByDedupKey = dedupKeys.Count == 0
             ? new Dictionary<string, long>(StringComparer.Ordinal)
             ? new Dictionary<string, long>(StringComparer.Ordinal)
             : (await _exceptionRep.AsQueryable()
             : (await _exceptionRep.AsQueryable()
-                .Where(x => x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId
+                // S8-TENANT-ONLY-BATCH5:活动异常按 Tenant + dedup_key 查,不再按工厂切。
+                // 预演必须与真跑同源:调度器侧的 FindOpenExceptionByDedupKeyAsync 也已只认 Tenant,
+                // 两边一旦分叉,预演给出的"会新建还是会刷新"就不再可信。
+                .Where(x => x.TenantId == scope.TenantId
                             && !x.IsDeleted && x.Status != "CLOSED"
                             && !x.IsDeleted && x.Status != "CLOSED"
                             && x.DedupKey != null && dedupKeys.Contains(x.DedupKey))
                             && x.DedupKey != null && dedupKeys.Contains(x.DedupKey))
                 .Select(x => new { x.DedupKey, x.Id })
                 .Select(x => new { x.DedupKey, x.Id })
@@ -112,7 +115,6 @@ public class S8WatchRulePreviewService : ITransient
             DatasetCode = definition.DatasetCode,
             DatasetCode = definition.DatasetCode,
             Enabled = rule.Enabled,
             Enabled = rule.Enabled,
             TenantId = scope.TenantId,
             TenantId = scope.TenantId,
-            FactoryId = scope.FactoryId,
             EvaluatedAt = DateTime.Now,
             EvaluatedAt = DateTime.Now,
             CandidateRows = candidateRows,
             CandidateRows = candidateRows,
             HitCount = hits.Count,
             HitCount = hits.Count,

+ 17 - 11
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchRuleService.cs

@@ -39,10 +39,10 @@ public class S8WatchRuleService : ITransient
     /// 照常返回,但 <c>DefinitionMissing = true</c>、定义字段为 null,
     /// 照常返回,但 <c>DefinitionMissing = true</c>、定义字段为 null,
     /// 由页面禁用相关操作 —— 不删、不隐藏,运维需要看得见它们。</para>
     /// 由页面禁用相关操作 —— 不删、不隐藏,运维需要看得见它们。</para>
     /// </summary>
     /// </summary>
-    public async Task<List<S8WatchRuleRowDto>> ListAsync(long tenantId, long factoryId)
+    public async Task<List<S8WatchRuleRowDto>> ListAsync(long tenantId)
     {
     {
         var rows = await _rep.AsQueryable()
         var rows = await _rep.AsQueryable()
-            .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId)
+            .Where(x => x.TenantId == tenantId)
             .ToListAsync();
             .ToListAsync();
 
 
         return rows.Select(ToRowDto).ToList();
         return rows.Select(ToRowDto).ToList();
@@ -190,10 +190,16 @@ public class S8WatchRuleService : ITransient
     public Task DeleteAsync(long id, S8TrustedScope scope) =>
     public Task DeleteAsync(long id, S8TrustedScope scope) =>
         throw new S8WriteRetiredException(S8WriteRetiredException.WatchRuleDeleteMessage);
         throw new S8WriteRetiredException(S8WriteRetiredException.WatchRuleDeleteMessage);
 
 
-    /// <summary>按 Id + 可信作用域取行;不在作用域内一律按「不存在」处理,不泄露他租户资源是否存在。</summary>
+    /// <summary>
+    /// 按 Id + 租户取行;不在作用域内一律按「不存在」处理,不泄露他租户资源是否存在。
+    ///
+    /// <para><b>S8-TENANT-ONLY-BATCH5:绑行只认 Tenant。</b>
+    /// 运行策略的身份在 Batch 2 已定为 (TenantId, RuleCode),而此处仍按工厂绑行 ——
+    /// 结果是 Batch 2 供给出来的 factory_id=0 的行,任何配置操作都会 404。</para>
+    /// </summary>
     private async Task<AdoS8WatchRule> LoadScopedAsync(long id, S8TrustedScope scope) =>
     private async Task<AdoS8WatchRule> LoadScopedAsync(long id, S8TrustedScope scope) =>
         await _rep.AsQueryable()
         await _rep.AsQueryable()
-            .Where(x => x.Id == id && x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId)
+            .Where(x => x.Id == id && x.TenantId == scope.TenantId)
             .FirstAsync() ?? throw new S8NotFoundException();
             .FirstAsync() ?? throw new S8NotFoundException();
 
 
     /// <summary>
     /// <summary>
@@ -232,7 +238,7 @@ public class S8WatchRuleService : ITransient
                 ParamsJson = next.ToParamsJson(),
                 ParamsJson = next.ToParamsJson(),
                 UpdatedAt = DateTime.Now
                 UpdatedAt = DateTime.Now
             })
             })
-            .Where(x => x.Id == id && x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId)
+            .Where(x => x.Id == id && x.TenantId == scope.TenantId)
             .ExecuteCommandAsync();
             .ExecuteCommandAsync();
 
 
         return await LoadScopedAsync(id, scope);
         return await LoadScopedAsync(id, scope);
@@ -328,7 +334,7 @@ public class S8WatchRuleService : ITransient
         // ② 数据集侧完整运行条件。按 Definition 的 dataset_code / rule_type 判定,
         // ② 数据集侧完整运行条件。按 Definition 的 dataset_code / rule_type 判定,
         //    而不是 DB 上那两列 —— 后者是投影,可能被人为改过。
         //    而不是 DB 上那两列 —— 后者是投影,可能被人为改过。
         _datasetEnableGate.EnsureCanEnable(
         _datasetEnableGate.EnsureCanEnable(
-            definition.DatasetCode, definition.RuleType, definition.RuleCode, scope.TenantId, scope.FactoryId);
+            definition.DatasetCode, definition.RuleType, definition.RuleCode, scope.TenantId);
 
 
         var now = DateTime.Now;
         var now = DateTime.Now;
         await _rep.Context.Updateable<AdoS8WatchRule>()
         await _rep.Context.Updateable<AdoS8WatchRule>()
@@ -338,7 +344,7 @@ public class S8WatchRuleService : ITransient
                 NextRunAt = now,
                 NextRunAt = now,
                 UpdatedAt = now
                 UpdatedAt = now
             })
             })
-            .Where(x => x.Id == id && x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId)
+            .Where(x => x.Id == id && x.TenantId == scope.TenantId)
             .ExecuteCommandAsync();
             .ExecuteCommandAsync();
 
 
         return await LoadScopedAsync(id, scope);
         return await LoadScopedAsync(id, scope);
@@ -364,7 +370,7 @@ public class S8WatchRuleService : ITransient
                 Enabled = false,
                 Enabled = false,
                 UpdatedAt = DateTime.Now
                 UpdatedAt = DateTime.Now
             })
             })
-            .Where(x => x.Id == id && x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId)
+            .Where(x => x.Id == id && x.TenantId == scope.TenantId)
             .ExecuteCommandAsync();
             .ExecuteCommandAsync();
 
 
         return await LoadScopedAsync(id, scope);
         return await LoadScopedAsync(id, scope);
@@ -418,7 +424,7 @@ public class S8WatchRuleService : ITransient
                 NextRunAt = now,
                 NextRunAt = now,
                 UpdatedAt = now
                 UpdatedAt = now
             })
             })
-            .Where(x => x.Id == id && x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId)
+            .Where(x => x.Id == id && x.TenantId == scope.TenantId)
             .ExecuteCommandAsync();
             .ExecuteCommandAsync();
         return new { id, queued = true, message = "已排队,最长 1 分钟内执行" };
         return new { id, queued = true, message = "已排队,最长 1 分钟内执行" };
     }
     }
@@ -438,7 +444,7 @@ public class S8WatchRuleService : ITransient
                 PauseReason = "MANUAL_PAUSED",
                 PauseReason = "MANUAL_PAUSED",
                 UpdatedAt = DateTime.Now
                 UpdatedAt = DateTime.Now
             })
             })
-            .Where(x => x.Id == id && x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId)
+            .Where(x => x.Id == id && x.TenantId == scope.TenantId)
             .ExecuteCommandAsync();
             .ExecuteCommandAsync();
         return new { id, paused = true, message = "已暂停" };
         return new { id, paused = true, message = "已暂停" };
     }
     }
@@ -461,7 +467,7 @@ public class S8WatchRuleService : ITransient
                 NextRunAt = now,
                 NextRunAt = now,
                 UpdatedAt = now
                 UpdatedAt = now
             })
             })
-            .Where(x => x.Id == id && x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId)
+            .Where(x => x.Id == id && x.TenantId == scope.TenantId)
             .ExecuteCommandAsync();
             .ExecuteCommandAsync();
         return new { id, resumed = true, message = "已恢复,并将在下一轮调度中执行" };
         return new { id, resumed = true, message = "已恢复,并将在下一轮调度中执行" };
     }
     }

+ 70 - 67
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchSchedulerService.cs

@@ -90,17 +90,26 @@ public class S8WatchSchedulerService : ITransient
         _ruleCatalog = ruleCatalog;
         _ruleCatalog = ruleCatalog;
     }
     }
 
 
-    public async Task<List<S8TenantFactoryScope>> ListEnabledScopesAsync()
+    /// <summary>
+    /// S8-TENANT-ONLY-BATCH5:调度遍历改为 <b>Tenant → Rule</b>。
+    ///
+    /// <para>原实现返回 DISTINCT (tenant_id, factory_id),于是同一租户在 N 个工厂下会被 tick N 次;
+    /// 而规则的运行策略按 Batch 2 的口径每租户只有一条,重复 tick 只会互相抢同一把租约。</para>
+    ///
+    /// <para>同时删掉 <c>AND r.factory_id &gt; 0</c>:Batch 2 供给出来的运行策略行
+    /// <c>factory_id</c> 恒为 0,那条谓词会让它们**永远不被调度、且不报任何错**
+    /// —— 页面显示已启用,实际从不执行。这是本批必须与供给同时闭合的缺口。</para>
+    /// </summary>
+    public async Task<List<long>> ListEnabledTenantsAsync()
     {
     {
-        return await _ruleRep.Context.Ado.SqlQueryAsync<S8TenantFactoryScope>(
+        return await _ruleRep.Context.Ado.SqlQueryAsync<long>(
             """
             """
-            SELECT DISTINCT r.tenant_id AS TenantId, r.factory_id AS FactoryId
+            SELECT DISTINCT r.tenant_id
             FROM ado_s8_watch_rule r
             FROM ado_s8_watch_rule r
             INNER JOIN SysTenant t ON t.Id = r.tenant_id AND t.Status = 1
             INNER JOIN SysTenant t ON t.Id = r.tenant_id AND t.Status = 1
             WHERE r.enabled = 1
             WHERE r.enabled = 1
               AND r.tenant_id > 0
               AND r.tenant_id > 0
-              AND r.factory_id > 0
-            ORDER BY r.tenant_id, r.factory_id
+            ORDER BY r.tenant_id
             """);
             """);
     }
     }
 
 
@@ -122,15 +131,15 @@ public class S8WatchSchedulerService : ITransient
     /// 经证实其唯一入口是 404 门禁的 debug controller,且 LoadExecutionRulesAsync 的过滤条件
     /// 经证实其唯一入口是 404 门禁的 debug controller,且 LoadExecutionRulesAsync 的过滤条件
     /// 在当前数据下恒返回空,已整体删除。本方法现在只保留三类正式 evaluator 路径。
     /// 在当前数据下恒返回空,已整体删除。本方法现在只保留三类正式 evaluator 路径。
     /// </summary>
     /// </summary>
-    public async Task<List<S8WatchCreationResult>> CreateExceptionsAsync(long tenantId, long factoryId)
+    public async Task<List<S8WatchCreationResult>> CreateExceptionsAsync(long tenantId)
     {
     {
         var results = new List<S8WatchCreationResult>();
         var results = new List<S8WatchCreationResult>();
 
 
         // R6 RunId:本次 CreateExceptionsAsync 调用对应的统一关联 id,落入 detection_log。
         // R6 RunId:本次 CreateExceptionsAsync 调用对应的统一关联 id,落入 detection_log。
         var runId = Guid.NewGuid().ToString("N").Substring(0, 16);
         var runId = Guid.NewGuid().ToString("N").Substring(0, 16);
-        results.AddRange(await ProcessRulesByTypeAsync(tenantId, factoryId, _timeoutEvaluator, S8TimeoutRuleEvaluator.RuleTypeCode, runId));
-        results.AddRange(await ProcessRulesByTypeAsync(tenantId, factoryId, _shortageEvaluator, S8ShortageRuleEvaluator.RuleTypeCode, runId));
-        results.AddRange(await ProcessRulesByTypeAsync(tenantId, factoryId, _outOfRangeEvaluator, S8OutOfRangeRuleEvaluator.RuleTypeCode, runId));
+        results.AddRange(await ProcessRulesByTypeAsync(tenantId, _timeoutEvaluator, S8TimeoutRuleEvaluator.RuleTypeCode, runId));
+        results.AddRange(await ProcessRulesByTypeAsync(tenantId, _shortageEvaluator, S8ShortageRuleEvaluator.RuleTypeCode, runId));
+        results.AddRange(await ProcessRulesByTypeAsync(tenantId, _outOfRangeEvaluator, S8OutOfRangeRuleEvaluator.RuleTypeCode, runId));
 
 
         return results;
         return results;
     }
     }
@@ -138,14 +147,14 @@ public class S8WatchSchedulerService : ITransient
     /// <summary>
     /// <summary>
     /// R2 TIMEOUT 类规则主链:薄包装,复用 <see cref="ProcessRulesByTypeAsync"/>。RunId 由内部生成。
     /// R2 TIMEOUT 类规则主链:薄包装,复用 <see cref="ProcessRulesByTypeAsync"/>。RunId 由内部生成。
     /// </summary>
     /// </summary>
-    public Task<List<S8WatchCreationResult>> ProcessTimeoutRulesAsync(long tenantId, long factoryId) =>
-        ProcessRulesByTypeAsync(tenantId, factoryId, _timeoutEvaluator, S8TimeoutRuleEvaluator.RuleTypeCode, Guid.NewGuid().ToString("N").Substring(0, 16));
+    public Task<List<S8WatchCreationResult>> ProcessTimeoutRulesAsync(long tenantId) =>
+        ProcessRulesByTypeAsync(tenantId, _timeoutEvaluator, S8TimeoutRuleEvaluator.RuleTypeCode, Guid.NewGuid().ToString("N").Substring(0, 16));
 
 
     /// <summary>
     /// <summary>
     /// R3 SHORTAGE 类规则主链:薄包装,复用 <see cref="ProcessRulesByTypeAsync"/>。RunId 由内部生成。
     /// R3 SHORTAGE 类规则主链:薄包装,复用 <see cref="ProcessRulesByTypeAsync"/>。RunId 由内部生成。
     /// </summary>
     /// </summary>
-    public Task<List<S8WatchCreationResult>> ProcessShortageRulesAsync(long tenantId, long factoryId) =>
-        ProcessRulesByTypeAsync(tenantId, factoryId, _shortageEvaluator, S8ShortageRuleEvaluator.RuleTypeCode, Guid.NewGuid().ToString("N").Substring(0, 16));
+    public Task<List<S8WatchCreationResult>> ProcessShortageRulesAsync(long tenantId) =>
+        ProcessRulesByTypeAsync(tenantId, _shortageEvaluator, S8ShortageRuleEvaluator.RuleTypeCode, Guid.NewGuid().ToString("N").Substring(0, 16));
 
 
     /// <summary>
     /// <summary>
     /// R3-OUT_OF_RANGE-REWRITE-1:OUT_OF_RANGE 类规则主链。
     /// R3-OUT_OF_RANGE-REWRITE-1:OUT_OF_RANGE 类规则主链。
@@ -153,8 +162,8 @@ public class S8WatchSchedulerService : ITransient
     /// (source_rule_id=rule.Id AND related_object_code=hit AND status!=CLOSED AND dedup_key IS NULL AND is_deleted=0)
     /// (source_rule_id=rule.Id AND related_object_code=hit AND status!=CLOSED AND dedup_key IS NULL AND is_deleted=0)
     /// 命中则 backfill 6 列,避免重复建单。RunId 由内部生成。
     /// 命中则 backfill 6 列,避免重复建单。RunId 由内部生成。
     /// </summary>
     /// </summary>
-    public Task<List<S8WatchCreationResult>> ProcessOutOfRangeRulesAsync(long tenantId, long factoryId) =>
-        ProcessRulesByTypeAsync(tenantId, factoryId, _outOfRangeEvaluator, S8OutOfRangeRuleEvaluator.RuleTypeCode, Guid.NewGuid().ToString("N").Substring(0, 16));
+    public Task<List<S8WatchCreationResult>> ProcessOutOfRangeRulesAsync(long tenantId) =>
+        ProcessRulesByTypeAsync(tenantId, _outOfRangeEvaluator, S8OutOfRangeRuleEvaluator.RuleTypeCode, Guid.NewGuid().ToString("N").Substring(0, 16));
 
 
     /// <summary>
     /// <summary>
     /// R2/R3 通用规则主链。S8-SCHED-CLEANUP-LEGACY-PATH-1:本方法已收敛为 thin wrapper,
     /// R2/R3 通用规则主链。S8-SCHED-CLEANUP-LEGACY-PATH-1:本方法已收敛为 thin wrapper,
@@ -171,13 +180,12 @@ public class S8WatchSchedulerService : ITransient
     /// 与正在持锁运行的 Job 撕扯。
     /// 与正在持锁运行的 Job 撕扯。
     /// </summary>
     /// </summary>
     private async Task<List<S8WatchCreationResult>> ProcessRulesByTypeAsync(
     private async Task<List<S8WatchCreationResult>> ProcessRulesByTypeAsync(
-        long tenantId, long factoryId, IS8RuleEvaluator evaluator, string ruleType, string runId)
+        long tenantId, IS8RuleEvaluator evaluator, string ruleType, string runId)
     {
     {
         var aggregate = new List<S8WatchCreationResult>();
         var aggregate = new List<S8WatchCreationResult>();
 
 
         var rules = await _ruleRep.AsQueryable()
         var rules = await _ruleRep.AsQueryable()
             .Where(x => x.TenantId == tenantId
             .Where(x => x.TenantId == tenantId
-                        && x.FactoryId == factoryId
                         && x.Enabled
                         && x.Enabled
                         && x.RuleType == ruleType)
                         && x.RuleType == ruleType)
             .ToListAsync();
             .ToListAsync();
@@ -214,7 +222,7 @@ public class S8WatchSchedulerService : ITransient
             S8RuleRunResult completion;
             S8RuleRunResult completion;
             try
             try
             {
             {
-                var ruleResults = await ProcessSingleRuleAsync(tenantId, factoryId, rule, ruleType, evaluator, runId);
+                var ruleResults = await ProcessSingleRuleAsync(tenantId, rule, ruleType, evaluator, runId);
                 aggregate.AddRange(ruleResults);
                 aggregate.AddRange(ruleResults);
                 completion = new S8RuleRunResult
                 completion = new S8RuleRunResult
                 {
                 {
@@ -316,12 +324,11 @@ public class S8WatchSchedulerService : ITransient
     /// 注意:本函数不消费 trigger_count_required;上游决定是否进入 CreateFromHitAsync。
     /// 注意:本函数不消费 trigger_count_required;上游决定是否进入 CreateFromHitAsync。
     /// </summary>
     /// </summary>
     private async Task<(AdoS8RuleDetectionState? state, int hitCount)> UpsertDetectionStateOnHitAsync(
     private async Task<(AdoS8RuleDetectionState? state, int hitCount)> UpsertDetectionStateOnHitAsync(
-        long tenantId, long factoryId, AdoS8WatchRule rule, S8RuleHit hit)
+        long tenantId, AdoS8WatchRule rule, S8RuleHit hit)
     {
     {
         var now = DateTime.Now;
         var now = DateTime.Now;
         var existing = await _detectionStateRep.AsQueryable()
         var existing = await _detectionStateRep.AsQueryable()
             .Where(x => x.TenantId == tenantId
             .Where(x => x.TenantId == tenantId
-                        && x.FactoryId == factoryId
                         && x.RuleCode == rule.RuleCode
                         && x.RuleCode == rule.RuleCode
                         && x.DedupKey == hit.DedupKey)
                         && x.DedupKey == hit.DedupKey)
             .FirstAsync();
             .FirstAsync();
@@ -331,7 +338,7 @@ public class S8WatchSchedulerService : ITransient
             var fresh = new AdoS8RuleDetectionState
             var fresh = new AdoS8RuleDetectionState
             {
             {
                 TenantId = tenantId,
                 TenantId = tenantId,
-                FactoryId = factoryId,
+                FactoryId = rule.FactoryId,
                 RuleCode = rule.RuleCode,
                 RuleCode = rule.RuleCode,
                 DedupKey = hit.DedupKey,
                 DedupKey = hit.DedupKey,
                 SourceObjectType = string.IsNullOrEmpty(hit.SourceObjectType) ? null : hit.SourceObjectType,
                 SourceObjectType = string.IsNullOrEmpty(hit.SourceObjectType) ? null : hit.SourceObjectType,
@@ -368,11 +375,10 @@ public class S8WatchSchedulerService : ITransient
         return (existing, newHitCount);
         return (existing, newHitCount);
     }
     }
 
 
-    private async Task<long> FindOpenExceptionByDedupKeyAsync(long tenantId, long factoryId, string dedupKey)
+    private async Task<long> FindOpenExceptionByDedupKeyAsync(long tenantId, string dedupKey)
     {
     {
         var ids = await _exceptionRep.AsQueryable()
         var ids = await _exceptionRep.AsQueryable()
             .Where(x => x.TenantId == tenantId
             .Where(x => x.TenantId == tenantId
-                        && x.FactoryId == factoryId
                         && !x.IsDeleted
                         && !x.IsDeleted
                         && x.Status != "CLOSED"
                         && x.Status != "CLOSED"
                         && x.DedupKey == dedupKey)
                         && x.DedupKey == dedupKey)
@@ -386,12 +392,11 @@ public class S8WatchSchedulerService : ITransient
     /// R3 OUT_OF_RANGE compat fallback 查找:用 (source_rule_id, related_object_code, status!=CLOSED,
     /// R3 OUT_OF_RANGE compat fallback 查找:用 (source_rule_id, related_object_code, status!=CLOSED,
     /// dedup_key IS NULL, is_deleted=0) 严格条件定位旧 AlertRule 主链留下的历史记录。
     /// dedup_key IS NULL, is_deleted=0) 严格条件定位旧 AlertRule 主链留下的历史记录。
     /// </summary>
     /// </summary>
-    private async Task<long> FindLegacyOutOfRangeExceptionAsync(long tenantId, long factoryId, long sourceRuleId, string relatedObjectCode)
+    private async Task<long> FindLegacyOutOfRangeExceptionAsync(long tenantId, long sourceRuleId, string relatedObjectCode)
     {
     {
         if (string.IsNullOrWhiteSpace(relatedObjectCode)) return 0L;
         if (string.IsNullOrWhiteSpace(relatedObjectCode)) return 0L;
         var ids = await _exceptionRep.AsQueryable()
         var ids = await _exceptionRep.AsQueryable()
             .Where(x => x.TenantId == tenantId
             .Where(x => x.TenantId == tenantId
-                        && x.FactoryId == factoryId
                         && !x.IsDeleted
                         && !x.IsDeleted
                         && x.Status != "CLOSED"
                         && x.Status != "CLOSED"
                         && x.DedupKey == null
                         && x.DedupKey == null
@@ -432,7 +437,7 @@ public class S8WatchSchedulerService : ITransient
     /// R6 返回 recoveredIds 供上游决定是否写 NO_HIT 日志,并对每个 recovered exception 写一条 RECOVERED 日志。
     /// R6 返回 recoveredIds 供上游决定是否写 NO_HIT 日志,并对每个 recovered exception 写一条 RECOVERED 日志。
     /// </summary>
     /// </summary>
     private async Task<List<long>> ReconcileRecoveriesForRuleAsync(
     private async Task<List<long>> ReconcileRecoveriesForRuleAsync(
-        long tenantId, long factoryId, AdoS8WatchRule rule, string ruleType, List<S8RuleHit> hits, string runId)
+        long tenantId, AdoS8WatchRule rule, string ruleType, List<S8RuleHit> hits, string runId)
     {
     {
         var hitDedupKeys = hits
         var hitDedupKeys = hits
             .Where(h => !string.IsNullOrWhiteSpace(h.DedupKey))
             .Where(h => !string.IsNullOrWhiteSpace(h.DedupKey))
@@ -441,7 +446,6 @@ public class S8WatchSchedulerService : ITransient
 
 
         var candidates = await _exceptionRep.AsQueryable()
         var candidates = await _exceptionRep.AsQueryable()
             .Where(x => x.TenantId == tenantId
             .Where(x => x.TenantId == tenantId
-                        && x.FactoryId == factoryId
                         && !x.IsDeleted
                         && !x.IsDeleted
                         && x.Status != "CLOSED"
                         && x.Status != "CLOSED"
                         && x.SourceRuleCode == rule.RuleCode
                         && x.SourceRuleCode == rule.RuleCode
@@ -484,8 +488,7 @@ public class S8WatchSchedulerService : ITransient
                     UpdatedAt = now
                     UpdatedAt = now
                 })
                 })
                 .Where(x => x.TenantId == tenantId
                 .Where(x => x.TenantId == tenantId
-                            && x.FactoryId == factoryId
-                            && x.RuleCode == rule.RuleCode
+                                && x.RuleCode == rule.RuleCode
                             && x.DedupKey == c.DedupKey)
                             && x.DedupKey == c.DedupKey)
                 .ExecuteCommandAsync();
                 .ExecuteCommandAsync();
 
 
@@ -509,7 +512,7 @@ public class S8WatchSchedulerService : ITransient
 
 
             await WriteDetectionLogAsync(new AdoS8DetectionLog
             await WriteDetectionLogAsync(new AdoS8DetectionLog
             {
             {
-                TenantId = tenantId, FactoryId = factoryId,
+                TenantId = tenantId, FactoryId = rule.FactoryId,
                 RuleId = rule.Id, RuleCode = rule.RuleCode, RuleType = ruleType, SceneCode = rule.SceneCode,
                 RuleId = rule.Id, RuleCode = rule.RuleCode, RuleType = ruleType, SceneCode = rule.SceneCode,
                 SourceObjectType = c.SourceObjectType, SourceObjectId = c.SourceObjectId,
                 SourceObjectType = c.SourceObjectType, SourceObjectId = c.SourceObjectId,
                 RelatedObjectCode = c.RelatedObjectCode, DedupKey = c.DedupKey,
                 RelatedObjectCode = c.RelatedObjectCode, DedupKey = c.DedupKey,
@@ -537,10 +540,10 @@ public class S8WatchSchedulerService : ITransient
 
 
     /// <summary>R6 通用 hit 日志构造(CREATED / REFRESHED 共用)。</summary>
     /// <summary>R6 通用 hit 日志构造(CREATED / REFRESHED 共用)。</summary>
     private static AdoS8DetectionLog BuildHitLog(
     private static AdoS8DetectionLog BuildHitLog(
-        long tenantId, long factoryId, AdoS8WatchRule rule, string ruleType, S8RuleHit hit,
+        long tenantId, AdoS8WatchRule rule, string ruleType, S8RuleHit hit,
         string detectResult, long exceptionId, string runId) => new()
         string detectResult, long exceptionId, string runId) => new()
     {
     {
-        TenantId = tenantId, FactoryId = factoryId,
+        TenantId = tenantId, FactoryId = rule.FactoryId,
         RuleId = rule.Id, RuleCode = rule.RuleCode, RuleType = ruleType, SceneCode = rule.SceneCode,
         RuleId = rule.Id, RuleCode = rule.RuleCode, RuleType = ruleType, SceneCode = rule.SceneCode,
         SourceObjectType = hit.SourceObjectType, SourceObjectId = hit.SourceObjectId,
         SourceObjectType = hit.SourceObjectType, SourceObjectId = hit.SourceObjectId,
         RelatedObjectCode = hit.RelatedObjectCode, DedupKey = hit.DedupKey,
         RelatedObjectCode = hit.RelatedObjectCode, DedupKey = hit.DedupKey,
@@ -576,7 +579,7 @@ public class S8WatchSchedulerService : ITransient
     /// 业务侧 RefreshDetectionAsync / BackfillLegacyExceptionAsync 不在限频范围。
     /// 业务侧 RefreshDetectionAsync / BackfillLegacyExceptionAsync 不在限频范围。
     /// </summary>
     /// </summary>
     private async Task<bool> HasRecentRefreshedDetectionLogAsync(
     private async Task<bool> HasRecentRefreshedDetectionLogAsync(
-        long tenantId, long factoryId, string ruleCode, string dedupKey, DateTime detectedAt)
+        long tenantId, string ruleCode, string dedupKey, DateTime detectedAt)
     {
     {
         if (string.IsNullOrWhiteSpace(ruleCode) || string.IsNullOrWhiteSpace(dedupKey))
         if (string.IsNullOrWhiteSpace(ruleCode) || string.IsNullOrWhiteSpace(dedupKey))
             return false;
             return false;
@@ -586,8 +589,7 @@ public class S8WatchSchedulerService : ITransient
         {
         {
             return await _detectionLogRep.AsQueryable()
             return await _detectionLogRep.AsQueryable()
                 .Where(x => x.TenantId == tenantId
                 .Where(x => x.TenantId == tenantId
-                            && x.FactoryId == factoryId
-                            && x.DedupKey == dedupKey
+                                && x.DedupKey == dedupKey
                             && x.RuleCode == ruleCode
                             && x.RuleCode == ruleCode
                             && x.DetectResult == DetectResultRefreshed
                             && x.DetectResult == DetectResultRefreshed
                             && x.DetectedAt >= cutoff)
                             && x.DetectedAt >= cutoff)
@@ -721,7 +723,7 @@ public class S8WatchSchedulerService : ITransient
     /// S8-SCHED-EXEC-1:释放过期 lease(lock_until &lt; NOW),不修改 last_status / last_error,仅清空 lock 三件套 + running_started_at。
     /// S8-SCHED-EXEC-1:释放过期 lease(lock_until &lt; NOW),不修改 last_status / last_error,仅清空 lock 三件套 + running_started_at。
     /// 返回释放的行数。
     /// 返回释放的行数。
     /// </summary>
     /// </summary>
-    public async Task<int> ResetExpiredLeasesAsync(long tenantId, long factoryId)
+    public async Task<int> ResetExpiredLeasesAsync(long tenantId)
     {
     {
         var now = DateTime.Now;
         var now = DateTime.Now;
         var affected = await _ruleRep.Context.Updateable<AdoS8WatchRule>()
         var affected = await _ruleRep.Context.Updateable<AdoS8WatchRule>()
@@ -734,15 +736,14 @@ public class S8WatchSchedulerService : ITransient
                 UpdatedAt = now
                 UpdatedAt = now
             })
             })
             .Where(x => x.TenantId == tenantId
             .Where(x => x.TenantId == tenantId
-                        && x.FactoryId == factoryId
                         && x.LockUntil != null
                         && x.LockUntil != null
                         && x.LockUntil < now)
                         && x.LockUntil < now)
             .ExecuteCommandAsync();
             .ExecuteCommandAsync();
         if (affected > 0)
         if (affected > 0)
         {
         {
             _logger.LogWarning(
             _logger.LogWarning(
-                "lease_reset tenantId={Tenant} factoryId={Factory} releasedCount={Count}",
-                tenantId, factoryId, affected);
+                "lease_reset tenantId={Tenant} releasedCount={Count}",
+                tenantId, affected);
         }
         }
         return affected;
         return affected;
     }
     }
@@ -755,14 +756,13 @@ public class S8WatchSchedulerService : ITransient
     /// 抢锁回写:lock_token / locked_by / lock_until = NOW + 5min / running_started_at = NOW / last_run_id = runId。
     /// 抢锁回写:lock_token / locked_by / lock_until = NOW + 5min / running_started_at = NOW / last_run_id = runId。
     /// affectedRows == 1 才算抢到;后续 OnRuleCompletedAsync 必须按 lockToken 回写,避免旧进程覆盖新 lease。
     /// affectedRows == 1 才算抢到;后续 OnRuleCompletedAsync 必须按 lockToken 回写,避免旧进程覆盖新 lease。
     /// </summary>
     /// </summary>
-    public async Task<List<S8RuleLease>> PickReadyRulesAsync(long tenantId, long factoryId, int batchSize, string lockedBy, string runId)
+    public async Task<List<S8RuleLease>> PickReadyRulesAsync(long tenantId, int batchSize, string lockedBy, string runId)
     {
     {
         if (batchSize <= 0) batchSize = 16;
         if (batchSize <= 0) batchSize = 16;
         var now = DateTime.Now;
         var now = DateTime.Now;
 
 
         var candidates = await _ruleRep.AsQueryable()
         var candidates = await _ruleRep.AsQueryable()
             .Where(x => x.TenantId == tenantId
             .Where(x => x.TenantId == tenantId
-                        && x.FactoryId == factoryId
                         && x.Enabled
                         && x.Enabled
                         // S8-STANDARD-DATASET-HARD-CUTOVER-1:取数唯一依赖 dataset_code。
                         // S8-STANDARD-DATASET-HARD-CUTOVER-1:取数唯一依赖 dataset_code。
                         // 保留这条谓词而非依赖 Enable Gate:Gate 只在"切换启用"那一刻生效,
                         // 保留这条谓词而非依赖 Enable Gate:Gate 只在"切换启用"那一刻生效,
@@ -830,24 +830,24 @@ public class S8WatchSchedulerService : ITransient
     /// S8-SCHED-EXEC-1:执行单条已抢锁规则的 evaluator → 抗抖去重 → 建单/刷新 → 恢复 reconcile。
     /// S8-SCHED-EXEC-1:执行单条已抢锁规则的 evaluator → 抗抖去重 → 建单/刷新 → 恢复 reconcile。
     /// 不释放 lease(OnRuleCompletedAsync 负责);evaluator 抛异常时 Result.Success=false 并保留 ErrorMessage。
     /// 不释放 lease(OnRuleCompletedAsync 负责);evaluator 抛异常时 Result.Success=false 并保留 ErrorMessage。
     /// </summary>
     /// </summary>
-    public async Task<S8RuleRunResult> RunSingleRuleAsync(long tenantId, long factoryId, S8RuleLease lease)
+    public async Task<S8RuleRunResult> RunSingleRuleAsync(long tenantId, S8RuleLease lease)
     {
     {
         // S8-P0-1-SCHEDULER-TRUSTED-SCOPE-1:执行入口必须按本次 tick 的可信作用域绑行。
         // S8-P0-1-SCHEDULER-TRUSTED-SCOPE-1:执行入口必须按本次 tick 的可信作用域绑行。
         // 原实现只按 lease.RuleId 装载,不校验 rule 归属;一旦 lease 来源被改写 / 未来新增调用方,
         // 原实现只按 lease.RuleId 装载,不校验 rule 归属;一旦 lease 来源被改写 / 未来新增调用方,
         // 就会在 A 租户的 scope 上下文里执行 B 租户的规则,而下游全部以传入的 tenantId/factoryId 落库。
         // 就会在 A 租户的 scope 上下文里执行 B 租户的规则,而下游全部以传入的 tenantId/factoryId 落库。
         // 这里同时做「谓词绑行」与「归属复核」两层,任一不符即 fail-fast,绝不静默降级。
         // 这里同时做「谓词绑行」与「归属复核」两层,任一不符即 fail-fast,绝不静默降级。
         var rule = await _ruleRep.AsQueryable()
         var rule = await _ruleRep.AsQueryable()
-            .Where(x => x.Id == lease.RuleId && x.TenantId == tenantId && x.FactoryId == factoryId)
+            .Where(x => x.Id == lease.RuleId && x.TenantId == tenantId)
             .FirstAsync();
             .FirstAsync();
         if (rule == null)
         if (rule == null)
         {
         {
             return new S8RuleRunResult { Success = false, ErrorMessage = "rule_not_found", Stats = new() };
             return new S8RuleRunResult { Success = false, ErrorMessage = "rule_not_found", Stats = new() };
         }
         }
-        if (rule.TenantId != tenantId || rule.FactoryId != factoryId)
+        if (rule.TenantId != tenantId)
         {
         {
             _logger.LogError(
             _logger.LogError(
-                "rule_scope_mismatch ruleId={RuleId} ruleTenant={RuleTenant} ruleFactory={RuleFactory} scopeTenant={ScopeTenant} scopeFactory={ScopeFactory}",
-                rule.Id, rule.TenantId, rule.FactoryId, tenantId, factoryId);
+                "rule_scope_mismatch ruleId={RuleId} ruleTenant={RuleTenant} scopeTenant={ScopeTenant}",
+                rule.Id, rule.TenantId, tenantId);
             return new S8RuleRunResult { Success = false, ErrorMessage = "rule_scope_mismatch", Stats = new() };
             return new S8RuleRunResult { Success = false, ErrorMessage = "rule_scope_mismatch", Stats = new() };
         }
         }
 
 
@@ -880,7 +880,7 @@ public class S8WatchSchedulerService : ITransient
 
 
         try
         try
         {
         {
-            var results = await ProcessSingleRuleAsync(tenantId, factoryId, rule, ruleType, evaluator, lease.RunId);
+            var results = await ProcessSingleRuleAsync(tenantId, rule, ruleType, evaluator, lease.RunId);
             var stats = new S8RuleRunStats
             var stats = new S8RuleRunStats
             {
             {
                 Hits = results.Count,
                 Hits = results.Count,
@@ -907,21 +907,21 @@ public class S8WatchSchedulerService : ITransient
     /// 与 ProcessRulesByTypeAsync 内单规则循环体语义一致;此处抽出便于新调度路径直接调用单条 rule。
     /// 与 ProcessRulesByTypeAsync 内单规则循环体语义一致;此处抽出便于新调度路径直接调用单条 rule。
     /// </summary>
     /// </summary>
     private async Task<List<S8WatchCreationResult>> ProcessSingleRuleAsync(
     private async Task<List<S8WatchCreationResult>> ProcessSingleRuleAsync(
-        long tenantId, long factoryId, AdoS8WatchRule rule, string ruleType,
+        long tenantId, AdoS8WatchRule rule, string ruleType,
         IS8RuleEvaluator evaluator, string runId)
         IS8RuleEvaluator evaluator, string runId)
     {
     {
         var results = new List<S8WatchCreationResult>();
         var results = new List<S8WatchCreationResult>();
         List<S8RuleHit> hits;
         List<S8RuleHit> hits;
         try
         try
         {
         {
-            hits = await evaluator.EvaluateAsync(tenantId, factoryId, rule);
+            hits = await evaluator.EvaluateAsync(tenantId, rule);
         }
         }
         catch (Exception ex)
         catch (Exception ex)
         {
         {
             var failureReason = ex is S8RuleEvaluatorException sre ? sre.Reason : ex.GetType().Name;
             var failureReason = ex is S8RuleEvaluatorException sre ? sre.Reason : ex.GetType().Name;
             await WriteDetectionLogAsync(new AdoS8DetectionLog
             await WriteDetectionLogAsync(new AdoS8DetectionLog
             {
             {
-                TenantId = tenantId, FactoryId = factoryId,
+                TenantId = tenantId, FactoryId = rule.FactoryId,
                 RuleId = rule.Id, RuleCode = rule.RuleCode, RuleType = ruleType, SceneCode = rule.SceneCode,
                 RuleId = rule.Id, RuleCode = rule.RuleCode, RuleType = ruleType, SceneCode = rule.SceneCode,
                 SourceObjectType = rule.SourceObjectType,
                 SourceObjectType = rule.SourceObjectType,
                 DetectResult = DetectResultEvaluateFailed,
                 DetectResult = DetectResultEvaluateFailed,
@@ -937,7 +937,7 @@ public class S8WatchSchedulerService : ITransient
         List<long> recoveredIds;
         List<long> recoveredIds;
         try
         try
         {
         {
-            recoveredIds = await ReconcileRecoveriesForRuleAsync(tenantId, factoryId, rule, ruleType, hits, runId);
+            recoveredIds = await ReconcileRecoveriesForRuleAsync(tenantId, rule, ruleType, hits, runId);
         }
         }
         catch (Exception ex)
         catch (Exception ex)
         {
         {
@@ -961,7 +961,7 @@ public class S8WatchSchedulerService : ITransient
             long matchedId;
             long matchedId;
             try
             try
             {
             {
-                matchedId = await FindOpenExceptionByDedupKeyAsync(tenantId, factoryId, hit.DedupKey);
+                matchedId = await FindOpenExceptionByDedupKeyAsync(tenantId, hit.DedupKey);
             }
             }
             catch (Exception ex)
             catch (Exception ex)
             {
             {
@@ -974,9 +974,9 @@ public class S8WatchSchedulerService : ITransient
                 try
                 try
                 {
                 {
                     await RefreshDetectionAsync(matchedId, hit);
                     await RefreshDetectionAsync(matchedId, hit);
-                    if (!await HasRecentRefreshedDetectionLogAsync(tenantId, factoryId, rule.RuleCode, hit.DedupKey, hit.DetectedAt))
+                    if (!await HasRecentRefreshedDetectionLogAsync(tenantId, rule.RuleCode, hit.DedupKey, hit.DetectedAt))
                     {
                     {
-                        await WriteDetectionLogAsync(BuildHitLog(tenantId, factoryId, rule, ruleType, hit, DetectResultRefreshed, matchedId, runId));
+                        await WriteDetectionLogAsync(BuildHitLog(tenantId, rule, ruleType, hit, DetectResultRefreshed, matchedId, runId));
                     }
                     }
                     results.Add(BuildSkippedDuplicate(rule, hit, matchedId));
                     results.Add(BuildSkippedDuplicate(rule, hit, matchedId));
                 }
                 }
@@ -992,7 +992,7 @@ public class S8WatchSchedulerService : ITransient
                 long compatId;
                 long compatId;
                 try
                 try
                 {
                 {
-                    compatId = await FindLegacyOutOfRangeExceptionAsync(tenantId, factoryId, rule.Id, hit.RelatedObjectCode);
+                    compatId = await FindLegacyOutOfRangeExceptionAsync(tenantId, rule.Id, hit.RelatedObjectCode);
                 }
                 }
                 catch (Exception ex)
                 catch (Exception ex)
                 {
                 {
@@ -1004,9 +1004,9 @@ public class S8WatchSchedulerService : ITransient
                     try
                     try
                     {
                     {
                         await BackfillLegacyExceptionAsync(compatId, hit);
                         await BackfillLegacyExceptionAsync(compatId, hit);
-                        if (!await HasRecentRefreshedDetectionLogAsync(tenantId, factoryId, rule.RuleCode, hit.DedupKey, hit.DetectedAt))
+                        if (!await HasRecentRefreshedDetectionLogAsync(tenantId, rule.RuleCode, hit.DedupKey, hit.DetectedAt))
                         {
                         {
-                            await WriteDetectionLogAsync(BuildHitLog(tenantId, factoryId, rule, ruleType, hit, DetectResultRefreshed, compatId, runId));
+                            await WriteDetectionLogAsync(BuildHitLog(tenantId, rule, ruleType, hit, DetectResultRefreshed, compatId, runId));
                         }
                         }
                         results.Add(BuildSkippedDuplicate(rule, hit, compatId));
                         results.Add(BuildSkippedDuplicate(rule, hit, compatId));
                     }
                     }
@@ -1022,9 +1022,12 @@ public class S8WatchSchedulerService : ITransient
             try
             try
             {
             {
                 typeExists = await _exceptionTypeRep.AsQueryable()
                 typeExists = await _exceptionTypeRep.AsQueryable()
+                    // S8-TENANT-ONLY-BATCH5:去掉工厂条件。这是一次**存在性**检查,
+                    // 少一个条件只会更宽松:factory_id=0 的供给规则也能看到本租户的工厂级异常类型行,
+                    // 否则它只能匹配到平台默认行、找不到就整条规则 exception_type_missing。
+                    // 注意 t.TenantId == 0 是平台默认哨兵(S8ConfigScope),不是工厂语义,保留。
                     .Where(t => t.TypeCode == hit.ExceptionTypeCode
                     .Where(t => t.TypeCode == hit.ExceptionTypeCode
                                 && (t.TenantId == 0 || t.TenantId == tenantId)
                                 && (t.TenantId == 0 || t.TenantId == tenantId)
-                                && (t.FactoryId == 0 || t.FactoryId == factoryId)
                                 && t.Enabled)
                                 && t.Enabled)
                     .AnyAsync();
                     .AnyAsync();
             }
             }
@@ -1044,7 +1047,7 @@ public class S8WatchSchedulerService : ITransient
             AdoS8RuleDetectionState? state;
             AdoS8RuleDetectionState? state;
             try
             try
             {
             {
-                (state, hitCount) = await UpsertDetectionStateOnHitAsync(tenantId, factoryId, rule, hit);
+                (state, hitCount) = await UpsertDetectionStateOnHitAsync(tenantId, rule, hit);
             }
             }
             catch (Exception ex)
             catch (Exception ex)
             {
             {
@@ -1064,7 +1067,7 @@ public class S8WatchSchedulerService : ITransient
 
 
             try
             try
             {
             {
-                var entity = await _reportDispatcher.CreateFromHitAsync(tenantId, factoryId, hit);
+                var entity = await _reportDispatcher.CreateFromHitAsync(tenantId, rule.FactoryId, hit);
                 await _exceptionRep.Context.Updateable<AdoS8Exception>()
                 await _exceptionRep.Context.Updateable<AdoS8Exception>()
                     .SetColumns(x => new AdoS8Exception
                     .SetColumns(x => new AdoS8Exception
                     {
                     {
@@ -1085,7 +1088,7 @@ public class S8WatchSchedulerService : ITransient
                         .Where(x => x.Id == state.Id)
                         .Where(x => x.Id == state.Id)
                         .ExecuteCommandAsync();
                         .ExecuteCommandAsync();
                 }
                 }
-                await WriteDetectionLogAsync(BuildHitLog(tenantId, factoryId, rule, ruleType, hit, DetectResultCreated, entity.Id, runId));
+                await WriteDetectionLogAsync(BuildHitLog(tenantId, rule, ruleType, hit, DetectResultCreated, entity.Id, runId));
                 await TryDispatchLayerNotificationAsync(entity);
                 await TryDispatchLayerNotificationAsync(entity);
                 results.Add(BuildCreatedResult(rule, hit, entity.Id));
                 results.Add(BuildCreatedResult(rule, hit, entity.Id));
             }
             }
@@ -1103,7 +1106,7 @@ public class S8WatchSchedulerService : ITransient
     /// 必须 WHERE id = lease.RuleId AND lock_token = lease.LockToken;affectedRows = 0 视为 lease 丢失,记录 Warning,不覆盖状态。
     /// 必须 WHERE id = lease.RuleId AND lock_token = lease.LockToken;affectedRows = 0 视为 lease 丢失,记录 Warning,不覆盖状态。
     /// 失败 ≥ 阈值(默认 3)写 paused_until = NOW + 1h。
     /// 失败 ≥ 阈值(默认 3)写 paused_until = NOW + 1h。
     /// </summary>
     /// </summary>
-    public async Task OnRuleCompletedAsync(long tenantId, long factoryId, S8RuleLease lease, S8RuleRunResult result, int durationMs)
+    public async Task OnRuleCompletedAsync(long tenantId, S8RuleLease lease, S8RuleRunResult result, int durationMs)
     {
     {
         var rule = await _ruleRep.AsQueryable().Where(x => x.Id == lease.RuleId).FirstAsync();
         var rule = await _ruleRep.AsQueryable().Where(x => x.Id == lease.RuleId).FirstAsync();
         if (rule == null)
         if (rule == null)
@@ -1184,7 +1187,7 @@ public class S8WatchSchedulerService : ITransient
     /// 3) 每条 rule 独立 try/catch 调用 RunSingleRuleAsync + OnRuleCompletedAsync
     /// 3) 每条 rule 独立 try/catch 调用 RunSingleRuleAsync + OnRuleCompletedAsync
     /// 单条规则失败不影响其他规则;整 tick 不抛异常。
     /// 单条规则失败不影响其他规则;整 tick 不抛异常。
     /// </summary>
     /// </summary>
-    public async Task<S8DispatchTickResult> RunDispatchTickAsync(long tenantId, long factoryId, int batchSize, string lockedBy)
+    public async Task<S8DispatchTickResult> RunDispatchTickAsync(long tenantId, int batchSize, string lockedBy)
     {
     {
         var tickId = Guid.NewGuid().ToString("N").Substring(0, 8);
         var tickId = Guid.NewGuid().ToString("N").Substring(0, 8);
         var runId = Guid.NewGuid().ToString("N").Substring(0, 16);
         var runId = Guid.NewGuid().ToString("N").Substring(0, 16);
@@ -1192,7 +1195,7 @@ public class S8WatchSchedulerService : ITransient
 
 
         try
         try
         {
         {
-            summary.LeaseReleased = await ResetExpiredLeasesAsync(tenantId, factoryId);
+            summary.LeaseReleased = await ResetExpiredLeasesAsync(tenantId);
         }
         }
         catch (Exception ex)
         catch (Exception ex)
         {
         {
@@ -1202,7 +1205,7 @@ public class S8WatchSchedulerService : ITransient
         List<S8RuleLease> leases;
         List<S8RuleLease> leases;
         try
         try
         {
         {
-            leases = await PickReadyRulesAsync(tenantId, factoryId, batchSize, lockedBy, runId);
+            leases = await PickReadyRulesAsync(tenantId, batchSize, lockedBy, runId);
         }
         }
         catch (Exception ex)
         catch (Exception ex)
         {
         {
@@ -1217,7 +1220,7 @@ public class S8WatchSchedulerService : ITransient
             S8RuleRunResult runResult;
             S8RuleRunResult runResult;
             try
             try
             {
             {
-                runResult = await RunSingleRuleAsync(tenantId, factoryId, lease);
+                runResult = await RunSingleRuleAsync(tenantId, lease);
             }
             }
             catch (Exception ex)
             catch (Exception ex)
             {
             {
@@ -1226,7 +1229,7 @@ public class S8WatchSchedulerService : ITransient
             sw.Stop();
             sw.Stop();
             try
             try
             {
             {
-                await OnRuleCompletedAsync(tenantId, factoryId, lease, runResult, (int)sw.ElapsedMilliseconds);
+                await OnRuleCompletedAsync(tenantId, lease, runResult, (int)sw.ElapsedMilliseconds);
             }
             }
             catch (Exception ex)
             catch (Exception ex)
             {
             {