feat: 引擎多出口路由/退回/移交/或签补齐 + 定义 layout 坐标
build / build-push (push) Successful in 5m11s

- 多出口:FlwTask 增 edge_name 列(sql/20260916),FlwTaskRepository.claimAndFinish
  同步落列;FlowTaskService.handle 出度>1 时 edgeName 必填;FlowEngine.continueAdvance
  按 edge name 选路(单出口原路径逐字节兼容,未命中不推进);FlowAdvanceService/
  FlowNodeAdvanceListener 透传;BranchScope 从 FlowEngine 提取
- DefValidator:多出口出口命名必填且同节点唯一;跨分支连线发布期明确拒绝
- 退回 POST /task/{id}/returnBack:默认退主管线最近已批节点,精确失效当前节点待办,
  split 幂等 upsert 使退回重走并行段不撞 flw_branch uk
- 移交 POST /task/{id}/transfer:TRANSFERRED(5)+新建待办,AssigneeResolver.requireUserName
- 或签 ANY 补齐:首个同意失效同节点兄弟待办
- 已办口径/撤回守卫:status in (1,2,4,5);新事件 flow.returned/flow.transferred
- FlowDiagramService:EdgeVo.name/NodeVo.state/DiagramVo.layout,办件图状态计算
- 新 IT:多出口×3、ANY 或签、退回重走并行段、退回/移交/撤回

mvn test 84/84 绿
This commit is contained in:
chenxing
2026-09-16 22:23:02 +08:00
parent 55e3a3f800
commit b8441065d0
31 changed files with 981 additions and 126 deletions
@@ -243,6 +243,131 @@ class FlowEngineIT extends FlowTestSupport {
assertEquals(2, taskRepository.findByInstanceIdOrderByIdAsc(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<FlwTask> 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<FlwTask> 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<FlwTask> all = taskRepository.findByInstanceIdOrderByIdAsc(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<FlwTask> 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());
}
private List<FlwBranch> branchRows(Long instanceId) {
return branchRepository.findByInstanceId(instanceId);
}