From 5c72f6599680e97877a5fbcd5f2d6f57d52ab82a Mon Sep 17 00:00:00 2001 From: lorne <1991wangliang@gmail.com> Date: Wed, 26 Aug 2026 17:13:56 +0800 Subject: [PATCH 1/6] =?UTF-8?q?fix:=20=E5=90=88=E5=B9=B6=E5=BE=85=E5=8A=9E?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E5=8F=91=E8=B5=B7=E4=BA=BA=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E4=B8=8E=E5=90=88=E5=B9=B6=E7=99=BB=E8=AE=B0?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E9=97=AE=E9=A2=98=20issue=20#222=20#223?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 详情接口各合并记录的 createdOperator 改用各记录自身的发起人(FlowContent.pushRecords 取 item 而非 record) - 待办合并登记幂等化:按 (todoId, recordId) 去重,避免详情/已读等再次保存时重复新增合并关系并膨胀合并计数(FlowRecordSaveService) - 新增 findByTodoIds 批量查询,JPA 与 mock 仓储同步实现 - 新增复现测试 FlowMergeDetailInitiatorTest(两个不同发起人的流程合并后校验详情发起人与合并状态) closes #222 closes #223 Co-Authored-By: Claude --- .../FlowTodoMergeRepositoryMockImpl.java | 7 + .../flow/pojo/response/FlowContent.java | 2 +- .../repository/FlowTodoMergeRepository.java | 7 + .../flow/service/FlowRecordSaveService.java | 40 ++++- .../FlowTodoMergeRepositoryImpl.java | 7 + .../service/FlowMergeDetailInitiatorTest.java | 159 ++++++++++++++++++ .../jpa/FlowTodoMargeEntityRepository.java | 2 + .../impl/FlowTodoMergeRepositoryImpl.java | 9 + 8 files changed, 227 insertions(+), 6 deletions(-) create mode 100644 flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowMergeDetailInitiatorTest.java 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/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/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(); + } } From 67e1d10ee09062ede93ec992fb387f7654d5f46d Mon Sep 17 00:00:00 2001 From: lorne <1991wangliang@gmail.com> Date: Wed, 26 Aug 2026 17:16:32 +0800 Subject: [PATCH 2/6] =?UTF-8?q?chore:=20=E6=9B=B4=E6=96=B0=20flow-frontend?= =?UTF-8?q?=20=E8=87=B3=20dev=20=E6=9C=80=E6=96=B0=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=EF=BC=88=E5=90=88=E5=B9=B6=E5=BE=85=E5=8A=9E=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E5=8F=91=E8=B5=B7=E4=BA=BA/=E6=8F=90=E4=BA=A4=E4=BA=BA?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E4=BF=AE=E5=A4=8D=EF=BC=89=20issue=20#57?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- flow-frontend | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flow-frontend b/flow-frontend index a42c3515..b8b85bd1 160000 --- a/flow-frontend +++ b/flow-frontend @@ -1 +1 @@ -Subproject commit a42c35157994f3d00d1b61644f8b30a2c5376830 +Subproject commit b8b85bd1daa988f11954bccc5f23d43ee73dcaa5 From 6e31220842563efc0f934ffab1d21b2b79946bcd Mon Sep 17 00:00:00 2001 From: lorne <1991wangliang@gmail.com> Date: Thu, 27 Aug 2026 16:36:53 +0800 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20=E7=9B=B8=E5=90=8C=E4=BA=BA?= =?UTF-8?q?=E5=91=98=E6=8F=90=E4=BA=A4=E5=AE=A1=E6=89=B9=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E9=80=9A=E8=BF=87=EF=BC=88issue=20#224?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 消费 SameOperatorAuditStrategy:提交人流转到该节点且审批人含本人时过滤本人, 全部审批人均为提交人时自动通过节点并继续生成下游记录 - 守卫条件(currentOperator == submitOperator)区分正常流转与加签,避免加签记录被误跳过 - ApprovalNode/HandleNode 默认策略集移除 SameOperator:未配置节点保持传统行为(配置驱动), 与前端「相同人员提交配置」可选配置项语义一致 - setStrategies 支持「不在默认集则添加」,保证显式配置的策略不被丢弃 - 新增场景测试:情况1/情况2/控制组 + 四种多人审批模式 × 自动审批(SEQUENCE/MERGE/ANY/RANDOM_ONE) closes #224 Co-Authored-By: Claude --- .../flow/manager/NodeStrategyManager.java | 13 + .../codingapi/flow/node/BaseAuditNode.java | 44 ++ .../com/codingapi/flow/node/BaseFlowNode.java | 6 + .../flow/node/nodes/ApprovalNode.java | 3 +- .../codingapi/flow/node/nodes/HandleNode.java | 3 +- ...ssue224SameOperatorAuditMultiModeTest.java | 446 ++++++++++++++++++ .../FlowIssue224SameOperatorAuditTest.java | 301 ++++++++++++ 7 files changed, 814 insertions(+), 2 deletions(-) create mode 100644 flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowIssue224SameOperatorAuditMultiModeTest.java create mode 100644 flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowIssue224SameOperatorAuditTest.java 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/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/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); + } +} From 55c9550f3184e879edd5f2b0c09120c9e7a5369c Mon Sep 17 00:00:00 2001 From: lorne <1991wangliang@gmail.com> Date: Fri, 28 Aug 2026 11:34:59 +0800 Subject: [PATCH 4/6] =?UTF-8?q?chore:=20=E6=9B=B4=E6=96=B0=20flow-frontend?= =?UTF-8?q?=20=E8=87=B3=200.2.7=20=E7=89=88=E6=9C=AC=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=20issue=20#57?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- flow-frontend | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flow-frontend b/flow-frontend index b8b85bd1..6d9afe67 160000 --- a/flow-frontend +++ b/flow-frontend @@ -1 +1 @@ -Subproject commit b8b85bd1daa988f11954bccc5f23d43ee73dcaa5 +Subproject commit 6d9afe67d9dfa8832fad09365a62c4158ae67354 From b4a936b8b8b429a436aaf31d5e6b9d08e44b5bb7 Mon Sep 17 00:00:00 2001 From: lorne <1991wangliang@gmail.com> Date: Mon, 31 Aug 2026 08:23:28 +0800 Subject: [PATCH 5/6] Update flow-frontend to latest main --- flow-frontend | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flow-frontend b/flow-frontend index 6d9afe67..a42c3515 160000 --- a/flow-frontend +++ b/flow-frontend @@ -1 +1 @@ -Subproject commit 6d9afe67d9dfa8832fad09365a62c4158ae67354 +Subproject commit a42c35157994f3d00d1b61644f8b30a2c5376830 From 59fc0cac6645ee0de378985dd30d1cae4390fec3 Mon Sep 17 00:00:00 2001 From: lorne <1991wangliang@gmail.com> Date: Mon, 31 Aug 2026 10:16:42 +0800 Subject: [PATCH 6/6] Update flow-frontend to latest main --- flow-frontend | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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