using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.S6.Manufacturing; /// /// S6-PI-RESUBMIT-1 源码契约测试:`S6_PROCESS_INSPECTION` 主管退回 → N1 后的重新提交闭环。 /// /// 缺口:`ReturnToPrev` 只回退 CurrentNodeId、**实例仍是 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 覆盖。 /// 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")); /// /// 提交审核按钮的归属页面。 /// 2026-09-14 更新:S6-IPQC-BILL-PAGE-1 把 Drawer 退役、审核动作搬到独立详情页 /// `process-quality/ipqcBillDetail.vue`,原先指向 `productionInstructionList.vue` 的断言 /// 在那批之后就已失效(该批未重跑本套件,本轮补上)。 /// private static string Frontend() => File.ReadAllText(FindFile( "Web", "src", "views", "aidop", "s6", "process-quality", "ipqcBillDetail.vue")); // ── 不得简单放宽 Running ── /// 原先那句无条件拒绝必须消失,否则重提永远进不来。 [Fact] public void SubmitReview_NoLongerRejectsEveryRunningInstance() { Assert.DoesNotContain("该检验单已在审核中,请勿重复提交", Svc()); } /// 但绝不能退化成「Running 就放行」——必须经过 N1 + 本人待办的判定。 [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); } // ── 重提判定:节点 + 待办归属 ── /// 仅凭 CurrentNodeId 不够:必须同时验证本人持有 Pending 任务且该任务在 N1。 [Fact] public void ResubmitGate_ChecksNodeAndPendingTaskOwnership() { var guard = Slice(Svc(), "private async Task RequireResubmittableTaskAsync", "private async Task HasRoleAsync"); Assert.Contains("inst.CurrentNodeId != S6ProcessInspectionFlowConst.NodeSubmit", guard); Assert.Contains("GetMyPendingTaskAsync(inst.Id)", guard); Assert.Contains("myTask.NodeId != S6ProcessInspectionFlowConst.NodeSubmit", guard); } /// 待办归属必须按当前用户过滤——同租户其他人不得重提。 [Fact] public void PendingTaskLookup_IsScopedToCurrentUser() { var helper = Slice(Svc(), "private async Task GetMyPendingTaskAsync", "private async Task<(ApprovalFlowInstance"); Assert.Contains("_userManager.UserId", helper); Assert.Contains("x.AssigneeId == userId", helper); Assert.Contains("x.Status == FlowTaskStatusEnum.Pending", helper); } /// 两类拒绝必须可区分,不得回落成模糊的「状态错误」。 [Theory] [InlineData("当前流程节点不允许提交审核")] [InlineData("当前用户不是该检验单当前检验任务处理人")] [InlineData("该检验单审核流程已结束")] public void RejectionMessages_AreSpecific(string message) { Assert.Contains(message, Svc()); } // ── 首次提交契约不得回归 ── /// COMPLETED 门槛对首次与重提一视同仁,且在分流之前。 [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 门槛必须先于重提分流"); } /// 租户隔离:分流前必须先经过租户作用域的单据加载。 [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 LoadBillAsync", "private async Task GetLatestInstanceAsync"); Assert.Contains("b.tenant_id=@tid", load); Assert.Contains("ResolveTenantOrThrow()", load); } /// 已终结(Approved)的流程仍必须拒绝,不得被重提分支放行。 [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); } // ── 重复副作用:重提不得重跑首次路径 ── /// StartFlow 每次都会新建实例,重提路径必须绕开它,只 Approve 既有任务。 [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(")); } /// 两条路径最终汇合到同一次 Approve,不得各写一份推进逻辑。 [Fact] public void BothPaths_ConvergeOnSingleApprove() { var body = SubmitReviewBody(); Assert.Equal(1, CountOf(body, "_flowEngine.Approve(")); Assert.Contains("_flowEngine.Approve(taskId.Value, input.Comment)", body); } /// 事务边界不得被破坏:业务判定 + Flow 推进仍同一事务且失败 rethrow。 [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 ── /// 退回态必须把 canSubmitReview 打开,且仍受 COMPLETED 约束。 [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); } /// 不得新增让前端自己推断业务状态的字段。 [Theory] [InlineData("IsResubmit")] [InlineData("IsRejected")] [InlineData("IsFirstSubmit")] public void State_DoesNotAddFrontendInferenceFlags(string field) { Assert.DoesNotContain(field, Svc()); } /// 前端仍只按后端 capability 显示按钮,不得自己复制 Flow 判定。 [Fact] public void Frontend_DrivenByBackendCapabilityOnly() { var s = VueSource.CodeOnly(Frontend()); Assert.Contains("review?.canSubmitReview", s); Assert.DoesNotContain("N1_SUBMIT", s); Assert.DoesNotContain("=== 'Running'", s); } // ── 上游语义冻结(本批不改,防被无意改坏)── /// ReturnToPrev 必须保持「同一实例 + 新建前节点待办」语义,重提正是建立在它之上。 [Fact] public void ReturnToPrev_KeepsSameInstanceAndRebuildsPrevTask() { var body = Slice(Engine(), "public async Task ReturnToPrev(long taskId, string? comment)", "/// \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 SubmitReview(", "/// 主管审核通过(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)); } }