Kaynağa Gözat

fix(s6): allow process inspection resubmit after supervisor reject

主管退回后 Flow 已正确回到 N1、N1 待办已重建、检验结果完整保留,但专用业务入口
没有合法出口,单据卡死在 N1。

根因:SubmitReview 用「实例 Running」当作「已提交」的代理判据。对 N2/N3 这个代理成立,
但 ReturnToPrev 只回退 CurrentNodeId、**实例仍是 Running**(FlowEngineService.cs:296-325
实证:Status 不动,仅 CurrentNodeId=prevNodeId,N2 置 Returned,新建前节点待办),
于是合法重提被一并挡死。

修法(Option A,复用同一入口):首次提交与重提是同一条业务命令 —— 校验相同
(本租户单据 + 结果 COMPLETED)、都不写业务行,唯一差异是要不要先 StartFlow。
故不新增 API,由后端按真实 Flow 状态分流:
  无实例 / 上一轮 Rejected|Cancelled → StartFlow + 通过 N1 → N2
  Running + 节点已回 N1 + 本人持有该 N1 待办 → 沿用同一实例,只通过既有任务 → N2

**不是放宽 Running**:新增 RequireResubmittableTaskAsync 三条同时成立才放行
(节点==N1_SUBMIT、当前用户持有 Pending 任务、该任务就在 N1)。仅凭 CurrentNodeId
不够——实例可能残留该字段却无待办,或待办属于别人。租户隔离仍由 LoadBillAsync 承担。

StartFlow 每次都会新建实例,故重提路径必须绕开它(只有 taskId==null 才起流程),
否则同一单据会出现多个并存实例。两条路径汇合到同一次 Approve,事务边界未改动。

前端无需改动:按钮本就由后端 canSubmitReview 驱动,本次在退回态把它打开,
不新增 isResubmit/isRejected 之类让前端自行推断的字段。

状态矩阵(Runtime 逐行实证,租户 838257186181189):
  NotStarted           → ALLOW 首次提交
  Running @N1 本人待办  → ALLOW 重新提交
  Running @N1 他人待办  → DENY「当前用户不是该检验单当前检验任务处理人」
  Running @N2          → DENY「当前流程节点不允许提交审核(检验主管审核)」
  Running @N3          → DENY「当前流程节点不允许提交审核(质量处置)」
  Approved/终态         → DENY「该检验单审核流程已结束」

Runtime(bill 6 完整二轮 N1→N2→退回→N1→重提→N2→通过→end):
- 同一 FlowInstance 848667087196229 全程复用,实例数未增
- 无双 Pending N1;历史四段可区分(首次提交/N1通过/N2退回/重提通过)
- 快照 MD5 未变,样本仅主动修正的那条变化,退回意见保留
- 跨租户(797)→「检验单不存在」,不泄漏存在性
- Generic approve/reject/batchApprove/batchReject 对 S6_PROCESS_INSPECTION 仍全阻断,0 mutation

已知不受本修复覆盖:bill 3 是迁移前实例,其 FlowJsonSnapshot 冻结了 legacy 数字
RoleId(1329916010002),重提会被 a75fa7e6b 的跨租户守卫拦下并整体回滚。该实例在
守卫之前同样解析 0 审批人(其 legacy 绑定已被删),属滞留的迁移前数据,另行处理。

验证:build 0 Error、改动文件 0 warning;全量 2452 passed / 1 failed
(S8AuthorizationGuardTests.MutationActions_DoNotUseReadOnlyCapabilities,
offender AdoS8OrderReviewKpiController 本批未改动,PRE-EXISTING)。
YY968XX 4 gün önce
ebeveyn
işleme
904c7c3aed

+ 230 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S6/Manufacturing/S6ProcessInspectionResubmitContractTests.cs

@@ -0,0 +1,230 @@
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S6.Manufacturing;
+
+/// <summary>
+/// S6-PI-RESUBMIT-1 源码契约测试:`S6_PROCESS_INSPECTION` 主管退回 → N1 后的重新提交闭环。
+///
+/// 缺口:`ReturnToPrev` 只回退 <c>CurrentNodeId</c>、**实例仍是 Running**,而 SubmitReview 原先
+/// 用「实例 Running」当作「已提交」的代理判据,于是把合法重提一并挡死,单据卡在 N1 无专用出口。
+///
+/// 修法(Option A):复用同一 submit-review 入口,按真实 Flow 状态分流 ——
+///   无实例 / 上一轮 Rejected|Cancelled → 首次提交(StartFlow + 通过 N1)
+///   Running + 节点已回 N1 + 本人持有该 N1 待办 → 重新提交(沿用同一实例,只通过既有任务)
+/// 关键是**不放宽 Running**:N2/N3 仍拒绝、非本人待办仍拒绝、终态仍拒绝。
+///
+/// 沿用本仓既有做法——源码契约断言而非行为级集成测试:该 service 依赖 ISqlSugarClient /
+/// FlowEngineService,裸 xUnit 进程无法实例化(见 S8ExceptionFlowTenantContextContractTests)。
+/// 真实状态矩阵由本批 Runtime R1–R15 覆盖。
+/// </summary>
+public class S6ProcessInspectionResubmitContractTests
+{
+    private static string Svc() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Manufacturing", "S6ProcessInspectionReviewService.cs"));
+
+    private static string Engine() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.ApprovalFlow", "Service", "FlowEngine", "FlowEngineService.cs"));
+
+    private static string Frontend() => File.ReadAllText(FindFile(
+        "Web", "src", "views", "aidop", "s6", "production-record", "productionInstructionList.vue"));
+
+    // ── 不得简单放宽 Running ──
+
+    /// <summary>原先那句无条件拒绝必须消失,否则重提永远进不来。</summary>
+    [Fact]
+    public void SubmitReview_NoLongerRejectsEveryRunningInstance()
+    {
+        Assert.DoesNotContain("该检验单已在审核中,请勿重复提交", Svc());
+    }
+
+    /// <summary>但绝不能退化成「Running 就放行」——必须经过 N1 + 本人待办的判定。</summary>
+    [Fact]
+    public void SubmitReview_DoesNotBlanketAllowRunning()
+    {
+        var body = SubmitReviewBody();
+        Assert.Contains("RequireResubmittableTaskAsync(inst)", body);
+        // 不得出现「Running 直接进重提分支」的写法
+        Assert.DoesNotContain("if (inst != null && inst.Status == FlowInstanceStatusEnum.Running)\n            return", body);
+    }
+
+    // ── 重提判定:节点 + 待办归属 ──
+
+    /// <summary>仅凭 CurrentNodeId 不够:必须同时验证本人持有 Pending 任务且该任务在 N1。</summary>
+    [Fact]
+    public void ResubmitGate_ChecksNodeAndPendingTaskOwnership()
+    {
+        var guard = Slice(Svc(),
+            "private async Task<ApprovalFlowTask> RequireResubmittableTaskAsync",
+            "private async Task<bool> HasRoleAsync");
+        Assert.Contains("inst.CurrentNodeId != S6ProcessInspectionFlowConst.NodeSubmit", guard);
+        Assert.Contains("GetMyPendingTaskAsync(inst.Id)", guard);
+        Assert.Contains("myTask.NodeId != S6ProcessInspectionFlowConst.NodeSubmit", guard);
+    }
+
+    /// <summary>待办归属必须按当前用户过滤——同租户其他人不得重提。</summary>
+    [Fact]
+    public void PendingTaskLookup_IsScopedToCurrentUser()
+    {
+        var helper = Slice(Svc(), "private async Task<ApprovalFlowTask?> GetMyPendingTaskAsync", "private async Task<(ApprovalFlowInstance");
+        Assert.Contains("_userManager.UserId", helper);
+        Assert.Contains("x.AssigneeId == userId", helper);
+        Assert.Contains("x.Status == FlowTaskStatusEnum.Pending", helper);
+    }
+
+    /// <summary>两类拒绝必须可区分,不得回落成模糊的「状态错误」。</summary>
+    [Theory]
+    [InlineData("当前流程节点不允许提交审核")]
+    [InlineData("当前用户不是该检验单当前检验任务处理人")]
+    [InlineData("该检验单审核流程已结束")]
+    public void RejectionMessages_AreSpecific(string message)
+    {
+        Assert.Contains(message, Svc());
+    }
+
+    // ── 首次提交契约不得回归 ──
+
+    /// <summary>COMPLETED 门槛对首次与重提一视同仁,且在分流之前。</summary>
+    [Fact]
+    public void CompletedGate_AppliesBeforeBranching()
+    {
+        var body = SubmitReviewBody();
+        var gateAt = body.IndexOf("INSPECTION_NOT_COMPLETED", StringComparison.Ordinal);
+        var branchAt = body.IndexOf("RequireResubmittableTaskAsync", StringComparison.Ordinal);
+        Assert.True(gateAt > 0 && branchAt > gateAt, "COMPLETED 门槛必须先于重提分流");
+    }
+
+    /// <summary>租户隔离:分流前必须先经过租户作用域的单据加载。</summary>
+    [Fact]
+    public void TenantScopedBillLoad_HappensFirst()
+    {
+        var body = SubmitReviewBody();
+        var loadAt = body.IndexOf("LoadBillAsync(input.BillId)", StringComparison.Ordinal);
+        var instAt = body.IndexOf("GetLatestInstanceAsync(input.BillId)", StringComparison.Ordinal);
+        Assert.True(loadAt >= 0 && instAt > loadAt, "必须先加载本租户单据再读流程状态");
+        var load = Slice(Svc(), "private async Task<BillRow> LoadBillAsync", "private async Task<ApprovalFlowInstance?> GetLatestInstanceAsync");
+        Assert.Contains("b.tenant_id=@tid", load);
+        Assert.Contains("ResolveTenantOrThrow()", load);
+    }
+
+    /// <summary>已终结(Approved)的流程仍必须拒绝,不得被重提分支放行。</summary>
+    [Fact]
+    public void CompletedFlow_StillDenied()
+    {
+        var body = SubmitReviewBody();
+        Assert.Contains("inst.Status != FlowInstanceStatusEnum.Rejected", body);
+        Assert.Contains("inst.Status != FlowInstanceStatusEnum.Cancelled", body);
+        Assert.Contains("该检验单审核流程已结束", body);
+    }
+
+    // ── 重复副作用:重提不得重跑首次路径 ──
+
+    /// <summary>StartFlow 每次都会新建实例,重提路径必须绕开它,只 Approve 既有任务。</summary>
+    [Fact]
+    public void Resubmit_ReusesSameInstance_DoesNotStartFlowAgain()
+    {
+        var body = SubmitReviewBody();
+        var startAt = body.IndexOf("_flowEngine.StartFlow(", StringComparison.Ordinal);
+        var guardAt = body.IndexOf("if (taskId == null)", StringComparison.Ordinal);
+        Assert.True(guardAt > 0, "缺少「无既有任务才起流程」的分支");
+        Assert.True(startAt > guardAt, "StartFlow 必须落在 taskId == null 分支内");
+        // 只有一处 StartFlow,避免重提路径另起一个实例
+        Assert.Equal(1, CountOf(body, "_flowEngine.StartFlow("));
+    }
+
+    /// <summary>两条路径最终汇合到同一次 Approve,不得各写一份推进逻辑。</summary>
+    [Fact]
+    public void BothPaths_ConvergeOnSingleApprove()
+    {
+        var body = SubmitReviewBody();
+        Assert.Equal(1, CountOf(body, "_flowEngine.Approve("));
+        Assert.Contains("_flowEngine.Approve(taskId.Value, input.Comment)", body);
+    }
+
+    /// <summary>事务边界不得被破坏:业务判定 + Flow 推进仍同一事务且失败 rethrow。</summary>
+    [Fact]
+    public void SubmitReview_StaysTransactional()
+    {
+        var body = SubmitReviewBody();
+        var tranAt = body.IndexOf("_db.AsTenant().UseTranAsync(", StringComparison.Ordinal);
+        var approveAt = body.IndexOf("_flowEngine.Approve(", StringComparison.Ordinal);
+        Assert.True(tranAt > 0 && approveAt > tranAt, "推进必须在事务内");
+        Assert.Contains("if (!tran.IsSuccess) throw tran.ErrorException;", body);
+    }
+
+    // ── capability flag ──
+
+    /// <summary>退回态必须把 canSubmitReview 打开,且仍受 COMPLETED 约束。</summary>
+    [Fact]
+    public void State_ExposesCanSubmitReviewOnReturnedNode()
+    {
+        var s = Svc();
+        var branch = Slice(s, "else if (myTask.NodeId == S6ProcessInspectionFlowConst.NodeSubmit)", "}\n        }\n        else");
+        Assert.Contains("output.CanSubmitReview = completed;", branch);
+        Assert.Contains("已退回", branch);
+    }
+
+    /// <summary>不得新增让前端自己推断业务状态的字段。</summary>
+    [Theory]
+    [InlineData("IsResubmit")]
+    [InlineData("IsRejected")]
+    [InlineData("IsFirstSubmit")]
+    public void State_DoesNotAddFrontendInferenceFlags(string field)
+    {
+        Assert.DoesNotContain(field, Svc());
+    }
+
+    /// <summary>前端仍只按后端 capability 显示按钮,不得自己复制 Flow 判定。</summary>
+    [Fact]
+    public void Frontend_DrivenByBackendCapabilityOnly()
+    {
+        var s = Frontend();
+        Assert.Contains("review?.canSubmitReview", s);
+        Assert.DoesNotContain("N1_SUBMIT", s);
+        Assert.DoesNotContain("=== 'Running'", s);
+    }
+
+    // ── 上游语义冻结(本批不改,防被无意改坏)──
+
+    /// <summary>ReturnToPrev 必须保持「同一实例 + 新建前节点待办」语义,重提正是建立在它之上。</summary>
+    [Fact]
+    public void ReturnToPrev_KeepsSameInstanceAndRebuildsPrevTask()
+    {
+        var body = Slice(Engine(), "public async Task ReturnToPrev(long taskId, string? comment)", "/// <summary>\n    /// 加签");
+        Assert.Contains("instance.CurrentNodeId = prevNodeId;", body);
+        Assert.Contains("CreateTasksForNode(instance, flowData, prevNodeId)", body);
+        Assert.Contains("FlowTaskStatusEnum.Returned", body);
+        // 不得在退回时把实例置为终态,否则重提无从谈起
+        Assert.DoesNotContain("instance.Status = FlowInstanceStatusEnum.Rejected", body);
+    }
+
+    private static string SubmitReviewBody() => Slice(Svc(),
+        "public async Task<S6ReviewStateOutput> SubmitReview(",
+        "/// <summary>主管审核通过(N2)");
+
+    private static int CountOf(string src, string needle)
+    {
+        int n = 0, i = 0;
+        while ((i = src.IndexOf(needle, i, StringComparison.Ordinal)) >= 0) { n++; i += needle.Length; }
+        return n;
+    }
+
+    private static string Slice(string src, string from, string to)
+    {
+        var a = src.IndexOf(from, StringComparison.Ordinal);
+        Assert.True(a >= 0, $"未找到起点:{from}");
+        var b = src.IndexOf(to, a + from.Length, StringComparison.Ordinal);
+        return b > a ? src[a..b] : src[a..];
+    }
+
+    private static string FindFile(params string[] parts)
+    {
+        var dir = new DirectoryInfo(AppContext.BaseDirectory);
+        while (dir != null)
+        {
+            var candidate = Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
+            if (File.Exists(candidate)) return candidate;
+            dir = dir.Parent;
+        }
+        throw new FileNotFoundException(string.Join("/", parts));
+    }
+}

+ 5 - 1
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S6/Manufacturing/S6ProcessInspectionReviewContractTests.cs

@@ -58,7 +58,11 @@ public class S6ProcessInspectionReviewContractTests
     public void Idempotent_And_TenantSafe_And_Transactional()
     {
         var s = Svc();
-        Assert.Contains("已在审核中,请勿重复提交", s);            // 一单一 active flow
+        // S6-PI-RESUBMIT-1:「一单一 active flow」这一原意不变,但不再由「Running 就拒绝」这句话承担
+        // ——那句会连主管退回后的合法重提一起挡死。现在改由两条共同保证:
+        //   ① 已在 N2/N3 的重复提交被节点判定拒绝;② 重提沿用同一实例,只有 taskId==null 才 StartFlow。
+        Assert.Contains("当前流程节点不允许提交审核", s);
+        Assert.Contains("if (taskId == null)", s);
         Assert.Contains("ResolveTenantOrThrow", s);
         Assert.Contains("UseTranAsync", s);
         Assert.DoesNotContain("tenant_id IS NULL", s);

+ 64 - 16
server/Plugins/Admin.NET.Plugin.AiDOP/Manufacturing/S6ProcessInspectionReviewService.cs

@@ -50,7 +50,19 @@ public class S6ProcessInspectionReviewService : IDynamicApiController, ITransien
         return await BuildStateAsync(billId);
     }
 
-    /// <summary>提交审核(仅 COMPLETED 可提交;幂等;发起流程并自动通过 N1→N2)。</summary>
+    /// <summary>
+    /// 提交审核(仅 COMPLETED 可提交),同时承载**首次提交**与**主管退回后的重新提交**。
+    ///
+    /// 两者是同一条业务命令:校验相同(本租户单据 + 结果 COMPLETED)、都不写业务行,
+    /// 唯一差异是要不要先起流程。故复用同一入口,由后端按真实 Flow 状态分流:
+    ///   无实例 / 上一轮已 Rejected|Cancelled → 首次提交:StartFlow + 通过 N1 → N2
+    ///   Running 且已退回 N1 且本人持有该 N1 待办 → 重新提交:沿用同一实例,只通过既有 N1 任务 → N2
+    ///
+    /// S6-PI-RESUBMIT-1:原实现用「实例 Running」当作「已提交」的代理判据,对 N2/N3 正确,
+    /// 但 ReturnToPrev 只回退 CurrentNodeId、**实例仍是 Running**,于是把合法重提一并挡死,
+    /// 单据卡在 N1 无任何专用出口。此处改为按「节点 + 本人待办」精确判定 ——
+    /// **不是放宽 Running**:N2/N3 仍拒绝,非本人待办仍拒绝,终态仍拒绝。
+    /// </summary>
     [DisplayName("提交审核")]
     [HttpPost("submit-review")]
     public async Task<S6ReviewStateOutput> SubmitReview([FromBody] S6SubmitReviewInput input)
@@ -60,26 +72,35 @@ public class S6ProcessInspectionReviewService : IDynamicApiController, ITransien
             throw Oops.Oh("INSPECTION_NOT_COMPLETED:检验未完成,无法提交审核");
 
         var inst = await GetLatestInstanceAsync(input.BillId);
-        if (inst != null && inst.Status == FlowInstanceStatusEnum.Running)
-            throw Oops.Oh("该检验单已在审核中,请勿重复提交");
-        if (inst != null && inst.Status != FlowInstanceStatusEnum.Running && inst.Status != FlowInstanceStatusEnum.Rejected && inst.Status != FlowInstanceStatusEnum.Cancelled)
+        var resubmitTask = inst != null && inst.Status == FlowInstanceStatusEnum.Running
+            ? await RequireResubmittableTaskAsync(inst)
+            : null;
+        if (resubmitTask == null && inst != null
+            && inst.Status != FlowInstanceStatusEnum.Rejected && inst.Status != FlowInstanceStatusEnum.Cancelled)
             throw Oops.Oh("该检验单审核流程已结束");
 
         var tran = await _db.AsTenant().UseTranAsync(async () =>
         {
-            var instanceId = await _flowEngine.StartFlow(new StartFlowInput
+            var taskId = resubmitTask?.Id;
+            if (taskId == null)
             {
-                BizType = S6ProcessInspectionFlowConst.BizType,
-                BizId = input.BillId,
-                BizNo = bill.WorkOrderNo,
-                Title = $"过程检验单 {bill.WorkOrderNo}",
-                Comment = input.Comment,
-            });
-            // N1_SUBMIT=发起人本人,取自己的 N1 待办并自动通过 → N2
-            var myTask = await GetMyPendingTaskAsync(instanceId);
-            if (myTask == null || myTask.NodeId != S6ProcessInspectionFlowConst.NodeSubmit)
-                throw Oops.Oh("提交审核节点异常,未取到发起人任务");
-            await _flowEngine.Approve(myTask.Id, input.Comment);
+                var instanceId = await _flowEngine.StartFlow(new StartFlowInput
+                {
+                    BizType = S6ProcessInspectionFlowConst.BizType,
+                    BizId = input.BillId,
+                    BizNo = bill.WorkOrderNo,
+                    Title = $"过程检验单 {bill.WorkOrderNo}",
+                    Comment = input.Comment,
+                });
+                // N1_SUBMIT=发起人本人,取自己的 N1 待办并自动通过 → N2
+                var myTask = await GetMyPendingTaskAsync(instanceId);
+                if (myTask == null || myTask.NodeId != S6ProcessInspectionFlowConst.NodeSubmit)
+                    throw Oops.Oh("提交审核节点异常,未取到发起人任务");
+                taskId = myTask.Id;
+            }
+            // 重提路径复用同一实例的既有 N1 待办,不再 StartFlow —— StartFlow 每次都会新建实例,
+            // 重跑首次路径会造成同一单据出现多个并存实例。
+            await _flowEngine.Approve(taskId.Value, input.Comment);
         });
         if (!tran.IsSuccess) throw tran.ErrorException;
 
@@ -226,6 +247,24 @@ public class S6ProcessInspectionReviewService : IDynamicApiController, ITransien
         return (inst, myTask);
     }
 
+    /// <summary>
+    /// S6-PI-RESUBMIT-1:判定一个 Running 实例是否处于「合法可重新提交」状态,是则返回本人那条 N1 待办。
+    ///
+    /// 仅凭 <c>CurrentNodeId == N1_SUBMIT</c> 不够——实例可能异常残留该字段却无待办,
+    /// 或待办属于别人。故三条同时成立才放行:节点已回到 N1、当前用户持有 Pending 任务、该任务就在 N1。
+    /// 租户隔离由调用前的 <see cref="LoadBillAsync"/> 承担(Flow 两表无 TenantId 列,
+    /// 与 <see cref="RequireSupervisorTaskAsync"/> 同一既有模式)。
+    /// </summary>
+    private async Task<ApprovalFlowTask> RequireResubmittableTaskAsync(ApprovalFlowInstance inst)
+    {
+        if (inst.CurrentNodeId != S6ProcessInspectionFlowConst.NodeSubmit)
+            throw Oops.Oh($"当前流程节点不允许提交审核({NodeNameOf(inst.CurrentNodeId) ?? inst.CurrentNodeId})");
+        var myTask = await GetMyPendingTaskAsync(inst.Id);
+        if (myTask == null || myTask.NodeId != S6ProcessInspectionFlowConst.NodeSubmit)
+            throw Oops.Oh("当前用户不是该检验单当前检验任务处理人");
+        return myTask;
+    }
+
     private async Task<bool> HasRoleAsync(long roleId)
     {
         var userId = _userManager.UserId;
@@ -301,6 +340,15 @@ public class S6ProcessInspectionReviewService : IDynamicApiController, ITransien
                 output.CanSubmitDisposition = true;
                 output.Message = "待当前用户提交质量处置";
             }
+            else if (myTask.NodeId == S6ProcessInspectionFlowConst.NodeSubmit)
+            {
+                // S6-PI-RESUBMIT-1:主管退回后流程回到 N1,本人持有该待办 → 可修正结果后重新提交。
+                // 复用既有 canSubmitReview(前端已按它显示同一个「提交审核」按钮),
+                // 不新增 isRejected / isResubmit 之类让前端自己推断业务状态的字段。
+                output.CanSubmitReview = completed;
+                output.DisplayStatus = "已退回待重新提交";
+                output.Message = completed ? "已退回,修正检验结果后可重新提交审核" : "已退回,检验结果未完成,暂不可提交审核";
+            }
             else
             {
                 output.Message = "流程进行中";