Files
flow-center-service/src/test/java/com/yunhai/flowcenter/engine/FlowEngineIT.java
T
chenxing a06b610d9d
build / build-push (push) Successful in 54s
feat(dept): 科室管理——flw_dept/flw_dept_member 组+成员模型取代单用户映射行(议题3,拍板D1选B)
- deptKey=本地自增 id;source_group_id 幂等(一键拉取=初始化/刷新语义,不重复建组);不做存量迁移
- 四处读源全部切新表:AssigneeResolver(宽容口径:deptKey 不存在/停用返回空,图示/轨迹不炸)、
  enabledDeptKeys 发布校验白名单、DeptLabelSupport 唯一出口(图示科室视图+轨迹富化)、编辑器 deptList 聚合
- /admin/dept/* 全套端点(CRUD/成员/拉取预览导入);旧 /map/* 映射 CRUD 端点删除
  (保留 /map/lookupUser 选人检索与 /user/teacherTree);flw_assignee_map 实体/表保留不删
- IT 种子切源(FlowTestSupport tag→deptKey 注册表,用例主体零改动);全量 191 绿;E2E 22/22
  (local-debug/four-topics-e-e2e.py,含切源后果验证与生产盘点 SQL)
2026-10-10 19:47:58 +08:00

786 lines
44 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<FlwTask> 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<FlwTask> 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<FlwTask> 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<FlwTask> 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<java.util.Map<String, Object>> trace =
queryService.detail(SCH, instanceId, null).getTrace();
java.util.List<String> kinds = new java.util.ArrayList<>();
for (java.util.Map<String, Object> 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<com.yunhai.flowcenter.model.FlowModels.TaskItem>
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<FlwTask> 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<FlwBranch> 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<FlwTask> 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<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 = 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<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());
}
/**
* 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<com.yunhai.flowcenter.entity.FlwCc> 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<java.util.Map<String, Object>> trace =
queryService.detail(SCH, instanceId, null).getTrace();
java.util.List<String> kinds = new java.util.ArrayList<>();
for (java.util.Map<String, Object> 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<com.yunhai.flowcenter.model.FlowModels.CcItem>
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<FlwBranch> 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<String, Integer> 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<FlwTask> 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<com.yunhai.flowcenter.entity.FlwRouteLog> logs =
routeLogRepository.findByInstanceIdOrderByIdAsc(instanceId);
assertEquals(1, logs.size());
assertEquals("甲线", logs.get(0).getOutlet());
assertEquals(1, logs.get(0).getAttempt());
java.util.List<java.util.Map<String, Object>> trace =
queryService.detail(SCH, instanceId, null).getTrace();
java.util.List<String> kinds = new java.util.ArrayList<>();
for (java.util.Map<String, Object> 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<com.yunhai.flowcenter.entity.FlwRouteLog> 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<FlwTask> 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<com.yunhai.flowcenter.entity.FlwRouteLog> 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<com.yunhai.flowcenter.entity.FlwRouteLog> 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<FlwBranch> branchRows(Long instanceId) {
return branchRepository.findByInstanceId(instanceId);
}
@Autowired
private com.yunhai.flowcenter.repository.FlwBranchRepository branchRepository;
/**
* 审批任务行(剔除 6/7 留痕事件行,2026-09-18 起"发起/重新提交/撤回"也落 flw_task 行):
* 只关心审批动作的断言(按序号取值、按数量计数)必须走这个口,否则会取到事件行。
*/
private List<FlwTask> 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<FlwTask> traceRows(Long instanceId) {
return taskRepository.findByInstanceIdOrderByIdAsc(instanceId);
}
private List<FlwTask> tasksOf(Long instanceId, Integer assigneeId) {
List<FlwTask> out = new java.util.ArrayList<>();
for (FlwTask t : taskRepository.findByInstanceIdAndStatus(instanceId, 0)) {
if (assigneeId.equals(t.getAssigneeId())) {
out.add(t);
}
}
return out;
}
}