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
@@ -97,7 +97,8 @@ class CallbackDispatchIT extends FlowTestSupport {
new java.util.concurrent.atomic.AtomicInteger();
CountingUserMsgClient() {
super(null);
// enabled=false:本用例只覆盖回调派发,站内信为哑实现(两个方法均覆写)
super(null, null, false, "");
}
@Override
@@ -34,6 +34,75 @@ class DefValidatorTest {
DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb")));
}
@Test
void multiOutlet_withoutNames_fails() {
String json = "{"
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
+ "\"nodes\":["
+ "{\"id\":\"start\",\"type\":\"START\"},"
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"审批\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
+ "{\"id\":\"n2\",\"type\":\"APPROVAL\",\"name\":\"复核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
+ "{\"id\":\"end\",\"type\":\"END\"}],"
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},"
+ "{\"from\":\"n1\",\"to\":\"n2\"},{\"from\":\"n1\",\"to\":\"end\"},"
+ "{\"from\":\"n2\",\"to\":\"end\"}]}";
BizException e = assertThrows(BizException.class,
() -> DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb"))));
assertTrue(e.getMessage().contains("出口必须命名"));
}
@Test
void multiOutlet_duplicateNames_fails() {
String json = "{"
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
+ "\"nodes\":["
+ "{\"id\":\"start\",\"type\":\"START\"},"
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"审批\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
+ "{\"id\":\"end\",\"type\":\"END\"}],"
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},"
+ "{\"from\":\"n1\",\"to\":\"end\",\"name\":\"通过\"},"
+ "{\"from\":\"n1\",\"to\":\"end\",\"name\":\"通过\"}]}";
BizException e = assertThrows(BizException.class,
() -> DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb"))));
assertTrue(e.getMessage().contains("出口名称重复"));
}
@Test
void multiOutlet_namedUnique_passes() {
String json = "{"
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
+ "\"nodes\":["
+ "{\"id\":\"start\",\"type\":\"START\"},"
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"审批\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
+ "{\"id\":\"end\",\"type\":\"END\"}],"
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},"
+ "{\"from\":\"n1\",\"to\":\"end\",\"name\":\"通过\"},"
+ "{\"from\":\"n1\",\"to\":\"end\",\"name\":\"驳回结束\"}]}";
DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb")));
}
@Test
void multiOutlet_crossBranchTarget_fails() {
// n1 在并行分支内,出口指向主线 end —— 跨分支连线一期拒绝
String json = "{"
+ "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\","
+ "\"nodes\":["
+ "{\"id\":\"start\",\"type\":\"START\"},"
+ "{\"id\":\"split1\",\"type\":\"PARALLEL_SPLIT\"},"
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"分支审批\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}},"
+ "{\"id\":\"join1\",\"type\":\"PARALLEL_JOIN\",\"waitFor\":\"ALL\"},"
+ "{\"id\":\"end\",\"type\":\"END\"}],"
+ "\"edges\":[{\"from\":\"start\",\"to\":\"split1\"},"
+ "{\"from\":\"split1\",\"to\":\"n1\"},"
+ "{\"from\":\"split1\",\"to\":\"end\",\"name\":\"直通\"},"
+ "{\"from\":\"n1\",\"to\":\"end\",\"name\":\"分支出口\"},"
+ "{\"from\":\"n1\",\"to\":\"join1\",\"name\":\"到汇聚\"},"
+ "{\"from\":\"join1\",\"to\":\"end\"}]}";
BizException e = assertThrows(BizException.class,
() -> DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb"))));
assertTrue(e.getMessage().contains("跨分支连线"));
}
@Test
void missingEnd_fails() {
String json = "{"
@@ -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);
}
@@ -17,7 +17,7 @@ import com.yunhai.flowcenter.repository.FlwDefRepository;
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
import com.yunhai.flowcenter.repository.FlwTaskRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.openapi.client.OpenApiUserClient;
import com.yunhai.platform.openapi.base.OpenApiUserClient;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import org.junit.jupiter.api.BeforeEach;
@@ -102,7 +102,9 @@ class FlowDiagramServiceTest {
FlowEngine engine = new FlowEngine(new ObjectMapper(),
taskRepository, mock(FlwBranchRepository.class), mock(AssigneeResolver.class),
mock(ApplicationEventPublisher.class));
service = new FlowDiagramService(defRepository, mapRepository, engine, userClient);
service = new FlowDiagramService(defRepository, mapRepository,
mock(com.yunhai.flowcenter.repository.FlwInstanceRepository.class),
taskRepository, engine, userClient);
}
private FlwDef def(String json) {
@@ -2,8 +2,8 @@ package com.yunhai.flowcenter.service;
import com.yunhai.flowcenter.entity.FlwAssigneeMap;
import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository;
import com.yunhai.platform.openapi.client.OpenApiGroupClient;
import com.yunhai.platform.openapi.client.OpenApiUserClient;
import com.yunhai.platform.openapi.base.OpenApiGroupClient;
import com.yunhai.platform.openapi.base.OpenApiUserClient;
import com.yunhai.platform.utils.model.BoaGroup;
import com.yunhai.platform.utils.model.BoaUser;
import org.junit.jupiter.api.BeforeEach;
@@ -1,11 +1,11 @@
package com.yunhai.flowcenter.stub;
import com.yunhai.platform.openapi.client.OpenApiUserClient;
import com.yunhai.platform.openapi.model.SysUserGroupDeleteItem;
import com.yunhai.platform.openapi.model.SysUserGroupStateRequest;
import com.yunhai.platform.openapi.model.SysUserMoveRequest;
import com.yunhai.platform.openapi.model.SysUserRequest;
import com.yunhai.platform.openapi.model.SysUserTransferSchoolRequest;
import com.yunhai.platform.openapi.base.OpenApiUserClient;
import com.yunhai.platform.openapi.base.model.SysUserGroupDeleteItem;
import com.yunhai.platform.openapi.base.model.SysUserGroupStateRequest;
import com.yunhai.platform.openapi.base.model.SysUserMoveRequest;
import com.yunhai.platform.openapi.base.model.SysUserRequest;
import com.yunhai.platform.openapi.base.model.SysUserTransferSchoolRequest;
import com.yunhai.platform.utils.model.BoaGroup;
import com.yunhai.platform.utils.model.BoaModule;
import com.yunhai.platform.utils.model.BoaRoleInfo;
@@ -103,4 +103,55 @@ public abstract class FlowTestSupport {
private static String codeOf() {
return "tc_" + UUID.randomUUID().toString().replace("-", "").substring(0, 8);
}
/** 多出口流程:START → n1(deptA,出口"转乙科"→n2 / "通过"→end) → end;n2(deptB) → end */
protected String multiOutletDef(String deptA, String deptB) {
return "{"
+ "\"code\":\"" + codeOf() + "\",\"name\":\"多出口测试流程\",\"start\":\"start\","
+ "\"nodes\":["
+ "{\"id\":\"start\",\"type\":\"START\",\"name\":\"开始\"},"
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"入口审核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptA + "\"}},"
+ "{\"id\":\"n2\",\"type\":\"APPROVAL\",\"name\":\"复核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptB + "\"}},"
+ "{\"id\":\"end\",\"type\":\"END\"}"
+ "],"
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},"
+ "{\"from\":\"n1\",\"to\":\"n2\",\"name\":\"转乙科\"},"
+ "{\"from\":\"n1\",\"to\":\"end\",\"name\":\"通过\"},"
+ "{\"from\":\"n2\",\"to\":\"end\"}],"
+ "\"settings\":{\"rejectPolicy\":\"END_REJECTED\"}}";
}
/** 或签流程:START → n1(deptKey, ANY 或签) → END */
protected String anyCountersignDef(String deptKey) {
return "{"
+ "\"code\":\"" + codeOf() + "\",\"name\":\"夡签测试流程\",\"start\":\"start\","
+ "\"nodes\":["
+ "{\"id\":\"start\",\"type\":\"START\",\"name\":\"开始\"},"
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"任一人审批\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptKey + "\"},\"countersign\":\"ANY\"},"
+ "{\"id\":\"end\",\"type\":\"END\"}"
+ "],"
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"end\"}],"
+ "\"settings\":{\"rejectPolicy\":\"END_REJECTED\"}}";
}
/** 主线带并行段:START → n1(deptA) → split → {ta(ka), tb(kb)} → join → n3(deptA) → END(退回重入用) */
protected String mainlineParallelTailDef(String deptA, String deptMain) {
return "{"
+ "\"code\":\"" + codeOf() + "\",\"name\":\"主线并行尾验流程\",\"start\":\"start\","
+ "\"nodes\":["
+ "{\"id\":\"start\",\"type\":\"START\",\"name\":\"开始\"},"
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"入口\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptMain + "\"}},"
+ "{\"id\":\"split1\",\"type\":\"PARALLEL_SPLIT\"},"
+ "{\"id\":\"ta\",\"type\":\"APPROVAL\",\"name\":\"甲科审核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ka\"}},"
+ "{\"id\":\"tb\",\"type\":\"APPROVAL\",\"name\":\"乙科审核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"kb\"}},"
+ "{\"id\":\"join1\",\"type\":\"PARALLEL_JOIN\",\"waitFor\":\"ALL\"},"
+ "{\"id\":\"n3\",\"type\":\"APPROVAL\",\"name\":\"出口复核\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + deptA + "\"}},"
+ "{\"id\":\"end\",\"type\":\"END\"}"
+ "],"
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"split1\"},"
+ "{\"from\":\"split1\",\"to\":\"ta\"},{\"from\":\"split1\",\"to\":\"tb\"},"
+ "{\"from\":\"ta\",\"to\":\"join1\"},{\"from\":\"tb\",\"to\":\"join1\"},"
+ "{\"from\":\"join1\",\"to\":\"n3\"},{\"from\":\"n3\",\"to\":\"end\"}],"
+ "\"settings\":{\"rejectPolicy\":\"END_REJECTED\"}}";
}
}