From 180aede13d0b393d3c9041f3d6967f978ba4120e Mon Sep 17 00:00:00 2001
From: lorne <1991wangliang@gmail.com>
Date: Thu, 12 Nov 2026 09:40:40 +0800
Subject: [PATCH 02/17] =?UTF-8?q?fix:=20=E5=B9=B6=E7=AD=BE=E6=9C=AA?=
=?UTF-8?q?=E9=85=8D=E7=BD=AE=E6=AF=94=E4=BE=8B=E6=97=B6=E6=8C=89=E5=85=A8?=
=?UTF-8?q?=E9=83=A8=E9=80=9A=E8=BF=87=E5=A4=84=E7=90=86=E5=B9=B6=E8=A1=A5?=
=?UTF-8?q?=E5=85=85=E5=90=88=E5=B9=B6=E5=AE=A1=E6=89=B9=E5=A4=9A=E6=A8=A1?=
=?UTF-8?q?=E5=BC=8F=E9=AA=8C=E8=AF=81=20(#265)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- NodeStrategyManager#getMultiOperatorAuditMergePercent 将比例归一化到 (0,1],
未配置(<=0)按 1 处理,避免并签因 percent=0 退化为或签
(首个审批人通过即完成节点,对方待办与合并记录被自动办结清空)
- 新增 FlowIssue265MergeAuditModeTest:覆盖并签/或签/循序/随机一人 在开启
RecordMergeStrategy 时的合并待办生成、部分通过后保留、通过后清理与合并关系一致性
Closes #265
---
.../flow/manager/NodeStrategyManager.java | 15 +-
.../FlowIssue265MergeAuditModeTest.java | 373 ++++++++++++++++++
2 files changed, 386 insertions(+), 2 deletions(-)
create mode 100644 flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowIssue265MergeAuditModeTest.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 05b0765a..6f41cf70 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
@@ -160,15 +160,26 @@ public MultiOperatorAuditStrategy.Type getMultiOperatorAuditStrategyType() {
/**
* 多操作者审批并签比例
+ *
+ * 比例语义为「通过人数占审批席位数的比例」,有效区间为 (0, 1]。
+ * 未配置(0 或负数)或超过 1 时按 1(全部通过)返回:比例为 0 会让并签退化成或签
+ * (首个审批人通过即完成节点、其他审批人待办被自动办结),与并签「全部通过才算完成」
+ * 的配置意图不符,因此按需全部通过处理(issue #265)。
+ *
+ * @return 归一化后的并签比例,取值区间 (0, 1]
*/
public float getMultiOperatorAuditMergePercent() {
List strategies = this.strategies;
for (INodeStrategy strategy : strategies) {
if (strategy instanceof MultiOperatorAuditStrategy) {
- return ((MultiOperatorAuditStrategy) strategy).getPercent();
+ float percent = ((MultiOperatorAuditStrategy) strategy).getPercent();
+ if (percent <= 0) {
+ return 1f;
+ }
+ return Math.min(percent, 1f);
}
}
- return 0;
+ return 1f;
}
/**
diff --git a/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowIssue265MergeAuditModeTest.java b/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowIssue265MergeAuditModeTest.java
new file mode 100644
index 00000000..9372db04
--- /dev/null
+++ b/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowIssue265MergeAuditModeTest.java
@@ -0,0 +1,373 @@
+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.record.FlowRecord;
+import com.codingapi.flow.record.FlowTodoRecord;
+import com.codingapi.flow.script.factory.FlowGroovyScriptFactory;
+import com.codingapi.flow.strategy.node.FormFieldPermissionStrategy;
+import com.codingapi.flow.strategy.node.MultiOperatorAuditStrategy;
+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.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * issue #265:多人审批四种模式(并签 / 或签 / 顺序 / 随机一人)在开启记录合并时的验证。
+ *
+ * 流程 A-B-C,B 节点审批人为 b1、b2 且开启 {@link RecordMergeStrategy}(默认按审批人合并)。
+ * 验证点:
+ *
+ * - 流程到达 B 时,b1、b2 都能在待办列表看到自己的合并待办(合并组各 1 条);
+ * - 并签(MERGE):未全部通过前对方的待办与合并组不应消失,全部通过后节点才完成、待办与合并关系全部清理;
+ * - 或签(ANY):任一人通过后流程立即结束,对方待办与合并组必须被清理;
+ * - 顺序(SEQUENCE):后序审批人的待办与合并组在激活前不产生,激活后才产生;
+ * - 随机一人(RANDOM_ONE):仅一人收到待办,通过后流程结束且待办与合并关系清理干净。
+ *
+ */
+class FlowIssue265MergeAuditModeTest {
+
+ private final MyFlowServiceFactory factory = new MyFlowServiceFactory();
+
+ private final User user = new User(1, "user");
+ private final User b1 = new User(2, "b1");
+ private final User b2 = new User(3, "b2");
+
+ @Test
+ void mergeAudit_shouldKeepBothTodosUntilAllPassed() {
+ // given A(开始) - B(并签 + 合并) - C(结束)
+ Workflow workflow = saveWorkflow(MultiOperatorAuditStrategy.Type.MERGE, 1.0f);
+ String bNodeId = nodeIdByName(workflow, "B审批");
+
+ // when
+ long startRecordId = createAndSubmit(workflow);
+
+ // then 流程停在 B:b1、b2 各自的合并待办组(按审批人合并,同实例内各 1 条)
+ for (User approver : List.of(b1, b2)) {
+ FlowTodoRecord group = singleGroupOf(approver, bNodeId);
+ assertEquals(1, group.getMargeCount(), approver.getName() + " 的合并组应有 1 条记录");
+ assertEquals(1, todoRecordsOf(approver, bNodeId).size(),
+ approver.getName() + " 在 B 节点应有 1 条待办记录");
+ assertEquals(1, factory.flowTodoMergeRepository.findByTodoId(group.getId()).size(),
+ approver.getName() + " 应各有 1 条合并关系");
+ }
+
+ // when b1 通过(并签比例 100%,节点未完成)
+ pass(todoRecordsOf(b1, bNodeId).get(0), workflow, b1);
+
+ // then b1 待办与合并组被摘除,b2 的待办与合并组必须保留
+ assertNoTodo(b1, bNodeId);
+ assertEquals(0, groupsOf(b1, bNodeId).size(), "b1 的合并待办组应被删除");
+ FlowTodoRecord b2Group = singleGroupOf(b2, bNodeId);
+ assertEquals(1, b2Group.getMargeCount(), "b2 的合并组应保留 1 条记录");
+ assertEquals(1, todoRecordsOf(b2, bNodeId).size(), "b2 的待办应保留");
+ assertEquals(1, factory.flowTodoMergeRepository.findByTodoId(b2Group.getId()).size(),
+ "b2 的合并关系应保留");
+ assertFalse(record(startRecordId).isFinish(), "并签未全部通过时流程不应结束");
+
+ // when b2 通过(达到 100%)
+ pass(todoRecordsOf(b2, bNodeId).get(0), workflow, b2);
+
+ // then 节点完成:待办与合并关系全部清理,流程结束
+ for (User approver : List.of(b1, b2)) {
+ assertNoTodo(approver, bNodeId);
+ assertEquals(0, groupsOf(approver, bNodeId).size(), approver.getName() + " 的合并待办组应被删除");
+ }
+ assertEquals(0, factory.flowTodoMergeRepository.findAll().size(), "不应残留任何合并关系");
+ assertTrue(record(startRecordId).isFinish(), "并签全部通过后流程应结束");
+ }
+
+ @Test
+ void mergeAuditWithoutConfiguredPercent_shouldRequireAllOperatorsPassed() {
+ // given 并签但未配置通过比例(percent=0,等价于设计器未填写比例)
+ Workflow workflow = saveWorkflow(MultiOperatorAuditStrategy.Type.MERGE, 0f);
+ String bNodeId = nodeIdByName(workflow, "B审批");
+
+ // when
+ long startRecordId = createAndSubmit(workflow);
+
+ // then 两位审批人都持有待办
+ assertEquals(1, todoRecordsOf(b1, bNodeId).size(), "b1 应有 1 条待办记录");
+ assertEquals(1, todoRecordsOf(b2, bNodeId).size(), "b2 应有 1 条待办记录");
+
+ // when b1 通过
+ pass(todoRecordsOf(b1, bNodeId).get(0), workflow, b1);
+
+ // then 未配置比例不应退化为或签:b2 的待办与合并组必须保留,流程不应结束
+ FlowTodoRecord b2Group = singleGroupOf(b2, bNodeId);
+ assertEquals(1, b2Group.getMargeCount(), "b2 的合并组应保留 1 条记录");
+ assertEquals(1, todoRecordsOf(b2, bNodeId).size(), "b2 的待办应保留");
+ assertFalse(record(startRecordId).isFinish(), "并签未配置比例时应按全部通过处理,流程不应提前结束");
+
+ // when b2 通过
+ pass(todoRecordsOf(b2, bNodeId).get(0), workflow, b2);
+
+ // then 全部通过后流程结束,待办与合并关系清理干净
+ for (User approver : List.of(b1, b2)) {
+ assertNoTodo(approver, bNodeId);
+ assertEquals(0, groupsOf(approver, bNodeId).size(),
+ approver.getName() + " 的合并待办组应被删除");
+ }
+ assertEquals(0, factory.flowTodoMergeRepository.findAll().size(), "不应残留任何合并关系");
+ assertTrue(record(startRecordId).isFinish(), "全部通过后流程应结束");
+ }
+
+ @Test
+ void anyAudit_shouldClearOtherTodoWhenOnePassed() {
+ // given A(开始) - B(或签 + 合并) - C(结束)
+ Workflow workflow = saveWorkflow(MultiOperatorAuditStrategy.Type.ANY, 0f);
+ String bNodeId = nodeIdByName(workflow, "B审批");
+
+ // when
+ long startRecordId = createAndSubmit(workflow);
+
+ // then b1、b2 各自都能看到自己的合并待办
+ for (User approver : List.of(b1, b2)) {
+ FlowTodoRecord group = singleGroupOf(approver, bNodeId);
+ assertEquals(1, group.getMargeCount(), approver.getName() + " 的合并组应有 1 条记录");
+ }
+ assertEquals(2, factory.flowTodoMergeRepository.findAll().size(), "两个审批人各 1 条合并关系");
+
+ FlowRecord b2Record = todoRecordsOf(b2, bNodeId).get(0);
+ String b2TodoKey = b2Record.getTodoKey();
+
+ // when b1 通过(或签:任一人通过即流转)
+ pass(todoRecordsOf(b1, bNodeId).get(0), workflow, b1);
+
+ // then 流程结束,b2 的待办、合并组与合并关系必须一起消失
+ assertNoTodo(b1, bNodeId);
+ assertNoTodo(b2, bNodeId);
+ assertEquals(0, groupsOf(b1, bNodeId).size(), "b1 的合并待办组应被删除");
+ assertEquals(0, groupsOf(b2, bNodeId).size(), "b2 的合并待办组应被删除");
+ assertNull(factory.flowTodoRecordRepository.getByTodoKey(b2TodoKey),
+ "b2 的合并待办行应被删除");
+ assertEquals(0, factory.flowTodoMergeRepository.findAll().size(), "不应残留任何合并关系");
+
+ FlowRecord autoDoneRecord = record(b2Record.getId());
+ assertTrue(autoDoneRecord.isDone(), "b2 的记录应被自动办结");
+ assertTrue(autoDoneRecord.isAutoDone(), "b2 的记录应为自动办结(无审批动作)");
+ assertTrue(record(startRecordId).isFinish(), "或签任一人通过后流程应结束");
+ }
+
+ @Test
+ void sequenceAudit_shouldCreateNextOperatorTodoOnlyAfterActivated() {
+ // given A(开始) - B(顺序 + 合并) - C(结束)
+ Workflow workflow = saveWorkflow(MultiOperatorAuditStrategy.Type.SEQUENCE, 0f);
+ String bNodeId = nodeIdByName(workflow, "B审批");
+
+ // when
+ long startRecordId = createAndSubmit(workflow);
+
+ // then 后序审批人(b2)的记录隐藏,不产生待办与合并组
+ assertEquals(1, todoRecordsOf(b1, bNodeId).size(), "b1 应有 1 条待办记录");
+ singleGroupOf(b1, bNodeId);
+ assertEquals(0, todoRecordsOf(b2, bNodeId).size(), "b2 在未被激活前不应有 todo 记录");
+ assertEquals(0, groupsOf(b2, bNodeId).size(), "b2 在未被激活前不应有合并待办组");
+ assertEquals(1, factory.flowTodoMergeRepository.findAll().size(), "仅 b1 有 1 条合并关系");
+
+ // when b1 通过
+ pass(todoRecordsOf(b1, bNodeId).get(0), workflow, b1);
+
+ // then b1 待办摘除,b2 被激活并产生自己的合并待办
+ assertNoTodo(b1, bNodeId);
+ assertEquals(0, groupsOf(b1, bNodeId).size(), "b1 的合并待办组应被删除");
+ assertEquals(1, todoRecordsOf(b2, bNodeId).size(), "b2 通过后应被激活产生待办");
+ FlowTodoRecord b2Group = singleGroupOf(b2, bNodeId);
+ assertEquals(1, b2Group.getMargeCount(), "b2 的合并组应有 1 条记录");
+ assertEquals(1, factory.flowTodoMergeRepository.findByTodoId(b2Group.getId()).size(),
+ "b2 应有 1 条合并关系");
+ assertFalse(record(startRecordId).isFinish(), "顺序审批未全部完成时流程不应结束");
+
+ // when b2 通过
+ pass(todoRecordsOf(b2, bNodeId).get(0), workflow, b2);
+
+ // then 流程结束,待办与合并关系清理干净
+ assertNoTodo(b2, bNodeId);
+ assertEquals(0, groupsOf(b2, bNodeId).size(), "b2 的合并待办组应被删除");
+ assertEquals(0, factory.flowTodoMergeRepository.findAll().size(), "不应残留任何合并关系");
+ assertTrue(record(startRecordId).isFinish(), "顺序审批全部通过后流程应结束");
+ }
+
+ @Test
+ void randomOneAudit_shouldCreateSingleOperatorTodo() {
+ // given A(开始) - B(随机一人 + 合并) - C(结束)
+ Workflow workflow = saveWorkflow(MultiOperatorAuditStrategy.Type.RANDOM_ONE, 0f);
+ String bNodeId = nodeIdByName(workflow, "B审批");
+
+ // when
+ long startRecordId = createAndSubmit(workflow);
+
+ // then 仅一人收到待办,且只有一条合并组
+ List receivers = List.of(b1, b2).stream()
+ .filter(approver -> !todoRecordsOf(approver, bNodeId).isEmpty())
+ .toList();
+ assertEquals(1, receivers.size(), "随机一人审批时应有且仅有一名审批人收到待办");
+ User receiver = receivers.get(0);
+ FlowTodoRecord group = singleGroupOf(receiver, bNodeId);
+ assertEquals(1, group.getMargeCount(), "接收人的合并组应有 1 条记录");
+ assertEquals(1, factory.flowTodoRecordRepository.findAll().size(), "只应产生 1 条合并待办");
+ assertEquals(1, factory.flowTodoMergeRepository.findAll().size(), "只应产生 1 条合并关系");
+
+ // when 接收人通过
+ pass(todoRecordsOf(receiver, bNodeId).get(0), workflow, receiver);
+
+ // then 流程结束,待办与合并关系清理干净
+ assertNoTodo(receiver, bNodeId);
+ assertEquals(0, groupsOf(receiver, bNodeId).size(), "接收人的合并待办组应被删除");
+ assertEquals(0, factory.flowTodoRecordRepository.findAll().size(), "不应残留任何合并待办");
+ assertEquals(0, factory.flowTodoMergeRepository.findAll().size(), "不应残留任何合并关系");
+ assertTrue(record(startRecordId).isFinish(), "随机一人通过后流程应结束");
+ }
+
+ // ─── 辅助方法 ───
+
+ private Workflow saveWorkflow(MultiOperatorAuditStrategy.Type type, float percent) {
+ registerUsers();
+ Workflow workflow = buildWorkflow(type, percent);
+ factory.workflowService.saveWorkflow(workflow);
+ return workflow;
+ }
+
+ private Workflow buildWorkflow(MultiOperatorAuditStrategy.Type type, float percent) {
+ FlowForm form = FlowFormBuilder.builder()
+ .name("测试流程")
+ .code("issue265")
+ .addField("name", "name", DataType.STRING)
+ .build();
+
+ StartNode startNode = StartNode.builder()
+ .strategies(NodeStrategyBuilder.builder()
+ .addStrategy(perm())
+ .build())
+ .actions(ActionBuilder.builder()
+ .addAction(CustomAction.defaultAction())
+ .build())
+ .build();
+
+ ApprovalNode approvalNode = ApprovalNode.builder()
+ .name("B审批")
+ .strategies(NodeStrategyBuilder.builder()
+ .addStrategy(perm())
+ .addStrategy(new OperatorLoadStrategy(FlowGroovyScriptFactory
+ .createOperatorLoadScript("def run(request){return [2,3]}").getKey()))
+ .addStrategy(new MultiOperatorAuditStrategy(type, percent))
+ .addStrategy(new RecordMergeStrategy(true))
+ .build())
+ .build();
+
+ EndNode endNode = EndNode.builder().build();
+
+ return WorkflowBuilder.builder()
+ .title("测试流程")
+ .code("issue265")
+ .createdOperator(user)
+ .form(form)
+ .addNode(startNode)
+ .addNode(approvalNode)
+ .addNode(endNode)
+ .build();
+ }
+
+ private FormFieldPermissionStrategy perm() {
+ return new FormFieldPermissionStrategy(FormFieldPermissionsBuilder.builder()
+ .addPermission("issue265", "name", PermissionType.WRITE)
+ .build());
+ }
+
+ /**
+ * 发起并提交到 B 节点,返回发起节点记录id
+ */
+ private long createAndSubmit(Workflow workflow) {
+ StartNode startNode = (StartNode) workflow.getStartNode();
+ IFlowAction startAction = startNode.actionManager().getActions().get(0);
+ Map data = Map.of("name", "lorne");
+
+ FlowCreateRequest createRequest = new FlowCreateRequest();
+ createRequest.setWorkCode(workflow.getCode());
+ createRequest.setFormData(data);
+ createRequest.setActionId(startAction.id());
+ createRequest.setOperatorId(user.getUserId());
+ long recordId = factory.flowService.create(createRequest);
+
+ pass(startAction, user, data, recordId);
+ return recordId;
+ }
+
+ private void registerUsers() {
+ List.of(user, b1, b2).forEach(factory.userGateway::save);
+ GatewayContext.getInstance().setFlowOperatorGateway(factory.userGateway);
+ }
+
+ private void pass(FlowRecord flowRecord, Workflow workflow, User operator) {
+ ApprovalNode approvalNode = (ApprovalNode) workflow.getNodes().stream()
+ .filter(node -> node.getName().equals("B审批"))
+ .findFirst().orElseThrow();
+ IFlowAction action = approvalNode.actionManager().getActions().get(0);
+ pass(action, operator, Map.of("name", "lorne"), flowRecord.getId());
+ }
+
+ private void pass(IFlowAction action, User operator, Map data, long recordId) {
+ FlowActionRequest request = new FlowActionRequest();
+ request.setFormData(data);
+ request.setRecordId(recordId);
+ request.setAdvice(new FlowAdviceBody(action.id(), "同意", operator.getUserId()));
+ factory.flowService.action(request);
+ }
+
+ private FlowRecord record(long recordId) {
+ return factory.flowRecordRepository.get(recordId);
+ }
+
+ private List todoRecordsOf(User approver, String nodeId) {
+ return factory.flowRecordRepository.findTodoByOperator(approver.getUserId()).stream()
+ .filter(record -> nodeId.equals(record.getNodeId()))
+ .toList();
+ }
+
+ private List groupsOf(User approver, String nodeId) {
+ return factory.flowTodoRecordRepository.findByOperatorId(approver.getUserId()).stream()
+ .filter(group -> nodeId.equals(group.getNodeId()))
+ .toList();
+ }
+
+ private FlowTodoRecord singleGroupOf(User approver, String nodeId) {
+ List groups = groupsOf(approver, nodeId);
+ assertEquals(1, groups.size(), approver.getName() + " 在 B 节点应有且仅有 1 条合并待办组");
+ return groups.get(0);
+ }
+
+ private void assertNoTodo(User approver, String nodeId) {
+ assertEquals(0, todoRecordsOf(approver, nodeId).size(), approver.getName() + " 在 B 节点不应有 todo 记录");
+ }
+
+ private String nodeIdByName(Workflow workflow, String name) {
+ return workflow.getNodes().stream()
+ .filter(node -> node.getName().equals(name))
+ .findFirst().orElseThrow().getId();
+ }
+}
From ca87ff1cb2f065ac6740bd4063c5d797f0a58b7d Mon Sep 17 00:00:00 2001
From: lorne <1991wangliang@gmail.com>
Date: Thu, 12 Nov 2026 09:40:40 +0800
Subject: [PATCH 03/17] =?UTF-8?q?fix:=20=E5=B9=B6=E7=AD=BE=E6=9C=AA?=
=?UTF-8?q?=E9=85=8D=E7=BD=AE=E6=AF=94=E4=BE=8B=E6=97=B6=E6=8C=89=E5=85=A8?=
=?UTF-8?q?=E9=83=A8=E9=80=9A=E8=BF=87=E5=A4=84=E7=90=86=E5=B9=B6=E8=A1=A5?=
=?UTF-8?q?=E5=85=85=E5=90=88=E5=B9=B6=E5=AE=A1=E6=89=B9=E5=A4=9A=E6=A8=A1?=
=?UTF-8?q?=E5=BC=8F=E9=AA=8C=E8=AF=81=20(#265)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- NodeStrategyManager#getMultiOperatorAuditMergePercent 将比例归一化到 (0,1],
未配置(<=0)按 1 处理,避免并签因 percent=0 退化为或签
(首个审批人通过即完成节点,对方待办与合并记录被自动办结清空)
- 新增 FlowIssue265MergeAuditModeTest:覆盖并签/或签/循序/随机一人 在开启
RecordMergeStrategy 时的合并待办生成、部分通过后保留、通过后清理与合并关系一致性
Closes #265
---
.../flow/manager/NodeStrategyManager.java | 15 +-
.../FlowIssue265MergeAuditModeTest.java | 373 ++++++++++++++++++
2 files changed, 386 insertions(+), 2 deletions(-)
create mode 100644 flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowIssue265MergeAuditModeTest.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 05b0765a..6f41cf70 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
@@ -160,15 +160,26 @@ public MultiOperatorAuditStrategy.Type getMultiOperatorAuditStrategyType() {
/**
* 多操作者审批并签比例
+ *
+ * 比例语义为「通过人数占审批席位数的比例」,有效区间为 (0, 1]。
+ * 未配置(0 或负数)或超过 1 时按 1(全部通过)返回:比例为 0 会让并签退化成或签
+ * (首个审批人通过即完成节点、其他审批人待办被自动办结),与并签「全部通过才算完成」
+ * 的配置意图不符,因此按需全部通过处理(issue #265)。
+ *
+ * @return 归一化后的并签比例,取值区间 (0, 1]
*/
public float getMultiOperatorAuditMergePercent() {
List strategies = this.strategies;
for (INodeStrategy strategy : strategies) {
if (strategy instanceof MultiOperatorAuditStrategy) {
- return ((MultiOperatorAuditStrategy) strategy).getPercent();
+ float percent = ((MultiOperatorAuditStrategy) strategy).getPercent();
+ if (percent <= 0) {
+ return 1f;
+ }
+ return Math.min(percent, 1f);
}
}
- return 0;
+ return 1f;
}
/**
diff --git a/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowIssue265MergeAuditModeTest.java b/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowIssue265MergeAuditModeTest.java
new file mode 100644
index 00000000..9372db04
--- /dev/null
+++ b/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowIssue265MergeAuditModeTest.java
@@ -0,0 +1,373 @@
+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.record.FlowRecord;
+import com.codingapi.flow.record.FlowTodoRecord;
+import com.codingapi.flow.script.factory.FlowGroovyScriptFactory;
+import com.codingapi.flow.strategy.node.FormFieldPermissionStrategy;
+import com.codingapi.flow.strategy.node.MultiOperatorAuditStrategy;
+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.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * issue #265:多人审批四种模式(并签 / 或签 / 顺序 / 随机一人)在开启记录合并时的验证。
+ *
+ * 流程 A-B-C,B 节点审批人为 b1、b2 且开启 {@link RecordMergeStrategy}(默认按审批人合并)。
+ * 验证点:
+ *
+ * - 流程到达 B 时,b1、b2 都能在待办列表看到自己的合并待办(合并组各 1 条);
+ * - 并签(MERGE):未全部通过前对方的待办与合并组不应消失,全部通过后节点才完成、待办与合并关系全部清理;
+ * - 或签(ANY):任一人通过后流程立即结束,对方待办与合并组必须被清理;
+ * - 顺序(SEQUENCE):后序审批人的待办与合并组在激活前不产生,激活后才产生;
+ * - 随机一人(RANDOM_ONE):仅一人收到待办,通过后流程结束且待办与合并关系清理干净。
+ *
+ */
+class FlowIssue265MergeAuditModeTest {
+
+ private final MyFlowServiceFactory factory = new MyFlowServiceFactory();
+
+ private final User user = new User(1, "user");
+ private final User b1 = new User(2, "b1");
+ private final User b2 = new User(3, "b2");
+
+ @Test
+ void mergeAudit_shouldKeepBothTodosUntilAllPassed() {
+ // given A(开始) - B(并签 + 合并) - C(结束)
+ Workflow workflow = saveWorkflow(MultiOperatorAuditStrategy.Type.MERGE, 1.0f);
+ String bNodeId = nodeIdByName(workflow, "B审批");
+
+ // when
+ long startRecordId = createAndSubmit(workflow);
+
+ // then 流程停在 B:b1、b2 各自的合并待办组(按审批人合并,同实例内各 1 条)
+ for (User approver : List.of(b1, b2)) {
+ FlowTodoRecord group = singleGroupOf(approver, bNodeId);
+ assertEquals(1, group.getMargeCount(), approver.getName() + " 的合并组应有 1 条记录");
+ assertEquals(1, todoRecordsOf(approver, bNodeId).size(),
+ approver.getName() + " 在 B 节点应有 1 条待办记录");
+ assertEquals(1, factory.flowTodoMergeRepository.findByTodoId(group.getId()).size(),
+ approver.getName() + " 应各有 1 条合并关系");
+ }
+
+ // when b1 通过(并签比例 100%,节点未完成)
+ pass(todoRecordsOf(b1, bNodeId).get(0), workflow, b1);
+
+ // then b1 待办与合并组被摘除,b2 的待办与合并组必须保留
+ assertNoTodo(b1, bNodeId);
+ assertEquals(0, groupsOf(b1, bNodeId).size(), "b1 的合并待办组应被删除");
+ FlowTodoRecord b2Group = singleGroupOf(b2, bNodeId);
+ assertEquals(1, b2Group.getMargeCount(), "b2 的合并组应保留 1 条记录");
+ assertEquals(1, todoRecordsOf(b2, bNodeId).size(), "b2 的待办应保留");
+ assertEquals(1, factory.flowTodoMergeRepository.findByTodoId(b2Group.getId()).size(),
+ "b2 的合并关系应保留");
+ assertFalse(record(startRecordId).isFinish(), "并签未全部通过时流程不应结束");
+
+ // when b2 通过(达到 100%)
+ pass(todoRecordsOf(b2, bNodeId).get(0), workflow, b2);
+
+ // then 节点完成:待办与合并关系全部清理,流程结束
+ for (User approver : List.of(b1, b2)) {
+ assertNoTodo(approver, bNodeId);
+ assertEquals(0, groupsOf(approver, bNodeId).size(), approver.getName() + " 的合并待办组应被删除");
+ }
+ assertEquals(0, factory.flowTodoMergeRepository.findAll().size(), "不应残留任何合并关系");
+ assertTrue(record(startRecordId).isFinish(), "并签全部通过后流程应结束");
+ }
+
+ @Test
+ void mergeAuditWithoutConfiguredPercent_shouldRequireAllOperatorsPassed() {
+ // given 并签但未配置通过比例(percent=0,等价于设计器未填写比例)
+ Workflow workflow = saveWorkflow(MultiOperatorAuditStrategy.Type.MERGE, 0f);
+ String bNodeId = nodeIdByName(workflow, "B审批");
+
+ // when
+ long startRecordId = createAndSubmit(workflow);
+
+ // then 两位审批人都持有待办
+ assertEquals(1, todoRecordsOf(b1, bNodeId).size(), "b1 应有 1 条待办记录");
+ assertEquals(1, todoRecordsOf(b2, bNodeId).size(), "b2 应有 1 条待办记录");
+
+ // when b1 通过
+ pass(todoRecordsOf(b1, bNodeId).get(0), workflow, b1);
+
+ // then 未配置比例不应退化为或签:b2 的待办与合并组必须保留,流程不应结束
+ FlowTodoRecord b2Group = singleGroupOf(b2, bNodeId);
+ assertEquals(1, b2Group.getMargeCount(), "b2 的合并组应保留 1 条记录");
+ assertEquals(1, todoRecordsOf(b2, bNodeId).size(), "b2 的待办应保留");
+ assertFalse(record(startRecordId).isFinish(), "并签未配置比例时应按全部通过处理,流程不应提前结束");
+
+ // when b2 通过
+ pass(todoRecordsOf(b2, bNodeId).get(0), workflow, b2);
+
+ // then 全部通过后流程结束,待办与合并关系清理干净
+ for (User approver : List.of(b1, b2)) {
+ assertNoTodo(approver, bNodeId);
+ assertEquals(0, groupsOf(approver, bNodeId).size(),
+ approver.getName() + " 的合并待办组应被删除");
+ }
+ assertEquals(0, factory.flowTodoMergeRepository.findAll().size(), "不应残留任何合并关系");
+ assertTrue(record(startRecordId).isFinish(), "全部通过后流程应结束");
+ }
+
+ @Test
+ void anyAudit_shouldClearOtherTodoWhenOnePassed() {
+ // given A(开始) - B(或签 + 合并) - C(结束)
+ Workflow workflow = saveWorkflow(MultiOperatorAuditStrategy.Type.ANY, 0f);
+ String bNodeId = nodeIdByName(workflow, "B审批");
+
+ // when
+ long startRecordId = createAndSubmit(workflow);
+
+ // then b1、b2 各自都能看到自己的合并待办
+ for (User approver : List.of(b1, b2)) {
+ FlowTodoRecord group = singleGroupOf(approver, bNodeId);
+ assertEquals(1, group.getMargeCount(), approver.getName() + " 的合并组应有 1 条记录");
+ }
+ assertEquals(2, factory.flowTodoMergeRepository.findAll().size(), "两个审批人各 1 条合并关系");
+
+ FlowRecord b2Record = todoRecordsOf(b2, bNodeId).get(0);
+ String b2TodoKey = b2Record.getTodoKey();
+
+ // when b1 通过(或签:任一人通过即流转)
+ pass(todoRecordsOf(b1, bNodeId).get(0), workflow, b1);
+
+ // then 流程结束,b2 的待办、合并组与合并关系必须一起消失
+ assertNoTodo(b1, bNodeId);
+ assertNoTodo(b2, bNodeId);
+ assertEquals(0, groupsOf(b1, bNodeId).size(), "b1 的合并待办组应被删除");
+ assertEquals(0, groupsOf(b2, bNodeId).size(), "b2 的合并待办组应被删除");
+ assertNull(factory.flowTodoRecordRepository.getByTodoKey(b2TodoKey),
+ "b2 的合并待办行应被删除");
+ assertEquals(0, factory.flowTodoMergeRepository.findAll().size(), "不应残留任何合并关系");
+
+ FlowRecord autoDoneRecord = record(b2Record.getId());
+ assertTrue(autoDoneRecord.isDone(), "b2 的记录应被自动办结");
+ assertTrue(autoDoneRecord.isAutoDone(), "b2 的记录应为自动办结(无审批动作)");
+ assertTrue(record(startRecordId).isFinish(), "或签任一人通过后流程应结束");
+ }
+
+ @Test
+ void sequenceAudit_shouldCreateNextOperatorTodoOnlyAfterActivated() {
+ // given A(开始) - B(顺序 + 合并) - C(结束)
+ Workflow workflow = saveWorkflow(MultiOperatorAuditStrategy.Type.SEQUENCE, 0f);
+ String bNodeId = nodeIdByName(workflow, "B审批");
+
+ // when
+ long startRecordId = createAndSubmit(workflow);
+
+ // then 后序审批人(b2)的记录隐藏,不产生待办与合并组
+ assertEquals(1, todoRecordsOf(b1, bNodeId).size(), "b1 应有 1 条待办记录");
+ singleGroupOf(b1, bNodeId);
+ assertEquals(0, todoRecordsOf(b2, bNodeId).size(), "b2 在未被激活前不应有 todo 记录");
+ assertEquals(0, groupsOf(b2, bNodeId).size(), "b2 在未被激活前不应有合并待办组");
+ assertEquals(1, factory.flowTodoMergeRepository.findAll().size(), "仅 b1 有 1 条合并关系");
+
+ // when b1 通过
+ pass(todoRecordsOf(b1, bNodeId).get(0), workflow, b1);
+
+ // then b1 待办摘除,b2 被激活并产生自己的合并待办
+ assertNoTodo(b1, bNodeId);
+ assertEquals(0, groupsOf(b1, bNodeId).size(), "b1 的合并待办组应被删除");
+ assertEquals(1, todoRecordsOf(b2, bNodeId).size(), "b2 通过后应被激活产生待办");
+ FlowTodoRecord b2Group = singleGroupOf(b2, bNodeId);
+ assertEquals(1, b2Group.getMargeCount(), "b2 的合并组应有 1 条记录");
+ assertEquals(1, factory.flowTodoMergeRepository.findByTodoId(b2Group.getId()).size(),
+ "b2 应有 1 条合并关系");
+ assertFalse(record(startRecordId).isFinish(), "顺序审批未全部完成时流程不应结束");
+
+ // when b2 通过
+ pass(todoRecordsOf(b2, bNodeId).get(0), workflow, b2);
+
+ // then 流程结束,待办与合并关系清理干净
+ assertNoTodo(b2, bNodeId);
+ assertEquals(0, groupsOf(b2, bNodeId).size(), "b2 的合并待办组应被删除");
+ assertEquals(0, factory.flowTodoMergeRepository.findAll().size(), "不应残留任何合并关系");
+ assertTrue(record(startRecordId).isFinish(), "顺序审批全部通过后流程应结束");
+ }
+
+ @Test
+ void randomOneAudit_shouldCreateSingleOperatorTodo() {
+ // given A(开始) - B(随机一人 + 合并) - C(结束)
+ Workflow workflow = saveWorkflow(MultiOperatorAuditStrategy.Type.RANDOM_ONE, 0f);
+ String bNodeId = nodeIdByName(workflow, "B审批");
+
+ // when
+ long startRecordId = createAndSubmit(workflow);
+
+ // then 仅一人收到待办,且只有一条合并组
+ List receivers = List.of(b1, b2).stream()
+ .filter(approver -> !todoRecordsOf(approver, bNodeId).isEmpty())
+ .toList();
+ assertEquals(1, receivers.size(), "随机一人审批时应有且仅有一名审批人收到待办");
+ User receiver = receivers.get(0);
+ FlowTodoRecord group = singleGroupOf(receiver, bNodeId);
+ assertEquals(1, group.getMargeCount(), "接收人的合并组应有 1 条记录");
+ assertEquals(1, factory.flowTodoRecordRepository.findAll().size(), "只应产生 1 条合并待办");
+ assertEquals(1, factory.flowTodoMergeRepository.findAll().size(), "只应产生 1 条合并关系");
+
+ // when 接收人通过
+ pass(todoRecordsOf(receiver, bNodeId).get(0), workflow, receiver);
+
+ // then 流程结束,待办与合并关系清理干净
+ assertNoTodo(receiver, bNodeId);
+ assertEquals(0, groupsOf(receiver, bNodeId).size(), "接收人的合并待办组应被删除");
+ assertEquals(0, factory.flowTodoRecordRepository.findAll().size(), "不应残留任何合并待办");
+ assertEquals(0, factory.flowTodoMergeRepository.findAll().size(), "不应残留任何合并关系");
+ assertTrue(record(startRecordId).isFinish(), "随机一人通过后流程应结束");
+ }
+
+ // ─── 辅助方法 ───
+
+ private Workflow saveWorkflow(MultiOperatorAuditStrategy.Type type, float percent) {
+ registerUsers();
+ Workflow workflow = buildWorkflow(type, percent);
+ factory.workflowService.saveWorkflow(workflow);
+ return workflow;
+ }
+
+ private Workflow buildWorkflow(MultiOperatorAuditStrategy.Type type, float percent) {
+ FlowForm form = FlowFormBuilder.builder()
+ .name("测试流程")
+ .code("issue265")
+ .addField("name", "name", DataType.STRING)
+ .build();
+
+ StartNode startNode = StartNode.builder()
+ .strategies(NodeStrategyBuilder.builder()
+ .addStrategy(perm())
+ .build())
+ .actions(ActionBuilder.builder()
+ .addAction(CustomAction.defaultAction())
+ .build())
+ .build();
+
+ ApprovalNode approvalNode = ApprovalNode.builder()
+ .name("B审批")
+ .strategies(NodeStrategyBuilder.builder()
+ .addStrategy(perm())
+ .addStrategy(new OperatorLoadStrategy(FlowGroovyScriptFactory
+ .createOperatorLoadScript("def run(request){return [2,3]}").getKey()))
+ .addStrategy(new MultiOperatorAuditStrategy(type, percent))
+ .addStrategy(new RecordMergeStrategy(true))
+ .build())
+ .build();
+
+ EndNode endNode = EndNode.builder().build();
+
+ return WorkflowBuilder.builder()
+ .title("测试流程")
+ .code("issue265")
+ .createdOperator(user)
+ .form(form)
+ .addNode(startNode)
+ .addNode(approvalNode)
+ .addNode(endNode)
+ .build();
+ }
+
+ private FormFieldPermissionStrategy perm() {
+ return new FormFieldPermissionStrategy(FormFieldPermissionsBuilder.builder()
+ .addPermission("issue265", "name", PermissionType.WRITE)
+ .build());
+ }
+
+ /**
+ * 发起并提交到 B 节点,返回发起节点记录id
+ */
+ private long createAndSubmit(Workflow workflow) {
+ StartNode startNode = (StartNode) workflow.getStartNode();
+ IFlowAction startAction = startNode.actionManager().getActions().get(0);
+ Map data = Map.of("name", "lorne");
+
+ FlowCreateRequest createRequest = new FlowCreateRequest();
+ createRequest.setWorkCode(workflow.getCode());
+ createRequest.setFormData(data);
+ createRequest.setActionId(startAction.id());
+ createRequest.setOperatorId(user.getUserId());
+ long recordId = factory.flowService.create(createRequest);
+
+ pass(startAction, user, data, recordId);
+ return recordId;
+ }
+
+ private void registerUsers() {
+ List.of(user, b1, b2).forEach(factory.userGateway::save);
+ GatewayContext.getInstance().setFlowOperatorGateway(factory.userGateway);
+ }
+
+ private void pass(FlowRecord flowRecord, Workflow workflow, User operator) {
+ ApprovalNode approvalNode = (ApprovalNode) workflow.getNodes().stream()
+ .filter(node -> node.getName().equals("B审批"))
+ .findFirst().orElseThrow();
+ IFlowAction action = approvalNode.actionManager().getActions().get(0);
+ pass(action, operator, Map.of("name", "lorne"), flowRecord.getId());
+ }
+
+ private void pass(IFlowAction action, User operator, Map data, long recordId) {
+ FlowActionRequest request = new FlowActionRequest();
+ request.setFormData(data);
+ request.setRecordId(recordId);
+ request.setAdvice(new FlowAdviceBody(action.id(), "同意", operator.getUserId()));
+ factory.flowService.action(request);
+ }
+
+ private FlowRecord record(long recordId) {
+ return factory.flowRecordRepository.get(recordId);
+ }
+
+ private List todoRecordsOf(User approver, String nodeId) {
+ return factory.flowRecordRepository.findTodoByOperator(approver.getUserId()).stream()
+ .filter(record -> nodeId.equals(record.getNodeId()))
+ .toList();
+ }
+
+ private List groupsOf(User approver, String nodeId) {
+ return factory.flowTodoRecordRepository.findByOperatorId(approver.getUserId()).stream()
+ .filter(group -> nodeId.equals(group.getNodeId()))
+ .toList();
+ }
+
+ private FlowTodoRecord singleGroupOf(User approver, String nodeId) {
+ List groups = groupsOf(approver, nodeId);
+ assertEquals(1, groups.size(), approver.getName() + " 在 B 节点应有且仅有 1 条合并待办组");
+ return groups.get(0);
+ }
+
+ private void assertNoTodo(User approver, String nodeId) {
+ assertEquals(0, todoRecordsOf(approver, nodeId).size(), approver.getName() + " 在 B 节点不应有 todo 记录");
+ }
+
+ private String nodeIdByName(Workflow workflow, String name) {
+ return workflow.getNodes().stream()
+ .filter(node -> node.getName().equals(name))
+ .findFirst().orElseThrow().getId();
+ }
+}
From 0f1aadb9aed77c1554fddb7104061f2ec5ad42fc Mon Sep 17 00:00:00 2001
From: lorne <1991wangliang@gmail.com>
Date: Thu, 12 Nov 2026 10:01:36 +0800
Subject: [PATCH 04/17] =?UTF-8?q?feat:=20=E5=A4=9A=E4=BA=BA=E5=AE=A1?=
=?UTF-8?q?=E6=89=B9=E8=8A=82=E7=82=B9=E6=96=B0=E5=A2=9E=E5=87=8F=E7=AD=BE?=
=?UTF-8?q?=E8=83=BD=E5=8A=9B=20(#266)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增 ActionType.REDUCE_AUDIT 与 ReduceAuditAction:由当前审批人通过
advice.forwardOperatorIds 撤下同一节点实例中的其他审批人,与加签对称;
仅支持循序(SEQUENCE)与并签(MERGE),或签/任意一人直接拒绝
- 被减签记录置撤销后统一走 repositoryHolder.saveRecords() →
FlowRecordSaveService.saveAll(),复用既有逻辑清理待办行与合并关系
(#260 的「只摘自身合并关系 + 回填代表记录」),不做假减人
- 循序审批的激活条件由 nodeOrder + 1 放宽为「最近一条更大 nodeOrder」,
减签撤销中间审批人后序号出现空洞也能正常激活下一位(PassAction)
- 减签前置校验:不能减签自己;撤销后节点仍必须保留至少一次待审批,
避免并签比例已达成却没有任何触发点导致流程永久卡死(BaseAuditNode)
- 新增 FlowRecordReduceAuditEvent 事件,携带被撤销记录与执行人
- 审批节点默认动作追加「减签」(末尾追加,保持既有动作顺序契约),
仅在循序/并签且存在其他待审批人时展示按钮
- 以 #265 的并签比例归一化修复为基线(cherry-pick 180aede1),
新增 FlowIssue266ReduceAuditTest(9 个用例)
Closes #266
关联 #264 #265(合并交付,是否关闭由维护者确认)
---
AGENTS.md | 6 +-
.../flow-engine-framework/action.md | 6 +-
docs/capabilities/index.md | 2 +-
.../com/codingapi/flow/action/ActionType.java | 2 +
.../flow/action/actions/PassAction.java | 20 +-
.../action/actions/ReduceAuditAction.java | 238 +++++++++
.../action/factory/FlowActionFactory.java | 1 +
.../event/FlowRecordReduceAuditEvent.java | 31 ++
.../exception/FlowExecutionException.java | 49 ++
.../codingapi/flow/manager/ActionManager.java | 7 +
.../codingapi/flow/node/BaseAuditNode.java | 47 +-
.../flow/node/nodes/ApprovalNode.java | 3 +
.../flow/service/FlowDetailServiceTest.java | 3 +-
.../service/FlowIssue266ReduceAuditTest.java | 462 ++++++++++++++++++
.../service/FlowMockSampleServiceTest.java | 7 +-
.../flow/service/FlowSampleServiceTest.java | 3 +-
16 files changed, 871 insertions(+), 16 deletions(-)
create mode 100644 flow-engine-framework/src/main/java/com/codingapi/flow/action/actions/ReduceAuditAction.java
create mode 100644 flow-engine-framework/src/main/java/com/codingapi/flow/event/FlowRecordReduceAuditEvent.java
create mode 100644 flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowIssue266ReduceAuditTest.java
diff --git a/AGENTS.md b/AGENTS.md
index 6e2788de..cc6bc6d4 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -22,7 +22,7 @@ Flow Engine 是一个企业级工作流引擎,基于 Java 17 和 Spring Boot 3
| 包路径 | 职责 |
|--------|------|
| `com.codingapi.flow.node` | 19 种流程节点类型 |
-| `com.codingapi.flow.action` | 8 种动作类型 |
+| `com.codingapi.flow.action` | 9 种动作类型 |
| `com.codingapi.flow.strategy` | 节点策略和工作流策略 |
| `com.codingapi.flow.script` | Groovy 脚本运行时 |
| `com.codingapi.flow.repository` | 仓储接口定义 |
@@ -57,14 +57,14 @@ REST 层通过请求参数 `mockKey` 分流到 Mock 实例(见 `FlowRecordCont
### 节点 / 动作 / 策略三元组
-19 种节点(`node/nodes/`)× 8 种动作(`action/actions/`),行为由策略(`strategy/node/`、`strategy/workflow/`)驱动而非继承扩展:
+19 种节点(`node/nodes/`)× 9 种动作(`action/actions/`),行为由策略(`strategy/node/`、`strategy/workflow/`)驱动而非继承扩展:
- `NodeStrategyManager` 持有节点策略(如 `RevokeStrategy`、`OperatorLoadStrategy`、`RecordMergeStrategy`)
- `ActionManager` 持有节点动作
- 节点类型常量见 `StartNode.NODE_TYPE`(= `NodeType.START.name()`),`FlowRecord.isNodeType(String)` 用于按类型判定
### 事件(IFlowEvent + EventPusher)
-8 种事件:开始 / 待办 / 已办 / 完成 / 催办 / 撤销(`FlowRecordRevokeEvent`)/ 删除(`FlowRecordDeleteEvent`)/ 子流程重置(`FlowSubProcessResetEvent`)。操作完成后用 `EventPusher.push(event)` 推送;记录类事件携带 `FlowRecord` + `mock` 标记,重置事件携带新旧聚合组快照。外部可用 Spring `@EventListener` 订阅。
+9 种事件:开始 / 待办 / 已办 / 完成 / 催办 / 撤销(`FlowRecordRevokeEvent`)/ 删除(`FlowRecordDeleteEvent`)/ 子流程重置(`FlowSubProcessResetEvent`)/ 减签(`FlowRecordReduceAuditEvent`)。操作完成后用 `EventPusher.push(event)` 推送;记录类事件携带 `FlowRecord` + `mock` 标记,重置事件携带新旧聚合组快照。外部可用 Spring `@EventListener` 订阅。
### API 模块划分
diff --git a/docs/capabilities/flow-engine-framework/action.md b/docs/capabilities/flow-engine-framework/action.md
index f3717f1a..2b7e6b9c 100644
--- a/docs/capabilities/flow-engine-framework/action.md
+++ b/docs/capabilities/flow-engine-framework/action.md
@@ -1,7 +1,7 @@
---
name: flow-engine-framework/action
module: flow-engine-framework
-description: 流程动作体系,包含 8 种动作类型、动作接口和动作工厂
+description: 流程动作体系,包含 9 种动作类型、动作接口和动作工厂
status: 已实现
scope: 后端
source: 项目自有
@@ -18,6 +18,7 @@ symbols:
- ReturnAction
- DelegateAction
- AddAuditAction
+ - ReduceAuditAction
- CustomAction
- SaveAction
- InitiatorSelectNodeService
@@ -28,7 +29,7 @@ content_hash: 0ec57b5fb719f4df594be65eda1e92ba172dad9dfb2aa8f9ec272b275e0a8321
提供流程节点上可执行动作的标准化体系,解决以下问题:
-- **动作类型标准化**:8 种标准动作覆盖审批、驳回、转办、退回、委派、加签、自定义、暂存场景
+- **动作类型标准化**:9 种标准动作覆盖审批、驳回、转办、退回、委派、加签、减签、自定义、暂存场景
- **动作执行入口**:`IFlowAction.run()` 和 `generateRecords()` 分别对应动作触发和记录生成
- **动作可用性控制**:`enable()` 控制动作在当前节点是否可用
- **动作创建统一化**:`FlowActionFactory` 通过反射从 Map 数据统一反序列化动作实例
@@ -45,6 +46,7 @@ content_hash: 0ec57b5fb719f4df594be65eda1e92ba172dad9dfb2aa8f9ec272b275e0a8321
| `ReturnAction` | RETURN | 退回,退回至上一节点 |
| `DelegateAction` | DELEGATE | 委派,临时委派给其他操作者 |
| `AddAuditAction` | ADD_AUDIT | 加签,增加审批人 |
+| `ReduceAuditAction` | REDUCE_AUDIT | 减签,由当前审批人把指定审批人从当前节点撤下(仅循序/并签) |
| `CustomAction` | CUSTOM | 自定义动作,通过脚本定义行为 |
| `SaveAction` | SAVE | 暂存,保存当前状态 |
diff --git a/docs/capabilities/index.md b/docs/capabilities/index.md
index 44841d7a..e6b15be8 100644
--- a/docs/capabilities/index.md
+++ b/docs/capabilities/index.md
@@ -7,7 +7,7 @@
| 名称 | 模块 | 描述 | 范围 | 来源 |
|------|------|------|------|------|
| [commons/lang3](./commons/lang3.md) | commons | Apache Commons 工具库,提供字符串、集合、IO 等通用工具方法 | 后端 | 框架:Apache Commons |
-| [flow-engine-framework/action](./flow-engine-framework/action.md) | flow-engine-framework | 流程动作体系,包含 8 种动作类型、动作接口和动作工厂 | 后端 | 项目自有 |
+| [flow-engine-framework/action](./flow-engine-framework/action.md) | flow-engine-framework | 流程动作体系,包含 9 种动作类型、动作接口和动作工厂 | 后端 | 项目自有 |
| [flow-engine-framework/delay](./flow-engine-framework/delay.md) | flow-engine-framework | 延迟任务调度能力,支持延迟节点的定时触发、任务持久化与共享调度线程 | 后端 | 项目自有 |
| [flow-engine-framework/exception](./flow-engine-framework/exception.md) | flow-engine-framework | 流程异常体系,包含异常基类和 6 种业务异常子类 | 后端 | 项目自有 |
| [flow-engine-framework/flow-service](./flow-engine-framework/flow-service.md) | flow-engine-framework | 流程服务层,封装流程的创建、审批、撤销、催办等核心业务操作 | 后端 | 项目自有 |
diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/action/ActionType.java b/flow-engine-framework/src/main/java/com/codingapi/flow/action/ActionType.java
index 750cd166..8158b061 100644
--- a/flow-engine-framework/src/main/java/com/codingapi/flow/action/ActionType.java
+++ b/flow-engine-framework/src/main/java/com/codingapi/flow/action/ActionType.java
@@ -12,6 +12,8 @@ public enum ActionType {
REJECT,
// 加签,指定给其他人一块审批,以会签模式来处理
ADD_AUDIT,
+ // 减签,把指定审批人从当前节点撤下(撤销其待办),流程继续流转(issue #266)
+ REDUCE_AUDIT,
// 委派,委派给其他人员来审批,当人员审批完成以后再流程给自己审批
DELEGATE,
// 退回,退回时需要设置退回的节点
diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/action/actions/PassAction.java b/flow-engine-framework/src/main/java/com/codingapi/flow/action/actions/PassAction.java
index fa51f533..0550af03 100644
--- a/flow-engine-framework/src/main/java/com/codingapi/flow/action/actions/PassAction.java
+++ b/flow-engine-framework/src/main/java/com/codingapi/flow/action/actions/PassAction.java
@@ -147,14 +147,24 @@ public void run(FlowSession flowSession) {
// 激活下一个按顺序审批的记录数据。
// 委派记录只是临时办理人,原审批人尚未确认,不能提前激活后续顺序的审批人(issue #239);
// 只激活尚未展示过的记录,避免委派返回后重复激活已显示/已办理的人员。
+ // 按「最近一条更大 nodeOrder」激活而不是固定的 nodeOrder + 1:减签撤销中间的隐藏审批人后
+ // 序号会出现空洞,按 +1 查找会永远找不到下一位,导致后续审批人永不激活(issue #266)
NodeStrategyManager nodeStrategyManager = currentNode.strategyManager();
if (!currentRecord.isDelegate() && nodeStrategyManager.isSequenceMultiOperatorType()) {
List currentRecords = flowSession.getCurrentNodeRecords();
- for (FlowRecord record : currentRecords) {
- if (record.getNodeOrder() == currentRecord.getNodeOrder() + 1 && record.isHidden()) {
- record.show();
- recordList.add(record);
- flowEvents.add(new FlowRecordTodoEvent(record, flowSession.isMock()));
+ int nextNodeOrder = currentRecords.stream()
+ .filter(FlowRecord::isHidden)
+ .filter(record -> record.getNodeOrder() > currentRecord.getNodeOrder())
+ .mapToInt(FlowRecord::getNodeOrder)
+ .min()
+ .orElse(-1);
+ if (nextNodeOrder >= 0) {
+ for (FlowRecord record : currentRecords) {
+ if (record.getNodeOrder() == nextNodeOrder && record.isHidden()) {
+ record.show();
+ recordList.add(record);
+ flowEvents.add(new FlowRecordTodoEvent(record, flowSession.isMock()));
+ }
}
}
}
diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/action/actions/ReduceAuditAction.java b/flow-engine-framework/src/main/java/com/codingapi/flow/action/actions/ReduceAuditAction.java
new file mode 100644
index 00000000..b0a58609
--- /dev/null
+++ b/flow-engine-framework/src/main/java/com/codingapi/flow/action/actions/ReduceAuditAction.java
@@ -0,0 +1,238 @@
+package com.codingapi.flow.action.actions;
+
+import com.codingapi.flow.action.ActionDisplay;
+import com.codingapi.flow.action.ActionType;
+import com.codingapi.flow.action.BaseAction;
+import com.codingapi.flow.action.IFlowAction;
+import com.codingapi.flow.event.FlowRecordReduceAuditEvent;
+import com.codingapi.flow.event.IFlowEvent;
+import com.codingapi.flow.exception.FlowExecutionException;
+import com.codingapi.flow.exception.FlowValidationException;
+import com.codingapi.flow.generator.FlowIDGeneratorGatewayContext;
+import com.codingapi.flow.manager.OperatorManager;
+import com.codingapi.flow.node.BaseAuditNode;
+import com.codingapi.flow.node.IFlowNode;
+import com.codingapi.flow.operator.IFlowOperator;
+import com.codingapi.flow.record.FlowRecord;
+import com.codingapi.flow.script.node.OperatorLoadScript;
+import com.codingapi.flow.session.FlowSession;
+import com.codingapi.flow.session.IRepositoryHolder;
+import com.codingapi.flow.strategy.node.MultiOperatorAuditStrategy;
+import com.codingapi.springboot.framework.event.EventPusher;
+import org.springframework.util.StringUtils;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * 减签:由当前审批人把指定审批人从当前审批节点撤下(issue #266)。
+ *
+ * 与加签({@link AddAuditAction})对称:被减签人通过 {@code advice.forwardOperatorIds} 传入,
+ * 可选人员范围通过动作的 {@code script} 限定。被减签的记录置为撤销,待办与合并关系由
+ * {@link IRepositoryHolder#saveRecords(List)} 统一维护,不直接操作待办表。
+ *
+ *
仅支持循序({@code SEQUENCE})与并签({@code MERGE})两种多人审批模式:
+ * 或签({@code ANY})与任意一人({@code RANDOM_ONE})单人通过即完成节点,减签没有业务意义。
+ *
+ *
减签不修改当前审批人的记录,当前审批人仍需通过「通过 / 拒绝」表达自己的审批意见。
+ */
+public class ReduceAuditAction extends BaseAction {
+
+ public static final String DEFAULT_TITLE = "减签";
+
+ /**
+ * 可以减签的人员范围,为空表示不限
+ */
+ private OperatorLoadScript script;
+
+ public static ReduceAuditAction defaultAction() {
+ ReduceAuditAction action = new ReduceAuditAction();
+ action.setId(FlowIDGeneratorGatewayContext.getInstance().generateActionId());
+ action.setTitle(DEFAULT_TITLE);
+ action.setEnable(true);
+ action.setType(ActionType.REDUCE_AUDIT.name());
+ action.setDisplay(ActionDisplay.defaultDisplay(DEFAULT_TITLE));
+ action.setScript(null);
+ return action;
+ }
+
+ public void setScript(String script) {
+ if (StringUtils.hasText(script)) {
+ this.script = new OperatorLoadScript(script);
+ }
+ }
+
+ /**
+ * 可以减签的人员范围
+ */
+ public List operators(FlowSession flowSession) {
+ return script.execute(flowSession);
+ }
+
+ @Override
+ public void copy(IFlowAction action) {
+ super.copy(action);
+ this.script = ((ReduceAuditAction) action).script;
+ }
+
+ /**
+ * 仅当当前节点存在可被减签的其他审批人时展示按钮。
+ *
+ * 单审批人节点、或签/任意一人节点、以及当前节点已无其他待审批人时不展示,
+ * 避免出现点击后才被服务端拒绝的无效入口。
+ */
+ @Override
+ public boolean show(FlowSession flowSession) {
+ if (!super.show(flowSession)) {
+ return false;
+ }
+ if (flowSession == null || !(flowSession.getCurrentNode() instanceof BaseAuditNode currentNode)) {
+ return false;
+ }
+ if (!isSupportedMode(currentNode)) {
+ return false;
+ }
+ return !pendingRecords(flowSession).isEmpty();
+ }
+
+ @Override
+ public void run(FlowSession flowSession) {
+ IRepositoryHolder repositoryHolder = flowSession.getRepositoryHolder();
+ FlowRecord currentRecord = flowSession.getCurrentRecord();
+ IFlowNode currentNode = flowSession.getCurrentNode();
+ if (!(currentNode instanceof BaseAuditNode auditNode) || !isSupportedMode(currentNode)) {
+ throw FlowExecutionException.reduceAuditUnsupported();
+ }
+ List targets = flowSession.getAdvice().getForwardOperators();
+ if (targets == null || targets.isEmpty()) {
+ throw FlowValidationException.required("forwardOperators");
+ }
+ // 可减签人员范围
+ if (script != null) {
+ OperatorManager operatorManager = new OperatorManager(script.execute(flowSession));
+ for (IFlowOperator target : targets) {
+ if (!operatorManager.match(target)) {
+ throw FlowExecutionException.operatorNotInScope("reduceAudit");
+ }
+ }
+ }
+
+ List currentNodeRecords = currentNodeRecords(flowSession);
+ List targetRecords = resolveTargetRecords(targets, currentNodeRecords, currentRecord);
+
+ // 撤销后必须仍需要一次真实审批,否则节点条件即使满足也没有触发点,流程会永久卡死
+ List targetRecordIds = targetRecords.stream().map(FlowRecord::getId).toList();
+ List remainingRecords = currentNodeRecords.stream()
+ .filter(record -> !targetRecordIds.contains(record.getId()))
+ .toList();
+ if (!auditNode.requiresAuditToFinish(remainingRecords)) {
+ throw FlowExecutionException.reduceAuditNoRemainingApproval();
+ }
+
+ List flowEvents = new ArrayList<>();
+ List saveRecords = new ArrayList<>();
+ for (FlowRecord targetRecord : targetRecords) {
+ targetRecord.revoke();
+ saveRecords.add(targetRecord);
+ flowEvents.add(new FlowRecordReduceAuditEvent(targetRecord,
+ currentRecord.getCurrentOperatorId(), flowSession.isMock()));
+ }
+
+ // 循序审批的链路由通过时的「最近一条更大 nodeOrder」激活保证,撤销中间记录不需要额外补链
+ repositoryHolder.saveRecords(saveRecords);
+ flowEvents.forEach(EventPusher::push);
+ }
+
+ /**
+ * 解析被减签的流程记录:目标必须是同一节点实例中尚未办理的审批记录。
+ *
+ * 同一审批人可能在该节点实例上持有多条待审批记录(例如加签会为其生成多条记录),
+ * 减签按「人」生效,必须一并撤销,否则目标仍残留待办,等于没有减签。
+ */
+ private List resolveTargetRecords(List targets,
+ List currentNodeRecords,
+ FlowRecord currentRecord) {
+ List targetRecords = new ArrayList<>();
+ for (IFlowOperator target : targets) {
+ long targetOperatorId = target.getUserId();
+ if (targetOperatorId == currentRecord.getCurrentOperatorId()) {
+ throw FlowExecutionException.reduceAuditOwnRecord();
+ }
+ List matchedRecords = currentNodeRecords.stream()
+ .filter(this::isPendingApprovalRecord)
+ .filter(record -> record.getCurrentOperatorId() == targetOperatorId)
+ .toList();
+ if (matchedRecords.isEmpty()) {
+ throw FlowExecutionException.reduceAuditTargetNotFound(targetOperatorId);
+ }
+ for (FlowRecord matchedRecord : matchedRecords) {
+ // 同一审批人被重复传入时按记录 id 去重
+ if (targetRecords.stream().noneMatch(record -> record.getId() == matchedRecord.getId())) {
+ targetRecords.add(matchedRecord);
+ }
+ }
+ }
+ return targetRecords;
+ }
+
+ /**
+ * 可减签的待办记录:当前节点实例中除自己以外的待审批记录(含顺序审批中尚未激活的隐藏记录)。
+ */
+ private List pendingRecords(FlowSession flowSession) {
+ FlowRecord currentRecord = flowSession.getCurrentRecord();
+ if (currentRecord == null) {
+ return List.of();
+ }
+ return currentNodeRecords(flowSession).stream()
+ .filter(this::isPendingApprovalRecord)
+ .filter(record -> record.getId() != currentRecord.getId())
+ .toList();
+ }
+
+ /**
+ * 取当前节点实例中属于同一流程实例的记录。
+ *
+ * 仓储按 {@code (fromId, nodeId)} 查询,开始节点({@code fromId = 0})等场景可能命中
+ * 其他流程实例的记录,必须按 {@code processId} 限定,避免误伤别的流程。
+ */
+ private List currentNodeRecords(FlowSession flowSession) {
+ FlowRecord currentRecord = flowSession.getCurrentRecord();
+ List currentNodeRecords = flowSession.getCurrentNodeRecords();
+ if (currentRecord == null || currentNodeRecords == null) {
+ return List.of();
+ }
+ return currentNodeRecords.stream()
+ .filter(Objects::nonNull)
+ .filter(record -> currentRecord.getProcessId().equals(record.getProcessId()))
+ .toList();
+ }
+
+ private boolean isPendingApprovalRecord(FlowRecord record) {
+ return !record.isRevoked()
+ && record.getRecordState() == FlowRecord.SATE_RECORD_TODO
+ && record.getFlowState() == FlowRecord.SATE_FLOW_RUNNING;
+ }
+
+ private boolean isSupportedMode(IFlowNode node) {
+ MultiOperatorAuditStrategy.Type type = node.strategyManager().getMultiOperatorAuditStrategyType();
+ return type == MultiOperatorAuditStrategy.Type.SEQUENCE
+ || type == MultiOperatorAuditStrategy.Type.MERGE;
+ }
+
+ public static ReduceAuditAction fromMap(Map data) {
+ ReduceAuditAction action = BaseAction.fromMap(data, ReduceAuditAction.class);
+ action.setScript((String) data.get("script"));
+ return action;
+ }
+
+ @Override
+ public Map toMap() {
+ Map data = super.toMap();
+ if (script != null) {
+ data.put("script", script.getScript());
+ }
+ return data;
+ }
+}
diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/action/factory/FlowActionFactory.java b/flow-engine-framework/src/main/java/com/codingapi/flow/action/factory/FlowActionFactory.java
index e7f44fac..17ec4c07 100644
--- a/flow-engine-framework/src/main/java/com/codingapi/flow/action/factory/FlowActionFactory.java
+++ b/flow-engine-framework/src/main/java/com/codingapi/flow/action/factory/FlowActionFactory.java
@@ -24,6 +24,7 @@ private FlowActionFactory() {
private void initNodes() {
actionClasses.put(ActionType.REJECT.name(), RejectAction.class);
actionClasses.put(ActionType.ADD_AUDIT.name(), AddAuditAction.class);
+ actionClasses.put(ActionType.REDUCE_AUDIT.name(), ReduceAuditAction.class);
actionClasses.put(ActionType.PASS.name(), PassAction.class);
actionClasses.put(ActionType.TRANSFER.name(), TransferAction.class);
actionClasses.put(ActionType.RETURN.name(), ReturnAction.class);
diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/event/FlowRecordReduceAuditEvent.java b/flow-engine-framework/src/main/java/com/codingapi/flow/event/FlowRecordReduceAuditEvent.java
new file mode 100644
index 00000000..0d989210
--- /dev/null
+++ b/flow-engine-framework/src/main/java/com/codingapi/flow/event/FlowRecordReduceAuditEvent.java
@@ -0,0 +1,31 @@
+package com.codingapi.flow.event;
+
+import com.codingapi.flow.record.FlowRecord;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * 减签事件
+ *
+ * 审批过程中由当前审批人执行减签时触发,登记被撤下的审批人记录与执行人。
+ * 被撤下记录的待办与合并关系由保存流程记录的同一批操作清理,订阅方据此同步外部待办。
+ */
+@Getter
+@AllArgsConstructor
+public class FlowRecordReduceAuditEvent implements IFlowEvent {
+
+ /**
+ * 被撤下的审批记录(已标记撤销)
+ */
+ private final FlowRecord flowRecord;
+
+ /**
+ * 执行减签的审批人id
+ */
+ private final long reduceOperatorId;
+
+ /**
+ * 是否为模拟环境
+ */
+ private final boolean mock;
+}
diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/exception/FlowExecutionException.java b/flow-engine-framework/src/main/java/com/codingapi/flow/exception/FlowExecutionException.java
index 5d5172f1..a23de0f8 100644
--- a/flow-engine-framework/src/main/java/com/codingapi/flow/exception/FlowExecutionException.java
+++ b/flow-engine-framework/src/main/java/com/codingapi/flow/exception/FlowExecutionException.java
@@ -183,4 +183,53 @@ public static FlowExecutionException nodeLoopDepthExceeded(int maxDepth) {
return new FlowExecutionException("execution.node.loopDepth",
String.format("Node execution count exceeds the loop guard limit: %d", maxDepth));
}
+
+ /**
+ * Reduce audit is not supported by the current multi operator audit strategy
+ *
+ * Only sequence (SEQUENCE) and merge (MERGE) multi operator audit nodes need to
+ * reduce approvers; ANY and RANDOM_ONE complete with a single approval, so
+ * removing an approver has no business meaning.
+ *
+ * @return exception
+ */
+ public static FlowExecutionException reduceAuditUnsupported() {
+ return new FlowExecutionException("execution.reduceAudit.unsupported",
+ "Reduce audit is only supported by sequence or merge multi-operator audit strategy");
+ }
+
+ /**
+ * Reduce audit target is not a pending approver of the current node instance
+ *
+ * @param operatorId target operator id
+ * @return exception
+ */
+ public static FlowExecutionException reduceAuditTargetNotFound(long operatorId) {
+ return new FlowExecutionException("execution.reduceAudit.targetNotFound",
+ String.format("Operator %d is not a pending approver of the current node", operatorId));
+ }
+
+ /**
+ * Reduce audit is not allowed to remove the operator who is executing the action
+ *
+ * @return exception
+ */
+ public static FlowExecutionException reduceAuditOwnRecord() {
+ return new FlowExecutionException("execution.reduceAudit.ownRecord",
+ "Reduce audit is not allowed to remove the current operator, use pass, reject or transfer instead");
+ }
+
+ /**
+ * Reduce audit would leave the node without any pending approval
+ *
+ * Node completion is only evaluated when an approval action is submitted. If the
+ * node is already satisfied (or has no pending approvers) after the removal, no
+ * action will ever trigger the completion and the flow stalls forever.
+ *
+ * @return exception
+ */
+ public static FlowExecutionException reduceAuditNoRemainingApproval() {
+ return new FlowExecutionException("execution.reduceAudit.noRemainingApproval",
+ "Reduce audit rejected: the node still requires at least one pending approval to finish");
+ }
}
diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/manager/ActionManager.java b/flow-engine-framework/src/main/java/com/codingapi/flow/manager/ActionManager.java
index 8973cd85..e775e0bc 100644
--- a/flow-engine-framework/src/main/java/com/codingapi/flow/manager/ActionManager.java
+++ b/flow-engine-framework/src/main/java/com/codingapi/flow/manager/ActionManager.java
@@ -127,6 +127,13 @@ public void verifySession(FlowSession session) {
}
}
+ // 减签操作(意见记录在执行人自己的记录上,不校验意见必填)
+ if (flowAction instanceof ReduceAuditAction) {
+ if (flowAdvice.getForwardOperators() == null || flowAdvice.getForwardOperators().isEmpty()) {
+ throw FlowValidationException.required("forwardOperators");
+ }
+ }
+
// 退回操作
if (flowAction instanceof ReturnAction) {
// 是否必须填写审批意见(意见记录在当前办理记录上)
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 da9b0c2b..8e16b43c 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
@@ -140,13 +140,13 @@ public boolean isFinish(FlowSession session) {
* 当前正在办理的记录即将通过,按通过计入(委派替办记录除外)。
*
* @param currentRecords 当前节点实例的全部记录
- * @param currentRecord 当前正在办理的记录
+ * @param currentRecord 当前正在办理的记录,可为 null(此时只统计已办记录,见 {@link #requiresAuditToFinish(List)})
* @return 已审批的槽位数量
*/
private long approvedNodeOrderCount(List currentRecords, FlowRecord currentRecord) {
Set approvedOrders = new HashSet<>();
for (FlowRecord record : currentRecords) {
- if (record.getId() == currentRecord.getId()) {
+ if (currentRecord != null && record.getId() == currentRecord.getId()) {
if (!currentRecord.isDelegate()) {
approvedOrders.add(record.getNodeOrder());
}
@@ -160,6 +160,49 @@ private long approvedNodeOrderCount(List currentRecords, FlowRecord
return approvedOrders.size();
}
+ /**
+ * 判断节点在给定记录集合下是否仍需至少一次真实审批才能完成(减签前置校验,issue #266)。
+ *
+ * 节点的完成判定只在有人提交审批动作时求值({@link #isFinish(FlowSession)}),
+ * 没有定时重算。因此减签必须保证撤销之后仍然存在一个「未来会被提交的审批动作」,
+ * 否则节点条件即使已经满足也不会向前流转,流程永久卡死:
+ *
+ * - 顺序审批(SEQUENCE):只要还有待办记录(含尚未激活的隐藏记录),就仍需审批;
+ * - 并签(MERGE):已通过槽位数小于「剩余槽位数 × 比例」时才仍需审批,
+ * 否则减签会让比例立刻达成却无人触发完成。
+ *
+ *
+ * @param records 减签后仍保留的当前节点实例记录
+ * @return true 表示仍需至少一次审批才能完成
+ */
+ public boolean requiresAuditToFinish(List records) {
+ if (records == null || records.isEmpty()) {
+ return false;
+ }
+ MultiOperatorAuditStrategy.Type multiOperatorAuditStrategyType = this.strategyManager().getMultiOperatorAuditStrategyType();
+ if (multiOperatorAuditStrategyType == MultiOperatorAuditStrategy.Type.SEQUENCE) {
+ return records.stream().anyMatch(this::isPendingApprovalRecord);
+ }
+ if (multiOperatorAuditStrategyType == MultiOperatorAuditStrategy.Type.MERGE) {
+ long total = records.stream()
+ .map(FlowRecord::getNodeOrder)
+ .distinct()
+ .count();
+ long approved = approvedNodeOrderCount(records, null);
+ return approved < total * this.strategyManager().getMultiOperatorAuditMergePercent();
+ }
+ return false;
+ }
+
+ /**
+ * 是否为待审批记录:含顺序审批中尚未激活的隐藏记录。
+ */
+ private boolean isPendingApprovalRecord(FlowRecord record) {
+ return !record.isRevoked()
+ && record.getRecordState() == FlowRecord.SATE_RECORD_TODO
+ && record.getFlowState() == FlowRecord.SATE_FLOW_RUNNING;
+ }
+
/**
* 生成当前节点的记录
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 82f3b1c5..0aa8058f 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
@@ -65,6 +65,9 @@ private static List defaultActions() {
actions.add(TransferAction.defaultAction());
actions.add(ReturnAction.defaultAction());
actions.add(DelegateAction.defaultAction());
+ // 减签追加在末尾,避免改变既有动作的顺序(动作列表是按序展示的对外契约);
+ // 仅循序/并签且存在其他待审批人时可见(issue #266)
+ actions.add(ReduceAuditAction.defaultAction());
return actions;
}
diff --git a/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowDetailServiceTest.java b/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowDetailServiceTest.java
index e95d3678..a7dbd374 100644
--- a/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowDetailServiceTest.java
+++ b/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowDetailServiceTest.java
@@ -146,7 +146,8 @@ void detail() {
detail = factory.flowService.detail(new FlowDetailRequest(bossRecordList.get(0).getId(),boss.getUserId()));
assertEquals(detail.getForm().getCode(), form.getCode());
- assertEquals(detail.getActions().size(), bossNode.actionManager().getActions().size());
+ // 单审批人节点不展示「减签」,可见动作数少于节点配置的动作数,按全部动作比较(issue #266)
+ assertEquals(detail.getActionList().size(), bossNode.actionManager().getActions().size());
assertEquals(1, detail.getTodos().size());
assertEquals(1, detail.getHistories().size());
assertEquals(data, detail.getTodos().get(0).getData());
diff --git a/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowIssue266ReduceAuditTest.java b/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowIssue266ReduceAuditTest.java
new file mode 100644
index 00000000..ed1ee164
--- /dev/null
+++ b/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowIssue266ReduceAuditTest.java
@@ -0,0 +1,462 @@
+package com.codingapi.flow.service;
+
+import com.codingapi.flow.action.ActionType;
+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.exception.FlowExecutionException;
+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.record.FlowTodoRecord;
+import com.codingapi.flow.script.factory.FlowGroovyScriptFactory;
+import com.codingapi.flow.strategy.node.FormFieldPermissionStrategy;
+import com.codingapi.flow.strategy.node.MultiOperatorAuditStrategy;
+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.Arrays;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * issue #266:多人审批减签能力(合并 #264 / #265)。
+ *
+ * 流程 A-B-C,B 为多人审批节点。验证点:
+ *
+ * - 并签(MERGE):减签后目标审批人的待办、合并待办组与合并关系一并清理,剩余审批人通过后节点完成;
+ * - 顺序审批(SEQUENCE):减签尚未激活的审批人后,通过时仍能激活「下一位」审批人(序号空洞不应导致链断裂);
+ * - 或签 / 随机一人:减签没有业务意义,直接拒绝;
+ * - 边界:不能减签自己;减签后节点仍必须保留至少一次待审批(并签比例已达成时拒绝)。
+ *
+ */
+class FlowIssue266ReduceAuditTest {
+
+ private final MyFlowServiceFactory factory = new MyFlowServiceFactory();
+
+ private final User user = new User(1, "user");
+ private final User b1 = new User(2, "b1");
+ private final User b2 = new User(3, "b2");
+ private final User b3 = new User(4, "b3");
+ private final User b4 = new User(5, "b4");
+ private final User b5 = new User(6, "b5");
+
+ @Test
+ void mergeAuditReduce_shouldClearTargetTodoAndFinishAfterRemainingPassed() {
+ // given A(开始) - B(并签 + 合并,b1/b2) - C(结束)
+ Workflow workflow = saveWorkflow(MultiOperatorAuditStrategy.Type.MERGE, 1f, List.of(2L, 3L), true);
+ String bNodeId = nodeIdByName(workflow, "B审批");
+ long startRecordId = createAndSubmit(workflow);
+
+ FlowRecord b1Record = todoRecordOf(b1, bNodeId);
+ FlowRecord b2Record = pendingRecordOf(b2, b1Record);
+ assertEquals(1, groupsOf(b2, bNodeId).size(), "减签前 b2 应有合并待办组");
+
+ // when b1 减签 b2
+ reduce(workflow, b1, b1Record, b2.getUserId());
+
+ // then b2 的记录被撤销,待办、合并待办组与合并关系一并清理,b1 的合并组保留
+ assertTrue(record(b2Record.getId()).isRevoked(), "b2 的记录应被撤销");
+ assertNoTodo(b2, bNodeId);
+ assertEquals(0, groupsOf(b2, bNodeId).size(), "b2 的合并待办组应被删除");
+ assertEquals(1, factory.flowTodoMergeRepository.findAll().size(), "只应保留 b1 的合并关系");
+ FlowTodoRecord b1Group = singleGroupOf(b1, bNodeId);
+ assertEquals(1, b1Group.getMargeCount(), "b1 的合并组应保留 1 条记录");
+ assertEquals(1, todoRecordsOf(b1, bNodeId).size(), "b1 的待办应保留");
+ assertFalse(record(startRecordId).isFinish(), "减签不应推进流程");
+
+ // when b1 通过
+ pass(todoRecordOf(b1, bNodeId), workflow, b1);
+
+ // then 流程结束,待办与合并关系清理干净
+ assertNoTodo(b1, bNodeId);
+ assertEquals(0, factory.flowTodoMergeRepository.findAll().size(), "不应残留合并关系");
+ assertTrue(record(startRecordId).isFinish(), "剩余审批人通过后流程应结束");
+ }
+
+ @Test
+ void sequenceAuditReduceHiddenApprover_shouldStillActivateNextApprover() {
+ // given A(开始) - B(顺序审批,b1/b2/b3) - C(结束)
+ Workflow workflow = saveWorkflow(MultiOperatorAuditStrategy.Type.SEQUENCE, 0f, List.of(2L, 3L, 4L), false);
+ String bNodeId = nodeIdByName(workflow, "B审批");
+ long startRecordId = createAndSubmit(workflow);
+
+ FlowRecord b1Record = todoRecordOf(b1, bNodeId);
+ FlowRecord b2Record = pendingRecordOf(b2, b1Record);
+ assertEquals(0, todoRecordsOf(b2, bNodeId).size(), "b2 未被激活前不应有 todo");
+
+ // when b1 减签尚未激活的 b2
+ reduce(workflow, b1, b1Record, b2.getUserId());
+
+ // then b2 记录撤销且不产生待办,b1 仍是唯一待办
+ assertTrue(record(b2Record.getId()).isRevoked(), "b2 的记录应被撤销");
+ assertEquals(0, todoRecordsOf(b2, bNodeId).size());
+ assertEquals(1, todoRecordsOf(b1, bNodeId).size());
+
+ // when b1 通过(序号出现空洞,仍应激活 b3)
+ pass(todoRecordOf(b1, bNodeId), workflow, b1);
+
+ // then b3 被激活,流程未结束
+ assertEquals(0, todoRecordsOf(b2, bNodeId).size(), "被减签的 b2 不应被激活");
+ assertEquals(1, todoRecordsOf(b3, bNodeId).size(), "序号空洞不应阻断后续审批人激活");
+ assertFalse(record(startRecordId).isFinish(), "b3 未审批时流程不应结束");
+
+ // when b3 通过
+ pass(todoRecordOf(b3, bNodeId), workflow, b3);
+
+ // then 流程结束
+ assertTrue(record(startRecordId).isFinish(), "最后一位审批人通过后流程应结束");
+ }
+
+ @Test
+ void sequenceAuditReduceAllFollowingApprovers_shouldFinishAfterCurrentPassed() {
+ // given A(开始) - B(顺序审批,b1/b2/b3) - C(结束)
+ Workflow workflow = saveWorkflow(MultiOperatorAuditStrategy.Type.SEQUENCE, 0f, List.of(2L, 3L, 4L), false);
+ String bNodeId = nodeIdByName(workflow, "B审批");
+ long startRecordId = createAndSubmit(workflow);
+
+ FlowRecord b1Record = todoRecordOf(b1, bNodeId);
+
+ // when b1 一次减签后续所有审批人
+ reduce(workflow, b1, b1Record, b2.getUserId(), b3.getUserId());
+
+ // then 仅剩 b1 待办,b2/b3 无待办
+ assertEquals(1, todoRecordsOf(b1, bNodeId).size());
+ assertEquals(0, todoRecordsOf(b2, bNodeId).size());
+ assertEquals(0, todoRecordsOf(b3, bNodeId).size());
+
+ // when b1 通过
+ pass(todoRecordOf(b1, bNodeId), workflow, b1);
+
+ // then 流程结束
+ assertTrue(record(startRecordId).isFinish(), "仅剩的审批人通过后流程应结束");
+ }
+
+ @Test
+ void addAuditThenReduce_shouldRemoveAddedApprover() {
+ // given A(开始) - B(并签 + 合并,b1/b2) - C(结束)
+ Workflow workflow = saveWorkflow(MultiOperatorAuditStrategy.Type.MERGE, 1f, List.of(2L, 3L), true);
+ String bNodeId = nodeIdByName(workflow, "B审批");
+ long startRecordId = createAndSubmit(workflow);
+
+ FlowRecord b1Record = todoRecordOf(b1, bNodeId);
+
+ // when b1 加签 b3,再减签 b3
+ addAudit(workflow, b1, b1Record, b3.getUserId());
+ assertFalse(todoRecordsOf(b3, bNodeId).isEmpty(), "加签后 b3 应有待办");
+
+ reduce(workflow, b1, todoRecordOf(b1, bNodeId), b3.getUserId());
+
+ // then 加签记录被撤销,待办清理,节点回到 b1/b2 并签
+ assertNoTodo(b3, bNodeId);
+ assertEquals(1, todoRecordsOf(b1, bNodeId).size());
+ assertEquals(1, todoRecordsOf(b2, bNodeId).size());
+ assertFalse(record(startRecordId).isFinish());
+ }
+
+ @Test
+ void reduceInAnyAudit_shouldReject() {
+ // given 或签节点
+ Workflow workflow = saveWorkflow(MultiOperatorAuditStrategy.Type.ANY, 0f, List.of(2L, 3L), false);
+ String bNodeId = nodeIdByName(workflow, "B审批");
+ createAndSubmit(workflow);
+
+ FlowRecord b1Record = todoRecordOf(b1, bNodeId);
+ FlowRecord b2Record = pendingRecordOf(b2, b1Record);
+
+ // when/then 或签减签没有业务意义,直接拒绝
+ assertThrows(FlowExecutionException.class,
+ () -> reduce(workflow, b1, b1Record, b2.getUserId()));
+ assertFalse(record(b2Record.getId()).isRevoked(), "被拒绝的减签不应产生撤销");
+ }
+
+ @Test
+ void reduceInRandomOneAudit_shouldReject() {
+ // given 任意一人审批节点
+ Workflow workflow = saveWorkflow(MultiOperatorAuditStrategy.Type.RANDOM_ONE, 0f, List.of(2L, 3L), false);
+ String bNodeId = nodeIdByName(workflow, "B审批");
+ long startRecordId = createAndSubmit(workflow);
+
+ // 随机一人只落库一条记录,执行人不一定是 b1
+ List nodeRecords = nodeRecordsOf(startRecordId, bNodeId);
+ assertEquals(1, nodeRecords.size(), "随机一人审批只应保留 1 条记录");
+ FlowRecord currentRecord = nodeRecords.get(0);
+ User executor = currentRecord.getCurrentOperatorId() == b1.getUserId() ? b1 : b2;
+ long targetOperatorId = executor.getUserId() == b1.getUserId() ? b2.getUserId() : b1.getUserId();
+
+ // when/then 随机一人减签没有业务意义,直接拒绝
+ assertThrows(FlowExecutionException.class,
+ () -> reduce(workflow, executor, currentRecord, targetOperatorId));
+ }
+
+ @Test
+ void reduceOwnRecord_shouldReject() {
+ // given
+ Workflow workflow = saveWorkflow(MultiOperatorAuditStrategy.Type.MERGE, 1f, List.of(2L, 3L), false);
+ String bNodeId = nodeIdByName(workflow, "B审批");
+ createAndSubmit(workflow);
+
+ FlowRecord b1Record = todoRecordOf(b1, bNodeId);
+
+ // when/then 当前审批人不能减签自己
+ assertThrows(FlowExecutionException.class,
+ () -> reduce(workflow, b1, b1Record, b1.getUserId()));
+ assertEquals(1, todoRecordsOf(b1, bNodeId).size(), "自己的待办应保留");
+ }
+
+ @Test
+ void mergeAuditReduceWhenPercentAlreadySatisfied_shouldReject() {
+ // given 并签比例 50%,5 位审批人:b1、b2 已通过,节点因 2 < 5 * 0.5 尚未完成
+ Workflow workflow = saveWorkflow(MultiOperatorAuditStrategy.Type.MERGE, 0.5f, List.of(2L, 3L, 4L, 5L, 6L), false);
+ String bNodeId = nodeIdByName(workflow, "B审批");
+ long startRecordId = createAndSubmit(workflow);
+
+ pass(todoRecordOf(b1, bNodeId), workflow, b1);
+ pass(todoRecordOf(b2, bNodeId), workflow, b2);
+ assertFalse(record(startRecordId).isFinish(), "2/5 未达到 50%,节点不应完成");
+
+ FlowRecord b3Record = todoRecordOf(b3, bNodeId);
+
+ // when/then 再减掉 b4、b5 会让比例立刻达成(2/3),没有触发点,必须拒绝
+ assertThrows(FlowExecutionException.class,
+ () -> reduce(workflow, b3, b3Record, b4.getUserId(), b5.getUserId()));
+ assertEquals(1, todoRecordsOf(b4, bNodeId).size(), "被拒绝的减签不应清理 b4 的待办");
+ assertEquals(1, todoRecordsOf(b5, bNodeId).size(), "被拒绝的减签不应清理 b5 的待办");
+ }
+
+ @Test
+ void reduceActionShouldBeVisibleOnlyWhenAnotherPendingApproverExists() {
+ // given 单人审批节点 + 双人并签节点
+ Workflow singleWorkflow = saveWorkflow("issue266-single", MultiOperatorAuditStrategy.Type.SEQUENCE,
+ 0f, List.of(2L), true);
+ createAndSubmit(singleWorkflow);
+ String singleNodeId = nodeIdByName(singleWorkflow, "B审批");
+
+ Workflow mergeWorkflow = saveWorkflow("issue266-merge", MultiOperatorAuditStrategy.Type.MERGE,
+ 1f, List.of(2L, 3L), true);
+ createAndSubmit(mergeWorkflow);
+ String mergeNodeId = nodeIdByName(mergeWorkflow, "B审批");
+
+ // when/then 单人节点不展示减签按钮
+ FlowContent singleDetail = factory.flowService.detail(
+ new FlowDetailRequest(todoRecordOf(b1, singleNodeId).getId(), b1.getUserId()));
+ assertFalse(containsAction(singleDetail.getActions(), ActionType.REDUCE_AUDIT),
+ "单人审批节点不应展示减签按钮");
+
+ // 多人节点展示减签按钮
+ FlowContent mergeDetail = factory.flowService.detail(
+ new FlowDetailRequest(todoRecordOf(b1, mergeNodeId).getId(), b1.getUserId()));
+ assertTrue(containsAction(mergeDetail.getActions(), ActionType.REDUCE_AUDIT),
+ "多人并签节点应展示减签按钮");
+ }
+
+ // ------------------------------------------------------------------
+ // 辅助方法
+ // ------------------------------------------------------------------
+
+ private Workflow saveWorkflow(MultiOperatorAuditStrategy.Type type, float percent,
+ List approverIds, boolean mergeable) {
+ return saveWorkflow("issue266", type, percent, approverIds, mergeable);
+ }
+
+ private Workflow saveWorkflow(String code, MultiOperatorAuditStrategy.Type type, float percent,
+ List approverIds, boolean mergeable) {
+ registerUsers();
+ FlowForm form = FlowFormBuilder.builder()
+ .name("测试流程")
+ .code(code)
+ .addField("name", "name", DataType.STRING)
+ .build();
+
+ StartNode startNode = StartNode.builder()
+ .strategies(NodeStrategyBuilder.builder()
+ .addStrategy(perm(code))
+ .build())
+ .actions(ActionBuilder.builder()
+ .addAction(CustomAction.defaultAction())
+ .build())
+ .build();
+
+ String operatorScript = "def run(request){return [" + String.join(",", approverIds.stream().map(String::valueOf).toList()) + "]}";
+ NodeStrategyBuilder strategyBuilder = NodeStrategyBuilder.builder()
+ .addStrategy(perm(code))
+ .addStrategy(new OperatorLoadStrategy(FlowGroovyScriptFactory.createOperatorLoadScript(operatorScript).getKey()))
+ .addStrategy(new MultiOperatorAuditStrategy(type, percent));
+ if (mergeable) {
+ strategyBuilder.addStrategy(new RecordMergeStrategy(true));
+ }
+
+ ApprovalNode approvalNode = ApprovalNode.builder()
+ .name("B审批")
+ .strategies(strategyBuilder.build())
+ .build();
+
+ EndNode endNode = EndNode.builder().build();
+
+ Workflow workflow = WorkflowBuilder.builder()
+ .title("测试流程")
+ .code(code)
+ .createdOperator(user)
+ .form(form)
+ .addNode(startNode)
+ .addNode(approvalNode)
+ .addNode(endNode)
+ .build();
+ factory.workflowService.saveWorkflow(workflow);
+ return workflow;
+ }
+
+ private FormFieldPermissionStrategy perm(String code) {
+ return new FormFieldPermissionStrategy(FormFieldPermissionsBuilder.builder()
+ .addPermission(code, "name", PermissionType.WRITE)
+ .build());
+ }
+
+ /**
+ * 发起并提交到 B 节点,返回发起节点记录id
+ */
+ private long createAndSubmit(Workflow workflow) {
+ StartNode startNode = (StartNode) workflow.getStartNode();
+ IFlowAction startAction = startNode.actionManager().getActions().get(0);
+ Map data = Map.of("name", "lorne");
+
+ FlowCreateRequest createRequest = new FlowCreateRequest();
+ createRequest.setWorkCode(workflow.getCode());
+ createRequest.setFormData(data);
+ createRequest.setActionId(startAction.id());
+ createRequest.setOperatorId(user.getUserId());
+ long recordId = factory.flowService.create(createRequest);
+
+ pass(startAction, user, data, recordId);
+ return recordId;
+ }
+
+ private void registerUsers() {
+ List.of(user, b1, b2, b3, b4, b5).forEach(factory.userGateway::save);
+ GatewayContext.getInstance().setFlowOperatorGateway(factory.userGateway);
+ }
+
+ private void pass(FlowRecord flowRecord, Workflow workflow, User operator) {
+ pass(actionOf(workflow, ActionType.PASS), operator, Map.of("name", "lorne"), flowRecord.getId());
+ }
+
+ private void addAudit(Workflow workflow, User operator, FlowRecord flowRecord, long... targetOperatorIds) {
+ execute(workflow, ActionType.ADD_AUDIT, operator, flowRecord, targetOperatorIds);
+ }
+
+ private void reduce(Workflow workflow, User operator, FlowRecord flowRecord, long... targetOperatorIds) {
+ execute(workflow, ActionType.REDUCE_AUDIT, operator, flowRecord, targetOperatorIds);
+ }
+
+ private void execute(Workflow workflow, ActionType actionType, User operator,
+ FlowRecord flowRecord, long... targetOperatorIds) {
+ FlowAdviceBody advice = new FlowAdviceBody(actionOf(workflow, actionType).id(), "同意", operator.getUserId());
+ advice.setForwardOperatorIds(Arrays.stream(targetOperatorIds).boxed().toList());
+ pass(actionOf(workflow, actionType), operator, advice, flowRecord.getId());
+ }
+
+ private void pass(IFlowAction action, User operator, Map data, long recordId) {
+ pass(action, operator, new FlowAdviceBody(action.id(), "同意", operator.getUserId()), recordId);
+ }
+
+ private void pass(IFlowAction action, User operator, FlowAdviceBody advice, long recordId) {
+ FlowActionRequest request = new FlowActionRequest();
+ request.setFormData(Map.of("name", "lorne"));
+ request.setRecordId(recordId);
+ request.setAdvice(advice);
+ factory.flowService.action(request);
+ }
+
+ private IFlowAction actionOf(Workflow workflow, ActionType actionType) {
+ ApprovalNode approvalNode = (ApprovalNode) workflow.getNodes().stream()
+ .filter(node -> node.getName().equals("B审批"))
+ .findFirst().orElseThrow();
+ IFlowAction action = approvalNode.actionManager().getActionByType(actionType.name());
+ assertTrue(action != null, "节点应配置动作 " + actionType.name());
+ return action;
+ }
+
+ private FlowRecord record(long recordId) {
+ return factory.flowRecordRepository.get(recordId);
+ }
+
+ private List nodeRecordsOf(long startRecordId, String nodeId) {
+ String processId = record(startRecordId).getProcessId();
+ return factory.flowRecordRepository.findProcessRecords(processId).stream()
+ .filter(record -> nodeId.equals(record.getNodeId()))
+ .toList();
+ }
+
+ private FlowRecord todoRecordOf(User approver, String nodeId) {
+ List records = todoRecordsOf(approver, nodeId);
+ assertEquals(1, records.size(), approver.getName() + " 在 B 节点应有且仅有 1 条待办");
+ return records.get(0);
+ }
+
+ /**
+ * 取指定审批人的待审批记录(含顺序审批中尚未激活的隐藏记录)
+ */
+ private FlowRecord pendingRecordOf(User approver, FlowRecord anyNodeRecord) {
+ return factory.flowRecordRepository
+ .findCurrentNodeRecords(anyNodeRecord.getFromId(), anyNodeRecord.getNodeId()).stream()
+ .filter(record -> record.getCurrentOperatorId() == approver.getUserId())
+ .findFirst()
+ .orElseThrow();
+ }
+
+ private List todoRecordsOf(User approver, String nodeId) {
+ return factory.flowRecordRepository.findTodoByOperator(approver.getUserId()).stream()
+ .filter(record -> nodeId.equals(record.getNodeId()))
+ .toList();
+ }
+
+ private List groupsOf(User approver, String nodeId) {
+ return factory.flowTodoRecordRepository.findByOperatorId(approver.getUserId()).stream()
+ .filter(group -> nodeId.equals(group.getNodeId()))
+ .toList();
+ }
+
+ private FlowTodoRecord singleGroupOf(User approver, String nodeId) {
+ List groups = groupsOf(approver, nodeId);
+ assertEquals(1, groups.size(), approver.getName() + " 在 B 节点应有且仅有 1 条合并待办组");
+ return groups.get(0);
+ }
+
+ private void assertNoTodo(User approver, String nodeId) {
+ assertEquals(0, todoRecordsOf(approver, nodeId).size(),
+ approver.getName() + " 在 B 节点不应有 todo 记录");
+ }
+
+ private String nodeIdByName(Workflow workflow, String name) {
+ return workflow.getNodes().stream()
+ .filter(node -> node.getName().equals(name))
+ .findFirst().orElseThrow().getId();
+ }
+
+ private boolean containsAction(List