Преглед изворни кода

feat(s8): 启用规则前校验审批流程依赖,堵住带病上线 | server 1.0.570

S8 规则启用门禁此前不检查审批流程是否存在。规则声明了复核 / 超时升级,
但当前租户没有对应的已发布流程时,规则照样能启用成功 —— 失败推迟到运行时,
而且是静默的:复核链的 StartFlow 被 TryStartVerificationFlowAsync 的 catch
吞成一条 warning(建单与提交照常成功、流程没起来);升级链虽然会抛,但人工
升级只回一条错误消息、自动升级作业记一条 s8_timeout_auto_escalate_failed,
规则本身一路显示「已启用 / 最近结果:成功」。每一层都报成功,最难被发现。

在既有的 S8RuleReadinessGate 上补两条判据,与已有的账号池判据成对 ——
那两条查「有没有人」,这两条查「有没有流程」:
  policy.RequiresVerification        → 必须有可用的 EXCEPTION_CLOSURE 流程
  policy.SupportsTimeoutEscalation   → 必须有可用的 EXCEPTION_ESCALATION 流程

「可用」的口径与 FlowEngineService.StartFlowCore 物理同源(含全局流程回落):
IsPublished && !IsDelete && (TenantId == 当前租户 || TenantId IS NULL)。
门禁比运行时宽会放行起不了流的规则,比运行时严会把「有全局流程可回落」的租户
误判成不可用,两种偏差都比没有门禁更糟,所以判据不重写、只此一份实现。

取数走新增的 IS8ApprovalFlowScopeReader(接口为测试留 seam,正式实现只有一个,
写法对齐 S8DepartmentScopeValidator)。其中 ClearFilter() 是必需的:全局多租户
AOP 对 ApprovalFlow 生成的是 TenantId = @current、不含 OR IS NULL,不关掉就永远
看不见全局流程;关掉后由方法自己写死租户谓词,是显式声明边界而非绕过租户。

两条判据刻意追加在既有判据之后,不插在各自的池判据旁边 —— 插在中间会改变既有
失败场景先命中哪个 reason code,而运维手册与既有测试按当前顺序写。判据之间无
从属关系,顺序只影响报哪一条,不影响放不放行。

本门禁不创建流程,只回答「依赖满不满足」。自动补一条空壳流程只会把配置错误
变成审批空转。

三条真实执行入口(EnableAsync / RunNowAsync / SchedulerService.RunSingleRuleAsync)
本就共用该门禁,未新增接线;Preview 与 Disable 不接 —— 停用必须在流程配坏之后
仍然可用,否则规则会卡在启用态关不掉。

验证:
- Admin.NET.sln 全 TFM build 0 Error,本批 3 文件 0 warning,总数与基线同为 674
- 新增 12 条契约测试,net10.0 / net8.0 各 84/84 通过(含既有 72 条)
- 真机端到端(797 租户,逐条配齐前置后观察门禁逐级推进):
    未配前置 → 400 [rule_required_parameter_missing]
    配部门   → 400 [rule_handler_pool_empty]
    配处理池 → 400 [rule_reviewer_pool_empty]
    配复核池 → 400 [rule_escalation_pool_empty]
    配升级池 → 400 [rule_escalation_flow_missing]   ← 本批新增判据命中
  被拦时 ado_s8_watch_rule.enabled 仍为 0,未写库;临时配置已撤回
- 流程齐备租户(838257186181189)enable / run-now 均 200,门禁不误拦
- disable 200,确认不过门禁
YY968XX пре 3 часа
родитељ
комит
8196dfd706

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

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

+ 345 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8ApprovalFlowReadinessTests.cs

@@ -0,0 +1,345 @@
+using System.Reflection;
+using System.Text.Json;
+using Admin.NET.Plugin.AiDOP.Const.S8;
+using Admin.NET.Plugin.AiDOP.Entity.S8;
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.Definitions;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S8;
+
+/// <summary>
+/// S8-RULE-FLOW-READINESS-1:审批流程依赖的启用门禁。
+///
+/// <para><b>要消灭的失败形态</b>:规则声明了复核 / 超时升级,但当前租户没有对应的
+/// <c>EXCEPTION_CLOSURE</c> / <c>EXCEPTION_ESCALATION</c> 已发布流程定义。
+/// 实际表现分两种,都不会让管理员看见问题 —— 复核链的 <c>StartFlow</c> 被
+/// <c>TryStartVerificationFlowAsync</c> 的 catch 吞成一条 warning(建单与提交照常成功);
+/// 升级链虽然会抛,但人工升级只回一条错误消息、自动升级作业记一条
+/// <c>s8_timeout_auto_escalate_failed</c>,而规则本身一路显示「已启用 / 最近结果:成功」。</para>
+///
+/// <para><b>本文件守三件事</b>:① 判据与 <c>FlowEngineService.StartFlowCore</c> 同源(含全局流程回落);
+/// ② 该要求是<b>逐规则声明</b>的(<c>RequiresVerification</c> / <c>SupportsTimeoutEscalation</c>),
+/// 未声明的规则完全不受影响;③ 停用路径<b>不经过</b>本门禁 ——
+/// 否则流程配坏的规则会卡在启用态关不掉。</para>
+/// </summary>
+public class S8ApprovalFlowReadinessTests
+{
+    private const long TenantA = 838257186181189L;
+    private const long DeptInA = 245L;
+
+    private const string ClosureBizType = "EXCEPTION_CLOSURE";
+    private const string EscalationBizType = "EXCEPTION_ESCALATION";
+
+    private static readonly string Rule01Code = S8PurchaseDeliveryRuleDefinitions.PurchaseDeliveryDateDelayCode;
+
+    /// <summary>Rule 01:四维全开(部门 / 处理池 / 复核 / 超时升级),因此两条流程判据都会被激活。</summary>
+    private static S8RuleDefinition Rule01() =>
+        new S8RuleCatalog(new IS8RuleDefinitionSource[] { new S8PurchaseDeliveryRuleDefinitions() })
+            .GetRequired(Rule01Code);
+
+    /// <summary>不声明复核 / 升级的规则:两条流程判据都不应被触发。</summary>
+    private static S8RuleDefinition RuleWithoutFlowRequirement() => new()
+    {
+        RuleCode = "UT_RULE_NO_FLOW_REQUIRED",
+        DisplayName = "单测规则",
+        DatasetCode = "UT_DATASET",
+        RuleType = "TIMEOUT",
+        SourceObjectType = "ORDER",
+        SceneCode = "S4",
+        ExceptionTypeCode = "UT_TYPE",
+        Timeout = new S8TimeoutSemantics { CompletedStates = new[] { "COMPLETED" } },
+        // RequiresVerification / SupportsTimeoutEscalation 均默认 false
+        Parameters = new S8RuleParameterPolicy()
+    };
+
+    private static AdoS8WatchRule Row(long tenantId, string ruleCode) => new()
+    {
+        Id = 1,
+        TenantId = tenantId,
+        FactoryId = 0,
+        RuleCode = ruleCode,
+        Enabled = false,
+        PollIntervalSeconds = 300,
+        TriggerCountRequired = 1,
+        RecoverCountRequired = 2,
+        Severity = "SERIOUS",
+        ParamsJson = JsonSerializer.Serialize(new Dictionary<string, object>
+        {
+            ["graceMinutes"] = 0,
+            ["defaultOccurrenceDeptId"] = DeptInA,
+            ["defaultResponsibleDeptId"] = DeptInA
+        })
+    };
+
+    /// <summary>部门替身:DeptInA 属租户 A。本文件不考察部门维,让它恒定通过。</summary>
+    private sealed class FakeDeptValidator : IS8DepartmentScopeValidator
+    {
+        public Task<bool> ExistsInTenantAsync(long? deptId, long tenantId) =>
+            Task.FromResult(deptId == DeptInA && tenantId == TenantA);
+    }
+
+    /// <summary>责任池替身:三类池各 1 名有效成员。本文件不考察池维,让它恒定通过。</summary>
+    private sealed class FakePool : IS8RuleResponsibilityReader
+    {
+        public Task<List<S8HandlerUserDto>> GetMembersAsync(long tenantId, string ruleCode, string responsibilityType) =>
+            Task.FromResult(new List<S8HandlerUserDto> { new() { UserId = 1000, Valid = true } });
+
+        public Task<IReadOnlyList<long>> GetMemberIdsAsync(long tenantId, string ruleCode, string responsibilityType) =>
+            Task.FromResult((IReadOnlyList<long>)new List<long> { 1000 });
+
+        public Task<IReadOnlyList<long>> GetValidMemberIdsAsync(long tenantId, string ruleCode, string responsibilityType) =>
+            Task.FromResult((IReadOnlyList<long>)new List<long> { 1000 });
+
+        public Task<bool> IsMemberAsync(long tenantId, string ruleCode, string responsibilityType, long userId) =>
+            Task.FromResult(true);
+    }
+
+    /// <summary>
+    /// 审批流程替身。<b>复刻 <c>FlowEngineService.StartFlowCore</c> 的候选口径</b>:
+    /// 本租户流程或全局流程(TenantId IS NULL)任一存在即可用。
+    /// </summary>
+    private sealed class FakeFlowReader : IS8ApprovalFlowScopeReader
+    {
+        private readonly HashSet<(string BizType, long TenantId)> _tenantFlows;
+        private readonly HashSet<string> _globalFlows;
+
+        public FakeFlowReader(
+            IEnumerable<(string BizType, long TenantId)>? tenantFlows = null,
+            IEnumerable<string>? globalFlows = null)
+        {
+            _tenantFlows = new HashSet<(string, long)>(tenantFlows ?? Array.Empty<(string, long)>());
+            _globalFlows = new HashSet<string>(globalFlows ?? Array.Empty<string>(), StringComparer.Ordinal);
+        }
+
+        public Task<bool> HasUsableFlowAsync(string bizType, long tenantId) =>
+            Task.FromResult(_tenantFlows.Contains((bizType, tenantId)) || _globalFlows.Contains(bizType));
+    }
+
+    private static S8RuleReadinessGate Gate(FakeFlowReader flows) =>
+        new(new FakeDeptValidator(), new FakePool(), flows);
+
+    private static Task<S8RuleReadinessCheck> Check(FakeFlowReader flows, S8RuleDefinition? definition = null)
+    {
+        var def = definition ?? Rule01();
+        return Gate(flows).CheckAsync(S8EffectiveRule.Resolve(Row(TenantA, def.RuleCode), def), TenantA);
+    }
+
+    // ── Case 1:本租户存在流程 → PASS ─────────────────────────────────────────
+
+    [Fact]
+    public async Task T1_TenantOwnFlows_ArePassed()
+    {
+        var r = await Check(new FakeFlowReader(tenantFlows: new[]
+        {
+            (ClosureBizType, TenantA),
+            (EscalationBizType, TenantA)
+        }));
+
+        Assert.True(r.Ok, r.Message);
+    }
+
+    // ── Case 2:本租户与全局都没有 → BLOCKED ──────────────────────────────────
+
+    [Fact]
+    public async Task T2_NoClosureFlowAnywhere_IsRejected()
+    {
+        // 只给升级流程,复核流程两侧皆无。
+        var r = await Check(new FakeFlowReader(tenantFlows: new[] { (EscalationBizType, TenantA) }));
+
+        Assert.False(r.Ok);
+        Assert.Equal(S8RuleReadinessReasonCode.ClosureFlowMissing, r.ReasonCode);
+        Assert.Contains(ClosureBizType, r.Message);
+    }
+
+    [Fact]
+    public async Task T3_NoEscalationFlowAnywhere_IsRejected()
+    {
+        // 只给复核流程,升级流程两侧皆无。
+        var r = await Check(new FakeFlowReader(tenantFlows: new[] { (ClosureBizType, TenantA) }));
+
+        Assert.False(r.Ok);
+        Assert.Equal(S8RuleReadinessReasonCode.EscalationFlowMissing, r.ReasonCode);
+        Assert.Contains(EscalationBizType, r.Message);
+    }
+
+    [Fact]
+    public async Task T4_NoFlowAtAll_IsRejected()
+    {
+        var r = await Check(new FakeFlowReader());
+
+        Assert.False(r.Ok);
+        Assert.Equal(S8RuleReadinessReasonCode.ClosureFlowMissing, r.ReasonCode);
+    }
+
+    // ── Case 3:本租户没有、但存在全局流程 → PASS(与运行时回落一致)────────────
+
+    [Fact]
+    public async Task T5_GlobalFlowFallback_IsPassed()
+    {
+        // 本租户一条都没建,全部靠 TenantId IS NULL 的全局流程回落 ——
+        // 这正是 FlowEngineService.StartFlowCore 的 (TenantId == current || TenantId == null) 语义。
+        var r = await Check(new FakeFlowReader(globalFlows: new[] { ClosureBizType, EscalationBizType }));
+
+        Assert.True(r.Ok, r.Message);
+    }
+
+    [Fact]
+    public async Task T6_GlobalClosure_PlusTenantEscalation_IsPassed()
+    {
+        // 混合来源:复核走全局、升级走本租户。运行时两者都能取到定义,门禁也必须放行。
+        var r = await Check(new FakeFlowReader(
+            tenantFlows: new[] { (EscalationBizType, TenantA) },
+            globalFlows: new[] { ClosureBizType }));
+
+        Assert.True(r.Ok, r.Message);
+    }
+
+    [Fact]
+    public async Task T7_OtherTenantFlow_DoesNotCount()
+    {
+        // 别的租户有流程不算数 —— 运行时的候选谓词里没有这一项。
+        var r = await Check(new FakeFlowReader(tenantFlows: new[]
+        {
+            (ClosureBizType, 838257212780613L),
+            (EscalationBizType, 838257212780613L)
+        }));
+
+        Assert.False(r.Ok);
+        Assert.Equal(S8RuleReadinessReasonCode.ClosureFlowMissing, r.ReasonCode);
+    }
+
+    // ── 逐规则声明:未声明复核 / 升级的规则完全不受影响 ────────────────────────
+
+    [Fact]
+    public async Task T8_RuleWithoutFlowRequirement_IsUnaffected()
+    {
+        // 一条流程都没有,但该规则既不复核也不升级 —— 不该被这两条判据挡住。
+        var r = await Check(new FakeFlowReader(), RuleWithoutFlowRequirement());
+
+        Assert.True(r.Ok, r.Message);
+    }
+
+    // ── 判据同源:Reader 的查询必须与 FlowEngine 的候选口径一致 ────────────────
+
+    [Fact]
+    public void T9_ScopeReader_MatchesFlowEngineCandidatePredicate()
+    {
+        var source = ReadSource(
+            "server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8RuleReadinessGate.cs");
+        var body = MethodBody(source, "public async Task<bool> HasUsableFlowAsync");
+
+        // FlowEngineService.StartFlowCore 的四个候选条件,缺一即门禁与运行时背离。
+        Assert.Contains("f.BizType == bizType", body, StringComparison.Ordinal);
+        Assert.Contains("f.IsPublished", body, StringComparison.Ordinal);
+        Assert.Contains("!f.IsDelete", body, StringComparison.Ordinal);
+        Assert.Contains("f.TenantId == tenantId || f.TenantId == null", body, StringComparison.Ordinal);
+
+        // 关掉全局 AOP 是为了能看见全局流程;关掉之后必须自己写死租户谓词(上一条已断言)。
+        Assert.Contains("ClearFilter()", body, StringComparison.Ordinal);
+
+        // 只读:门禁不得写流程定义。
+        Assert.DoesNotContain("Insertable", body, StringComparison.Ordinal);
+        Assert.DoesNotContain("Updateable", body, StringComparison.Ordinal);
+        Assert.DoesNotContain("Deleteable", body, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void T10_GateBizTypes_MatchRuntimeStartFlowBizTypes()
+    {
+        var gate = ReadSource(
+            "server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8RuleReadinessGate.cs");
+        var taskFlow = ReadSource(
+            "server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8TaskFlowService.cs");
+
+        // 门禁检查的 BizType 必须正好是运行时 StartFlow 要启动的那两条。
+        Assert.Contains($"ClosureBizType = \"{ClosureBizType}\"", gate, StringComparison.Ordinal);
+        Assert.Contains($"EscalationBizType = \"{EscalationBizType}\"", gate, StringComparison.Ordinal);
+        Assert.Contains($"BizType = \"{ClosureBizType}\"", taskFlow, StringComparison.Ordinal);
+        Assert.Contains($"BizType = \"{EscalationBizType}\"", taskFlow, StringComparison.Ordinal);
+    }
+
+    // ── DI 注册契约:新判据必须能被容器解析 ───────────────────────────────────
+
+    /// <summary>
+    /// <c>S8ApprovalFlowScopeReader</c> 必须实现 <c>ITransient</c> 并映射到
+    /// <see cref="IS8ApprovalFlowScopeReader"/>,否则 <c>S8RuleReadinessGate</c> 在运行时
+    /// 解析失败 —— 而那会让 Enable / RunNow / Scheduler 三条入口<b>一起</b> 500,
+    /// 是比缺门禁更严重的回归。注册方式与 <c>S8DepartmentScopeValidator</c> 保持同构。
+    /// </summary>
+    [Fact]
+    public void T12_ScopeReader_IsRegisteredAsTransient()
+    {
+        var impl = typeof(S8ApprovalFlowScopeReader);
+
+        Assert.True(typeof(IS8ApprovalFlowScopeReader).IsAssignableFrom(impl),
+            "S8ApprovalFlowScopeReader 未实现 IS8ApprovalFlowScopeReader");
+
+        var transient = impl.GetInterfaces().FirstOrDefault(i => i.Name == "ITransient");
+        Assert.True(transient != null,
+            "S8ApprovalFlowScopeReader 未实现 ITransient —— 容器不会注册它,Gate 将无法解析");
+
+        // Gate 的构造函数必须按接口依赖(留 seam),不得直接依赖实现或仓储。
+        var ctor = typeof(S8RuleReadinessGate).GetConstructors().Single();
+        Assert.Contains(ctor.GetParameters(), p => p.ParameterType == typeof(IS8ApprovalFlowScopeReader));
+        Assert.DoesNotContain(ctor.GetParameters(), p => p.ParameterType == impl);
+    }
+
+    // ── Case 4:停用不过门禁 ──────────────────────────────────────────────────
+
+    [Fact]
+    public void T11_DisablePath_DoesNotGoThroughReadinessGate()
+    {
+        var source = ReadSource(
+            "server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchRuleService.cs");
+
+        var enableBody = MethodBody(source, "public async Task<AdoS8WatchRule> EnableAsync");
+        var disableBody = MethodBody(source, "public async Task<AdoS8WatchRule> DisableAsync");
+
+        // 启用必须过门禁。
+        Assert.Contains("_readinessGate", enableBody, StringComparison.Ordinal);
+
+        // 停用必须不过 —— 流程配坏的规则仍然要关得掉,否则会卡在启用态。
+        Assert.DoesNotContain("_readinessGate", disableBody, StringComparison.Ordinal);
+    }
+
+    // ── helpers ──────────────────────────────────────────────────────────────
+
+    private static string ReadSource(string repoRelativePath)
+    {
+        var dir = new DirectoryInfo(AppContext.BaseDirectory);
+        while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "server")))
+            dir = dir.Parent;
+
+        Assert.NotNull(dir);
+        var full = Path.Combine(dir!.FullName, repoRelativePath);
+        Assert.True(File.Exists(full), $"未找到源文件:{full}");
+        return File.ReadAllText(full);
+    }
+
+    /// <summary>按大括号配平截取方法体,避免相邻方法的内容串入断言。</summary>
+    private static string MethodBody(string source, string signature)
+    {
+        var start = source.IndexOf(signature, StringComparison.Ordinal);
+        Assert.True(start >= 0, $"未找到方法:{signature}");
+
+        var open = source.IndexOf('{', start);
+        Assert.True(open >= 0, $"未找到方法体起始大括号:{signature}");
+
+        var depth = 0;
+        for (var i = open; i < source.Length; i++)
+        {
+            if (source[i] == '{') depth++;
+            else if (source[i] == '}')
+            {
+                depth--;
+                if (depth == 0) return source[open..(i + 1)];
+            }
+        }
+
+        Assert.Fail($"方法体大括号不配平:{signature}");
+        return string.Empty;
+    }
+}

+ 17 - 3
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8RuleEnableReadinessTests.cs

@@ -135,14 +135,28 @@ public class S8RuleEnableReadinessTests
             Task.FromResult(For(responsibilityType).Valid > 0);
     }
 
-    /// <summary>门禁实例:DeptInA 属租户 A,DeptInB 属租户 B;三类责任池默认各有 1 名有效成员。</summary>
+    /// <summary>
+    /// S8-RULE-FLOW-READINESS-1:审批流程判据替身。
+    /// <para>默认<b>有流程</b> —— 同 <see cref="FakePool"/> 的理由:本文件考察的是部门与责任池两维,
+    /// 让流程维恒定通过,失败时才能确定是哪一维出的问题。流程维本身由
+    /// <c>S8ApprovalFlowReadinessTests</c> 专门考察。</para>
+    /// </summary>
+    private sealed class FakeFlowReader : IS8ApprovalFlowScopeReader
+    {
+        private readonly bool _hasFlow;
+        public FakeFlowReader(bool hasFlow) => _hasFlow = hasFlow;
+
+        public Task<bool> HasUsableFlowAsync(string bizType, long tenantId) => Task.FromResult(_hasFlow);
+    }
+
+    /// <summary>门禁实例:DeptInA 属租户 A,DeptInB 属租户 B;三类责任池默认各有 1 名有效成员;审批流程默认已配置。</summary>
     private static S8RuleReadinessGate Gate(int validMembers = 1, int totalMembers = 1,
-        Dictionary<string, (int, int)>? perType = null) =>
+        Dictionary<string, (int, int)>? perType = null, bool hasFlow = true) =>
         new(new FakeDeptValidator(new Dictionary<long, long>
         {
             [DeptInA] = TenantA,
             [DeptInB] = TenantB
-        }), new FakePool(validMembers, totalMembers, perType));
+        }), new FakePool(validMembers, totalMembers, perType), new FakeFlowReader(hasFlow));
 
     private static Task<S8RuleReadinessCheck> Check(long tenantId, long? occ, long? resp, S8RuleDefinition? def = null)
     {

+ 137 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8RuleReadinessGate.cs

@@ -31,6 +31,78 @@ public static class S8RuleReadinessReasonCode
     /// <para>异常超时后没有任何人被通知,异常静静躺到过期 —— 升级作业每一轮都报成功。</para>
     /// </summary>
     public const string EscalationPoolEmpty = "rule_escalation_pool_empty";
+
+    /// <summary>
+    /// 规则声明了包含复核环节,却没有可用的 <c>EXCEPTION_CLOSURE</c> 审批流程定义
+    /// (S8-RULE-FLOW-READINESS-1)。
+    /// <para>与 <see cref="ReviewerPoolEmpty"/> 是同一条链的另一半:那条查「有没有人」,
+    /// 这条查「有没有流程」。两者缺任一,提交复核都走不下去 —— 差别只在失败位置:
+    /// 人池为空是候选人列表空,流程缺失是 <c>StartFlow</c> 抛「未找到已发布流程定义」,
+    /// 而该调用被 <c>TryStartVerificationFlowAsync</c> 的 catch 吞成一条 warning,
+    /// 业务侧完全无感。</para>
+    /// </summary>
+    public const string ClosureFlowMissing = "rule_closure_flow_missing";
+
+    /// <summary>
+    /// 规则声明了参与超时升级,却没有可用的 <c>EXCEPTION_ESCALATION</c> 审批流程定义
+    /// (S8-RULE-FLOW-READINESS-1)。
+    /// <para>与 <see cref="EscalationPoolEmpty"/> 同理成对。本条比复核那条更硬:
+    /// <c>S8TaskFlowService.UpgradeAsync</c> 里的 <c>StartFlow</c> <b>没有 try/catch</b>,
+    /// 缺流程时人工升级直接失败、自动升级作业记 <c>s8_timeout_auto_escalate_failed</c>。</para>
+    /// </summary>
+    public const string EscalationFlowMissing = "rule_escalation_flow_missing";
+}
+
+/// <summary>
+/// S8-RULE-FLOW-READINESS-1:「某个 BizType 在当前租户下有没有可用的审批流程定义」的<b>唯一</b>判定。
+///
+/// <para><b>判据必须与 <c>FlowEngineService.StartFlowCore</c> 同源</b>:那里的候选集合是
+/// <c>IsPublished &amp;&amp; !IsDelete &amp;&amp; (TenantId == 当前租户 || TenantId IS NULL)</c>。
+/// 门禁若比运行时宽,会放行一条起不了流的规则;若比运行时严,会把「有全局流程可回落」的
+/// 租户误判成不可用、挡住本该允许的启用。两种偏差都比没有门禁更糟,所以判据同源、不重写。</para>
+///
+/// <para><b>为什么必须 <c>ClearFilter()</c></b>:全局多租户 AOP 对 <c>ApprovalFlow</c> 生成的是
+/// <c>TenantId = @current</c>,<b>不含 <c>OR TenantId IS NULL</c></b>。不关掉它就永远看不见全局流程,
+/// 于是「该租户能回落到全局流程」会被判成「无流程」。这里关掉全局过滤后<b>由本方法自己写死
+/// 租户谓词</b>(<c>TenantId == tenantId || TenantId == null</c>)——是显式声明边界,不是绕过租户:
+/// 越权读别的租户的流程在本谓词下不可能发生。<c>FlowEngineService</c> 与
+/// <c>S8DepartmentScopeValidator</c> 用的是同一套做法。</para>
+///
+/// <para><b>只读、只判存在性</b>:不返回流程实体、不参与运行时选择(优先本租户、再回落全局、
+/// 按 Version 倒序取最新那一条),那仍然只由 <c>StartFlowCore</c> 决定。</para>
+/// </summary>
+public interface IS8ApprovalFlowScopeReader
+{
+    /// <summary>该 BizType 在当前租户下是否存在可用(已发布 / 未删 / 本租户或全局)的流程定义。</summary>
+    Task<bool> HasUsableFlowAsync(string bizType, long tenantId);
+}
+
+/// <summary>
+/// 正式实现。<b>生产代码里只有这一份</b> —— 接口的存在是为了给测试留 seam,
+/// 不是为了允许出现第二种判据。
+/// </summary>
+public sealed class S8ApprovalFlowScopeReader : IS8ApprovalFlowScopeReader, ITransient
+{
+    private readonly SqlSugarRepository<Admin.NET.Plugin.ApprovalFlow.ApprovalFlow> _flowRep;
+
+    public S8ApprovalFlowScopeReader(SqlSugarRepository<Admin.NET.Plugin.ApprovalFlow.ApprovalFlow> flowRep)
+        => _flowRep = flowRep;
+
+    /// <summary>
+    /// <paramref name="bizType"/> 为空或 <paramref name="tenantId"/> 非正数一律返回 false ——
+    /// 拿不到可信租户时按「不可用」处理(fail closed),不静默放行。
+    /// </summary>
+    public async Task<bool> HasUsableFlowAsync(string bizType, long tenantId)
+    {
+        if (string.IsNullOrWhiteSpace(bizType) || tenantId <= 0) return false;
+
+        return await _flowRep.AsQueryable().ClearFilter()
+            .Where(f => f.BizType == bizType
+                        && f.IsPublished
+                        && !f.IsDelete
+                        && (f.TenantId == tenantId || f.TenantId == null))
+            .AnyAsync();
+    }
 }
 
 /// <summary>就绪判定结果。<see cref="Ok"/> 为 true 时另外两项为 null。</summary>
@@ -84,15 +156,31 @@ public sealed class S8RuleReadinessCheck
 /// </summary>
 public sealed class S8RuleReadinessGate : ITransient
 {
+    /// <summary>
+    /// 复核链审批流程的 BizType。与 <c>S8TaskFlowService.TryStartVerificationFlowAsync</c>
+    /// 和 <c>S8ApprovalFlowRepairService.ClosureBizType</c> 必须一致 ——
+    /// 门禁检查的流程必须正好是运行时要启动的那一条,否则「检查通过、起流失败」照旧发生。
+    /// </summary>
+    private const string ClosureBizType = "EXCEPTION_CLOSURE";
+
+    /// <summary>
+    /// 升级链审批流程的 BizType。与 <c>S8TaskFlowService.UpgradeAsync</c>
+    /// 和 <c>S8ApprovalFlowRepairService.EscalationBizType</c> 必须一致,理由同上。
+    /// </summary>
+    private const string EscalationBizType = "EXCEPTION_ESCALATION";
+
     private readonly IS8DepartmentScopeValidator _deptValidator;
     private readonly IS8RuleResponsibilityReader _pools;
+    private readonly IS8ApprovalFlowScopeReader _flows;
 
     public S8RuleReadinessGate(
         IS8DepartmentScopeValidator deptValidator,
-        IS8RuleResponsibilityReader pools)
+        IS8RuleResponsibilityReader pools,
+        IS8ApprovalFlowScopeReader flows)
     {
         _deptValidator = deptValidator;
         _pools = pools;
+        _flows = flows;
     }
 
     /// <summary>
@@ -150,9 +238,57 @@ public sealed class S8RuleReadinessGate : ITransient
             if (!check.Ok) return check;
         }
 
+        // ⑤⑥ 审批流程定义 —— 与 ③④ 的账号池判据成对:那两条查「有没有人」,这两条查「有没有流程」。
+        //
+        // 刻意<b>追加在末尾</b>而不是紧贴各自的池判据:插在中间会改变既有失败场景先命中哪个
+        // reason code,而运维手册与既有测试按当前顺序写。判据之间无从属关系,顺序只影响报哪一条,
+        // 不影响放不放行。
+        if (policy.RequiresVerification)
+        {
+            var check = await CheckFlowAsync(
+                tenantId, ClosureBizType,
+                S8RuleReadinessReasonCode.ClosureFlowMissing,
+                "启用失败:当前规则包含复核环节,依赖 EXCEPTION_CLOSURE 审批流程,"
+                + "但当前租户没有可用的已发布流程(本租户与全局均无),请先完成审批流程配置。");
+            if (!check.Ok) return check;
+        }
+
+        if (policy.SupportsTimeoutEscalation)
+        {
+            var check = await CheckFlowAsync(
+                tenantId, EscalationBizType,
+                S8RuleReadinessReasonCode.EscalationFlowMissing,
+                "启用失败:当前规则参与超时升级,依赖 EXCEPTION_ESCALATION 审批流程,"
+                + "但当前租户没有可用的已发布流程(本租户与全局均无),请先完成审批流程配置。");
+            if (!check.Ok) return check;
+        }
+
         return S8RuleReadinessCheck.Pass();
     }
 
+    /// <summary>
+    /// 审批流程判据:该 BizType 在本租户下必须有<b>可用</b>的流程定义。
+    ///
+    /// <para>「可用」的口径由 <see cref="IS8ApprovalFlowScopeReader"/> 与
+    /// <c>FlowEngineService.StartFlowCore</c> 同源保证 —— 含全局流程回落。
+    /// 因此「本租户没建、但有全局流程」是<b>通过</b>,与运行时行为一致。</para>
+    ///
+    /// <para><b>为什么必须在启用时挡</b>:缺流程的实际表现分两种,都不会让管理员看见问题 ——
+    /// 复核链的 <c>StartFlow</c> 被 catch 吞成 warning(建单/提交照常成功,流程没起来);
+    /// 升级链虽然会抛,但人工升级只回一条错误消息、自动升级作业记一条 warning,
+    /// 而规则本身一路显示「已启用 / 最近结果:成功」。与本门禁其余判据是同一类静默失败。</para>
+    ///
+    /// <para><b>本门禁不负责创建流程</b>:只回答「依赖满不满足」。补流程是管理员在
+    /// 审批流程管理页的动作,自动补一条空壳流程只会把配置错误变成审批空转。</para>
+    /// </summary>
+    private async Task<S8RuleReadinessCheck> CheckFlowAsync(
+        long tenantId, string bizType, string reasonCode, string message)
+    {
+        return await _flows.HasUsableFlowAsync(bizType, tenantId)
+            ? S8RuleReadinessCheck.Pass()
+            : S8RuleReadinessCheck.Fail(reasonCode, message);
+    }
+
     private async Task<S8RuleReadinessCheck> CheckDepartmentsAsync(S8EffectiveRule effective, long tenantId)
     {
         var occurrence = effective.Parameters.DefaultOccurrenceDeptId;