Просмотр исходного кода

fix(s7): block generic flow mutation entries for FQC

S7 成品检验单的 DedicatedEntryOnly 守卫此前只挂在 approve / reject /
batchApprove / batchReject 四处,而 ReturnToPrev / Withdraw / Transfer /
AddSign 四个会改流程状态的通用入口全部直通引擎、零守卫。因 S7 的
IFlowBizHandler 三个回调全是 no-op,业务副作用一律由 FqcInspBillFlowService
在引擎之外同事务完成,故这四个入口只改流程、不改业务:

- ReturnToPrev 绕过专用 supervisor-reject 的强制退回意见、同事务复位
  qms_fqcbj.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 处置。

实现复用既有机制,不新建第二套守卫:名单与文案的唯一来源仍是
DedicatedEntryOnlyBizTypes;新增 FlowMutationDedicatedOnlyBizTypes 仅表达
「封堵范围是否延伸到变更型入口」这一作用域,且必须是主名单的子集(测试断言)。
只有 S7 纳入——其余三条目前仅封堵同意/拒绝,一并纳入会改变 S5/S6/IPQC
的现有行为,超出本批范围。FlowEngineService 一行未改。

守卫是四个动作方法的第一条语句。这一点是硬要求:引擎侧这些动作都没有
[UnitOfWork],进入后按「取消同节点待办 → 改 task 状态 → 改 CurrentNodeId →
建新任务」逐步提交,中途抛错无法回滚,守卫晚一步就不再是 ZERO MUTATION。

Escalate / Urge 本批未动(前者因 S7 定义未配 manual escalation 在变更前即
抛错;后者不改流程状态,其权限问题属 ApprovalFlow 通用治理),并有测试冻结。

验证:ApprovalFlow 与 AiDOP 插件编译 0 Error;S7 守卫契约测试 52 项全过;
S5/S6/IPQC 守卫与 S7 租户授权、S7 Initiator 守卫四套回归全过;插件全量
2907 passed / 1 failed / 2 skipped,唯一失败为既有 S8 用例
(AdoS8OrderReviewKpiController.RefreshAsync → s8:dashboard:read),
该测试对 FlowTaskService / ApprovalFlow / S7 的引用均为 0,与本批无关。

无 schema 变化,未创建 UpdateScript。版本号未随本次提交递增:csproj 在
HEAD..origin/master 区间已被他方改动(origin 已至 1.0.556,本地基线 1.0.553),
按共享工作区规约不覆盖 FOREIGN 改动,版本收口另行处理。
YY968XX 3 дней назад
Родитель
Сommit
84236267a2

+ 171 - 3
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/ApprovalFlow/S7FqcGenericApprovalGuardContractTests.cs

@@ -142,14 +142,21 @@ public class S7FqcGenericApprovalGuardContractTests
         Assert.DoesNotContain(bizType, Dict());
     }
 
-    /// <summary>§9 禁止散落 if:不得在四个动作里各写一遍 S7 的判断。</summary>
+    /// <summary>
+    /// §9 禁止散落 if:不得在动作方法里各写一遍 S7 的判断。
+    ///
+    /// <para>字面量允许出现的位置恰好两处,且都是声明式集合成员,不是条件分支:
+    /// ① <c>DedicatedEntryOnlyBizTypes</c> 的键(名单与文案的唯一来源);
+    /// ② <c>FlowMutationDedicatedOnlyBizTypes</c> 的成员(封堵范围是否延伸到变更型入口)。
+    /// 真正要防的「散落 if」由下面的 <c>DoesNotContain("== ...")</c> 断言守住。</para>
+    /// </summary>
     [Fact]
     public void NoScatteredPerBizTypeIfStatements()
     {
         var s = FlowTaskService();
-        // BizType 字面量只应出现在名单里(1 次),不应出现在任何 if 条件中
-        Assert.Equal(1, CountOf(s, $"\"{BizType}\""));
+        Assert.Equal(2, CountOf(s, $"\"{BizType}\""));
         Assert.DoesNotContain($"== \"{BizType}\"", s);
+        Assert.DoesNotContain($"if (instance.BizType == \"{BizType}\"", s);
     }
 
     // ── §12 权威来源与不泄漏 ──
@@ -235,6 +242,167 @@ public class S7FqcGenericApprovalGuardContractTests
         Assert.Contains("SyncBillToStdSafeAsync", body);
     }
 
+    // ══════════════════════════════════════════════════════════════
+    //  变更型通用入口封堵(S7-FQC-GENERIC-FLOW-MUTATION-GUARD-1)
+    //
+    //  上一批 Evidence Audit 确认:DedicatedEntryOnly 守卫此前只挂在
+    //  approve / reject / batchApprove / batchReject 四处,而 ReturnToPrev /
+    //  Withdraw / Transfer / AddSign 四个**会改流程状态**的入口全部直通引擎、零守卫。
+    //  本区块冻结这四处的封堵,并冻结「不得顺手扩大到其它 BizType」。
+    // ══════════════════════════════════════════════════════════════
+
+    private const string MutationGuardByTask = "EnsureDedicatedFlowMutationOnlyByTaskAsync";
+    private const string MutationGuardByInstance = "EnsureDedicatedFlowMutationOnlyByInstanceAsync";
+
+    private static string MutationScope() =>
+        Slice(FlowTaskService(), "FlowMutationDedicatedOnlyBizTypes = new(StringComparer.Ordinal)", "};");
+
+    /// <summary>
+    /// T1–T4:四个变更型入口各自的守卫必须是方法的**第一条语句**。
+    ///
+    /// <para>为什么必须是第一条:引擎侧这四个动作都没有 <c>[UnitOfWork]</c>,
+    /// 一旦进入就按「取消同节点待办 → 改 task 状态 → 改 CurrentNodeId → 建新任务」
+    /// 逐步提交,中途抛错无法回滚。守卫晚一步就不再是 ZERO MUTATION。</para>
+    /// </summary>
+    [Theory]
+    [InlineData("public async Task ReturnToPrev(TaskActionInput input)", MutationGuardByTask, "input.TaskId")]
+    [InlineData("public async Task Transfer(TaskTransferInput input)", MutationGuardByTask, "input.TaskId")]
+    [InlineData("public async Task AddSign(TaskAddSignInput input)", MutationGuardByTask, "input.TaskId")]
+    [InlineData("public async Task Withdraw(TaskWithdrawInput input)", MutationGuardByInstance, "input.InstanceId")]
+    public void FlowMutationEntries_GuardIsFirstStatement(string signature, string guard, string arg)
+    {
+        var body = Slice(FlowTaskService(), signature, "}");
+        var first = body.Split('\n').Select(l => l.Trim())
+            .Where(l => l != "" && l != "{" && !l.StartsWith("//", StringComparison.Ordinal)
+                        && !l.StartsWith("public ", StringComparison.Ordinal))
+            .FirstOrDefault();
+        Assert.Equal($"await {guard}({arg});", first);
+    }
+
+    /// <summary>T5:守卫调用必须严格早于对应的 FlowEngine mutation 调用。</summary>
+    [Theory]
+    [InlineData("public async Task ReturnToPrev(TaskActionInput input)", "_engine.ReturnToPrev(")]
+    [InlineData("public async Task Transfer(TaskTransferInput input)", "_engine.Transfer(")]
+    [InlineData("public async Task AddSign(TaskAddSignInput input)", "_engine.AddSign(")]
+    [InlineData("public async Task Withdraw(TaskWithdrawInput input)", "_engine.Withdraw(")]
+    public void FlowMutationEntries_GuardPrecedesEngineCall(string signature, string engineCall)
+    {
+        var body = Slice(FlowTaskService(), signature, "}");
+        var guardAt = body.IndexOf("EnsureDedicatedFlowMutationOnly", StringComparison.Ordinal);
+        var engineAt = body.IndexOf(engineCall, StringComparison.Ordinal);
+        Assert.True(guardAt >= 0, $"{signature} 缺少变更型守卫");
+        Assert.True(engineAt > 0, $"未找到引擎调用 {engineCall}");
+        Assert.True(guardAt < engineAt, $"{signature} 的守卫晚于 {engineCall},无法保证 ZERO MUTATION");
+    }
+
+    /// <summary>
+    /// T6:封堵范围不得顺手扩大——其余三个 dedicated BizType 目前仅封堵同意/拒绝,
+    /// 纳入变更型入口会改变它们的现有行为,属另批范围。
+    /// </summary>
+    [Theory]
+    [InlineData("S5_IQC_INSPBILL")]
+    [InlineData("S6_PROCESS_INSPECTION")]
+    [InlineData("IPQC_INSPECTION")]
+    public void FlowMutationScope_ExcludesOtherDedicatedBizTypes(string bizType)
+    {
+        Assert.DoesNotContain(bizType, MutationScope());
+    }
+
+    /// <summary>T6 续:作用域恰好一条,且必须是主名单的子集(证明它不是第二套名单)。</summary>
+    [Fact]
+    public void FlowMutationScope_IsSingleMemberSubsetOfMainDict()
+    {
+        var scope = MutationScope();
+        Assert.Equal(2, CountOf(scope, "\""));  // 恰好一个字符串成员 → 一对引号
+        Assert.Contains($"\"{BizType}\"", scope);
+        Assert.Contains($"[\"{BizType}\"]", Dict());
+    }
+
+    /// <summary>T6 续:通用审批 BizType 不得被纳入变更型封堵。</summary>
+    [Theory]
+    [InlineData("CONTRACT_REVIEW")]
+    [InlineData("ORDER_REVIEW")]
+    [InlineData("SMART_OPS_IMPROVEMENT")]
+    public void FlowMutationScope_DoesNotBlockGenericBizTypes(string bizType)
+    {
+        Assert.DoesNotContain(bizType, MutationScope());
+    }
+
+    /// <summary>T7:原 approve/reject/batch 守卫必须仍然调用**原**守卫,未被新守卫替换。</summary>
+    [Theory]
+    [InlineData("public async Task Approve(TaskActionInput input)")]
+    [InlineData("public async Task Reject(TaskActionInput input)")]
+    public void ApproveReject_StillUseOriginalGuard(string signature)
+    {
+        var body = Slice(FlowTaskService(), signature, "}");
+        Assert.Contains($"{Guard}(input.TaskId)", body);
+        Assert.DoesNotContain("EnsureDedicatedFlowMutationOnly", body);
+    }
+
+    /// <summary>
+    /// T8:Escalate / Urge 本批明确 OUT OF SCOPE,不得被误加守卫。
+    /// Escalate 当前因 S7 定义未配 manual escalation 而在变更前即抛错;
+    /// Urge 不改流程状态,其权限问题属 ApprovalFlow 通用治理。
+    /// </summary>
+    [Theory]
+    [InlineData("public async Task Escalate(TaskActionInput input)", "_engine.Escalate(")]
+    [InlineData("public async Task Urge(TaskUrgeInput input)", "_engine.Urge(")]
+    public void EscalateAndUrge_RemainUntouched(string signature, string engineCall)
+    {
+        var body = Slice(FlowTaskService(), signature, "}");
+        Assert.Contains(engineCall, body);
+        Assert.DoesNotContain("EnsureDedicatedFlowMutationOnly", body);
+        Assert.DoesNotContain(Guard + "(", body);
+    }
+
+    /// <summary>两个新守卫自身只读——判定过程不得改任何实体(ZERO MUTATION 的静态前提)。</summary>
+    [Theory]
+    [InlineData(MutationGuardByTask)]
+    [InlineData(MutationGuardByInstance)]
+    public void FlowMutationGuards_PerformNoMutation(string guard)
+    {
+        var body = Slice(FlowTaskService(), $"private async Task {guard}(long", "}\n\n    /// <summary>");
+        foreach (var write in new[] { "AsUpdateable", "InsertAsync", "DeleteAsync", "ExecuteCommandAsync", "UseTranAsync" })
+            Assert.DoesNotContain(write, body);
+    }
+
+    /// <summary>新守卫的权威 BizType 同样必须来自库,且复用同一字典(不得自带文案体系)。</summary>
+    [Fact]
+    public void FlowMutationGuard_ReadsBizTypeFromDatabaseAndReusesDict()
+    {
+        var byInstance = Slice(FlowTaskService(), $"private async Task {MutationGuardByInstance}(long", "}\n\n    /// <summary>");
+        Assert.Contains("_instanceRep.GetByIdAsync(instanceId)", byInstance);
+        Assert.Contains("DedicatedEntryOnlyBizTypes.TryGetValue(instance.BizType", byInstance);
+        Assert.DoesNotContain("input.BizType", byInstance);
+
+        var byTask = Slice(FlowTaskService(), $"private async Task {MutationGuardByTask}(long", "}\n\n    /// <summary>");
+        Assert.Contains("_taskRep.GetByIdAsync(taskId)", byTask);
+        Assert.Contains($"{MutationGuardByInstance}(task.InstanceId)", byTask);
+    }
+
+    /// <summary>S7 文案必须覆盖本批新增的四个动作,否则用户点「转办」却看到「不支持同意/拒绝」。</summary>
+    [Fact]
+    public void GuardMessage_CoversFlowMutationActions()
+    {
+        var line = Dict().Split('\n').Single(l => l.Contains($"[\"{BizType}\"]", StringComparison.Ordinal));
+        foreach (var act in new[] { "退回", "撤回", "转办", "加签" })
+            Assert.Contains(act, line);
+    }
+
+    /// <summary>
+    /// 封堵成立的前提:S7 合法退回能力仍在专用入口上。
+    /// 通用 ReturnToPrev 被封后,N2→N1 必须仍可经 supervisor-reject 完成。
+    /// </summary>
+    [Fact]
+    public void FlowMutationBlock_LeavesDedicatedRejectAsTheOnlyReturnPath()
+    {
+        var s = FlowService();
+        Assert.Contains("[HttpPost(\"supervisor-reject\")]", s);
+        Assert.Contains("_flowEngine.ReturnToPrev(", s);
+        // 专用入口直调引擎,不经 FlowTaskService,故不会被本批守卫拦住
+        Assert.DoesNotContain("FlowTaskService", s);
+    }
+
     private static int CountOf(string src, string needle)
     {
         int n = 0, i = 0;

+ 65 - 1
server/Plugins/Admin.NET.Plugin.ApprovalFlow/Service/FlowTask/FlowTaskService.cs

@@ -199,7 +199,39 @@ public class FlowTaskService : IDynamicApiController, ITransient
         //    检验进度永远停在「检验完成」,且通用 Reject 连退回意见都不要求。
         // ④ N3 通用 approve 推到 end 而 ado_s7_fqc_qe_disposition 永不写入 —— 流程显示闭环、
         //    处置轨迹为空且事后无法补记(与 IPQC_INSPECTION 同款,该形态 2026-09-14 已实证发生过)。
-        ["S7_FQC_INSPBILL"] = "成品检验单请在「FQC检验单详情」页完成检验结果提交、主管审核或质量处置,不支持在审批中心直接同意/拒绝。",
+        ["S7_FQC_INSPBILL"] = "成品检验单请在「FQC检验单详情」页完成检验结果提交、主管审核或质量处置,不支持在审批中心直接同意/拒绝/退回/撤回/转办/加签。",
+    };
+
+    /// <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>
+    /// </summary>
+    private static readonly HashSet<string> FlowMutationDedicatedOnlyBizTypes = new(StringComparer.Ordinal)
+    {
+        "S7_FQC_INSPBILL",
     };
 
     /// <summary>
@@ -220,6 +252,34 @@ public class FlowTaskService : IDynamicApiController, ITransient
             throw Oops.Oh(hint);
     }
 
+    /// <summary>
+    /// 变更型通用入口(退回上一步 / 转办 / 加签)的前置守卫:按 taskId 反查权威 BizType,
+    /// 命中 <see cref="FlowMutationDedicatedOnlyBizTypes"/> 即拒绝。
+    ///
+    /// <para>必须作为动作方法的第一条语句调用——引擎侧这些动作没有 <c>[UnitOfWork]</c>,
+    /// 一旦进入就会按「取消同节点待办 → 改 task 状态 → 改 CurrentNodeId → 建新任务」的顺序
+    /// 逐步提交,中途失败无法回滚。守卫放在进入引擎之前才能保证 ZERO MUTATION。</para>
+    /// </summary>
+    private async Task EnsureDedicatedFlowMutationOnlyByTaskAsync(long taskId)
+    {
+        var task = await _taskRep.GetByIdAsync(taskId);
+        if (task == null) return;
+        await EnsureDedicatedFlowMutationOnlyByInstanceAsync(task.InstanceId);
+    }
+
+    /// <summary>
+    /// 同上,但按 instanceId 判定——撤回(Withdraw)的入参只有 instanceId,没有 taskId。
+    /// 只做只读反查,不改任何实体。
+    /// </summary>
+    private async Task EnsureDedicatedFlowMutationOnlyByInstanceAsync(long instanceId)
+    {
+        var instance = await _instanceRep.GetByIdAsync(instanceId);
+        if (instance?.BizType == null) return;
+        if (!FlowMutationDedicatedOnlyBizTypes.Contains(instance.BizType)) return;
+        if (DedicatedEntryOnlyBizTypes.TryGetValue(instance.BizType, out var mutationHint))
+            throw Oops.Oh(mutationHint);
+    }
+
     /// <summary>
     /// 同意
     /// </summary>
@@ -252,6 +312,7 @@ public class FlowTaskService : IDynamicApiController, ITransient
     [DisplayName("转办")]
     public async Task Transfer(TaskTransferInput input)
     {
+        await EnsureDedicatedFlowMutationOnlyByTaskAsync(input.TaskId);
         await _engine.Transfer(input.TaskId, input.TargetUserId, input.Comment);
     }
 
@@ -263,6 +324,7 @@ public class FlowTaskService : IDynamicApiController, ITransient
     [DisplayName("撤回")]
     public async Task Withdraw(TaskWithdrawInput input)
     {
+        await EnsureDedicatedFlowMutationOnlyByInstanceAsync(input.InstanceId);
         await _engine.Withdraw(input.InstanceId);
     }
 
@@ -274,6 +336,7 @@ public class FlowTaskService : IDynamicApiController, ITransient
     [DisplayName("退回上一步")]
     public async Task ReturnToPrev(TaskActionInput input)
     {
+        await EnsureDedicatedFlowMutationOnlyByTaskAsync(input.TaskId);
         await _engine.ReturnToPrev(input.TaskId, input.Comment);
     }
 
@@ -285,6 +348,7 @@ public class FlowTaskService : IDynamicApiController, ITransient
     [DisplayName("加签")]
     public async Task AddSign(TaskAddSignInput input)
     {
+        await EnsureDedicatedFlowMutationOnlyByTaskAsync(input.TaskId);
         await _engine.AddSign(input.TaskId, input.TargetUserId, input.Comment);
     }