diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/manager/NodeStrategyManager.java b/flow-engine-framework/src/main/java/com/codingapi/flow/manager/NodeStrategyManager.java
index d4874d46..05b0765a 100644
--- a/flow-engine-framework/src/main/java/com/codingapi/flow/manager/NodeStrategyManager.java
+++ b/flow-engine-framework/src/main/java/com/codingapi/flow/manager/NodeStrategyManager.java
@@ -171,6 +171,19 @@ public float getMultiOperatorAuditMergePercent() {
return 0;
}
+ /**
+ * 提交人与审批人一致时是否自动审批
+ *
+ *
对应 {@link SameOperatorAuditStrategy.Type#AUTO_PASS}:
+ * 当前节点的审批人为流程提交人本人时,自动通过该节点(无需本人审批,issue #224)。
+ *
+ * @return true 表示提交人与审批人相同时自动通过当前节点
+ */
+ public boolean isSameOperatorAutoPass() {
+ SameOperatorAuditStrategy strategy = getStrategy(SameOperatorAuditStrategy.class);
+ return strategy != null && strategy.getType() == SameOperatorAuditStrategy.Type.AUTO_PASS;
+ }
+
public void verifyNode(FlowForm form) {
for (INodeStrategy strategy : strategies) {
strategy.verifyNode(form);
diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/mock/repository/FlowTodoMergeRepositoryMockImpl.java b/flow-engine-framework/src/main/java/com/codingapi/flow/mock/repository/FlowTodoMergeRepositoryMockImpl.java
index b2b041da..80c87449 100644
--- a/flow-engine-framework/src/main/java/com/codingapi/flow/mock/repository/FlowTodoMergeRepositoryMockImpl.java
+++ b/flow-engine-framework/src/main/java/com/codingapi/flow/mock/repository/FlowTodoMergeRepositoryMockImpl.java
@@ -42,6 +42,13 @@ public List findByTodoId(long todoId) {
.toList();
}
+ @Override
+ public List findByTodoIds(List todoIds) {
+ return cache.values().stream().
+ filter(relation -> todoIds.contains(relation.getTodoId()))
+ .toList();
+ }
+
public List findAll() {
return cache.values().stream().toList();
diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/node/BaseAuditNode.java b/flow-engine-framework/src/main/java/com/codingapi/flow/node/BaseAuditNode.java
index 1d5591ee..10a15ca1 100644
--- a/flow-engine-framework/src/main/java/com/codingapi/flow/node/BaseAuditNode.java
+++ b/flow-engine-framework/src/main/java/com/codingapi/flow/node/BaseAuditNode.java
@@ -178,6 +178,22 @@ private List generateCurrentRecords(FlowSession session, Set
}
}
List operators = operatorManager.getOperators();
+ // 提交人与审批人一致时自动审批(issue #224):提交人流转到该节点且审批人含提交人本人时,
+ // 过滤掉与提交人一致的操作员,避免本人审批本人提交的节点(AUTO_PASS 自动通过 / MANUAL_PASS 不跳过)。
+ // 守卫条件(currentOperator == submitOperator)用于区分正常流转与加签等"为他人新增记录"的调用路径:
+ // 加签(AddAuditAction)通过 updateSession(加签目标) 构造会话,currentOperator 为被加签人而非提交人。
+ if (nodeStrategyManager.isSameOperatorAutoPass()
+ && session.getCurrentOperator() != null
+ && session.getCurrentOperator().getUserId() == session.getSubmitOperatorId()) {
+ long submitOperatorId = session.getSubmitOperatorId();
+ operators = operators.stream()
+ .filter(operator -> operator.getUserId() != submitOperatorId)
+ .toList();
+ // 全部审批人均与提交人一致,当前节点自动通过,继续向后续节点生成记录
+ if (operators.isEmpty()) {
+ return this.generateNextNodeRecords(session);
+ }
+ }
for (int order = 0; order < operators.size(); order++) {
IFlowOperator operator = operators.get(order);
FlowRecord flowRecord = new FlowRecord(session.updateSession(operator), order);
@@ -213,6 +229,34 @@ private List generateCurrentRecords(FlowSession session, Set
}
+ /**
+ * 当前节点自动通过(如审批人均为提交人本人且配置相同人员自动审批),
+ * 继续向后续节点生成流程记录。
+ *
+ * 与 {@link com.codingapi.flow.action.BaseAction#triggerNode} 的节点遍历语义一致:
+ * 控制节点(条件/并行等)递归深入,业务节点直接生成记录(issue #224)。
+ *
+ * @param session 当前会话(currentNode 为自动通过的节点)
+ * @return 后续节点生成的流程记录,可能为空
+ */
+ private List generateNextNodeRecords(FlowSession session) {
+ List nextNodes = session.matchNextNodes();
+ if (nextNodes == null || nextNodes.isEmpty()) {
+ return new ArrayList<>();
+ }
+ List records = new ArrayList<>();
+ for (IFlowNode node : nextNodes) {
+ FlowSession nextSession = session.updateSession(node);
+ if (node.handle(nextSession)) {
+ records.addAll(this.generateNextNodeRecords(nextSession));
+ } else {
+ records.addAll(node.generateCurrentRecords(nextSession));
+ }
+ }
+ return records;
+ }
+
+
@SneakyThrows
public static T formMap(Map map, Class clazz) {
T node = BaseFlowNode.fromMap(map, clazz);
diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/node/BaseFlowNode.java b/flow-engine-framework/src/main/java/com/codingapi/flow/node/BaseFlowNode.java
index 3ce2a0e1..7ee46a4c 100644
--- a/flow-engine-framework/src/main/java/com/codingapi/flow/node/BaseFlowNode.java
+++ b/flow-engine-framework/src/main/java/com/codingapi/flow/node/BaseFlowNode.java
@@ -77,6 +77,10 @@ public abstract class BaseFlowNode implements IFlowNode {
/**
* 节点策略
*
+ * 节点默认持有完整策略集(如 {@link ApprovalNode} 的默认集),自定义/反序列化传入的策略按类型合并:
+ * 类型已存在则覆盖其配置,类型不存在则追加保留——保证显式配置的策略(如
+ * {@link com.codingapi.flow.strategy.node.SameOperatorAuditStrategy},未注册为默认)能真正生效(配置驱动)。
+ *
* @param strategies 节点策略
*/
public void setStrategies(List strategies) {
@@ -87,6 +91,8 @@ public void setStrategies(List strategies) {
INodeStrategy currentStrategy = nodeStrategyManager.getStrategy(nodeStrategy.getClass());
if (currentStrategy != null) {
currentStrategy.copy(nodeStrategy);
+ } else {
+ this.strategies.add(nodeStrategy);
}
}
}else {
diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/node/nodes/ApprovalNode.java b/flow-engine-framework/src/main/java/com/codingapi/flow/node/nodes/ApprovalNode.java
index b7d754dd..82f3b1c5 100644
--- a/flow-engine-framework/src/main/java/com/codingapi/flow/node/nodes/ApprovalNode.java
+++ b/flow-engine-framework/src/main/java/com/codingapi/flow/node/nodes/ApprovalNode.java
@@ -43,7 +43,8 @@ private static List defaultStrategies() {
List strategies = new ArrayList<>();
strategies.add(TimeoutStrategy.defaultStrategy());
strategies.add(MultiOperatorAuditStrategy.defaultStrategy());
- strategies.add(SameOperatorAuditStrategy.defaultStrategy());
+ // 相同人员审批(SameOperatorAuditStrategy)不设默认:配置驱动,显式配置才生效(issue #224)。
+ // 若默认 AUTO_PASS 会令所有"提交人==审批人"节点被自动跳过,改变未配置流程的传统行为。
strategies.add(RecordMergeStrategy.defaultStrategy());
strategies.add(ResubmitStrategy.defaultStrategy());
strategies.add(AdviceStrategy.defaultStrategy());
diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/node/nodes/HandleNode.java b/flow-engine-framework/src/main/java/com/codingapi/flow/node/nodes/HandleNode.java
index 43317827..86b4850f 100644
--- a/flow-engine-framework/src/main/java/com/codingapi/flow/node/nodes/HandleNode.java
+++ b/flow-engine-framework/src/main/java/com/codingapi/flow/node/nodes/HandleNode.java
@@ -43,7 +43,8 @@ private static List defaultStrategies() {
List strategies = new ArrayList<>();
strategies.add(TimeoutStrategy.defaultStrategy());
strategies.add(MultiOperatorAuditStrategy.defaultStrategy());
- strategies.add(SameOperatorAuditStrategy.defaultStrategy());
+ // 相同人员审批(SameOperatorAuditStrategy)不设默认:配置驱动,显式配置才生效(issue #224)。
+ // 若默认 AUTO_PASS 会令所有"提交人==办理人"节点被自动跳过,改变未配置流程的传统行为。
strategies.add(RecordMergeStrategy.defaultStrategy());
strategies.add(ResubmitStrategy.defaultStrategy());
strategies.add(AdviceStrategy.defaultStrategy());
diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/pojo/response/FlowContent.java b/flow-engine-framework/src/main/java/com/codingapi/flow/pojo/response/FlowContent.java
index 62309c4d..4dafa851 100644
--- a/flow-engine-framework/src/main/java/com/codingapi/flow/pojo/response/FlowContent.java
+++ b/flow-engine-framework/src/main/java/com/codingapi/flow/pojo/response/FlowContent.java
@@ -260,7 +260,7 @@ public void pushRecords(FlowRecord record, List mergeRecords) {
body.setNodeName(item.getNodeName());
body.setNodeType(item.getNodeType());
body.setSubmitOperator(new FlowOperator(item.getSubmitOperatorId(), item.getSubmitOperatorName()));
- body.setCreatedOperator(new FlowOperator(record.getCreateOperatorId(), record.getCreateOperatorName()));
+ body.setCreatedOperator(new FlowOperator(item.getCreateOperatorId(), item.getCreateOperatorName()));
body.setTitle(item.getTitle());
body.setData(item.getFormData());
body.setRecordState(item.getRecordState());
diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/repository/FlowTodoMergeRepository.java b/flow-engine-framework/src/main/java/com/codingapi/flow/repository/FlowTodoMergeRepository.java
index f43e0ebf..44b78da4 100644
--- a/flow-engine-framework/src/main/java/com/codingapi/flow/repository/FlowTodoMergeRepository.java
+++ b/flow-engine-framework/src/main/java/com/codingapi/flow/repository/FlowTodoMergeRepository.java
@@ -12,4 +12,11 @@ public interface FlowTodoMergeRepository {
List findByTodoId(long todoId);
+ /**
+ * 按待办id批量查询合并关系
+ * @param todoIds 待办id列表
+ * @return 合并关系列表
+ */
+ List findByTodoIds(List todoIds);
+
}
diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/service/FlowRecordSaveService.java b/flow-engine-framework/src/main/java/com/codingapi/flow/service/FlowRecordSaveService.java
index 194444e9..071e242a 100644
--- a/flow-engine-framework/src/main/java/com/codingapi/flow/service/FlowRecordSaveService.java
+++ b/flow-engine-framework/src/main/java/com/codingapi/flow/service/FlowRecordSaveService.java
@@ -9,9 +9,11 @@
import java.util.ArrayList;
import java.util.HashMap;
+import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
+import java.util.Set;
/**
* 流程记录保存服务,负责保存流程记录和待办记录的合并关系
@@ -65,6 +67,22 @@ private void saveTodoMargeRecords() {
}
}
+ // 幂等登记:预加载已存在待办的合并关系,用于判断某条流程记录是否已登记过,
+ // 避免已读/详情等场景重复保存时反复新增合并关系、膨胀合并计数(issue #223)
+ List existedTodoIds = existedByKey.values().stream()
+ .map(FlowTodoRecord::getId)
+ .toList();
+ Map> recordIdsByTodoId = new HashMap<>();
+ if (!existedTodoIds.isEmpty()) {
+ for (FlowTodoMerge relation : flowTodoMergeRepository.findByTodoIds(existedTodoIds)) {
+ recordIdsByTodoId.computeIfAbsent(relation.getTodoId(), k -> new HashSet<>())
+ .add(relation.getRecordId());
+ }
+ }
+
+ // 需要新增的合并关系:todoKey -> 流程记录id(新建待办或该流程记录首次登记时产生)
+ Map> relationCandidates = new HashMap<>();
+
List flowTodoRecords = new ArrayList<>();
for (FlowRecord flowRecord : flowRecords) {
if (flowRecord.isTodo()) {
@@ -72,10 +90,20 @@ private void saveTodoMargeRecords() {
if (todoMargeRecord == null) {
todoMargeRecord = new FlowTodoRecord(flowRecord);
existedByKey.put(flowRecord.getTodoKey(), todoMargeRecord);
+ if (flowRecord.isMergeable()) {
+ relationCandidates
+ .computeIfAbsent(flowRecord.getTodoKey(), k -> new ArrayList<>())
+ .add(flowRecord.getId());
+ }
} else {
todoMargeRecord.update(flowRecord);
- if (flowRecord.isMergeable()) {
+ if (flowRecord.isMergeable()
+ && !recordIdsByTodoId.getOrDefault(todoMargeRecord.getId(), Set.of())
+ .contains(flowRecord.getId())) {
todoMargeRecord.addMergeCount();
+ relationCandidates
+ .computeIfAbsent(flowRecord.getTodoKey(), k -> new ArrayList<>())
+ .add(flowRecord.getId());
}
}
flowTodoRecords.add(todoMargeRecord);
@@ -85,11 +113,13 @@ private void saveTodoMargeRecords() {
flowTodoRecordRepository.saveAll(flowTodoRecords);
}
- if (!flowTodoRecords.isEmpty()) {
+ if (!relationCandidates.isEmpty()) {
List relationList = new ArrayList<>();
- for (FlowTodoRecord margeRecord : flowTodoRecords) {
- if (margeRecord.isMergeable()) {
- relationList.add(new FlowTodoMerge(margeRecord));
+ for (Map.Entry> entry : relationCandidates.entrySet()) {
+ FlowTodoRecord todoMargeRecord = existedByKey.get(entry.getKey());
+ for (long recordId : entry.getValue()) {
+ relationList.add(new FlowTodoMerge(0L, todoMargeRecord.getId(), recordId,
+ todoMargeRecord.getCreateTime()));
}
}
flowTodoMergeRepository.saveAll(relationList);
diff --git a/flow-engine-framework/src/test/java/com/codingapi/flow/repository/FlowTodoMergeRepositoryImpl.java b/flow-engine-framework/src/test/java/com/codingapi/flow/repository/FlowTodoMergeRepositoryImpl.java
index 45ce65a8..3d5d7c49 100644
--- a/flow-engine-framework/src/test/java/com/codingapi/flow/repository/FlowTodoMergeRepositoryImpl.java
+++ b/flow-engine-framework/src/test/java/com/codingapi/flow/repository/FlowTodoMergeRepositoryImpl.java
@@ -41,6 +41,13 @@ public List findByTodoId(long todoId) {
.toList();
}
+ @Override
+ public List findByTodoIds(List todoIds) {
+ return cache.values().stream().
+ filter(relation -> todoIds.contains(relation.getTodoId()))
+ .toList();
+ }
+
public List findAll() {
return cache.values().stream().toList();
diff --git a/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowIssue224SameOperatorAuditMultiModeTest.java b/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowIssue224SameOperatorAuditMultiModeTest.java
new file mode 100644
index 00000000..cd393e43
--- /dev/null
+++ b/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowIssue224SameOperatorAuditMultiModeTest.java
@@ -0,0 +1,446 @@
+package com.codingapi.flow.service;
+
+import com.codingapi.flow.builder.NodeStrategyBuilder;
+import com.codingapi.flow.context.GatewayContext;
+import com.codingapi.flow.factory.MyFlowServiceFactory;
+import com.codingapi.flow.form.DataType;
+import com.codingapi.flow.form.FlowForm;
+import com.codingapi.flow.form.FlowFormBuilder;
+import com.codingapi.flow.node.IFlowNode;
+import com.codingapi.flow.node.nodes.ApprovalNode;
+import com.codingapi.flow.node.nodes.EndNode;
+import com.codingapi.flow.node.nodes.StartNode;
+import com.codingapi.flow.pojo.body.FlowAdviceBody;
+import com.codingapi.flow.pojo.request.FlowActionRequest;
+import com.codingapi.flow.pojo.request.FlowCreateRequest;
+import com.codingapi.flow.record.FlowRecord;
+import com.codingapi.flow.script.factory.FlowGroovyScriptFactory;
+import com.codingapi.flow.strategy.node.MultiOperatorAuditStrategy;
+import com.codingapi.flow.strategy.node.OperatorLoadStrategy;
+import com.codingapi.flow.strategy.node.SameOperatorAuditStrategy;
+import com.codingapi.flow.user.User;
+import com.codingapi.flow.workflow.Workflow;
+import com.codingapi.flow.workflow.WorkflowBuilder;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * issue #224:相同人员自动审批 × 四种多人审批模式
+ *
+ * 流程:A(开始) -> B(多人审批) -> C(c1 审批) -> D(结束),发起人 a 提交,B 节点审批人 a、b、c。
+ *
+ *
验证:B 节点配置 {@link SameOperatorAuditStrategy.Type#AUTO_PASS}(提交人与审批人一致自动通过)
+ * 时,发起人 a 在四种多人审批模式下的行为:
+ *
+ * - SEQUENCE(依次审批)—— a 被自动跳过,剩余审批人依次办理;
+ * - MERGE(会签,比例 1.0)—— a 被自动跳过,剩余审批人全部审批通过后节点完成;
+ * - ANY(任意审批,或签)—— a 被自动跳过,剩余审批人任一办理即通过;
+ * - RANDOM_ONE(任意一人)—— a 被自动跳过,剩余审批人中随机一人办理。
+ *
+ *
+ * RANDOM_ONE 存在随机性:多轮循环创建流程,每轮断言「a 无待办、b/c 中恰有一人收到待办」,
+ * 并统计被选中人员,最后断言 b、c 均被选中过(随机确实在两名候选人间切换)。
+ */
+class FlowIssue224SameOperatorAuditMultiModeTest {
+
+ private final MyFlowServiceFactory factory = new MyFlowServiceFactory();
+
+ // 人员:a=1 发起人/提交人,b=2 / c=3 B 节点其余审批人,c1=4 C 节点审批人
+ private static final long A = 1L;
+ private static final long B = 2L;
+ private static final long C = 3L;
+ private static final long C1 = 4L;
+
+ // ==================== 基础工具方法 ====================
+
+ private void registerUsers(User... users) {
+ for (User user : users) {
+ factory.userGateway.save(user);
+ }
+ GatewayContext.getInstance().setFlowOperatorGateway(factory.userGateway);
+ }
+
+ private FlowForm form() {
+ return FlowFormBuilder.builder()
+ .name("请假流程")
+ .code("leave")
+ .addField("请假人", "name", DataType.STRING)
+ .addField("请假天数", "days", DataType.INTEGER)
+ .build();
+ }
+
+ private Map data() {
+ return Map.of("name", "lorne", "days", 1);
+ }
+
+ private StartNode startNode() {
+ return StartNode.builder().build();
+ }
+
+ /**
+ * 构建审批节点:审批人脚本 + 相同人员自动审批 + 多人审批模式
+ *
+ * @param name 节点名称
+ * @param operatorIds 审批人脚本返回值,如 "[1,2,3]"
+ * @param sameOperator 相同人员审批策略(AUTO_PASS 自动通过)
+ * @param auditStrategy 多人审批模式(SEQUENCE / MERGE / ANY / RANDOM_ONE)
+ */
+ private ApprovalNode approvalNode(String name, String operatorIds,
+ SameOperatorAuditStrategy sameOperator,
+ MultiOperatorAuditStrategy auditStrategy) {
+ NodeStrategyBuilder strategyBuilder = NodeStrategyBuilder.builder()
+ .addStrategy(sameOperator)
+ .addStrategy(new OperatorLoadStrategy(
+ FlowGroovyScriptFactory.createOperatorLoadScript("def run(request){return " + operatorIds + "}").getKey()));
+ if (auditStrategy != null) {
+ strategyBuilder.addStrategy(auditStrategy);
+ }
+ return ApprovalNode.builder()
+ .name(name)
+ .strategies(strategyBuilder.build())
+ .build();
+ }
+
+ /**
+ * 保存流程(多轮循环时传唯一 code,避免版本冲突)
+ */
+ private Workflow saveWorkflow(User creator, String code, IFlowNode... nodes) {
+ WorkflowBuilder builder = WorkflowBuilder.builder()
+ .title("审批流程")
+ .code(code)
+ .createdOperator(creator)
+ .form(form());
+ for (IFlowNode node : nodes) {
+ builder.addNode(node);
+ }
+ Workflow workflow = builder.build();
+ factory.workflowService.saveWorkflow(workflow);
+ return workflow;
+ }
+
+ /**
+ * 发起人提交开始节点(直接通过)
+ */
+ private void submitStart(Workflow workflow, StartNode startNode, User user, Map data) {
+ FlowCreateRequest createRequest = new FlowCreateRequest();
+ createRequest.setWorkCode(workflow.getCode());
+ createRequest.setFormData(data);
+ createRequest.setActionId(startNode.actionManager().getActions().get(0).id());
+ createRequest.setOperatorId(user.getUserId());
+ factory.flowService.create(createRequest);
+
+ FlowRecord todo = todoOf(user);
+ pass(todo, startNode, user, data);
+ }
+
+ private void pass(FlowRecord record, IFlowNode node, User operator, Map data) {
+ FlowActionRequest request = new FlowActionRequest();
+ request.setFormData(data);
+ request.setRecordId(record.getId());
+ request.setAdvice(new FlowAdviceBody(node.actionManager().getActions().get(0).id(), "同意", operator.getUserId()));
+ factory.flowService.action(request);
+ }
+
+ private FlowRecord todoOf(User user) {
+ List list = factory.flowRecordRepository.findTodoByOperator(user.getUserId());
+ assertEquals(1, list.size(), user.getName() + " 应有且仅有一条待办");
+ return list.get(0);
+ }
+
+ private List todosOf(User user) {
+ return factory.flowRecordRepository.findTodoByOperator(user.getUserId());
+ }
+
+ private void assertNoTodo(User user) {
+ assertEquals(0, factory.flowRecordRepository.findTodoByOperator(user.getUserId()).size(),
+ user.getName() + " 不应有待办");
+ }
+
+ private List processRecords(FlowRecord anyRecord) {
+ return factory.flowRecordRepository.findProcessRecords(anyRecord.getProcessId());
+ }
+
+ private List recordsOfNode(List records, IFlowNode node) {
+ return records.stream().filter(r -> r.getNodeId().equals(node.getId())).toList();
+ }
+
+ // ==================== 场景装配:A - B(四人审批) - C(c1) - D ====================
+
+ /**
+ * 构建标准流程:发起人 a、B 节点审批人 a/b/c(配置指定多人模式 + 自动通过)、C 节点 c1 审批。
+ * startNode 必须由调用方创建并复用(工作流内节点与提交用的节点为同一实例,
+ * 动作 id 才一致——不同实例的动作 id 不同,create 按 id 查不到动作)。
+ */
+ private Workflow buildWorkflow(String code, User a, StartNode startNode,
+ ApprovalNode bNode, ApprovalNode cNode, EndNode endNode) {
+ return saveWorkflow(a, code, startNode, bNode, cNode, endNode);
+ }
+
+ private ApprovalNode bNode(String mode) {
+ MultiOperatorAuditStrategy.Type type = switch (mode) {
+ case "SEQUENCE" -> MultiOperatorAuditStrategy.Type.SEQUENCE;
+ case "MERGE" -> MultiOperatorAuditStrategy.Type.MERGE;
+ case "ANY" -> MultiOperatorAuditStrategy.Type.ANY;
+ case "RANDOM_ONE" -> MultiOperatorAuditStrategy.Type.RANDOM_ONE;
+ default -> throw new IllegalArgumentException("未知模式: " + mode);
+ };
+ return approvalNode("B审批", "[1,2,3]",
+ new SameOperatorAuditStrategy(SameOperatorAuditStrategy.Type.AUTO_PASS),
+ new MultiOperatorAuditStrategy(type, type == MultiOperatorAuditStrategy.Type.MERGE ? 1.0f : 0));
+ }
+
+ private ApprovalNode cNode() {
+ return approvalNode("C审批", "[4]",
+ new SameOperatorAuditStrategy(SameOperatorAuditStrategy.Type.AUTO_PASS), null);
+ }
+
+ // ==================== 依次审批(SEQUENCE)====================
+
+ /**
+ * SEQUENCE:B 节点审批人 a、b、c 依次审批 + 自动通过。
+ *
a 作为提交人被自动跳过;b 收到待办(c 记录隐藏),b 审批后激活 c,c 审批后到 C 节点。
+ */
+ @Test
+ void autoPassWithSequenceShouldSkipInitiatorAndKeepRemainingOrder() {
+ User a = new User(A, "a");
+ User b = new User(B, "b");
+ User c = new User(C, "c");
+ User c1 = new User(C1, "c1");
+ registerUsers(a, b, c, c1);
+
+ ApprovalNode bNode = bNode("SEQUENCE");
+ ApprovalNode cNode = cNode();
+ EndNode endNode = EndNode.builder().build();
+
+ StartNode startNode = startNode();
+ Workflow workflow = buildWorkflow("sequence", a, startNode, bNode, cNode, endNode);
+ submitStart(workflow, startNode, a, data());
+
+ // a 作为提交人被自动跳过:a 无待办;b 收到待办(依次首位),c 记录隐藏
+ assertNoTodo(a);
+ FlowRecord bTodo = todoOf(b);
+ assertNoTodo(c);
+ assertEquals(bNode.getId(), bTodo.getNodeId(), "b 的待办应落在 B 审批节点");
+
+ // B 节点仅产生 b 一条记录(a 被跳过、c 隐藏),可见记录为 开始 + b 共 2 条
+ List records = processRecords(bTodo);
+ assertEquals(2, records.size(), "依次审批下 a 被自动跳过,B 节点应仅 b 一条可见记录");
+ assertEquals(1, recordsOfNode(records, bNode).size());
+
+ // b 审批 -> 激活 c:c 收到待办,流程未到 C
+ pass(bTodo, bNode, b, data());
+ FlowRecord cTodo = todoOf(c);
+ assertNoTodo(b);
+ assertNoTodo(c1);
+
+ // c 审批 -> 到 C 节点:c1 收到待办
+ pass(cTodo, bNode, c, data());
+ FlowRecord c1Todo = todoOf(c1);
+ assertEquals(cNode.getId(), c1Todo.getNodeId(), "c 审批后应流转到 C 审批节点");
+
+ // B 节点 a、b、c 中仅 b、c 产生记录(a 被跳过),两条均为已办
+ records = processRecords(c1Todo);
+ assertEquals(2, recordsOfNode(records, bNode).size(), "B 节点应产生 b、c 两条记录,a 被跳过");
+ assertEquals(2, recordsOfNode(records, bNode).stream().filter(FlowRecord::isDone).count());
+
+ // c1 审批 -> 流程结束
+ pass(c1Todo, cNode, c1, data());
+ records = processRecords(c1Todo);
+ assertEquals(4, records.size());
+ assertTrue(records.stream().allMatch(FlowRecord::isFinish), "流程结束后全部记录应为完成状态");
+ assertNoTodo(a);
+ assertNoTodo(b);
+ assertNoTodo(c);
+ assertNoTodo(c1);
+ }
+
+ // ==================== 会签(MERGE,比例 1.0)====================
+
+ /**
+ * MERGE:B 节点审批人 a、b、c 会签(比例 1.0)+ 自动通过。
+ * a 作为提交人被自动跳过;b、c 均需审批通过(会签比例 1/2、2/2 分步达成),全部通过后到 C 节点。
+ */
+ @Test
+ void autoPassWithMergeShouldSkipInitiatorAndRequireRemainingAllPass() {
+ User a = new User(A, "a");
+ User b = new User(B, "b");
+ User c = new User(C, "c");
+ User c1 = new User(C1, "c1");
+ registerUsers(a, b, c, c1);
+
+ ApprovalNode bNode = bNode("MERGE");
+ ApprovalNode cNode = cNode();
+ EndNode endNode = EndNode.builder().build();
+
+ StartNode startNode = startNode();
+ Workflow workflow = buildWorkflow("merge", a, startNode, bNode, cNode, endNode);
+ submitStart(workflow, startNode, a, data());
+
+ // a 被自动跳过;b、c 同时收到待办(会签)
+ assertNoTodo(a);
+ FlowRecord bTodo = todoOf(b);
+ FlowRecord cTodo = todoOf(c);
+ assertNoTodo(c1);
+
+ // B 节点产生 b、c 两条记录(a 被跳过)
+ List records = processRecords(bTodo);
+ assertEquals(3, records.size(), "应为 开始 + b、c 两条会签记录 共 3 条");
+ assertEquals(2, recordsOfNode(records, bNode).size(), "B 节点应产生 b、c 两条记录,a 被跳过");
+
+ // b 审批 -> 会签比例 1/2 未达成,流程不向下流转,c 仍待办
+ pass(bTodo, bNode, b, data());
+ assertNoTodo(c1);
+ cTodo = todoOf(c);
+ records = processRecords(cTodo);
+ assertEquals(1, records.stream().filter(FlowRecord::isTodo).count(), "仅 c 一条待办");
+
+ // c 审批 -> 会签 2/2 达成,到 C 节点
+ pass(cTodo, bNode, c, data());
+ FlowRecord c1Todo = todoOf(c1);
+ assertEquals(cNode.getId(), c1Todo.getNodeId(), "会签全部通过后应流转到 C 审批节点");
+
+ records = processRecords(c1Todo);
+ assertEquals(2, recordsOfNode(records, bNode).stream().filter(FlowRecord::isDone).count(),
+ "B 节点 b、c 两条记录均应为已办");
+
+ // c1 审批 -> 流程结束
+ pass(c1Todo, cNode, c1, data());
+ records = processRecords(c1Todo);
+ assertEquals(4, records.size());
+ assertTrue(records.stream().allMatch(FlowRecord::isFinish), "流程结束后全部记录应为完成状态");
+ assertNoTodo(a);
+ assertNoTodo(b);
+ assertNoTodo(c);
+ assertNoTodo(c1);
+ }
+
+ // ==================== 任意审批(ANY,或签)====================
+
+ /**
+ * ANY:B 节点审批人 a、b、c 或签 + 自动通过。
+ * a 作为提交人被自动跳过;b、c 任一办理即通过,另一人自动置为已办,流程到 C 节点。
+ */
+ @Test
+ void autoPassWithAnyShouldSkipInitiatorAndPassOnFirstRemainingApproval() {
+ User a = new User(A, "a");
+ User b = new User(B, "b");
+ User c = new User(C, "c");
+ User c1 = new User(C1, "c1");
+ registerUsers(a, b, c, c1);
+
+ ApprovalNode bNode = bNode("ANY");
+ ApprovalNode cNode = cNode();
+ EndNode endNode = EndNode.builder().build();
+
+ StartNode startNode = startNode();
+ Workflow workflow = buildWorkflow("any", a, startNode, bNode, cNode, endNode);
+ submitStart(workflow, startNode, a, data());
+
+ // a 被自动跳过;b、c 同时收到待办(或签)
+ assertNoTodo(a);
+ FlowRecord bTodo = todoOf(b);
+ todoOf(c);
+ assertNoTodo(c1);
+
+ // B 节点产生 b、c 两条记录(a 被跳过)
+ List records = processRecords(bTodo);
+ assertEquals(3, records.size(), "应为 开始 + b、c 两条或签记录 共 3 条");
+ assertEquals(2, recordsOfNode(records, bNode).size(), "B 节点应产生 b、c 两条记录,a 被跳过");
+
+ // b 办理(任一剩余审批人)-> c 自动置为已办,流程到 C 节点
+ pass(bTodo, bNode, b, data());
+ assertNoTodo(c);
+ FlowRecord c1Todo = todoOf(c1);
+ assertEquals(cNode.getId(), c1Todo.getNodeId(), "或签任一人办理后应流转到 C 审批节点");
+
+ records = processRecords(c1Todo);
+ assertEquals(2, recordsOfNode(records, bNode).stream().filter(FlowRecord::isDone).count(),
+ "或签模式下 b、c 两条记录均应为已办");
+
+ // c1 审批 -> 流程结束
+ pass(c1Todo, cNode, c1, data());
+ records = processRecords(c1Todo);
+ assertEquals(4, records.size());
+ assertTrue(records.stream().allMatch(FlowRecord::isFinish), "流程结束后全部记录应为完成状态");
+ assertNoTodo(a);
+ assertNoTodo(b);
+ assertNoTodo(c);
+ assertNoTodo(c1);
+ }
+
+ // ==================== 任意一人(RANDOM_ONE,多轮随机断言)====================
+
+ /**
+ * RANDOM_ONE:B 节点审批人 a、b、c 随机一人 + 自动通过。
+ * a 作为提交人被自动跳过;b、c 中随机一人收到待办,该人办理后到 C 节点。
+ *
随机性验证:多轮创建流程,每轮断言「a 无待办、b/c 中恰有一人收到待办」,
+ * 统计被选中人次,最后断言 b、c 均被选中过。
+ */
+ @Test
+ void autoPassWithRandomOneShouldSkipInitiatorAndPickOneRandomly() {
+ User a = new User(A, "a");
+ User b = new User(B, "b");
+ User c = new User(C, "c");
+ User c1 = new User(C1, "c1");
+ registerUsers(a, b, c, c1);
+
+ int rounds = 12;
+ int bChosen = 0;
+ int cChosen = 0;
+
+ for (int i = 0; i < rounds; i++) {
+ ApprovalNode bNode = bNode("RANDOM_ONE");
+ ApprovalNode cNode = cNode();
+ EndNode endNode = EndNode.builder().build();
+ StartNode startNode = startNode();
+ Workflow workflow = buildWorkflow("random_" + i, a, startNode, bNode, cNode, endNode);
+ submitStart(workflow, startNode, a, data());
+
+ // a 作为提交人被自动跳过:a 无待办
+ assertNoTodo(a);
+
+ // b、c 中恰有一人收到待办,落在 B 节点
+ List bTodos = todosOf(b);
+ List cTodos = todosOf(c);
+ assertEquals(1, bTodos.size() + cTodos.size(),
+ "第 " + i + " 轮:随机一人模式应仅一名审批人收到待办");
+
+ boolean bChosenThisRound = bTodos.size() == 1;
+ User chosen = bChosenThisRound ? b : c;
+ FlowRecord chosenTodo = bChosenThisRound ? bTodos.get(0) : cTodos.get(0);
+ assertEquals(bNode.getId(), chosenTodo.getNodeId(), "第 " + i + " 轮:待办应落在 B 审批节点");
+ if (bChosenThisRound) {
+ bChosen++;
+ } else {
+ cChosen++;
+ }
+
+ // B 节点仅一条记录(a 被跳过、随机一人)
+ List records = processRecords(chosenTodo);
+ assertEquals(2, records.size(), "第 " + i + " 轮:随机一人模式 B 节点应仅一条记录");
+ assertEquals(1, recordsOfNode(records, bNode).size());
+
+ // 选中的审批人办理 -> 到 C 节点
+ pass(chosenTodo, bNode, chosen, data());
+ FlowRecord c1Todo = todoOf(c1);
+ assertEquals(cNode.getId(), c1Todo.getNodeId(), "第 " + i + " 轮:办理后应流转到 C 审批节点");
+
+ // c1 审批 -> 流程结束
+ pass(c1Todo, cNode, c1, data());
+ records = processRecords(c1Todo);
+ assertEquals(3, records.size());
+ assertTrue(records.stream().allMatch(FlowRecord::isFinish), "第 " + i + " 轮:流程结束后全部记录应为完成状态");
+ assertNoTodo(a);
+ assertNoTodo(c1);
+ }
+
+ // 随机性验证:多轮中 b、c 均被选中过(随机确实在两名候选人间切换,而非固定一人)
+ assertTrue(bChosen > 0, "多轮随机中 b 应至少被选中一次,实际 b=" + bChosen + ", c=" + cChosen);
+ assertTrue(cChosen > 0, "多轮随机中 c 应至少被选中一次,实际 b=" + bChosen + ", c=" + cChosen);
+ }
+}
diff --git a/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowIssue224SameOperatorAuditTest.java b/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowIssue224SameOperatorAuditTest.java
new file mode 100644
index 00000000..462f1fbf
--- /dev/null
+++ b/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowIssue224SameOperatorAuditTest.java
@@ -0,0 +1,301 @@
+package com.codingapi.flow.service;
+
+import com.codingapi.flow.builder.NodeStrategyBuilder;
+import com.codingapi.flow.context.GatewayContext;
+import com.codingapi.flow.factory.MyFlowServiceFactory;
+import com.codingapi.flow.form.DataType;
+import com.codingapi.flow.form.FlowForm;
+import com.codingapi.flow.form.FlowFormBuilder;
+import com.codingapi.flow.node.IFlowNode;
+import com.codingapi.flow.node.nodes.ApprovalNode;
+import com.codingapi.flow.node.nodes.EndNode;
+import com.codingapi.flow.node.nodes.StartNode;
+import com.codingapi.flow.pojo.body.FlowAdviceBody;
+import com.codingapi.flow.pojo.request.FlowActionRequest;
+import com.codingapi.flow.pojo.request.FlowCreateRequest;
+import com.codingapi.flow.record.FlowRecord;
+import com.codingapi.flow.script.factory.FlowGroovyScriptFactory;
+import com.codingapi.flow.strategy.node.MultiOperatorAuditStrategy;
+import com.codingapi.flow.strategy.node.OperatorLoadStrategy;
+import com.codingapi.flow.strategy.node.SameOperatorAuditStrategy;
+import com.codingapi.flow.user.User;
+import com.codingapi.flow.workflow.Workflow;
+import com.codingapi.flow.workflow.WorkflowBuilder;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * issue #224:相同人员提交审批
+ *
+ * 流程:A(开始) -> B(审批) -> C(审批) -> D(结束),发起人 a,C 节点审批人 c。
+ *
+ *
审批节点可配置 {@link SameOperatorAuditStrategy}(提交人与审批人一致时的处理方式):
+ *
+ * - AUTO_PASS —— 提交人与审批人为同一人时,自动通过该节点(无需本人审批);
+ * - MANUAL_PASS —— 即使提交人与审批人相同,仍需手动审批。
+ *
+ *
+ * 场景一:B 节点审批人仅为 a(与提交人一致)+ 相同人员自动审批,a 提交后应自动跳过
+ * B 节点,直达 C 节点由 c 审批。
+ *
+ *
场景二:B 节点审批人为 a、b(提交人 a 位列首位)+ 相同人员自动审批 + 依次审批,
+ * a 提交后应自动跳过 a 本人,B 节点直接转交 b 审批。
+ *
+ *
控制组:B 节点审批人仅为 a + 相同人员手动审批,a 提交后仍需 a 本人审批 B 节点。
+ */
+class FlowIssue224SameOperatorAuditTest {
+
+ private final MyFlowServiceFactory factory = new MyFlowServiceFactory();
+
+ // 人员:a=1 发起人,b=2 B 节点第二审批人,c=3 C 节点审批人
+ private static final long A = 1L;
+ private static final long B = 2L;
+ private static final long C = 3L;
+
+ // ==================== 基础工具方法 ====================
+
+ private void registerUsers(User... users) {
+ for (User user : users) {
+ factory.userGateway.save(user);
+ }
+ GatewayContext.getInstance().setFlowOperatorGateway(factory.userGateway);
+ }
+
+ private FlowForm form() {
+ return FlowFormBuilder.builder()
+ .name("请假流程")
+ .code("leave")
+ .addField("请假人", "name", DataType.STRING)
+ .addField("请假天数", "days", DataType.INTEGER)
+ .build();
+ }
+
+ private Map data() {
+ return Map.of("name", "lorne", "days", 1);
+ }
+
+ private StartNode startNode() {
+ return StartNode.builder().build();
+ }
+
+ /**
+ * 构建审批节点
+ *
+ * @param name 节点名称
+ * @param operatorIds 审批人脚本返回值,如 "[1]" / "[1,2]"
+ * @param sameOperatorType 相同人员审批策略(AUTO_PASS 自动通过 / MANUAL_PASS 手动审批)
+ * @param auditStrategy 多人审批策略,单审批人时传 null
+ */
+ private ApprovalNode approvalNode(String name, String operatorIds,
+ SameOperatorAuditStrategy.Type sameOperatorType,
+ MultiOperatorAuditStrategy auditStrategy) {
+ NodeStrategyBuilder strategyBuilder = NodeStrategyBuilder.builder()
+ .addStrategy(new SameOperatorAuditStrategy(sameOperatorType))
+ .addStrategy(new OperatorLoadStrategy(
+ FlowGroovyScriptFactory.createOperatorLoadScript("def run(request){return " + operatorIds + "}").getKey()));
+ if (auditStrategy != null) {
+ strategyBuilder.addStrategy(auditStrategy);
+ }
+ return ApprovalNode.builder()
+ .name(name)
+ .strategies(strategyBuilder.build())
+ .build();
+ }
+
+ private Workflow saveWorkflow(User creator, IFlowNode... nodes) {
+ WorkflowBuilder builder = WorkflowBuilder.builder()
+ .title("审批流程")
+ .code("leave")
+ .createdOperator(creator)
+ .form(form());
+ for (IFlowNode node : nodes) {
+ builder.addNode(node);
+ }
+ Workflow workflow = builder.build();
+ factory.workflowService.saveWorkflow(workflow);
+ return workflow;
+ }
+
+ /**
+ * 发起人提交开始节点(直接通过)
+ */
+ private void submitStart(Workflow workflow, StartNode startNode, User user, Map data) {
+ FlowCreateRequest createRequest = new FlowCreateRequest();
+ createRequest.setWorkCode(workflow.getCode());
+ createRequest.setFormData(data);
+ createRequest.setActionId(startNode.actionManager().getActions().get(0).id());
+ createRequest.setOperatorId(user.getUserId());
+ factory.flowService.create(createRequest);
+
+ FlowRecord todo = todoOf(user);
+ pass(todo, startNode, user, data);
+ }
+
+ private void pass(FlowRecord record, IFlowNode node, User operator, Map data) {
+ FlowActionRequest request = new FlowActionRequest();
+ request.setFormData(data);
+ request.setRecordId(record.getId());
+ request.setAdvice(new FlowAdviceBody(node.actionManager().getActions().get(0).id(), "同意", operator.getUserId()));
+ factory.flowService.action(request);
+ }
+
+ private FlowRecord todoOf(User user) {
+ List list = factory.flowRecordRepository.findTodoByOperator(user.getUserId());
+ assertEquals(1, list.size(), user.getName() + " 应有且仅有一条待办");
+ return list.get(0);
+ }
+
+ private List todosOf(User user) {
+ return factory.flowRecordRepository.findTodoByOperator(user.getUserId());
+ }
+
+ private void assertNoTodo(User user) {
+ assertEquals(0, factory.flowRecordRepository.findTodoByOperator(user.getUserId()).size(),
+ user.getName() + " 不应有待办");
+ }
+
+ private List processRecords(FlowRecord anyRecord) {
+ return factory.flowRecordRepository.findProcessRecords(anyRecord.getProcessId());
+ }
+
+ // ==================== 场景一:单审批人 = 提交人,自动通过,跳过 B 节点 ====================
+
+ /**
+ * 场景一:B 节点审批人仅为 a(与发起人一致)+ 相同人员自动审批(AUTO_PASS)。
+ * a 提交后应自动跳过 B 节点(B 节点不产生记录、a 无待办),
+ * 直接流转到 C 节点由 c 审批。
+ */
+ @Test
+ void autoPassShouldSkipBNodeWhenOnlyApproverIsInitiator() {
+ User a = new User(A, "a");
+ User c = new User(C, "c");
+ registerUsers(a, c);
+
+ StartNode startNode = startNode();
+ ApprovalNode bNode = approvalNode("B审批", "[1]",
+ SameOperatorAuditStrategy.Type.AUTO_PASS, null);
+ ApprovalNode cNode = approvalNode("C审批", "[3]",
+ SameOperatorAuditStrategy.Type.AUTO_PASS, null);
+ EndNode endNode = EndNode.builder().build();
+
+ Workflow workflow = saveWorkflow(a, startNode, bNode, cNode, endNode);
+ submitStart(workflow, startNode, a, data());
+
+ // a 本人不应收到 B 节点待办(相同人员自动通过,跳过 B 节点)
+ assertNoTodo(a);
+
+ // 直接流转到 C 节点:c 收到待办,且待办节点为 C 审批节点
+ FlowRecord cTodo = todoOf(c);
+ assertEquals(cNode.getId(), cTodo.getNodeId(), "a 提交后应直达 C 节点,待办应落在 C 审批节点");
+
+ // 流程记录:开始 + C 共 2 条,B 节点未产生任何记录(自动通过)
+ List records = processRecords(cTodo);
+ assertEquals(2, records.size(), "B 节点自动通过,流程记录应仅含 开始、C 两条");
+ assertEquals(0, records.stream().filter(r -> bNode.getId().equals(r.getNodeId())).count(),
+ "B 节点不应产生流程记录");
+
+ // c 审批后流程正常结束
+ pass(cTodo, cNode, c, data());
+ records = processRecords(cTodo);
+ assertEquals(2, records.size());
+ assertTrue(records.stream().allMatch(FlowRecord::isFinish), "流程结束后全部记录应为完成状态");
+ assertNoTodo(c);
+ }
+
+ // ==================== 场景二:多人依次审批 + 提交人位列首位,自动跳过提交人 ====================
+
+ /**
+ * 场景二:B 节点审批人为 a、b(提交人 a 位列首位)+ 相同人员自动审批(AUTO_PASS)
+ * + 依次审批(SEQUENCE)。
+ * a 提交后应自动跳过 a 本人,B 节点直接转交 b 审批(b 收到待办、a 无待办);
+ * b 审批后流转到 C 节点由 c 审批。
+ */
+ @Test
+ void autoPassWithSequenceShouldSkipInitiatorAndTransferToNextApprover() {
+ User a = new User(A, "a");
+ User b = new User(B, "b");
+ User c = new User(C, "c");
+ registerUsers(a, b, c);
+
+ StartNode startNode = startNode();
+ ApprovalNode bNode = approvalNode("B审批", "[1,2]",
+ SameOperatorAuditStrategy.Type.AUTO_PASS,
+ new MultiOperatorAuditStrategy(MultiOperatorAuditStrategy.Type.SEQUENCE, 0));
+ ApprovalNode cNode = approvalNode("C审批", "[3]",
+ SameOperatorAuditStrategy.Type.AUTO_PASS, null);
+ EndNode endNode = EndNode.builder().build();
+
+ Workflow workflow = saveWorkflow(a, startNode, bNode, cNode, endNode);
+ submitStart(workflow, startNode, a, data());
+
+ // 提交人 a 被自动跳过:a 无待办、c 尚未收到待办
+ assertNoTodo(a);
+ assertNoTodo(c);
+
+ // B 节点直接转交 b 审批:b 收到且仅收到一条待办,落在 B 审批节点
+ List bTodos = todosOf(b);
+ assertEquals(1, bTodos.size(), "B 节点应仅 b 一人收到待办");
+ assertEquals(bNode.getId(), bTodos.get(0).getNodeId(), "b 的待办应落在 B 审批节点");
+
+ // 流程记录:开始 + b(B 节点仅一条记录,a 的记录被跳过)共 2 条
+ List records = processRecords(bTodos.get(0));
+ assertEquals(2, records.size(), "B 节点仅产生 b 一条记录,a 的记录被自动跳过");
+ assertEquals(1, records.stream().filter(r -> bNode.getId().equals(r.getNodeId())).count());
+
+ // b 审批后流转到 C 节点:c 收到待办
+ pass(bTodos.get(0), bNode, b, data());
+ FlowRecord cTodo = todoOf(c);
+ assertEquals(cNode.getId(), cTodo.getNodeId(), "b 审批后应流转到 C 审批节点");
+
+ // c 审批后流程正常结束
+ pass(cTodo, cNode, c, data());
+ records = processRecords(cTodo);
+ assertEquals(3, records.size());
+ assertTrue(records.stream().allMatch(FlowRecord::isFinish), "流程结束后全部记录应为完成状态");
+ assertNoTodo(b);
+ assertNoTodo(c);
+ }
+
+ // ==================== 控制组:手动审批(MANUAL_PASS)时不跳过提交人 ====================
+
+ /**
+ * 控制组:B 节点审批人仅为 a + 相同人员手动审批(MANUAL_PASS)。
+ * 即使提交人 a 与审批人一致,仍需 a 本人审批 B 节点,不能跳过。
+ */
+ @Test
+ void manualPassShouldNotSkipInitiator() {
+ User a = new User(A, "a");
+ User c = new User(C, "c");
+ registerUsers(a, c);
+
+ StartNode startNode = startNode();
+ ApprovalNode bNode = approvalNode("B审批", "[1]",
+ SameOperatorAuditStrategy.Type.MANUAL_PASS, null);
+ ApprovalNode cNode = approvalNode("C审批", "[3]",
+ SameOperatorAuditStrategy.Type.MANUAL_PASS, null);
+ EndNode endNode = EndNode.builder().build();
+
+ Workflow workflow = saveWorkflow(a, startNode, bNode, cNode, endNode);
+ submitStart(workflow, startNode, a, data());
+
+ // a 仍需审批 B 节点,c 未收到待办
+ FlowRecord aTodo = todoOf(a);
+ assertEquals(bNode.getId(), aTodo.getNodeId(), "手动审批下 a 应收到 B 节点待办");
+ assertNoTodo(c);
+
+ // a 审批 B 节点后流转到 C 节点
+ pass(aTodo, bNode, a, data());
+ FlowRecord cTodo = todoOf(c);
+ assertEquals(cNode.getId(), cTodo.getNodeId(), "a 审批 B 节点后应流转到 C 审批节点");
+
+ // c 审批后流程结束
+ pass(cTodo, cNode, c, data());
+ assertNoTodo(a);
+ assertNoTodo(c);
+ }
+}
diff --git a/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowMergeDetailInitiatorTest.java b/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowMergeDetailInitiatorTest.java
new file mode 100644
index 00000000..17d86eb9
--- /dev/null
+++ b/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowMergeDetailInitiatorTest.java
@@ -0,0 +1,159 @@
+package com.codingapi.flow.service;
+
+import com.codingapi.flow.action.IFlowAction;
+import com.codingapi.flow.action.actions.CustomAction;
+import com.codingapi.flow.builder.ActionBuilder;
+import com.codingapi.flow.builder.FormFieldPermissionsBuilder;
+import com.codingapi.flow.builder.NodeStrategyBuilder;
+import com.codingapi.flow.context.GatewayContext;
+import com.codingapi.flow.factory.MyFlowServiceFactory;
+import com.codingapi.flow.form.DataType;
+import com.codingapi.flow.form.FlowForm;
+import com.codingapi.flow.form.FlowFormBuilder;
+import com.codingapi.flow.form.permission.PermissionType;
+import com.codingapi.flow.node.nodes.ApprovalNode;
+import com.codingapi.flow.node.nodes.EndNode;
+import com.codingapi.flow.node.nodes.StartNode;
+import com.codingapi.flow.pojo.body.FlowAdviceBody;
+import com.codingapi.flow.pojo.request.FlowActionRequest;
+import com.codingapi.flow.pojo.request.FlowCreateRequest;
+import com.codingapi.flow.pojo.request.FlowDetailRequest;
+import com.codingapi.flow.pojo.response.FlowContent;
+import com.codingapi.flow.record.FlowRecord;
+import com.codingapi.flow.script.factory.FlowGroovyScriptFactory;
+import com.codingapi.flow.strategy.node.FormFieldPermissionStrategy;
+import com.codingapi.flow.strategy.node.OperatorLoadStrategy;
+import com.codingapi.flow.strategy.node.RecordMergeStrategy;
+import com.codingapi.flow.user.User;
+import com.codingapi.flow.workflow.Workflow;
+import com.codingapi.flow.workflow.WorkflowBuilder;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 合并待办详情中,各合并记录的发起人(createdOperator)应保持各自流程的发起人,
+ * 而不是全部等于当前打开记录(record)的发起人。
+ */
+public class FlowMergeDetailInitiatorTest {
+
+ private final MyFlowServiceFactory factory = new MyFlowServiceFactory();
+
+ @Test
+ void detail_should_preserve_each_merge_record_creator() {
+ // given 两个不同发起人 + 一个审批人
+ User initiator1 = new User(1, "initiator1");
+ User initiator2 = new User(2, "initiator2");
+ User boss = new User(3, "boss");
+ factory.userGateway.save(initiator1);
+ factory.userGateway.save(initiator2);
+ factory.userGateway.save(boss);
+ GatewayContext.getInstance().setFlowOperatorGateway(factory.userGateway);
+
+ FlowForm form = FlowFormBuilder.builder()
+ .name("请假流程")
+ .code("leave")
+ .addField("请假人", "name", DataType.STRING)
+ .addField("请假天数", "days", DataType.INTEGER)
+ .addField("请假事由", "reason", DataType.STRING)
+ .build();
+
+ StartNode startNode = StartNode
+ .builder()
+ .strategies(NodeStrategyBuilder.builder()
+ .addStrategy(new FormFieldPermissionStrategy(FormFieldPermissionsBuilder.builder()
+ .addPermission("leave", "name", PermissionType.WRITE)
+ .addPermission("leave", "days", PermissionType.WRITE)
+ .addPermission("leave", "reason", PermissionType.WRITE)
+ .build()))
+ .build())
+ .actions(ActionBuilder.builder()
+ .addAction(CustomAction.defaultAction())
+ .build())
+ .build();
+
+ ApprovalNode bossNode = ApprovalNode.builder()
+ .name("经理审批")
+ .strategies(NodeStrategyBuilder.builder()
+ .addStrategy(new FormFieldPermissionStrategy(FormFieldPermissionsBuilder.builder()
+ .addPermission("leave", "name", PermissionType.WRITE)
+ .addPermission("leave", "days", PermissionType.WRITE)
+ .addPermission("leave", "reason", PermissionType.WRITE)
+ .build()))
+ .addStrategy(new OperatorLoadStrategy(
+ FlowGroovyScriptFactory.createOperatorLoadScript("def run(request){return [3]}").getKey()))
+ .addStrategy(new RecordMergeStrategy(true))
+ .build())
+ .build();
+
+ EndNode endNode = EndNode.builder().build();
+ Workflow workflow = WorkflowBuilder.builder()
+ .title("请假流程")
+ .code("leave")
+ .createdOperator(initiator1)
+ .form(form)
+ .addNode(startNode)
+ .addNode(bossNode)
+ .addNode(endNode)
+ .build();
+ factory.workflowService.saveWorkflow(workflow);
+
+ Map data = Map.of("name", "lorne", "days", 1, "reason", "leave");
+
+ // when 两个不同发起人先后发起并提交,汇聚到审批人待办(合并为一条)
+ for (User initiator : List.of(initiator1, initiator2)) {
+ List startActions = startNode.actionManager().getActions();
+ FlowCreateRequest createRequest = new FlowCreateRequest();
+ createRequest.setWorkCode(workflow.getCode());
+ createRequest.setFormData(data);
+ createRequest.setActionId(startActions.get(0).id());
+ createRequest.setOperatorId(initiator.getUserId());
+ factory.flowService.create(createRequest);
+
+ FlowRecord todoRecord = factory.flowRecordRepository.findTodoByOperator(initiator.getUserId()).get(0);
+ FlowActionRequest actionRequest = new FlowActionRequest();
+ actionRequest.setFormData(data);
+ actionRequest.setRecordId(todoRecord.getId());
+ actionRequest.setAdvice(new FlowAdviceBody(startActions.get(0).id(), "同意", initiator.getUserId()));
+ factory.flowService.action(actionRequest);
+ }
+
+ // 审批人待办汇聚为一条合并待办(cnt=2),但对应两条流程记录
+ List bossTodoRecords = factory.flowRecordRepository.findTodoByOperator(boss.getUserId());
+ assertEquals(2, bossTodoRecords.size());
+ assertEquals(2, factory.flowTodoRecordRepository.findAll().get(0).getMargeCount());
+
+ // when 打开合并待办详情(传入其中一条流程记录 id)
+ FlowRecord opened = bossTodoRecords.get(0);
+ assertEquals(2, factory.flowRecordService.getMergeRecord(opened.getTodoKey()).size(),
+ "详情查询前合并关系应保持原始两条");
+ FlowContent detail = factory.flowService.detail(new FlowDetailRequest(opened.getId(), boss.getUserId()));
+
+ // then 详情应恰好包含两条合并记录,且无重复
+ List todos = detail.getTodos();
+ assertEquals(2, todos.size(), "详情应返回两条合并记录,且不得因查询详情而增多");
+ assertEquals(2, todos.stream().map(FlowContent.Body::getRecordId).distinct().count(),
+ "详情返回的合并记录不得重复");
+
+ // then 每条合并记录的发起人应为各自流程自身的发起人(getMergeRecord 中的真实 creator)
+ List mergeRecords = factory.flowRecordService.getMergeRecord(opened.getTodoKey());
+ Map recordToCreator = mergeRecords.stream()
+ .collect(java.util.stream.Collectors.toMap(FlowRecord::getId, FlowRecord::getCreateOperatorId));
+ assertTrue(recordToCreator.values().stream().distinct().count() == 2,
+ "前置条件:两条合并记录应来自不同发起人");
+ for (FlowContent.Body body : todos) {
+ assertEquals(recordToCreator.get(body.getRecordId()), body.getCreatedOperator().getId(),
+ "合并记录 " + body.getRecordId() + " 的发起人应为其流程自身的发起人");
+ }
+
+ // then 查询详情不得改写入库合并状态(计数仍为 2)
+ assertEquals(2, factory.flowTodoRecordRepository.findAll().get(0).getMargeCount(),
+ "查询详情不得改变合并计数");
+ assertEquals(2, factory.flowTodoMergeRepository.findAll().size(),
+ "查询详情不得新增合并关系");
+ }
+}
\ No newline at end of file
diff --git a/flow-engine-starter-infra/src/main/java/com/codingapi/flow/infra/jpa/FlowTodoMargeEntityRepository.java b/flow-engine-starter-infra/src/main/java/com/codingapi/flow/infra/jpa/FlowTodoMargeEntityRepository.java
index 342c2eb7..adaf5395 100644
--- a/flow-engine-starter-infra/src/main/java/com/codingapi/flow/infra/jpa/FlowTodoMargeEntityRepository.java
+++ b/flow-engine-starter-infra/src/main/java/com/codingapi/flow/infra/jpa/FlowTodoMargeEntityRepository.java
@@ -8,4 +8,6 @@
public interface FlowTodoMargeEntityRepository extends FastRepository {
List findByTodoId(long todoId);
+
+ List findByTodoIdIn(List todoIds);
}
diff --git a/flow-engine-starter-infra/src/main/java/com/codingapi/flow/infra/repository/impl/FlowTodoMergeRepositoryImpl.java b/flow-engine-starter-infra/src/main/java/com/codingapi/flow/infra/repository/impl/FlowTodoMergeRepositoryImpl.java
index ec32221b..0188d7aa 100644
--- a/flow-engine-starter-infra/src/main/java/com/codingapi/flow/infra/repository/impl/FlowTodoMergeRepositoryImpl.java
+++ b/flow-engine-starter-infra/src/main/java/com/codingapi/flow/infra/repository/impl/FlowTodoMergeRepositoryImpl.java
@@ -37,4 +37,13 @@ public List findByTodoId(long todoId) {
return flowTodoMargeEntityRepository.findByTodoId(todoId)
.stream().map(FlowTodoMargeConvertor::convert).toList();
}
+
+ @Override
+ public List findByTodoIds(List todoIds) {
+ if (todoIds == null || todoIds.isEmpty()) {
+ return List.of();
+ }
+ return flowTodoMargeEntityRepository.findByTodoIdIn(todoIds)
+ .stream().map(FlowTodoMargeConvertor::convert).toList();
+ }
}
diff --git a/flow-frontend b/flow-frontend
index a42c3515..073a69bd 160000
--- a/flow-frontend
+++ b/flow-frontend
@@ -1 +1 @@
-Subproject commit a42c35157994f3d00d1b61644f8b30a2c5376830
+Subproject commit 073a69bdc31e95759be77d6a4e1d05ed89156faa