Переглянути джерело

fix(s6): restore IPQC editing after supervisor reject

主管退回后流程回到 N1、实例仍是 Running,而详情页把编辑权限写成本地表达式
(flowStatus !== 'NotStarted' → 只读),导致后端已允许重新提交、前端却改不动数据,
形成「能提交但改不了」的死角。

根因是后端能力缺口:state 只暴露 4 个按钮门控,没有 canEdit,前端只能自己推断流程。

改法:
- 新增只读能力 S6ReviewStateOutput.CanEdit,在 BuildStateAsync 单点计算;
  授权复用与 canSubmitReview 同一份 N1 待办归属判定(节点 N1 + Pending + AssigneeId
  == 当前用户),但**不叠加「检验已完成」** —— 退回后修改结果的过程中 ResultStatus 会回到
  IN_PROGRESS,叠加会在刚开始改的一瞬把编辑权关掉。实测该时刻 canEdit=true /
  canSubmitReview=false,两者语义确不等价,故未做成别名。
- NotStarted 显式可编辑(保留首提录入,同样不受 completed 约束);终态显式 false;
  N2 / N3 / 无本人待办三条路径靠 bool 默认 false 兜底。
- 前端改为纯消费 review.canEdit,状态缺失时 fail-closed(旧实现在 review 为空时是放开的)。
- 契约测试冻结状态矩阵、两个 capability 的非等价性、以及 ReturnToPrev 的
  「同实例 + 回到 N1 + 不置终态」上游语义。

顺带修正上一批(IPQC 独立详情页)遗留的失效断言:提交审核按钮已随 Drawer 退役搬到
ipqcBillDetail.vue,Frontend_DrivenByBackendCapabilityOnly 仍指向 productionInstructionList.vue。
前端文本契约统一改为先去注释再断言,避免把注释里的说明文字当成违规实现。

未改 FlowEngineService / FlowTaskService / ApprovalFlow schema,无 DDL。

Runtime(租户 838257186181189,bill 8 全链路 + bill 9 首提):
N2 只读 → 退回(实例 Running / CurrentNode=N1_SUBMIT / pending 恰 1 条 / assignee 同租户)
→ canEdit 恢复、样2 改 8.1 落库、完成检验 → 重新提交回 N2、canEdit 回落 → 主管通过终态全 false。

后端版本号本次未升:csproj 版本行正被另一实例在途改动(543→544 并注册 1.0.544.sql),
与本批要改的是同一行,无法在不破坏其未提交工作的前提下单独暂存,遂 DEFER 并上报,
待其提交后补升;本批不含 SQL 脚本,不影响 AutoVersionUpdate。
YY968XX 3 днів тому
батько
коміт
251b0a0811

+ 1 - 1
Web/package.json

@@ -1,7 +1,7 @@
 {
 {
 	"name": "admin.net",
 	"name": "admin.net",
 	"type": "module",
 	"type": "module",
-	"version": "2.4.399",
+	"version": "2.4.400",
 	"packageManager": "pnpm@10.32.1",
 	"packageManager": "pnpm@10.32.1",
 	"lastBuildTime": "2026.03.15",
 	"lastBuildTime": "2026.03.15",
 	"description": "Admin.NET 站在巨人肩膀上的 .NET 通用权限开发框架",
 	"description": "Admin.NET 站在巨人肩膀上的 .NET 通用权限开发框架",

+ 2 - 0
Web/src/views/aidop/s6/api/productionInstruction.ts

@@ -179,6 +179,8 @@ export interface ReviewState {
 	isCompleted?: boolean;
 	isCompleted?: boolean;
 	hasDisposition?: boolean;
 	hasDisposition?: boolean;
 	dispositionRequired?: number;
 	dispositionRequired?: number;
+	/** S6-IPQC-EDIT-1 后端编辑权限。语义 ≠ canSubmitReview(后者额外要求检验已完成),前端不得互相顶替。 */
+	canEdit?: boolean;
 	canSubmitReview?: boolean;
 	canSubmitReview?: boolean;
 	canSupervisorApprove?: boolean;
 	canSupervisorApprove?: boolean;
 	canSupervisorReturn?: boolean;
 	canSupervisorReturn?: boolean;

+ 11 - 8
Web/src/views/aidop/s6/process-quality/ipqcBillDetail.vue

@@ -155,9 +155,9 @@
  * - 保存/动作失败不再叠加 ElMessage —— 交给 `request.ts` 拦截器统一弹后端真实 message,
  * - 保存/动作失败不再叠加 ElMessage —— 交给 `request.ts` 拦截器统一弹后端真实 message,
  *   避免同时弹出业务文案与 axios 技术文案。
  *   避免同时弹出业务文案与 axios 技术文案。
  *
  *
- * 🔴 已登记的后端缺口(本批不改后端):`/api/S6ProcessInspectionReview/state` **没有 `canEdit`**
- * 本页沿用原 Drawer 既有的同一条表达式(`flowStatus` 非 NotStarted 即锁录入),
- * 属**搬运既有 authority,不是新造第二套判断**。该字段补齐后应直接换成 `canEdit`
+ * 编辑权限(S6-IPQC-EDIT-1,2026-09-14 闭环):由后端 `state.canEdit` 单点决定
+ * 上一版曾因后端无该字段而在本页本地推断(`flowStatus` 非 NotStarted 即锁录入),
+ * 导致主管退回回到 N1 后仍是只读 —— 已改为后端 Authority + 前端纯消费,详见 `canEdit` 处注释
  */
  */
 import { computed, onActivated, onMounted, ref, watch } from 'vue';
 import { computed, onActivated, onMounted, ref, watch } from 'vue';
 import { useRoute, useRouter } from 'vue-router';
 import { useRoute, useRouter } from 'vue-router';
@@ -207,11 +207,14 @@ const headTitle = computed(() => {
 	return wo ? `IPQC 检查单 · ${wo}` : `IPQC 检查单 #${billIdRaw.value || '—'}`;
 	return wo ? `IPQC 检查单 · ${wo}` : `IPQC 检查单 #${billIdRaw.value || '—'}`;
 });
 });
 
 
-/** 🔴 见文件头:后端无 canEdit,此处沿用原 Drawer 的同一表达式,未新造判断。 */
-const canEdit = computed(() => {
-	const fs = review.value?.flowStatus;
-	return !(fs && fs !== 'NotStarted');
-});
+/**
+ * S6-IPQC-EDIT-1:编辑权限**完全由后端 Authority 决定**(`GET .../review/state` 的 `canEdit`)。
+ * 前端只做纯消费,不再本地推断流程状态:
+ *   - 不判断 flowStatus / currentNodeCode;
+ *   - 不用 canSubmitReview 顶替(后者额外要求「检验已完成」,退回后改结果的过程中为 false);
+ *   - 状态未取到(首次加载中 / 接口失败)时 fail-closed 关闭编辑,避免旧实现在 review 为空时放开的问题。
+ */
+const canEdit = computed(() => review.value?.canEdit === true);
 
 
 const hasAnyAction = computed(
 const hasAnyAction = computed(
 	() =>
 	() =>

+ 334 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S6/Manufacturing/S6ProcessInspectionEditAuthorityContractTests.cs

@@ -0,0 +1,334 @@
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S6.Manufacturing;
+
+/// <summary>
+/// 前端源码文本契约的公共视图:**去注释**后再断言。
+/// 缘由:这些页面的注释里会成段引用节点码(N1_SUBMIT)、字段名(flowStatus / canSubmitReview)
+/// 来解释"为什么不这么做",裸文本 DoesNotContain 会把说明文字当成违规实现命中。
+/// </summary>
+internal static class VueSource
+{
+    /// <summary>剥掉 /* */ 块注释、&lt;!-- --&gt; HTML 注释、以及整行 // 与 * 续行注释。</summary>
+    public static string CodeOnly(string src)
+    {
+        var s = System.Text.RegularExpressions.Regex.Replace(src, @"/\*.*?\*/", "", System.Text.RegularExpressions.RegexOptions.Singleline);
+        s = System.Text.RegularExpressions.Regex.Replace(s, @"<!--.*?-->", "", System.Text.RegularExpressions.RegexOptions.Singleline);
+        var kept = s.Split('\n').Where(l =>
+        {
+            var t = l.TrimStart();
+            return !t.StartsWith("//", StringComparison.Ordinal) && !t.StartsWith("* ", StringComparison.Ordinal) && t != "*";
+        });
+        return string.Join("\n", kept);
+    }
+}
+
+/// <summary>
+/// S6-IPQC-EDIT-1 源码契约测试:IPQC 检查单详情页「编辑权限」归后端 Authority。
+///
+/// 缺口:`ReturnToPrev` 后实例仍是 Running(只回退 CurrentNodeId),而详情页把编辑权限写成
+/// 本地表达式 `flowStatus !== 'NotStarted' → 只读`,于是主管退回回到 N1 后录入区仍锁死 ——
+/// 后端已允许重新提交(canSubmitReview=true),前端却不允许改数据,形成"能提交但改不动"。
+///
+/// 修法:后端 `S6ReviewStateOutput.CanEdit`(复用与 canSubmitReview 同一份 N1 待办归属判定,
+/// 但不叠加 completed),前端纯消费、状态缺失时 fail-closed。
+///
+/// 关键不变量:
+///   1. canEdit ≠ canSubmitReview(后者多一个 completed 条件)——不得互相顶替;
+///   2. 授权不能只看节点,必须节点 + Pending + AssigneeId==当前用户;
+///   3. 首次录入(NotStarted)不得被本次修复关掉;
+///   4. 前端不得保留任何本地流程推断。
+///
+/// 沿用本仓既有做法——源码契约断言而非行为级集成测试(service 依赖 ISqlSugarClient /
+/// FlowEngineService,裸 xUnit 进程无法实例化)。真实状态矩阵由本批 Runtime R1–R12 覆盖。
+/// </summary>
+public class S6ProcessInspectionEditAuthorityContractTests
+{
+    private static string Svc() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Manufacturing", "S6ProcessInspectionReviewService.cs"));
+
+    private static string Dto() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Manufacturing", "Dto", "S6ProcessInspectionReviewDto.cs"));
+
+    private static string DetailPage() => File.ReadAllText(FindFile(
+        "Web", "src", "views", "aidop", "s6", "process-quality", "ipqcBillDetail.vue"));
+
+    private static string ItemPanel() => File.ReadAllText(FindFile(
+        "Web", "src", "views", "aidop", "s6", "process-quality", "components", "IpqcBillItemPanel.vue"));
+
+    private static string ApiTypes() => File.ReadAllText(FindFile(
+        "Web", "src", "views", "aidop", "s6", "api", "productionInstruction.ts"));
+
+    // ── 后端必须成为唯一 authority ──
+
+    /// <summary>capability 必须落在只读状态 DTO 上(与其余 4 个按钮门控同一处)。</summary>
+    [Fact]
+    public void Dto_ExposesCanEditCapability()
+    {
+        Assert.Contains("public bool CanEdit { get; set; }", Dto());
+    }
+
+    /// <summary>编辑权限必须在 BuildStateAsync 里算,不得散落到 Controller / 前端。</summary>
+    [Fact]
+    public void State_ComputesCanEditInBuildState()
+    {
+        var body = BuildStateBody();
+        Assert.Contains("output.CanEdit", body);
+    }
+
+    // ── §30 状态矩阵 ──
+
+    /// <summary>NotStarted:首次录入必须可编辑,且**不受 completed 约束**(否则录完即锁死)。</summary>
+    [Fact]
+    public void Matrix_NotStarted_IsEditable()
+    {
+        var branch = Slice(BuildStateBody(), "output.FlowStatus = \"NotStarted\";", "return output;");
+        Assert.Contains("output.CanEdit = true;", branch);
+        Assert.DoesNotContain("output.CanEdit = completed", branch);
+    }
+
+    /// <summary>退回到 N1 且本人持有该待办:可编辑,且同样不叠加 completed。</summary>
+    [Fact]
+    public void Matrix_ReturnedToN1_IsEditable()
+    {
+        var branch = ReturnedBranch();
+        Assert.Contains("output.CanEdit = true;", branch);
+        Assert.DoesNotContain("output.CanEdit = completed", branch);
+    }
+
+    /// <summary>终态(Approved / Rejected / Cancelled / Terminated):显式不可编辑。</summary>
+    [Fact]
+    public void Matrix_Terminal_IsReadOnly()
+    {
+        var branch = Slice(BuildStateBody(),
+            "if (inst.Status != FlowInstanceStatusEnum.Running)", "output.CurrentNodeCode = inst.CurrentNodeId;");
+        Assert.Contains("output.CanEdit = false;", branch);
+    }
+
+    /// <summary>
+    /// N2 主管审核 / N3 质量处置 / 本人无待办三条路径必须靠 bool 默认 false 兜底 ——
+    /// 即这些分支里不得出现任何把 CanEdit 打开的赋值。
+    /// </summary>
+    [Theory]
+    [InlineData("if (myTask.NodeId == S6ProcessInspectionFlowConst.NodeSupervisor)", "else if (myTask.NodeId == S6ProcessInspectionFlowConst.NodeDisposition)")]
+    [InlineData("else if (myTask.NodeId == S6ProcessInspectionFlowConst.NodeDisposition)", "else if (myTask.NodeId == S6ProcessInspectionFlowConst.NodeSubmit)")]
+    [InlineData("output.Message = \"流程进行中(当前用户无待办)\";", "return output;")]
+    public void Matrix_NonEditableBranches_NeverOpenCanEdit(string from, string to)
+    {
+        var branch = Slice(BuildStateBody(), from, to);
+        Assert.DoesNotContain("output.CanEdit = true", branch);
+    }
+
+    /// <summary>CanEdit 在整个 service 里只有三处赋值(NotStarted / 终态 / 退回 N1),防止后续被到处打开。</summary>
+    [Fact]
+    public void CanEdit_HasExactlyThreeAssignments()
+    {
+        Assert.Equal(3, CountOf(Svc(), "output.CanEdit"));
+    }
+
+    // ── §8 授权不能只看节点 ──
+
+    /// <summary>退回态的可编辑判定必须建立在「本人持有的 Pending 待办」之上,不是只看 CurrentNodeId。</summary>
+    [Fact]
+    public void EditAuthority_RequiresOwnedPendingTaskNotJustNode()
+    {
+        var body = BuildStateBody();
+        // 分支入口本身就由 myTask 决定
+        Assert.Contains("var myTask = await GetMyPendingTaskAsync(inst.Id);", body);
+        Assert.Contains("if (myTask != null)", body);
+        Assert.Contains("else if (myTask.NodeId == S6ProcessInspectionFlowConst.NodeSubmit)", body);
+        // 不得出现「只按实例当前节点」开放编辑的写法
+        Assert.DoesNotContain("inst.CurrentNodeId == S6ProcessInspectionFlowConst.NodeSubmit)\n            output.CanEdit", body);
+    }
+
+    /// <summary>待办归属必须按当前用户 + Pending 过滤(与重提复用同一 helper)。</summary>
+    [Fact]
+    public void PendingTaskHelper_ScopedToCurrentUserAndPending()
+    {
+        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("FlowTaskStatusEnum.Pending", helper);
+    }
+
+    /// <summary>租户收口:state 入口先走 LoadBillAsync,且 BuildStateAsync 的单据查询带 tenant_id。</summary>
+    [Fact]
+    public void State_IsTenantScoped()
+    {
+        var s = Svc();
+        Assert.Contains("await LoadBillAsync(billId);", s);
+        Assert.Contains("var tid = ResolveTenantOrThrow();", BuildStateBody());
+        Assert.Contains("WHERE b.id=@id AND b.tenant_id=@tid", BuildStateBody());
+    }
+
+    // ── §10 两个 capability 语义不等价 ──
+
+    /// <summary>
+    /// canEdit 与 canSubmitReview 不得等价:退回分支里 canSubmitReview 仍带 completed 门槛,
+    /// canEdit 不带 —— 这正是"退回后开始改结果(ResultStatus 回到 IN_PROGRESS)仍要能改"的依据。
+    /// </summary>
+    [Fact]
+    public void CanEdit_IsNotAliasOfCanSubmitReview()
+    {
+        var branch = ReturnedBranch();
+        Assert.Contains("output.CanEdit = true;", branch);
+        Assert.Contains("output.CanSubmitReview = completed;", branch);
+    }
+
+    /// <summary>后端不得把 canEdit 直接写成 canSubmitReview 的别名。</summary>
+    [Theory]
+    [InlineData("output.CanEdit = output.CanSubmitReview")]
+    [InlineData("output.CanEdit = completed")]
+    public void CanEdit_NotDerivedFromSubmitCapability(string forbidden)
+    {
+        Assert.DoesNotContain(forbidden, Svc());
+    }
+
+    // ── §31 Reject→N1 契约冻结(上游语义不得被改坏)──
+
+    /// <summary>退回必须保持实例 Running + 回到 N1,否则 canEdit 的退回分支永远进不到。</summary>
+    [Fact]
+    public void SupervisorReturn_KeepsInstanceRunningAtN1()
+    {
+        var engine = File.ReadAllText(FindFile(
+            "server", "Plugins", "Admin.NET.Plugin.ApprovalFlow", "Service", "FlowEngine", "FlowEngineService.cs"));
+        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.DoesNotContain("instance.Status = FlowInstanceStatusEnum.Rejected", body);
+    }
+
+    /// <summary>退回后展示状态仍须是业务中文(后端拥有语义,前端不拼)。</summary>
+    [Fact]
+    public void ReturnedBranch_KeepsBackendOwnedDisplayStatus()
+    {
+        Assert.Contains("output.DisplayStatus = \"已退回待重新提交\";", ReturnedBranch());
+    }
+
+    // ── 前端:纯消费 + fail-closed ──
+
+    /// <summary>详情页必须直接消费后端字段。</summary>
+    [Fact]
+    public void Frontend_ConsumesBackendCanEdit()
+    {
+        Assert.Contains("review.value?.canEdit === true", DetailPage());
+    }
+
+    /// <summary>状态缺失(加载中 / 接口失败)时必须 fail-closed —— 旧实现在 review 为空时是放开的。</summary>
+    [Fact]
+    public void Frontend_FailsClosedWhenStateMissing()
+    {
+        var s = DetailPage();
+        Assert.DoesNotContain("!(fs && fs !== 'NotStarted')", s);
+        Assert.DoesNotContain("review.value?.canEdit !== false", s);
+    }
+
+    /// <summary>
+    /// 前端不得保留任何本地流程推断作为编辑权限来源。
+    /// 断言只作用于 canEdit 那一行本身 —— 页面别处按 capability 显示按钮(hasAnyAction 聚合
+    /// canSubmitReview 等)是正当用法,不能一并禁掉。
+    /// </summary>
+    [Theory]
+    [InlineData("flowStatus")]
+    [InlineData("currentNodeCode")]
+    [InlineData("canSubmitReview")]
+    [InlineData("N1_SUBMIT")]
+    public void Frontend_CanEditExpressionHasNoLocalFlowInference(string forbidden)
+    {
+        var line = VueSource.CodeOnly(DetailPage())
+            .Split('\n').Single(l => l.Contains("const canEdit", StringComparison.Ordinal));
+        Assert.DoesNotContain(forbidden, line);
+    }
+
+    /// <summary>整页不得残留旧的本地推断表达式。</summary>
+    [Fact]
+    public void Frontend_OldLocalExpressionIsGone()
+    {
+        Assert.DoesNotContain("!(fs && fs !== 'NotStarted')", VueSource.CodeOnly(DetailPage()));
+    }
+
+    /// <summary>TS 类型须同步补齐,否则 vue-tsc 下 canEdit 会是隐式 any/报错。</summary>
+    [Fact]
+    public void FrontendTypes_DeclareCanEdit()
+    {
+        Assert.Contains("canEdit?: boolean;", ApiTypes());
+    }
+
+    /// <summary>
+    /// §12:录入区的输入框与保存/完成页脚必须由**同一个** canEdit 控制 ——
+    /// 不允许出现"按钮 disabled 但 input 还能改"。
+    /// </summary>
+    [Fact]
+    public void ItemPanel_SingleEditSwitchGovernsInputsAndFooter()
+    {
+        var s = VueSource.CodeOnly(ItemPanel());
+        Assert.Contains(":disabled=\"!canEdit\"", s);
+        Assert.Contains("v-if=\"canEdit\" class=\"panel-foot\"", s);
+        // 组件内不得自己看流程(去注释后断言:注释里成段解释"不按 flowStatus 推断",不算实现)
+        Assert.DoesNotContain("flowStatus", s);
+        Assert.DoesNotContain("currentNodeCode", s);
+    }
+
+    /// <summary>子组件 canEdit 默认必须是 false(fail-closed)。</summary>
+    [Fact]
+    public void ItemPanel_DefaultsToReadOnly()
+    {
+        Assert.Contains("canEdit: false", ItemPanel());
+    }
+
+    // ── 范围守卫:本轮不得动审批流框架 ──
+
+    /// <summary>编辑权限不得改成前端可传入 —— state 是只读输出,不接受回传判定。</summary>
+    [Theory]
+    [InlineData("CanEdit { get; set; }", "S6ReviewStateOutput")]
+    public void CanEdit_LivesOnlyOnReadOnlyStateOutput(string decl, string owner)
+    {
+        var dto = Dto();
+        var cls = Slice(dto, $"public class {owner}", "public class S6DispositionView");
+        Assert.Contains(decl, cls);
+        // 入参 DTO 一律不得带 CanEdit
+        foreach (var input in new[] { "S6SubmitReviewInput", "S6ReviewApproveInput", "S6ReviewReturnInput", "S6QeDispositionInput" })
+        {
+            var start = dto.IndexOf($"public class {input}", StringComparison.Ordinal);
+            Assert.True(start >= 0, $"未找到入参类 {input}");
+            var end = dto.IndexOf("public class ", start + input.Length + 14, StringComparison.Ordinal);
+            var slice = end > start ? dto[start..end] : dto[start..];
+            Assert.DoesNotContain("CanEdit", slice);
+        }
+    }
+
+    private static string BuildStateBody() => Slice(Svc(),
+        "private async Task<S6ReviewStateOutput> BuildStateAsync(long billId)",
+        "// ── Phase 1E 展示辅助");
+
+    private static string ReturnedBranch() => Slice(BuildStateBody(),
+        "else if (myTask.NodeId == S6ProcessInspectionFlowConst.NodeSubmit)",
+        "            else\n            {");
+
+    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));
+    }
+}

+ 8 - 2
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S6/Manufacturing/S6ProcessInspectionResubmitContractTests.cs

@@ -25,8 +25,14 @@ public class S6ProcessInspectionResubmitContractTests
     private static string Engine() => File.ReadAllText(FindFile(
     private static string Engine() => File.ReadAllText(FindFile(
         "server", "Plugins", "Admin.NET.Plugin.ApprovalFlow", "Service", "FlowEngine", "FlowEngineService.cs"));
         "server", "Plugins", "Admin.NET.Plugin.ApprovalFlow", "Service", "FlowEngine", "FlowEngineService.cs"));
 
 
+    /// <summary>
+    /// 提交审核按钮的归属页面。
+    /// 2026-09-14 更新:S6-IPQC-BILL-PAGE-1 把 Drawer 退役、审核动作搬到独立详情页
+    /// `process-quality/ipqcBillDetail.vue`,原先指向 `productionInstructionList.vue` 的断言
+    /// 在那批之后就已失效(该批未重跑本套件,本轮补上)。
+    /// </summary>
     private static string Frontend() => File.ReadAllText(FindFile(
     private static string Frontend() => File.ReadAllText(FindFile(
-        "Web", "src", "views", "aidop", "s6", "production-record", "productionInstructionList.vue"));
+        "Web", "src", "views", "aidop", "s6", "process-quality", "ipqcBillDetail.vue"));
 
 
     // ── 不得简单放宽 Running ──
     // ── 不得简单放宽 Running ──
 
 
@@ -177,7 +183,7 @@ public class S6ProcessInspectionResubmitContractTests
     [Fact]
     [Fact]
     public void Frontend_DrivenByBackendCapabilityOnly()
     public void Frontend_DrivenByBackendCapabilityOnly()
     {
     {
-        var s = Frontend();
+        var s = VueSource.CodeOnly(Frontend());
         Assert.Contains("review?.canSubmitReview", s);
         Assert.Contains("review?.canSubmitReview", s);
         Assert.DoesNotContain("N1_SUBMIT", s);
         Assert.DoesNotContain("N1_SUBMIT", s);
         Assert.DoesNotContain("=== 'Running'", s);
         Assert.DoesNotContain("=== 'Running'", s);

+ 9 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Manufacturing/Dto/S6ProcessInspectionReviewDto.cs

@@ -19,6 +19,15 @@ public class S6ReviewStateOutput
     /// <summary>网关判据(FAIL→1)。</summary>
     /// <summary>网关判据(FAIL→1)。</summary>
     public int DispositionRequired { get; set; }
     public int DispositionRequired { get; set; }
     // 按钮门控(后端计算)
     // 按钮门控(后端计算)
+    /// <summary>
+    /// S6-IPQC-EDIT-1:当前用户此刻是否可修改检验录入(项目结果 / 样本实测)。
+    /// 与 <see cref="CanSubmitReview"/> 共用同一份 N1 待办归属判定,但**不叠加"检验已完成"**条件 ——
+    /// 二者语义不等价:N1 己有待办且 ResultStatus=IN_PROGRESS 时 CanEdit=true / CanSubmitReview=false,
+    /// 而这正是需要编辑的状态。故前端不得用 canSubmitReview 顶替 canEdit。
+    /// 取值口径:未起流程 → true(首次录入);主管退回回到 N1 且待办归属本人 → true;
+    /// N2 / N3 / 无本人待办 / 流程已结束 → false。
+    /// </summary>
+    public bool CanEdit { get; set; }
     public bool CanSubmitReview { get; set; }
     public bool CanSubmitReview { get; set; }
     public bool CanSupervisorApprove { get; set; }
     public bool CanSupervisorApprove { get; set; }
     public bool CanSupervisorReturn { get; set; }
     public bool CanSupervisorReturn { get; set; }

+ 11 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Manufacturing/S6ProcessInspectionReviewService.cs

@@ -295,6 +295,8 @@ public class S6ProcessInspectionReviewService : IDynamicApiController, ITransien
         if (inst == null)
         if (inst == null)
         {
         {
             output.FlowStatus = "NotStarted";
             output.FlowStatus = "NotStarted";
+            // S6-IPQC-EDIT-1:流程未起 → 首次录入阶段,可编辑(不受 completed 约束,否则录完就锁死)。
+            output.CanEdit = true;
             output.CanSubmitReview = completed;
             output.CanSubmitReview = completed;
             output.DisplayStatus = completed ? "待提交审核" : "检验中";
             output.DisplayStatus = completed ? "待提交审核" : "检验中";
             output.Message = completed ? "检验完成,待提交审核" : "检验未完成,暂不可提交审核";
             output.Message = completed ? "检验完成,待提交审核" : "检验未完成,暂不可提交审核";
@@ -313,6 +315,11 @@ public class S6ProcessInspectionReviewService : IDynamicApiController, ITransien
                 : inst.Status == FlowInstanceStatusEnum.Cancelled ? "已撤回" : "已结束";
                 : inst.Status == FlowInstanceStatusEnum.Cancelled ? "已撤回" : "已结束";
             output.Message = inst.Status == FlowInstanceStatusEnum.Approved ? "审核已闭环(CLOSED)" : $"流程已结束({inst.Status})";
             output.Message = inst.Status == FlowInstanceStatusEnum.Approved ? "审核已闭环(CLOSED)" : $"流程已结束({inst.Status})";
             output.CanSubmitReview = inst.Status is FlowInstanceStatusEnum.Rejected or FlowInstanceStatusEnum.Cancelled && completed;
             output.CanSubmitReview = inst.Status is FlowInstanceStatusEnum.Rejected or FlowInstanceStatusEnum.Cancelled && completed;
+            // S6-IPQC-EDIT-1:流程已结束 → 一律不可编辑。
+            // 注:Rejected / Cancelled 下 CanSubmitReview 为 true(会新起一条流程),但此处仍保持
+            // CanEdit=false —— 与本次修复前的前端表达式对该状态的取值一致,不在本轮放宽未经运行时
+            // 验证的路径(专用链的主管退回走 ReturnToPrev 保持 Running@N1,不进入本分支)。
+            output.CanEdit = false;
             return output;
             return output;
         }
         }
 
 
@@ -345,6 +352,10 @@ public class S6ProcessInspectionReviewService : IDynamicApiController, ITransien
                 // S6-PI-RESUBMIT-1:主管退回后流程回到 N1,本人持有该待办 → 可修正结果后重新提交。
                 // S6-PI-RESUBMIT-1:主管退回后流程回到 N1,本人持有该待办 → 可修正结果后重新提交。
                 // 复用既有 canSubmitReview(前端已按它显示同一个「提交审核」按钮),
                 // 复用既有 canSubmitReview(前端已按它显示同一个「提交审核」按钮),
                 // 不新增 isRejected / isResubmit 之类让前端自己推断业务状态的字段。
                 // 不新增 isRejected / isResubmit 之类让前端自己推断业务状态的字段。
+                // S6-IPQC-EDIT-1:同一份待办归属即编辑权限来源(node==N1 且 AssigneeId==当前用户 且 Pending)。
+                // 这里**不叠加 completed** —— 退回后通常要把某项结果改掉,改的过程中 ResultStatus 会回到
+                // IN_PROGRESS,若叠加 completed 会在"刚开始改"的一瞬间把编辑权关掉。
+                output.CanEdit = true;
                 output.CanSubmitReview = completed;
                 output.CanSubmitReview = completed;
                 output.DisplayStatus = "已退回待重新提交";
                 output.DisplayStatus = "已退回待重新提交";
                 output.Message = completed ? "已退回,修正检验结果后可重新提交审核" : "已退回,检验结果未完成,暂不可提交审核";
                 output.Message = completed ? "已退回,修正检验结果后可重新提交审核" : "已退回,检验结果未完成,暂不可提交审核";