S6ProcessInspectionResubmitContractTests.cs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. using Xunit;
  2. namespace Admin.NET.Plugin.AiDOP.Tests.S6.Manufacturing;
  3. /// <summary>
  4. /// S6-PI-RESUBMIT-1 源码契约测试:`S6_PROCESS_INSPECTION` 主管退回 → N1 后的重新提交闭环。
  5. ///
  6. /// 缺口:`ReturnToPrev` 只回退 <c>CurrentNodeId</c>、**实例仍是 Running**,而 SubmitReview 原先
  7. /// 用「实例 Running」当作「已提交」的代理判据,于是把合法重提一并挡死,单据卡在 N1 无专用出口。
  8. ///
  9. /// 修法(Option A):复用同一 submit-review 入口,按真实 Flow 状态分流 ——
  10. /// 无实例 / 上一轮 Rejected|Cancelled → 首次提交(StartFlow + 通过 N1)
  11. /// Running + 节点已回 N1 + 本人持有该 N1 待办 → 重新提交(沿用同一实例,只通过既有任务)
  12. /// 关键是**不放宽 Running**:N2/N3 仍拒绝、非本人待办仍拒绝、终态仍拒绝。
  13. ///
  14. /// 沿用本仓既有做法——源码契约断言而非行为级集成测试:该 service 依赖 ISqlSugarClient /
  15. /// FlowEngineService,裸 xUnit 进程无法实例化(见 S8ExceptionFlowTenantContextContractTests)。
  16. /// 真实状态矩阵由本批 Runtime R1–R15 覆盖。
  17. /// </summary>
  18. public class S6ProcessInspectionResubmitContractTests
  19. {
  20. private static string Svc() => File.ReadAllText(FindFile(
  21. "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Manufacturing", "S6ProcessInspectionReviewService.cs"));
  22. private static string Engine() => File.ReadAllText(FindFile(
  23. "server", "Plugins", "Admin.NET.Plugin.ApprovalFlow", "Service", "FlowEngine", "FlowEngineService.cs"));
  24. /// <summary>
  25. /// 提交审核按钮的归属页面。
  26. /// 2026-09-14 更新:S6-IPQC-BILL-PAGE-1 把 Drawer 退役、审核动作搬到独立详情页
  27. /// `process-quality/ipqcBillDetail.vue`,原先指向 `productionInstructionList.vue` 的断言
  28. /// 在那批之后就已失效(该批未重跑本套件,本轮补上)。
  29. /// </summary>
  30. private static string Frontend() => File.ReadAllText(FindFile(
  31. "Web", "src", "views", "aidop", "s6", "process-quality", "ipqcBillDetail.vue"));
  32. // ── 不得简单放宽 Running ──
  33. /// <summary>原先那句无条件拒绝必须消失,否则重提永远进不来。</summary>
  34. [Fact]
  35. public void SubmitReview_NoLongerRejectsEveryRunningInstance()
  36. {
  37. Assert.DoesNotContain("该检验单已在审核中,请勿重复提交", Svc());
  38. }
  39. /// <summary>但绝不能退化成「Running 就放行」——必须经过 N1 + 本人待办的判定。</summary>
  40. [Fact]
  41. public void SubmitReview_DoesNotBlanketAllowRunning()
  42. {
  43. var body = SubmitReviewBody();
  44. Assert.Contains("RequireResubmittableTaskAsync(inst)", body);
  45. // 不得出现「Running 直接进重提分支」的写法
  46. Assert.DoesNotContain("if (inst != null && inst.Status == FlowInstanceStatusEnum.Running)\n return", body);
  47. }
  48. // ── 重提判定:节点 + 待办归属 ──
  49. /// <summary>仅凭 CurrentNodeId 不够:必须同时验证本人持有 Pending 任务且该任务在 N1。</summary>
  50. [Fact]
  51. public void ResubmitGate_ChecksNodeAndPendingTaskOwnership()
  52. {
  53. var guard = Slice(Svc(),
  54. "private async Task<ApprovalFlowTask> RequireResubmittableTaskAsync",
  55. "private async Task<bool> HasRoleAsync");
  56. Assert.Contains("inst.CurrentNodeId != S6ProcessInspectionFlowConst.NodeSubmit", guard);
  57. Assert.Contains("GetMyPendingTaskAsync(inst.Id)", guard);
  58. Assert.Contains("myTask.NodeId != S6ProcessInspectionFlowConst.NodeSubmit", guard);
  59. }
  60. /// <summary>待办归属必须按当前用户过滤——同租户其他人不得重提。</summary>
  61. [Fact]
  62. public void PendingTaskLookup_IsScopedToCurrentUser()
  63. {
  64. var helper = Slice(Svc(), "private async Task<ApprovalFlowTask?> GetMyPendingTaskAsync", "private async Task<(ApprovalFlowInstance");
  65. Assert.Contains("_userManager.UserId", helper);
  66. Assert.Contains("x.AssigneeId == userId", helper);
  67. Assert.Contains("x.Status == FlowTaskStatusEnum.Pending", helper);
  68. }
  69. /// <summary>两类拒绝必须可区分,不得回落成模糊的「状态错误」。</summary>
  70. [Theory]
  71. [InlineData("当前流程节点不允许提交审核")]
  72. [InlineData("当前用户不是该检验单当前检验任务处理人")]
  73. [InlineData("该检验单审核流程已结束")]
  74. public void RejectionMessages_AreSpecific(string message)
  75. {
  76. Assert.Contains(message, Svc());
  77. }
  78. // ── 首次提交契约不得回归 ──
  79. /// <summary>COMPLETED 门槛对首次与重提一视同仁,且在分流之前。</summary>
  80. [Fact]
  81. public void CompletedGate_AppliesBeforeBranching()
  82. {
  83. var body = SubmitReviewBody();
  84. var gateAt = body.IndexOf("INSPECTION_NOT_COMPLETED", StringComparison.Ordinal);
  85. var branchAt = body.IndexOf("RequireResubmittableTaskAsync", StringComparison.Ordinal);
  86. Assert.True(gateAt > 0 && branchAt > gateAt, "COMPLETED 门槛必须先于重提分流");
  87. }
  88. /// <summary>租户隔离:分流前必须先经过租户作用域的单据加载。</summary>
  89. [Fact]
  90. public void TenantScopedBillLoad_HappensFirst()
  91. {
  92. var body = SubmitReviewBody();
  93. var loadAt = body.IndexOf("LoadBillAsync(input.BillId)", StringComparison.Ordinal);
  94. var instAt = body.IndexOf("GetLatestInstanceAsync(input.BillId)", StringComparison.Ordinal);
  95. Assert.True(loadAt >= 0 && instAt > loadAt, "必须先加载本租户单据再读流程状态");
  96. var load = Slice(Svc(), "private async Task<BillRow> LoadBillAsync", "private async Task<ApprovalFlowInstance?> GetLatestInstanceAsync");
  97. Assert.Contains("b.tenant_id=@tid", load);
  98. Assert.Contains("ResolveTenantOrThrow()", load);
  99. }
  100. /// <summary>已终结(Approved)的流程仍必须拒绝,不得被重提分支放行。</summary>
  101. [Fact]
  102. public void CompletedFlow_StillDenied()
  103. {
  104. var body = SubmitReviewBody();
  105. Assert.Contains("inst.Status != FlowInstanceStatusEnum.Rejected", body);
  106. Assert.Contains("inst.Status != FlowInstanceStatusEnum.Cancelled", body);
  107. Assert.Contains("该检验单审核流程已结束", body);
  108. }
  109. // ── 重复副作用:重提不得重跑首次路径 ──
  110. /// <summary>StartFlow 每次都会新建实例,重提路径必须绕开它,只 Approve 既有任务。</summary>
  111. [Fact]
  112. public void Resubmit_ReusesSameInstance_DoesNotStartFlowAgain()
  113. {
  114. var body = SubmitReviewBody();
  115. var startAt = body.IndexOf("_flowEngine.StartFlow(", StringComparison.Ordinal);
  116. var guardAt = body.IndexOf("if (taskId == null)", StringComparison.Ordinal);
  117. Assert.True(guardAt > 0, "缺少「无既有任务才起流程」的分支");
  118. Assert.True(startAt > guardAt, "StartFlow 必须落在 taskId == null 分支内");
  119. // 只有一处 StartFlow,避免重提路径另起一个实例
  120. Assert.Equal(1, CountOf(body, "_flowEngine.StartFlow("));
  121. }
  122. /// <summary>两条路径最终汇合到同一次 Approve,不得各写一份推进逻辑。</summary>
  123. [Fact]
  124. public void BothPaths_ConvergeOnSingleApprove()
  125. {
  126. var body = SubmitReviewBody();
  127. Assert.Equal(1, CountOf(body, "_flowEngine.Approve("));
  128. Assert.Contains("_flowEngine.Approve(taskId.Value, input.Comment)", body);
  129. }
  130. /// <summary>事务边界不得被破坏:业务判定 + Flow 推进仍同一事务且失败 rethrow。</summary>
  131. [Fact]
  132. public void SubmitReview_StaysTransactional()
  133. {
  134. var body = SubmitReviewBody();
  135. var tranAt = body.IndexOf("_db.AsTenant().UseTranAsync(", StringComparison.Ordinal);
  136. var approveAt = body.IndexOf("_flowEngine.Approve(", StringComparison.Ordinal);
  137. Assert.True(tranAt > 0 && approveAt > tranAt, "推进必须在事务内");
  138. Assert.Contains("if (!tran.IsSuccess) throw tran.ErrorException;", body);
  139. }
  140. // ── capability flag ──
  141. /// <summary>退回态必须把 canSubmitReview 打开,且仍受 COMPLETED 约束。</summary>
  142. [Fact]
  143. public void State_ExposesCanSubmitReviewOnReturnedNode()
  144. {
  145. var s = Svc();
  146. var branch = Slice(s, "else if (myTask.NodeId == S6ProcessInspectionFlowConst.NodeSubmit)", "}\n }\n else");
  147. Assert.Contains("output.CanSubmitReview = completed;", branch);
  148. Assert.Contains("已退回", branch);
  149. }
  150. /// <summary>不得新增让前端自己推断业务状态的字段。</summary>
  151. [Theory]
  152. [InlineData("IsResubmit")]
  153. [InlineData("IsRejected")]
  154. [InlineData("IsFirstSubmit")]
  155. public void State_DoesNotAddFrontendInferenceFlags(string field)
  156. {
  157. Assert.DoesNotContain(field, Svc());
  158. }
  159. /// <summary>前端仍只按后端 capability 显示按钮,不得自己复制 Flow 判定。</summary>
  160. [Fact]
  161. public void Frontend_DrivenByBackendCapabilityOnly()
  162. {
  163. var s = VueSource.CodeOnly(Frontend());
  164. Assert.Contains("review?.canSubmitReview", s);
  165. Assert.DoesNotContain("N1_SUBMIT", s);
  166. Assert.DoesNotContain("=== 'Running'", s);
  167. }
  168. // ── 上游语义冻结(本批不改,防被无意改坏)──
  169. /// <summary>ReturnToPrev 必须保持「同一实例 + 新建前节点待办」语义,重提正是建立在它之上。</summary>
  170. [Fact]
  171. public void ReturnToPrev_KeepsSameInstanceAndRebuildsPrevTask()
  172. {
  173. var body = Slice(Engine(), "public async Task ReturnToPrev(long taskId, string? comment)", "/// <summary>\n /// 加签");
  174. Assert.Contains("instance.CurrentNodeId = prevNodeId;", body);
  175. Assert.Contains("CreateTasksForNode(instance, flowData, prevNodeId)", body);
  176. Assert.Contains("FlowTaskStatusEnum.Returned", body);
  177. // 不得在退回时把实例置为终态,否则重提无从谈起
  178. Assert.DoesNotContain("instance.Status = FlowInstanceStatusEnum.Rejected", body);
  179. }
  180. private static string SubmitReviewBody() => Slice(Svc(),
  181. "public async Task<S6ReviewStateOutput> SubmitReview(",
  182. "/// <summary>主管审核通过(N2)");
  183. private static int CountOf(string src, string needle)
  184. {
  185. int n = 0, i = 0;
  186. while ((i = src.IndexOf(needle, i, StringComparison.Ordinal)) >= 0) { n++; i += needle.Length; }
  187. return n;
  188. }
  189. private static string Slice(string src, string from, string to)
  190. {
  191. var a = src.IndexOf(from, StringComparison.Ordinal);
  192. Assert.True(a >= 0, $"未找到起点:{from}");
  193. var b = src.IndexOf(to, a + from.Length, StringComparison.Ordinal);
  194. return b > a ? src[a..b] : src[a..];
  195. }
  196. private static string FindFile(params string[] parts)
  197. {
  198. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  199. while (dir != null)
  200. {
  201. var candidate = Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
  202. if (File.Exists(candidate)) return candidate;
  203. dir = dir.Parent;
  204. }
  205. throw new FileNotFoundException(string.Join("/", parts));
  206. }
  207. }