package com.yunhai.flowcenter.engine; import com.yunhai.flowcenter.entity.FlwBranch; import com.yunhai.flowcenter.entity.FlwDef; import com.yunhai.flowcenter.entity.FlwInstance; import com.yunhai.flowcenter.entity.FlwTask; import com.yunhai.flowcenter.enums.FlwEnums; import com.yunhai.flowcenter.service.FlowInstanceService; import com.yunhai.flowcenter.service.FlowTaskService; import com.yunhai.flowcenter.support.FlowTestSupport; import com.yunhai.platform.apistd.BizException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Primary; import org.springframework.test.context.ActiveProfiles; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; /** * 引擎闭环 IT(真实 MySQL bsch_flowcenter_test;不开测试事务——AFTER_COMMIT 推进 * 监听器依赖真实提交):激活/ALL 会签合并/并行分支/拒绝/撤回/催办/R4 重启重提/乐观锁。 * 注意:本类必须单类独跑(平台 IT 判据,与他类合跑共享库必假红)。 */ @SpringBootTest @ActiveProfiles("test") class FlowEngineIT extends FlowTestSupport { @Autowired private FlowInstanceService instanceService; @Autowired private FlowTaskService taskService; @Autowired private com.yunhai.flowcenter.service.FlowQueryService queryService; @Autowired private com.yunhai.flowcenter.service.FlowRouterAdminService routerAdminService; @TestConfiguration static class StubConfig { @Bean @Primary public com.yunhai.flowcenter.stub.StubUserClient stubUserClient() { return new com.yunhai.flowcenter.stub.StubUserClient(); } /** * 路由判断桩(2026-09-24 CONDITION):替换真实 RouteJudgeClient(不出网), * 每次调用取 StubJudge 当前编程值——出口名或抛错。真实客户端的重试/寻址逻辑 * 由 RouteJudgeClientTest 覆盖;本类聚焦引擎行为(选路/挂起/重跑/重判)。 */ @Bean @Primary public RouteJudgeClient stubRouteJudgeClient() { return new RouteJudgeClient(null, new com.fasterxml.jackson.databind.ObjectMapper()) { @Override public RouteJudgeClient.JudgeResult call(com.yunhai.flowcenter.entity.FlwRouter router, RouteJudgeClient.JudgeBody body) { return StubJudge.next(); } }; } } /** 判断桩的编程值:outlet=命中出口;failure=非空时抛出(模拟服务不可用) */ static final class StubJudge { static volatile String outlet = "甲线"; static volatile RuntimeException failure; static RouteJudgeClient.JudgeResult next() { if (failure != null) { throw failure; } return new RouteJudgeClient.JudgeResult(outlet, "{\"outlet\":\"" + outlet + "\"}"); } static void reset(String defaultOutlet) { outlet = defaultOutlet; failure = null; } } @BeforeEach void setUp() { cleanDb(); StubJudge.reset("甲线"); } private FlowInstanceService.ApplyOutcome apply(String flowGuid, String bizId, Integer submitterId, String submitterName) { FlwDef def = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc( SCH, flowGuid, 1).orElseThrow(IllegalStateException::new); FlowInstanceService.ApplyCommand cmd = new FlowInstanceService.ApplyCommand( SCH, def, "svc_item", bizId, "张三的测试办件", "http://biz/1", "{\"k\":\"v\"}", submitterId, submitterName, null); return instanceService.apply(cmd); } @Test void apply_createsInstanceAndCountersignTasks_allApprove_completes() { mapDept("ybb", 101, "甲"); mapDept("ybb", 102, "乙"); FlwDef def = saveAndPublishDef("t1", "会签流程", linearDef("ybb", "联合审核")); FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-1", 201, "张三"); assertFalse(outcome.isDuplicated()); FlwInstance instance = instanceRepository.findById(outcome.getInstanceId()).orElse(null); assertNotNull(instance); assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(), instance.getStatus()); // ALL 会签:每办理人一条任务 List tasks = taskRepository.findByInstanceIdAndStatus(instance.getId(), 0); assertEquals(2, tasks.size()); assertEquals("n1", instance.getCurrentNodes()); // 第一人同意:节点未清零,仍在途 taskService.handle(SCH, tasks.get(0).getId(), true, "同意", 101); assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(), instanceRepository.findById(instance.getId()).get().getStatus()); // 第二人同意:全部清零 → 推进 END → 办件完成 taskService.handle(SCH, tasks.get(1).getId(), true, null, 102); FlwInstance after = instanceRepository.findById(instance.getId()).get(); assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(), after.getStatus()); assertNotNull(after.getFinishTime()); assertTrue(after.getVersion() >= 2, "完成时乐观锁版本应递增"); } @Test void anyReject_endsRejected_andInvalidatesOthers() { mapDept("ybb", 101, "甲"); mapDept("ybb", 102, "乙"); FlwDef def = saveAndPublishDef("t2", "会签拒绝", linearDef("ybb", "联合审核")); FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-2", 201, "张三"); List tasks = taskRepository.findByInstanceIdAndStatus(outcome.getInstanceId(), 0); taskService.handle(SCH, tasks.get(0).getId(), false, "材料不全", 101); FlwInstance instance = instanceRepository.findById(outcome.getInstanceId()).get(); assertEquals(FlwEnums.InstanceStatus.REJECTED.getCode(), instance.getStatus()); assertNotNull(instance.getFinishTime()); // 同批另一待办已失效 List all = allTasks(instance.getId()); assertEquals(FlwEnums.TaskStatus.REJECTED.getCode(), all.get(0).getStatus()); assertEquals(FlwEnums.TaskStatus.INVALIDATED.getCode(), all.get(1).getStatus()); } @Test void reject_requiresOpinion() { mapDept("ybb", 101, "甲"); FlwDef def = saveAndPublishDef("t3", "意见必填", linearDef("ybb", "审核")); FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-3", 201, "张三"); FlwTask task = taskRepository.findByInstanceIdAndStatus(outcome.getInstanceId(), 0).get(0); assertThrows(BizException.class, () -> taskService.handle(SCH, task.getId(), false, " ", 101)); } @Test void withdraw_beforeAnyHandle_ok_afterHandle_forbidden() { mapDept("ybb", 101, "甲"); FlwDef def = saveAndPublishDef("t4", "撤回", linearDef("ybb", "审核")); FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-4", 201, "张三"); instanceService.withdraw(SCH, outcome.getInstanceId()); assertEquals(FlwEnums.InstanceStatus.WITHDRAWN.getCode(), instanceRepository.findById(outcome.getInstanceId()).get().getStatus()); // 撤回留痕(2026-09-18):轨迹里必须有"撤回申请"行,否则撤回在时间线上是空白 List afterWithdraw = traceRows(outcome.getInstanceId()); FlwTask withdrawRow = afterWithdraw.stream() .filter(t -> FlwEnums.TaskStatus.NODE_WITHDRAW.equals(t.getNodeId())).findFirst().orElse(null); assertNotNull(withdrawRow, "撤回必须留痕"); assertEquals(FlwEnums.TaskStatus.WITHDRAWN.getCode(), withdrawRow.getStatus()); assertEquals(201, withdrawRow.getAssigneeId()); // 操作人=提交人 // 留痕行不算"已被办理":重提前的撤回守卫口径不能被自己写的事件行挡住 assertFalse(FlwEnums.TaskStatus.handledCodes().contains(withdrawRow.getStatus())); // 重提重启后再办理,随后撤回必须被拒(已有任务被办理) FlowInstanceService.ApplyOutcome reapplied = apply(def.getFlowGuid(), "biz-4", 201, "张三"); FlwTask task = taskRepository.findByInstanceIdAndStatus(reapplied.getInstanceId(), 0).get(0); taskService.handle(SCH, task.getId(), true, "ok", 101); assertThrows(BizException.class, () -> instanceService.withdraw(SCH, reapplied.getInstanceId())); } /** * 轨迹时间流 + 已办去重(2026-09-18): * ① 发起/重新提交留痕成事件行,detail.trace 按发生时间升序且带 kind/round/科室富化; * ② 同一办件被本人办理两次(驳回 + 重提后再同意)时,已办列表只出一行、doneCount=2、 * 结果取最后一次(同意),并保留"共办理 N 次"的提示依据。 */ @Test void trace_timeline_kindsRounds_andDoneDedup() { mapDept("ybb", 101, "甲"); FlwDef def = saveAndPublishDef("t20", "轨迹时间流", linearDef("ybb", "审核")); FlowInstanceService.ApplyOutcome first = apply(def.getFlowGuid(), "biz-20", 201, "张三"); Long instanceId = first.getInstanceId(); // 一轮:办理人驳回;提交人重提;二轮:同一个人同意 → 同一办件上本人两条已办 taskService.handle(SCH, taskRepository.findByInstanceIdAndStatus(instanceId, 0).get(0).getId(), false, "材料不全", 101); apply(def.getFlowGuid(), "biz-20", 201, "张三"); taskService.handle(SCH, taskRepository.findByInstanceIdAndStatus(instanceId, 0).get(0).getId(), true, "补齐了", 101); assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(), instanceRepository.findById(instanceId).get().getStatus()); // ① 轨迹时间流:发起 → 驳回 → 重新提交 → 同意 java.util.List> trace = queryService.detail(SCH, instanceId, null).getTrace(); java.util.List kinds = new java.util.ArrayList<>(); for (java.util.Map row : trace) { kinds.add((String) row.get("kind")); } assertEquals(java.util.Arrays.asList("SUBMIT", "REJECT", "RESUBMIT", "APPROVE"), kinds); // 时间为升序(前端直接按后端顺序渲染) for (int i = 1; i < trace.size(); i++) { assertTrue(String.valueOf(trace.get(i).get("time")) .compareTo(String.valueOf(trace.get(i - 1).get("time"))) >= 0, "轨迹必须按发生时间升序:" + trace); } // 轮次:驳回属于第 1 轮,重新提交与二轮同意属于第 2 轮 assertEquals(1, trace.get(1).get("round")); assertEquals(2, trace.get(2).get("round")); assertEquals(2, trace.get(3).get("round")); // 科室口径富化(DEPT_KEY 节点):deptName 取映射里的科室名,前端"按科室"据此换掉人名 assertEquals("DEPT_KEY", trace.get(3).get("assigneeType")); assertEquals(dk("ybb"), trace.get(3).get("deptKey")); assertEquals("ybb科", trace.get(3).get("deptName")); // ② 已办去重:同一办件一行,结果是最后一次(同意),次数=2 com.yunhai.platform.utils.model.BasePageResult done = queryService.pageDone(SCH, 101, 1, 10); assertEquals(1, done.getContent().size(), "一个办件只能有一条已办记录"); assertEquals(2, done.getContent().get(0).getDoneCount()); assertEquals(FlwEnums.TaskStatus.APPROVED.getCode(), done.getContent().get(0).getStatus()); assertEquals("审核", done.getContent().get(0).getNodeName()); } @Test void reapply_inFlight_duplicated_noNewInstance() { mapDept("ybb", 101, "甲"); FlwDef def = saveAndPublishDef("t5", "在途幂等", linearDef("ybb", "审核")); FlowInstanceService.ApplyOutcome first = apply(def.getFlowGuid(), "biz-5", 201, "张三"); FlowInstanceService.ApplyOutcome second = apply(def.getFlowGuid(), "biz-5", 201, "张三"); assertTrue(second.isDuplicated()); assertEquals(first.getInstanceId(), second.getInstanceId()); // 在途命中只返回不重建,任务不翻倍 assertEquals(1, taskRepository.findByInstanceIdAndStatus(first.getInstanceId(), 0).size()); } @Test void reapply_afterReject_restartsSameInstance_withHistoryKept() { mapDept("ybb", 101, "甲"); FlwDef def = saveAndPublishDef("t6", "R4 重启", linearDef("ybb", "审核")); FlowInstanceService.ApplyOutcome first = apply(def.getFlowGuid(), "biz-6", 201, "张三"); FlwInstance before = instanceRepository.findById(first.getInstanceId()).get(); int versionBefore = before.getVersion(); // 拒绝 → 终态 FlwTask task = taskRepository.findByInstanceIdAndStatus(first.getInstanceId(), 0).get(0); taskService.handle(SCH, task.getId(), false, "不合格", 101); assertEquals(FlwEnums.InstanceStatus.REJECTED.getCode(), instanceRepository.findById(first.getInstanceId()).get().getStatus()); // R4:重提复用同一办件行重启推进 FlowInstanceService.ApplyOutcome second = apply(def.getFlowGuid(), "biz-6", 201, "张三"); assertTrue(second.isDuplicated()); assertEquals(first.getInstanceId(), second.getInstanceId()); FlwInstance after = instanceRepository.findById(first.getInstanceId()).get(); assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(), after.getStatus()); assertTrue(after.getVersion() > versionBefore, "重启后 instanceVersion 必须单调递增"); // 轨迹连续:历史拒绝任务保留 + 新一轮任务待办 List all = allTasks(first.getInstanceId()); assertEquals(FlwEnums.TaskStatus.REJECTED.getCode(), all.get(0).getStatus()); assertEquals(1, taskRepository.findByInstanceIdAndStatus(first.getInstanceId(), 0).size()); assertNull(after.getFinishTime()); // 二轮通过 → 完成 FlwTask newTask = taskRepository.findByInstanceIdAndStatus(first.getInstanceId(), 0).get(0); taskService.handle(SCH, newTask.getId(), true, "通过", 101); assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(), instanceRepository.findById(first.getInstanceId()).get().getStatus()); } @Test void parallelSplitJoin_bothBranchesArrive_thenComplete() { mapDept("ka", 101, "甲"); mapDept("kb", 102, "乙"); FlwDef def = saveAndPublishDef("t7", "并行", parallelDef("ka", "kb")); FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-7", 201, "张三"); Long instanceId = outcome.getInstanceId(); // split 激活两条分支 List branches = instanceRepository.findById(instanceId) .map(i -> i.getId()).map(id -> branchRows(id)).orElse(null); assertNotNull(branches); assertEquals(2, branches.size()); // 甲科先过:分支一到达汇聚点,办件仍在途 FlwTask taskA = tasksOf(instanceId, 101).get(0); taskService.handle(SCH, taskA.getId(), true, "同意", 101); assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(), instanceRepository.findById(instanceId).get().getStatus()); // 乙科再过:全部到达 → JOIN 放行 → END → 完成 FlwTask taskB = tasksOf(instanceId, 102).get(0); taskService.handle(SCH, taskB.getId(), true, "同意", 102); assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(), instanceRepository.findById(instanceId).get().getStatus()); } @Test void reject_inParallelBranch_failsWholeInstance() { mapDept("ka", 101, "甲"); mapDept("kb", 102, "乙"); FlwDef def = saveAndPublishDef("t8", "并行拒绝", parallelDef("ka", "kb")); FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-8", 201, "张三"); FlwTask taskB = tasksOf(outcome.getInstanceId(), 102).get(0); taskService.handle(SCH, taskB.getId(), false, "乙科不同意", 102); FlwInstance instance = instanceRepository.findById(outcome.getInstanceId()).get(); assertEquals(FlwEnums.InstanceStatus.REJECTED.getCode(), instance.getStatus()); // 另一分支的待办被失效(失效后不在待办集合,按全量任务查找) FlwTask taskA = allTasks(outcome.getInstanceId()).stream() .filter(t -> t.getAssigneeId().equals(101)).findFirst().get(); assertEquals(FlwEnums.TaskStatus.INVALIDATED.getCode(), taskRepository.findById(taskA.getId()).get().getStatus()); } @Test void concurrentApprove_sameNode_onlyOnePathAdvances() throws Exception { mapDept("ybb", 101, "甲"); mapDept("ybb", 102, "乙"); FlwDef def = saveAndPublishDef("t9", "并发会签", linearDef("ybb", "会签审核")); FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-9", 201, "张三"); List tasks = taskRepository.findByInstanceIdAndStatus(outcome.getInstanceId(), 0); // 两线程同时办理各自任务(推进互斥由乐观锁保证) Thread t1 = new Thread(() -> taskService.handle(SCH, tasks.get(0).getId(), true, "ok", 101)); Thread t2 = new Thread(() -> taskService.handle(SCH, tasks.get(1).getId(), true, "ok", 102)); t1.start(); t2.start(); t1.join(10_000); t2.join(10_000); assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(), instanceRepository.findById(outcome.getInstanceId()).get().getStatus()); // 只应有一轮 END 完成(无重复推进导致的重复任务创建:总任务数=2) assertEquals(2, allTasks(outcome.getInstanceId()).size()); } @Test void multiOutlet_approveWithChosenEdge_routesToTarget() { mapDept("km", 101, "甲"); mapDept("kf", 102, "乙"); FlwDef def = saveAndPublishDef("t10", "多出口", multiOutletDef("km", "kf")); FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-10", 201, "张三"); Long instanceId = outcome.getInstanceId(); FlwTask task = taskRepository.findByInstanceIdAndStatus(instanceId, 0).get(0); // 选"通过"出口 → 直达 END 完成 taskService.handle(SCH, task.getId(), true, "ok", 101, "通过"); assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(), instanceRepository.findById(instanceId).get().getStatus()); assertEquals("通过", taskRepository.findById(task.getId()).get().getEdgeName()); } @Test void multiOutlet_chooseOtherEdge_routesToSecondNode() throws Exception { mapDept("km", 101, "甲"); mapDept("kf", 102, "乙"); FlwDef def = saveAndPublishDef("t11", "多出口转向", multiOutletDef("km", "kf")); FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-11", 201, "张三"); Long instanceId = outcome.getInstanceId(); FlwTask task = taskRepository.findByInstanceIdAndStatus(instanceId, 0).get(0); // 选"转乙科"出口 → 到 n2 建新待办 taskService.handle(SCH, task.getId(), true, "转给乙科", 101, "转乙科"); assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(), instanceRepository.findById(instanceId).get().getStatus()); List pending = taskRepository.findByInstanceIdAndStatus(instanceId, 0); assertEquals(1, pending.size()); assertEquals("n2", pending.get(0).getNodeId()); assertEquals(102, pending.get(0).getAssigneeId()); // 乙科通过 → 完成 taskService.handle(SCH, pending.get(0).getId(), true, "ok", 102); assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(), instanceRepository.findById(instanceId).get().getStatus()); } @Test void multiOutlet_multiOutletNode_requiresEdgeName() { mapDept("km", 101, "甲"); mapDept("kf", 102, "乙"); FlwDef def = saveAndPublishDef("t12", "多出口必选", multiOutletDef("km", "kf")); FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-12", 201, "张三"); FlwTask task = taskRepository.findByInstanceIdAndStatus(outcome.getInstanceId(), 0).get(0); assertThrows(BizException.class, () -> taskService.handle(SCH, task.getId(), true, "ok", 101, null)); assertThrows(BizException.class, () -> taskService.handle(SCH, task.getId(), true, "ok", 101, "不存在的出口")); } @Test void anyCountersign_firstApprove_invalidatesSiblings_thenAdvances() { mapDept("ky", 101, "甲"); mapDept("ky", 102, "乙"); FlwDef def = saveAndPublishDef("t13", "或签", anyCountersignDef("ky")); FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-13", 201, "张三"); Long instanceId = outcome.getInstanceId(); List pending = taskRepository.findByInstanceIdAndStatus(instanceId, 0); assertEquals(2, pending.size()); // 或签:任一人同意即放行,兄弟待办失效(修复前行为=卡到全员办完) taskService.handle(SCH, pending.get(0).getId(), true, "我先批", 101); FlwInstance instance = instanceRepository.findById(instanceId).get(); assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(), instance.getStatus()); List all = allTasks(instanceId); assertEquals(FlwEnums.TaskStatus.APPROVED.getCode(), all.get(0).getStatus()); assertEquals(FlwEnums.TaskStatus.INVALIDATED.getCode(), all.get(1).getStatus()); } @Test void returnBack_fromTailNode_backToHead_thenReTraverseParallel_noUkViolation() { mapDept("km", 103, "主"); mapDept("ka", 101, "甲"); mapDept("kb", 102, "乙"); FlwDef def = saveAndPublishDef("t14", "退回重入", mainlineParallelTailDef("km", "km")); FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-14", 201, "张三"); Long instanceId = outcome.getInstanceId(); // 顺序推进:n1 → 并行双分支 → join → n3(在出口复核节点) taskService.handle(SCH, tasksOf(instanceId, 103).get(0).getId(), true, "ok", 103); taskService.handle(SCH, tasksOf(instanceId, 101).get(0).getId(), true, "ok", 101); taskService.handle(SCH, tasksOf(instanceId, 102).get(0).getId(), true, "ok", 102); FlwTask tail = tasksOf(instanceId, 103).get(0); assertEquals("n3", tail.getNodeId()); // 退回至入口(缺省主线最近已办=n1;并行内节点被过滤,不会命中 ta/tb) taskService.returnBack(SCH, tail.getId(), null, "重新审一遍", 103); FlwInstance returned = instanceRepository.findById(instanceId).get(); assertEquals("n1", returned.getCurrentNodes()); assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(), returned.getStatus()); // 重入并行段:split 幂等 upsert,不撞 uk(instance,branchKey) taskService.handle(SCH, tasksOf(instanceId, 103).get(0).getId(), true, "再入口", 103); taskService.handle(SCH, tasksOf(instanceId, 101).get(0).getId(), true, "ok", 101); taskService.handle(SCH, tasksOf(instanceId, 102).get(0).getId(), true, "ok", 102); FlwTask n3Again = tasksOf(instanceId, 103).get(0); assertEquals("n3", n3Again.getNodeId()); taskService.handle(SCH, n3Again.getId(), true, "复核通过", 103); assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(), instanceRepository.findById(instanceId).get().getStatus()); // 轨迹含 RETURNED 行 assertEquals(1, allTasks(instanceId).stream() .filter(t -> FlwEnums.TaskStatus.RETURNED.getCode() == t.getStatus()).count()); } @Test void returnBack_noMainlineTarget_throws_andTransfer_reassigns() { mapDept("km", 101, "甲"); FlwDef def = saveAndPublishDef("t15", "退回目标", linearDef("km", "首审")); FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-15", 201, "张三"); Long instanceId = outcome.getInstanceId(); FlwTask task = taskRepository.findByInstanceIdAndStatus(instanceId, 0).get(0); // 首节点无上游已办:缺省退回应明确报错 assertThrows(BizException.class, () -> taskService.returnBack(SCH, task.getId(), null, "试试", 101)); // 移交:当前任务置 TRANSFERRED,同节点给新办理人建待办 taskService.transfer(SCH, task.getId(), 202, "人不在岗", 101); List handled = allTasks(instanceId); assertEquals(2, handled.size(), "移交后应产生新待办行"); assertEquals(FlwEnums.TaskStatus.TRANSFERRED.getCode(), handled.get(0).getStatus()); FlwTask reassigned = taskRepository.findByInstanceIdAndStatus(instanceId, 0).get(0); assertEquals(202, reassigned.getAssigneeId()); assertEquals(101, handled.get(0).getAssigneeId()); // 原办理人留在轨迹 // 新办理人通过 → 完成;已退回/移交后提交人不能撤回 taskService.handle(SCH, reassigned.getId(), true, "接手批准", 202); assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(), instanceRepository.findById(instanceId).get().getStatus()); } /** * NOTIFY 抄送·主线(2026-09-23):激活即逐人落 flw_cc,不建任务、不进 currentNodes, * 流程无停顿直达下一审批节点;办件轨迹出现 kind=CC 行且"抄送我的"可查到。 */ @Test void notify_onMainline_writesCcRows_andKeepsFlowMoving() { mapDept("km", 101, "甲"); FlwDef def = saveAndPublishDef("t16", "抄送主线", notifyMainlineDef("km")); FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-16", 201, "张三"); Long instanceId = outcome.getInstanceId(); // 抄送瞬时放行:两个接收人各一行,无 cc 节点任务,直接建审核待办 List ccs = ccRepository.findByInstanceIdOrderByIdAsc(instanceId); assertEquals(2, ccs.size()); assertEquals("cc1", ccs.get(0).getNodeId()); assertEquals("抄送校领导", ccs.get(0).getNodeName()); assertEquals(301, ccs.get(0).getUserId()); assertEquals(302, ccs.get(1).getUserId()); assertEquals(1, taskRepository.findByInstanceIdAndStatus(instanceId, 0).size()); FlwInstance instance = instanceRepository.findById(instanceId).get(); assertEquals("n1", instance.getCurrentNodes()); assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(), instance.getStatus()); // 审核通过 → 完成(抄送节点从未阻塞过推进) taskService.handle(SCH, tasksOf(instanceId, 101).get(0).getId(), true, "ok", 101); assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(), instanceRepository.findById(instanceId).get().getStatus()); // 轨迹:发起 → 抄送×2 → 同意。时间列毫秒精度(DATETIME(3))后亚秒机器链因果序可复现; // 展示字符串截断到秒(str),排序用原值。 java.util.List> trace = queryService.detail(SCH, instanceId, null).getTrace(); java.util.List kinds = new java.util.ArrayList<>(); for (java.util.Map row : trace) { kinds.add((String) row.get("kind")); } assertEquals(java.util.Arrays.asList("SUBMIT", "CC", "CC", "APPROVE"), kinds); // 抄送我的:301 视角一行,回填办件信息 com.yunhai.platform.utils.model.BasePageResult ccPage = queryService.pageCc(SCH, 301, 1, 10); assertEquals(1, ccPage.getContent().size()); assertEquals(instanceId, ccPage.getContent().get(0).getInstanceId()); assertEquals("张三的测试办件", ccPage.getContent().get(0).getBizTitle()); } /** NOTIFY 在并行分支内:瞬时到达 JOIN 不占等待(审批支到齐即放行),落库与轨迹同主线口径 */ @Test void notify_inParallelBranch_reachesJoin_withoutBlocking() { mapDept("km", 101, "甲"); FlwDef def = saveAndPublishDef("t17", "抄送并行", notifyParallelDef("km")); FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-17", 201, "张三"); Long instanceId = outcome.getInstanceId(); // 抄送支激活即写行并到达 JOIN:分支一 RUNNING(审批支)、一 ARRIVED(抄送支) List branches = branchRows(instanceId); assertEquals(2, branches.size()); assertEquals(1, branches.stream() .filter(b -> FlwEnums.BranchStatus.RUNNING.getCode() == b.getStatus()).count()); assertEquals(1, ccRepository.findByInstanceIdOrderByIdAsc(instanceId).size()); // 办件仍在途等审批支:乙支瞬时完成没有让 JOIN 提前放行 assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(), instanceRepository.findById(instanceId).get().getStatus()); // 审批支到齐 → JOIN 放行 → 完成 taskService.handle(SCH, tasksOf(instanceId, 101).get(0).getId(), true, "ok", 101); assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(), instanceRepository.findById(instanceId).get().getStatus()); } /** NOTIFY 接收人解析不出人(映射停用/名单空):不阻塞推进,只是没有抄送行 */ @Test void notify_unresolvableReceivers_skipsCc_butFlowContinues() { mapDept("km", 101, "甲"); // deptKey 未注册任何映射(mapDept 只注册 km,抄送挂 kx) String json = notifyMainlineDef("km").replace( "\"receivers\":{\"type\":\"USERS\",\"userIds\":[301,302]}}", "\"receivers\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"kx\"}}"); FlwDef def = saveAndPublishDef("t18", "抄送空对象", json); FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-18", 201, "张三"); Long instanceId = outcome.getInstanceId(); assertTrue(ccRepository.findByInstanceIdOrderByIdAsc(instanceId).isEmpty(), "解析不出人不应落行"); assertEquals("n1", instanceRepository.findById(instanceId).get().getCurrentNodes()); assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(), instanceRepository.findById(instanceId).get().getStatus()); taskService.handle(SCH, tasksOf(instanceId, 101).get(0).getId(), true, "ok", 101); assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(), instanceRepository.findById(instanceId).get().getStatus()); } /** * 嵌套并行 scope 修复(2026-09-24 §4.6):外层分支 tb 内再套并行段 s2{tc,td}—— * 修复前 JOIN 计数是实例级(tb 行仍在进行),内层 join2 到不齐、外层 join1 等 tb,互锁死。 * 修复后:内层两支到齐 → join2 放行并以外层上下文 tb 续走 → join1 两轮到齐才完成。 */ @Test void nestedParallel_innerJoinReleasesFirst_outerCompletesAfterBoth() { mapDept("km", 103, "主"); mapDept("ka", 101, "甲"); mapDept("kb", 102, "乙"); mapDept("kc", 104, "丙"); mapDept("kd", 105, "丁"); FlwDef def = saveAndPublishDef("t19", "嵌套并行", nestedParallelDef("km", "ka", "kb", "kc", "kd")); FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-19", 201, "张三"); Long instanceId = outcome.getInstanceId(); taskService.handle(SCH, tasksOf(instanceId, 103).get(0).getId(), true, "ok", 103); // 外层 split:顶层分支键无点号(存量口径) assertEquals(new java.util.TreeSet<>(java.util.Arrays.asList("ta", "tb")), branchRows(instanceId).stream().map(FlwBranch::getBranchKey) .collect(java.util.stream.Collectors.toCollection(java.util.TreeSet::new))); // 甲支先过:join1 等外层 tb(其内层段未走),办件仍在途 taskService.handle(SCH, tasksOf(instanceId, 101).get(0).getId(), true, "ok", 101); assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(), instanceRepository.findById(instanceId).get().getStatus()); // 乙支过 → 进入内层并行段:分支键=路径前缀 tb.tc / tb.td taskService.handle(SCH, tasksOf(instanceId, 102).get(0).getId(), true, "ok", 102); java.util.Map byKey = branchRows(instanceId).stream() .collect(java.util.stream.Collectors.toMap(FlwBranch::getBranchKey, FlwBranch::getStatus)); assertEquals(4, byKey.size()); assertEquals(FlwEnums.BranchStatus.ARRIVED.getCode(), (int) byKey.get("ta")); assertEquals(FlwEnums.BranchStatus.RUNNING.getCode(), (int) byKey.get("tb")); assertEquals(FlwEnums.BranchStatus.RUNNING.getCode(), (int) byKey.get("tb.tc")); assertEquals(FlwEnums.BranchStatus.RUNNING.getCode(), (int) byKey.get("tb.td")); // 内层丙支先过:join2 等丁支(修复前这里会因实例级计数连带等 tb 而死锁) taskService.handle(SCH, tasksOf(instanceId, 104).get(0).getId(), true, "ok", 104); assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(), instanceRepository.findById(instanceId).get().getStatus()); // 丁支到齐 → join2 放行(tb 标记到达)→ join1 两轮到齐 → END 完成 taskService.handle(SCH, tasksOf(instanceId, 105).get(0).getId(), true, "ok", 105); assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(), instanceRepository.findById(instanceId).get().getStatus()); } /** 出口分支内并行段(CONDITION 分支内并行的前置形态):选"乙线"出口 → 并行段两支到齐 → 汇聚完成 */ @Test void outletBranch_withParallelSegment_joinsAndCompletes() { mapDept("km", 103, "主"); mapDept("ka", 101, "甲"); mapDept("kb", 102, "乙"); FlwDef def = saveAndPublishDef("t21", "出口并行", outletParallelDef("km", "ka", "kb")); FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-21", 201, "张三"); Long instanceId = outcome.getInstanceId(); // 选"乙线"出口进入并行段(scope=主线,出口校验应放行) taskService.handle(SCH, tasksOf(instanceId, 103).get(0).getId(), true, "走乙线", 103, "乙线"); assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(), instanceRepository.findById(instanceId).get().getStatus()); assertEquals(2, taskRepository.findByInstanceIdAndStatus(instanceId, 0).size()); // 两支到齐 → join2 放行 → END 完成 taskService.handle(SCH, tasksOf(instanceId, 101).get(0).getId(), true, "ok", 101); assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(), instanceRepository.findById(instanceId).get().getStatus()); taskService.handle(SCH, tasksOf(instanceId, 102).get(0).getId(), true, "ok", 102); assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(), instanceRepository.findById(instanceId).get().getStatus()); } /** * CONDITION 命中选路(2026-09-24 §4):判断返回"甲线"→ 只激活甲线待办,乙线不建任务; * 办理后完成;轨迹出现 kind=ROUTE 行("系统判断 → 甲线"),留痕 outlet=甲线。 */ @Test void condition_routesByOutlet_andTraceShowsRouteRow() { mapDept("km", 101, "甲"); mapDept("kb", 102, "乙"); registerRouter("test.judge"); FlwDef def = saveAndPublishDef("t22", "条件分支", conditionDef("test.judge", "km", "kb")); StubJudge.reset("甲线"); FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-22", 201, "张三"); Long instanceId = outcome.getInstanceId(); // 甲线命中:仅甲科一条待办,CONDITION 已放行(不在 currentNodes) List pending = taskRepository.findByInstanceIdAndStatus(instanceId, 0); assertEquals(1, pending.size()); assertEquals("n1", pending.get(0).getNodeId()); assertEquals("n1", instanceRepository.findById(instanceId).get().getCurrentNodes()); taskService.handle(SCH, pending.get(0).getId(), true, "ok", 101); assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(), instanceRepository.findById(instanceId).get().getStatus()); // 留痕 + 轨迹:ROUTE 行在发起之后、办理之前 List logs = routeLogRepository.findByInstanceIdOrderByIdAsc(instanceId); assertEquals(1, logs.size()); assertEquals("甲线", logs.get(0).getOutlet()); assertEquals(1, logs.get(0).getAttempt()); java.util.List> trace = queryService.detail(SCH, instanceId, null).getTrace(); java.util.List kinds = new java.util.ArrayList<>(); for (java.util.Map row : trace) { kinds.add((String) row.get("kind")); } assertEquals(java.util.Arrays.asList("SUBMIT", "ROUTE", "APPROVE"), kinds); assertEquals("甲线", trace.get(1).get("outlet")); } /** * CONDITION 判断失败挂起 + 管理端重跑(§4.4 已定策略):失败=节点保持在途、办件仍 10、 * 落 outlet=NULL 留痕行(无默认出口,宁挂起);重跑成功后按新出口续走到完成。 */ @Test void condition_judgeFails_suspends_thenAdminReroute_resumes() { mapDept("km", 101, "甲"); mapDept("kb", 102, "乙"); registerRouter("test.judge"); FlwDef def = saveAndPublishDef("t23", "条件挂起", conditionDef("test.judge", "km", "kb")); StubJudge.failure = new RouteJudgeClient.JudgeUnavailableException("服务不可用", null); FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-23", 201, "张三"); Long instanceId = outcome.getInstanceId(); // 挂起:办件在途、CONDITION 占位在 currentNodes、无待办任务 FlwInstance suspended = instanceRepository.findById(instanceId).get(); assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(), suspended.getStatus()); assertEquals("cond1", suspended.getCurrentNodes()); assertTrue(taskRepository.findByInstanceIdAndStatus(instanceId, 0).isEmpty()); List failLogs = routeLogRepository.findByInstanceIdOrderByIdAsc(instanceId); assertEquals(1, failLogs.size()); assertNull(failLogs.get(0).getOutlet(), "失败行 outlet=NULL"); assertEquals(1, failLogs.get(0).getAttempt()); // 重跑仍失败:留痕 attempt=2,办件继续挂起(业务错抛出) assertThrows(com.yunhai.platform.apistd.BizException.class, () -> routerAdminService.reroute(SCH, instanceId)); assertEquals(2, routeLogRepository.findByInstanceIdOrderByIdAsc(instanceId).size()); assertEquals("cond1", instanceRepository.findById(instanceId).get().getCurrentNodes()); // 判断恢复 → 重跑成功:按乙线续走建待办,办理完成;attempt=3 留痕 outlet=乙线 StubJudge.reset("乙线"); routerAdminService.reroute(SCH, instanceId); FlwInstance resumed = instanceRepository.findById(instanceId).get(); assertEquals("n2", resumed.getCurrentNodes()); List pending = taskRepository.findByInstanceIdAndStatus(instanceId, 0); assertEquals(1, pending.size()); assertEquals(102, pending.get(0).getAssigneeId()); taskService.handle(SCH, pending.get(0).getId(), true, "ok", 102); assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(), instanceRepository.findById(instanceId).get().getStatus()); List logs = routeLogRepository.findByInstanceIdOrderByIdAsc(instanceId); assertEquals(3, logs.size()); assertEquals("乙线", logs.get(2).getOutlet()); assertEquals(3, logs.get(2).getAttempt()); // 完成后的办件不再可重跑 assertThrows(com.yunhai.platform.apistd.BizException.class, () -> routerAdminService.reroute(SCH, instanceId)); } /** * 退回重走 CONDITION 必须重新调判断(§4.4:单据可能已改,留痕只增不改多轮可见): * 一轮判"甲线"到 n1,退回 n0 重走判"乙线"直达 END;route_log 两行 attempt 递增。 */ @Test void condition_returnBack_rewalk_rejudges() { mapDept("km", 101, "甲"); mapDept("ka", 102, "乙"); registerRouter("test.judge"); FlwDef def = saveAndPublishDef("t24", "条件重判", mainlineConditionDef("test.judge", "km", "ka")); StubJudge.reset("甲线"); FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-24", 201, "张三"); Long instanceId = outcome.getInstanceId(); // 一轮:n0 过 → 判甲线 → n1 待办 taskService.handle(SCH, tasksOf(instanceId, 101).get(0).getId(), true, "ok", 101); assertEquals("n1", instanceRepository.findById(instanceId).get().getCurrentNodes()); // 退回入口 n0 重走(改判乙线) StubJudge.reset("乙线"); FlwTask n1Task = taskRepository.findByInstanceIdAndStatus(instanceId, 0).get(0); taskService.returnBack(SCH, n1Task.getId(), "n0", "改判乙线", 102); taskService.handle(SCH, tasksOf(instanceId, 101).get(0).getId(), true, "再走一遍", 101); // 乙线直达 END:办件完成(n1 未再激活) assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(), instanceRepository.findById(instanceId).get().getStatus()); // 留痕两轮:attempt 1=甲线、2=乙线(只增不改) List logs = routeLogRepository.findByInstanceIdOrderByIdAsc(instanceId); assertEquals(2, logs.size()); assertEquals("甲线", logs.get(0).getOutlet()); assertEquals(1, logs.get(0).getAttempt()); assertEquals("乙线", logs.get(1).getOutlet()); assertEquals(2, logs.get(1).getAttempt()); } private List branchRows(Long instanceId) { return branchRepository.findByInstanceId(instanceId); } @Autowired private com.yunhai.flowcenter.repository.FlwBranchRepository branchRepository; /** * 审批任务行(剔除 6/7 留痕事件行,2026-09-18 起"发起/重新提交/撤回"也落 flw_task 行): * 只关心审批动作的断言(按序号取值、按数量计数)必须走这个口,否则会取到事件行。 */ private List allTasks(Long instanceId) { return taskRepository.findByInstanceIdOrderByIdAsc(instanceId).stream() .filter(t -> !FlwEnums.TaskStatus.eventCodes().contains(t.getStatus())) .collect(java.util.stream.Collectors.toList()); } /** 全量轨迹行(含 6/7 留痕事件行):留痕类断言用 */ private List traceRows(Long instanceId) { return taskRepository.findByInstanceIdOrderByIdAsc(instanceId); } private List tasksOf(Long instanceId, Integer assigneeId) { List out = new java.util.ArrayList<>(); for (FlwTask t : taskRepository.findByInstanceIdAndStatus(instanceId, 0)) { if (assigneeId.equals(t.getAssigneeId())) { out.add(t); } } return out; } }