Selaa lähdekoodia

refactor(flow): consolidate dedicated entry policy

上一批(c6935407e)为封堵 S7 的四个变更型通用入口,新增了
FlowMutationDedicatedOnlyBizTypes,与 DedicatedEntryOnlyBizTypes 形成两份
BizType policy source。行为正确,但两套名单不接受长期保留。本批只修这个
设计问题,不改变任何已验证的功能行为。

收敛方式:字典值由 string 升级为 DedicatedEntryPolicy(Message, BlockFlowMutation),
把「是否只能走专用入口」与「封堵范围是否延伸到变更型入口」放进同一条记录:

  S5_IQC_INSPBILL        BlockFlowMutation = false
  S6_PROCESS_INSPECTION  BlockFlowMutation = false
  IPQC_INSPECTION        BlockFlowMutation = false
  S7_FQC_INSPBILL        BlockFlowMutation = true

Approve / Reject / BatchApprove / BatchReject 仍按「BizType 在名单内即拒绝」
的原语义走 EnsureDedicatedEntryOnlyAsync,行为不变(仅取文案改为 hint.Message)。
四个变更型入口改为读同一条记录的 BlockFlowMutation 显式字段,不再查第二个集合;
判定不从文案内容、字典顺序或 BizType 字面量反推,也未引入 if/switch 分支——
S7 字面量在 FlowTaskService 中现只出现 1 次(policy 键)。

Guard placement 未变:四个入口的守卫仍是方法第一条语句,仍在进入 FlowEngine 前
fail-closed。FlowEngineService 一行未改。S5/S6/IPQC 的 Generic Mutation 行为
完全保持(BlockFlowMutation=false)。Escalate / Urge 未动。

S5IqcGenericApprovalGuardContractTests 的一处断言由 Oops.Oh(hint) 改为
Oops.Oh(hint.Message):TEST ONLY COMPATIBILITY UPDATE,仅适配源码形状,
业务契约不变(文案仍来自字典查表、仍不取自入参),S5 生产代码未改。
该适配无法通过生产侧兼容写法避免——S5/S6/IPQC 共同要求
TryGetValue(..., out var hint) 字面量,而 S5 又要求 hint 可直传 Oops.Oh,
两者同时成立只能让字典值退回 string,与本批目标矛盾。

验证:ApprovalFlow 与 AiDOP 插件编译 0 Error;S7 守卫契约 57/57;
S5 11/11、S6 15/15、IPQC 22/22、S7TenantAuthority 36/36、
S7FqcInitiatorTenantGuard 30/30;全量 2912 passed / 1 failed / 2 skipped,
唯一失败为既有 S8 用例(AdoS8OrderReviewKpiController.RefreshAsync →
s8:dashboard:read),错误内容与上一批逐字相同,与本批无关。

无 schema / migration / 版本号改动。Runtime 本批未执行,仍为
RUNTIME ACCEPTANCE PENDING。
YY968XX 2 päivää sitten
vanhempi
commit
fdaebfd4d5

+ 4 - 1
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/ApprovalFlow/S5IqcGenericApprovalGuardContractTests.cs

@@ -47,7 +47,10 @@ public class S5IqcGenericApprovalGuardContractTests
         Assert.Contains("_taskRep.GetByIdAsync(taskId)", guard);
         Assert.Contains("_taskRep.GetByIdAsync(taskId)", guard);
         Assert.Contains("_instanceRep.GetByIdAsync(task.InstanceId)", guard);
         Assert.Contains("_instanceRep.GetByIdAsync(task.InstanceId)", guard);
         Assert.Contains("instance.BizType", guard);
         Assert.Contains("instance.BizType", guard);
-        Assert.Contains("Oops.Oh(hint)", guard);
+        // 2026-09-18 Policy Consolidation:字典值由 string 升级为 DedicatedEntryPolicy
+        // (Message + BlockFlowMutation 同属一条记录,消除第二份 BizType 名单),
+        // 故抛错取 hint.Message。业务契约不变——文案仍来自字典查表、仍不取自入参。
+        Assert.Contains("Oops.Oh(hint.Message)", guard);
         // TaskActionInput 只有 TaskId/Comment,不存在可被伪造的 bizType 入参
         // TaskActionInput 只有 TaskId/Comment,不存在可被伪造的 bizType 入参
         Assert.DoesNotContain("input.BizType", guard);
         Assert.DoesNotContain("input.BizType", guard);
     }
     }

+ 65 - 28
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/ApprovalFlow/S7FqcGenericApprovalGuardContractTests.cs

@@ -57,11 +57,11 @@ public class S7FqcGenericApprovalGuardContractTests
         Assert.Contains($"[\"{bizType}\"]", Dict());
         Assert.Contains($"[\"{bizType}\"]", Dict());
     }
     }
 
 
-    /// <summary>名单恰好四条 —— 防止本批顺手扩大守卫范围。</summary>
+    /// <summary>名单恰好四条 —— 防止顺手扩大守卫范围(T3)。</summary>
     [Fact]
     [Fact]
     public void Guard_CoversExactlyFourBizTypes()
     public void Guard_CoversExactlyFourBizTypes()
     {
     {
-        Assert.Equal(4, CountOf(Dict(), "\"] = \""));
+        Assert.Equal(4, CountOf(Dict(), "\"] = new("));
     }
     }
 
 
     // ── 四个通用入口全覆盖(§14)──
     // ── 四个通用入口全覆盖(§14)──
@@ -143,20 +143,19 @@ public class S7FqcGenericApprovalGuardContractTests
     }
     }
 
 
     /// <summary>
     /// <summary>
-    /// §9 禁止散落 if:不得在动作方法里各写一遍 S7 的判断。
+    /// T10 / §9 禁止散落 if:不得在动作方法里各写一遍 S7 的判断。
     ///
     ///
-    /// <para>字面量允许出现的位置恰好两处,且都是声明式集合成员,不是条件分支:
-    /// ① <c>DedicatedEntryOnlyBizTypes</c> 的键(名单与文案的唯一来源);
-    /// ② <c>FlowMutationDedicatedOnlyBizTypes</c> 的成员(封堵范围是否延伸到变更型入口)。
-    /// 真正要防的「散落 if」由下面的 <c>DoesNotContain("== ...")</c> 断言守住。</para>
+    /// <para>Policy Consolidation 之后,S7 字面量在整个文件中**只应出现一次**——
+    /// 即统一 policy 字典的键。封堵范围由该条记录的 <c>BlockFlowMutation</c> 字段表达,
+    /// 不再有第二处成员声明。</para>
     /// </summary>
     /// </summary>
     [Fact]
     [Fact]
     public void NoScatteredPerBizTypeIfStatements()
     public void NoScatteredPerBizTypeIfStatements()
     {
     {
         var s = FlowTaskService();
         var s = FlowTaskService();
-        Assert.Equal(2, CountOf(s, $"\"{BizType}\""));
+        Assert.Equal(1, CountOf(s, $"\"{BizType}\""));
         Assert.DoesNotContain($"== \"{BizType}\"", s);
         Assert.DoesNotContain($"== \"{BizType}\"", s);
-        Assert.DoesNotContain($"if (instance.BizType == \"{BizType}\"", s);
+        Assert.DoesNotContain($"case \"{BizType}\"", s);
     }
     }
 
 
     // ── §12 权威来源与不泄漏 ──
     // ── §12 权威来源与不泄漏 ──
@@ -254,8 +253,8 @@ public class S7FqcGenericApprovalGuardContractTests
     private const string MutationGuardByTask = "EnsureDedicatedFlowMutationOnlyByTaskAsync";
     private const string MutationGuardByTask = "EnsureDedicatedFlowMutationOnlyByTaskAsync";
     private const string MutationGuardByInstance = "EnsureDedicatedFlowMutationOnlyByInstanceAsync";
     private const string MutationGuardByInstance = "EnsureDedicatedFlowMutationOnlyByInstanceAsync";
 
 
-    private static string MutationScope() =>
-        Slice(FlowTaskService(), "FlowMutationDedicatedOnlyBizTypes = new(StringComparer.Ordinal)", "};");
+    /// <summary>取某 BizType 在统一 policy 字典里的整条记录(跨行,含 BlockFlowMutation 字段)。</summary>
+    private static string PolicyOf(string bizType) => Slice(Dict(), $"[\"{bizType}\"] = new(", "),");
 
 
     /// <summary>
     /// <summary>
     /// T1–T4:四个变更型入口各自的守卫必须是方法的**第一条语句**。
     /// T1–T4:四个变更型入口各自的守卫必须是方法的**第一条语句**。
@@ -296,36 +295,74 @@ public class S7FqcGenericApprovalGuardContractTests
     }
     }
 
 
     /// <summary>
     /// <summary>
-    /// T6:封堵范围不得顺手扩大——其余三个 dedicated BizType 目前仅封堵同意/拒绝,
-    /// 纳入变更型入口会改变它们的现有行为,属另批范围。
+    /// T1:只允许存在**一份** BizType policy authority source ——
+    /// 恰好一个 Dictionary、零个旁路 HashSet。
+    /// </summary>
+    [Fact]
+    public void Policy_HasExactlyOneAuthoritySource()
+    {
+        var s = FlowTaskService();
+        Assert.Equal(1, CountOf(s, "private static readonly Dictionary"));
+        Assert.Equal(0, CountOf(s, "private static readonly HashSet"));
+        Assert.Contains("Dictionary<string, DedicatedEntryPolicy> DedicatedEntryOnlyBizTypes = new()", s);
+    }
+
+    /// <summary>
+    /// T2:第二套名单必须已彻底删除 —— 连同任何「换个名字的同款集合」。
+    /// </summary>
+    [Theory]
+    [InlineData("FlowMutationDedicatedOnlyBizTypes")]
+    [InlineData("FlowMutationBizTypes")]
+    [InlineData("S7FlowMutationBizTypes")]
+    [InlineData("ShouldBlockS7")]
+    [InlineData("IsS7")]
+    public void Policy_SecondListIsRemoved(string forbidden)
+    {
+        Assert.DoesNotContain(forbidden, FlowTaskService());
+    }
+
+    /// <summary>T4:S7 的封堵范围延伸到变更型入口。</summary>
+    [Fact]
+    public void Policy_S7BlocksFlowMutation()
+    {
+        Assert.Contains("BlockFlowMutation: true", PolicyOf(BizType));
+    }
+
+    /// <summary>
+    /// T5:其余三条只封堵同意/拒绝,变更型入口保持既有行为 ——
+    /// 本次 consolidation 不得改变 S5 / S6 / IPQC 的任何行为。
     /// </summary>
     /// </summary>
     [Theory]
     [Theory]
     [InlineData("S5_IQC_INSPBILL")]
     [InlineData("S5_IQC_INSPBILL")]
     [InlineData("S6_PROCESS_INSPECTION")]
     [InlineData("S6_PROCESS_INSPECTION")]
     [InlineData("IPQC_INSPECTION")]
     [InlineData("IPQC_INSPECTION")]
-    public void FlowMutationScope_ExcludesOtherDedicatedBizTypes(string bizType)
+    public void Policy_OtherDedicatedBizTypesDoNotBlockFlowMutation(string bizType)
     {
     {
-        Assert.DoesNotContain(bizType, MutationScope());
+        Assert.Contains("BlockFlowMutation: false", PolicyOf(bizType));
     }
     }
 
 
-    /// <summary>T6 续:作用域恰好一条,且必须是主名单的子集(证明它不是第二套名单)。</summary>
+    /// <summary>
+    /// T4/T5 续:恰好一条 <c>true</c>、三条 <c>false</c> ——
+    /// 防止后续顺手把别的 BizType 翻成 true。
+    /// </summary>
     [Fact]
     [Fact]
-    public void FlowMutationScope_IsSingleMemberSubsetOfMainDict()
+    public void Policy_ExactlyOneBizTypeBlocksFlowMutation()
     {
     {
-        var scope = MutationScope();
-        Assert.Equal(2, CountOf(scope, "\""));  // 恰好一个字符串成员 → 一对引号
-        Assert.Contains($"\"{BizType}\"", scope);
-        Assert.Contains($"[\"{BizType}\"]", Dict());
+        var dict = Dict();
+        Assert.Equal(1, CountOf(dict, "BlockFlowMutation: true"));
+        Assert.Equal(3, CountOf(dict, "BlockFlowMutation: false"));
     }
     }
 
 
-    /// <summary>T6 续:通用审批 BizType 不得被纳入变更型封堵。</summary>
-    [Theory]
-    [InlineData("CONTRACT_REVIEW")]
-    [InlineData("ORDER_REVIEW")]
-    [InlineData("SMART_OPS_IMPROVEMENT")]
-    public void FlowMutationScope_DoesNotBlockGenericBizTypes(string bizType)
+    /// <summary>
+    /// 判定必须走显式字段,禁止从文案内容 / 字典顺序 / BizType 字面量前缀反推(§4)。
+    /// </summary>
+    [Fact]
+    public void Policy_DecisionUsesExplicitFlagOnly()
     {
     {
-        Assert.DoesNotContain(bizType, MutationScope());
+        var byInstance = Slice(FlowTaskService(), $"private async Task {MutationGuardByInstance}(long", "}\n\n    /// <summary>");
+        Assert.Contains("policy.BlockFlowMutation", byInstance);
+        foreach (var inferred in new[] { "Message.Contains", "StartsWith", "EndsWith", "IndexOf", "ElementAt", "First()" })
+            Assert.DoesNotContain(inferred, byInstance);
     }
     }
 
 
     /// <summary>T7:原 approve/reject/batch 守卫必须仍然调用**原**守卫,未被新守卫替换。</summary>
     /// <summary>T7:原 approve/reject/batch 守卫必须仍然调用**原**守卫,未被新守卫替换。</summary>

+ 46 - 39
server/Plugins/Admin.NET.Plugin.ApprovalFlow/Service/FlowTask/FlowTaskService.cs

@@ -170,15 +170,22 @@ public class FlowTaskService : IDynamicApiController, ITransient
     ///
     ///
     /// 这里刻意硬编码 BizType 字符串:ApprovalFlow 插件不引用 AiDOP 插件
     /// 这里刻意硬编码 BizType 字符串:ApprovalFlow 插件不引用 AiDOP 插件
     /// (依赖方向为 AiDOP → ApprovalFlow,反向会成环),无法引用业务侧常量。
     /// (依赖方向为 AiDOP → ApprovalFlow,反向会成环),无法引用业务侧常量。
+    ///
+    /// <para><b>唯一 Authority Source</b>:某 BizType 的「是否只能走专用入口」以及
+    /// 「封堵范围延伸到哪些通用动作」都由本字典的同一条记录表达,不得再另立集合。
+    /// 判定一律走 <see cref="DedicatedEntryPolicy"/> 的显式字段,禁止从文案内容、
+    /// 字典顺序或 BizType 字面量前缀反推。</para>
     /// </summary>
     /// </summary>
-    private static readonly Dictionary<string, string> DedicatedEntryOnlyBizTypes = new()
+    private static readonly Dictionary<string, DedicatedEntryPolicy> DedicatedEntryOnlyBizTypes = new()
     {
     {
-        ["S5_IQC_INSPBILL"] = "来料检验单请在「来料检验单详情」页完成审核,不支持在审批中心直接同意/拒绝。",
+        ["S5_IQC_INSPBILL"] = new("来料检验单请在「来料检验单详情」页完成审核,不支持在审批中心直接同意/拒绝。",
+            BlockFlowMutation: false),
         // S6 过程检验单:N3_QE_DISPOSITION 的处置记录(ado_s6_ipqc_quality_disposition)是由
         // S6 过程检验单:N3_QE_DISPOSITION 的处置记录(ado_s6_ipqc_quality_disposition)是由
         // S6ProcessInspectionReview.qe-disposition 在同一事务里 INSERT 后才推进流程的。走通用审批会把
         // S6ProcessInspectionReview.qe-disposition 在同一事务里 INSERT 后才推进流程的。走通用审批会把
         // 流程直接推到 end 而永不写处置记录 —— 流程显示已闭环、处置轨迹却为空,且事后无法补记。
         // 流程直接推到 end 而永不写处置记录 —— 流程显示已闭环、处置轨迹却为空,且事后无法补记。
         // 另:专用 supervisor-return 要求退回意见必填且回退到 N1,通用 Reject 会把实例直接置为终态。
         // 另:专用 supervisor-return 要求退回意见必填且回退到 N1,通用 Reject 会把实例直接置为终态。
-        ["S6_PROCESS_INSPECTION"] = "过程检验单请在「过程检验」业务页完成主管审核或质量处置,不支持在审批中心直接同意/拒绝。",
+        ["S6_PROCESS_INSPECTION"] = new("过程检验单请在「过程检验」业务页完成主管审核或质量处置,不支持在审批中心直接同意/拒绝。",
+            BlockFlowMutation: false),
         // S6 IPQC 过程检验单(源 qms_gcjyd,与上面 Phase1C 链同名不同源):同款旁路,且破坏面更大。
         // S6 IPQC 过程检验单(源 qms_gcjyd,与上面 Phase1C 链同名不同源):同款旁路,且破坏面更大。
         // ① N3_QUALITY_DISPOSITION 的处置记录(ado_s6_ipqc_quality_disposition)由
         // ① N3_QUALITY_DISPOSITION 的处置记录(ado_s6_ipqc_quality_disposition)由
         //    IpqcInspectionDisposition.submit 在同一事务内 INSERT 后才推进流程;
         //    IpqcInspectionDisposition.submit 在同一事务内 INSERT 后才推进流程;
@@ -186,7 +193,8 @@ public class FlowTaskService : IDynamicApiController, ITransient
         // 走通用审批两者都不会发生 —— 流程显示已闭环,处置轨迹为空且业务状态永久停在旧值。
         // 走通用审批两者都不会发生 —— 流程显示已闭环,处置轨迹为空且业务状态永久停在旧值。
         // 另:专用 supervisor-return 要求退回意见必填、经 ReturnToPrev 回到 N1,通用 Reject 直接置实例终态。
         // 另:专用 supervisor-return 要求退回意见必填、经 ReturnToPrev 回到 N1,通用 Reject 直接置实例终态。
         // 2026-09-14 实测:通用 approve 曾真实把一条 IPQC N2 待办推进到 end。
         // 2026-09-14 实测:通用 approve 曾真实把一条 IPQC N2 待办推进到 end。
-        ["IPQC_INSPECTION"] = "过程检验单请在「过程检验」业务页完成检验、主管审核或质量处置,不支持在审批中心直接同意/拒绝。",
+        ["IPQC_INSPECTION"] = new("过程检验单请在「过程检验」业务页完成检验、主管审核或质量处置,不支持在审批中心直接同意/拒绝。",
+            BlockFlowMutation: false),
         // S7 成品检验单:本链的 IFlowBizHandler(FqcInspBillFlowBizHandler)三个回调**全是 no-op**,
         // S7 成品检验单:本链的 IFlowBizHandler(FqcInspBillFlowBizHandler)三个回调**全是 no-op**,
         // 业务副作用一律由专用 API 在引擎之外、同事务内完成,故通用审批绕过的是全部业务语义:
         // 业务副作用一律由专用 API 在引擎之外、同事务内完成,故通用审批绕过的是全部业务语义:
         // ① N1 通用 approve 会在 submit-result 从未写入 pd/hgsl/bhgsl/clfs 的情况下推到 N2 ——
         // ① N1 通用 approve 会在 submit-result 从未写入 pd/hgsl/bhgsl/clfs 的情况下推到 N2 ——
@@ -199,40 +207,35 @@ public class FlowTaskService : IDynamicApiController, ITransient
         //    检验进度永远停在「检验完成」,且通用 Reject 连退回意见都不要求。
         //    检验进度永远停在「检验完成」,且通用 Reject 连退回意见都不要求。
         // ④ N3 通用 approve 推到 end 而 ado_s7_fqc_qe_disposition 永不写入 —— 流程显示闭环、
         // ④ N3 通用 approve 推到 end 而 ado_s7_fqc_qe_disposition 永不写入 —— 流程显示闭环、
         //    处置轨迹为空且事后无法补记(与 IPQC_INSPECTION 同款,该形态 2026-09-14 已实证发生过)。
         //    处置轨迹为空且事后无法补记(与 IPQC_INSPECTION 同款,该形态 2026-09-14 已实证发生过)。
-        ["S7_FQC_INSPBILL"] = "成品检验单请在「FQC检验单详情」页完成检验结果提交、主管审核或质量处置,不支持在审批中心直接同意/拒绝/退回/撤回/转办/加签。",
+        //
+        // BlockFlowMutation=true 的唯一一条:除同意/拒绝外,退回上一步/撤回/转办/加签
+        // 四个变更型通用入口也一并封堵。理由同样源于「业务副作用不在引擎里」:
+        // ⑤ 通用 ReturnToPrev 绕过专用 supervisor-reject 的强制退回意见、同事务复位
+        //    FINSPECTSTATUS、以及 mdp_std_fqc_task.inspect_progress 同步;从 N3 调用还会退到 N2,
+        //    产生专用设计从不产生的状态。
+        // ⑥ 通用 Withdraw 把实例置 Cancelled 而 OnFlowCompleted 为 no-op,报检单永久停在
+        //    「检验中」:重提结果被「流程已结束」拒、重新认领被「负责人已锁定」拒、
+        //    重新生成检验单被幂等唯一约束拒 —— 三条恢复路径全部关闭。
+        // ⑦ 通用 Transfer / AddSign 把 AssigneeId 原样写入(不校验存在性/租户/角色),
+        //    而专用 N2/N3 门禁是「持有本节点待办」而非「持有角色」,于是无
+        //    ROLE_S7_FQC_SUPERVISOR / ROLE_S7_FQC_QE 的用户可直接完成主管审核或 QE 处置。
+        ["S7_FQC_INSPBILL"] = new("成品检验单请在「FQC检验单详情」页完成检验结果提交、主管审核或质量处置,不支持在审批中心直接同意/拒绝/退回/撤回/转办/加签。",
+            BlockFlowMutation: true),
     };
     };
 
 
     /// <summary>
     /// <summary>
-    /// S7-FQC-GENERIC-FLOW-MUTATION-GUARD-1:<see cref="DedicatedEntryOnlyBizTypes"/> 中**同时**需要
-    /// 封堵变更型通用入口(退回上一步 / 撤回 / 转办 / 加签)的 BizType 子集。
-    ///
-    /// <para>这不是第二套名单——名单与文案的唯一来源仍是 <see cref="DedicatedEntryOnlyBizTypes"/>,
-    /// 本集合只表达「该 BizType 的封堵范围是否延伸到变更型入口」这一作用域,
-    /// 故必须是上面字典键的子集(由契约测试断言)。</para>
-    ///
-    /// <para><b>为什么只有 S7</b>:其余三条目前仅封堵同意/拒绝,把它们一并纳入会改变
-    /// S5/S6/IPQC 的现有行为,超出本批范围。它们是否也该纳入需各自独立评估,
-    /// 不在此顺手扩大。</para>
-    ///
-    /// <para><b>为什么 S7 必须纳入</b>:S7 的 <c>IFlowBizHandler</c> 三个回调全是 no-op,
-    /// 业务副作用一律由 <c>FqcInspBillFlowService</c> 在引擎之外、同事务内完成,因此
-    /// 任何通用流程变更都只改流程、不改业务:
-    /// <list type="bullet">
-    /// <item><b>退回上一步</b>绕过专用 <c>supervisor-reject</c> 的三件事——强制退回意见、
-    /// 同事务复位 <c>qms_fqcbj.FINSPECTSTATUS='检验中'</c>、同步 <c>mdp_std_fqc_task.inspect_progress</c>;
-    /// 且从 N3 调用会退到 N2,产生专用设计从不产生的状态。</item>
-    /// <item><b>撤回</b>把实例置 Cancelled,而 <c>OnFlowCompleted</c> 为 no-op → 报检单永久停在
-    /// 「检验中」:重提结果被「流程已结束」拒、重新认领被「负责人已锁定」拒、重新生成检验单被
-    /// 幂等唯一约束拒,三条恢复路径全部关闭。</item>
-    /// <item><b>转办 / 加签</b>把 <c>AssigneeId</c> 原样写入(不校验存在性/租户/角色),而专用
-    /// N2/N3 的门禁是「持有本节点待办」而非「持有角色」,于是无 <c>ROLE_S7_FQC_SUPERVISOR</c> /
-    /// <c>ROLE_S7_FQC_QE</c> 的用户可直接完成主管审核或 QE 处置。</item>
-    /// </list></para>
+    /// 单条 BizType 的专用入口策略。<b>与 BizType 同属一条记录</b>——封堵范围不再由
+    /// 另一个集合表达,避免出现第二份 BizType authority source。
     /// </summary>
     /// </summary>
-    private static readonly HashSet<string> FlowMutationDedicatedOnlyBizTypes = new(StringComparer.Ordinal)
-    {
-        "S7_FQC_INSPBILL",
-    };
+    /// <param name="Message">命中时对外抛出的引导文案。同一条 BizType 的所有通用入口共用它。</param>
+    /// <param name="BlockFlowMutation">
+    /// 是否把封堵范围从「同意 / 拒绝」延伸到变更型通用入口
+    /// (退回上一步 / 撤回 / 转办 / 加签)。
+    /// <para><c>false</c> 表示该 BizType 目前只封堵同意/拒绝,变更型入口保持既有行为——
+    /// S5 / S6 / IPQC 三条即如此:它们的绕过理由与 S7 同源,很可能也应纳入,
+    /// 但纳入会改变其现有行为,需各自独立评估,不在此顺手扩大。</para>
+    /// </param>
+    private sealed record DedicatedEntryPolicy(string Message, bool BlockFlowMutation);
 
 
     /// <summary>
     /// <summary>
     /// 通用审批动作前置守卫:按 taskId 从库中反查权威 BizType,命中名单即拒绝。
     /// 通用审批动作前置守卫:按 taskId 从库中反查权威 BizType,命中名单即拒绝。
@@ -249,12 +252,12 @@ public class FlowTaskService : IDynamicApiController, ITransient
         var instance = await _instanceRep.GetByIdAsync(task.InstanceId);
         var instance = await _instanceRep.GetByIdAsync(task.InstanceId);
         if (instance?.BizType == null) return;
         if (instance?.BizType == null) return;
         if (DedicatedEntryOnlyBizTypes.TryGetValue(instance.BizType, out var hint))
         if (DedicatedEntryOnlyBizTypes.TryGetValue(instance.BizType, out var hint))
-            throw Oops.Oh(hint);
+            throw Oops.Oh(hint.Message);
     }
     }
 
 
     /// <summary>
     /// <summary>
     /// 变更型通用入口(退回上一步 / 转办 / 加签)的前置守卫:按 taskId 反查权威 BizType,
     /// 变更型通用入口(退回上一步 / 转办 / 加签)的前置守卫:按 taskId 反查权威 BizType,
-    /// 命中 <see cref="FlowMutationDedicatedOnlyBizTypes"/> 即拒绝。
+    /// 命中且 <see cref="DedicatedEntryPolicy.BlockFlowMutation"/> 为真即拒绝。
     ///
     ///
     /// <para>必须作为动作方法的第一条语句调用——引擎侧这些动作没有 <c>[UnitOfWork]</c>,
     /// <para>必须作为动作方法的第一条语句调用——引擎侧这些动作没有 <c>[UnitOfWork]</c>,
     /// 一旦进入就会按「取消同节点待办 → 改 task 状态 → 改 CurrentNodeId → 建新任务」的顺序
     /// 一旦进入就会按「取消同节点待办 → 改 task 状态 → 改 CurrentNodeId → 建新任务」的顺序
@@ -270,14 +273,18 @@ public class FlowTaskService : IDynamicApiController, ITransient
     /// <summary>
     /// <summary>
     /// 同上,但按 instanceId 判定——撤回(Withdraw)的入参只有 instanceId,没有 taskId。
     /// 同上,但按 instanceId 判定——撤回(Withdraw)的入参只有 instanceId,没有 taskId。
     /// 只做只读反查,不改任何实体。
     /// 只做只读反查,不改任何实体。
+    ///
+    /// <para>判定读的是同一份 <see cref="DedicatedEntryOnlyBizTypes"/> 里该 BizType 记录的
+    /// <see cref="DedicatedEntryPolicy.BlockFlowMutation"/> 显式字段,
+    /// 不查任何第二个集合、不从文案内容或 BizType 字面量反推。</para>
     /// </summary>
     /// </summary>
     private async Task EnsureDedicatedFlowMutationOnlyByInstanceAsync(long instanceId)
     private async Task EnsureDedicatedFlowMutationOnlyByInstanceAsync(long instanceId)
     {
     {
         var instance = await _instanceRep.GetByIdAsync(instanceId);
         var instance = await _instanceRep.GetByIdAsync(instanceId);
         if (instance?.BizType == null) return;
         if (instance?.BizType == null) return;
-        if (!FlowMutationDedicatedOnlyBizTypes.Contains(instance.BizType)) return;
-        if (DedicatedEntryOnlyBizTypes.TryGetValue(instance.BizType, out var mutationHint))
-            throw Oops.Oh(mutationHint);
+        if (!DedicatedEntryOnlyBizTypes.TryGetValue(instance.BizType, out var policy)) return;
+        if (!policy.BlockFlowMutation) return;
+        throw Oops.Oh(policy.Message);
     }
     }
 
 
     /// <summary>
     /// <summary>