From 1eb2a2d0314532c2ce4f013020391b3a8d3a1f8f Mon Sep 17 00:00:00 2001 From: lorne <1991wangliang@gmail.com> Date: Tue, 10 Nov 2026 20:21:21 +0800 Subject: [PATCH 01/17] =?UTF-8?q?chore:=20=E5=8D=87=E7=BA=A7=20codingapi.f?= =?UTF-8?q?ramework=20=E5=88=B0=2017.3.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 4024534e..37d62c63 100644 --- a/pom.xml +++ b/pom.xml @@ -36,7 +36,7 @@ 2.17.0 3.20.0 1.2.0 - 17.3.1 + 17.3.2 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}(默认按审批人合并)。 + * 验证点: + *

+ */ +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> actions, ActionType actionType) { + return actions.stream().anyMatch(action -> actionType.name().equals(action.get("type"))); + } +} diff --git a/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowMockSampleServiceTest.java b/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowMockSampleServiceTest.java index da8e881c..dc612525 100644 --- a/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowMockSampleServiceTest.java +++ b/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowMockSampleServiceTest.java @@ -1,5 +1,6 @@ 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; @@ -2187,7 +2188,11 @@ void custom() { FlowActionRequest bossRequest = new FlowActionRequest(); bossRequest.setFormData(data); bossRequest.setRecordId(bossRecordList.get(0).getId()); - bossRequest.setAdvice(new FlowAdviceBody(bossActions.get(7).id(), boss.getUserId())); + // 按类型取自定义动作:动作列表会随默认动作增加而变化(issue #266) + IFlowAction customAction = bossActions.stream() + .filter(action -> ActionType.CUSTOM.name().equals(action.type())) + .findFirst().orElseThrow(); + bossRequest.setAdvice(new FlowAdviceBody(customAction.id(), boss.getUserId())); factory.flowService.action(bossRequest); List records = factory.flowRecordRepository.findProcessRecords(bossRecordList.get(0).getProcessId()); diff --git a/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowSampleServiceTest.java b/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowSampleServiceTest.java index c105d787..fec46306 100644 --- a/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowSampleServiceTest.java +++ b/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowSampleServiceTest.java @@ -95,7 +95,8 @@ void create() { List keys = GroovyScriptAnnotationScannerUtils.findGroovyScriptFields(workflow).getKeys(); System.out.println(keys); - assertEquals(15,keys.size()); + // 审批节点默认动作新增「减签」,其人员范围脚本字段一并计入(issue #266) + assertEquals(16,keys.size()); factory.workflowService.saveWorkflow(workflow); From 8856ebb0a90ebb4fb6155eac34bf7928eab104bd Mon Sep 17 00:00:00 2001 From: lorne <1991wangliang@gmail.com> Date: Thu, 12 Nov 2026 10:30:41 +0800 Subject: [PATCH 05/17] =?UTF-8?q?test:=20=E8=A1=A5=E5=85=85=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=B1=82=E7=9B=B4=E6=8E=A5=E5=A2=9E=E5=88=A0=E5=AE=A1?= =?UTF-8?q?=E6=89=B9=E4=BA=BA=E7=9A=84=E6=9C=BA=E5=88=B6=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E7=94=A8=E4=BE=8B=20(#264)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 跳过服务层编排,直接操作仓储(等价于运维改库)验证多人审批的待办与节点完成判定 - 场景 5 按 #266 的激活链路修复(最近一条更大 nodeOrder)断言:撤销中间审批人后 不再卡死,下一位审批人会被自动激活 - 其余用例固化「只插待办行 / 只插记录行 / 只删待办行 / 撤销最后一个审批人」的风险行为, 作为「不要直接改库」的可执行文档 Refs #264 --- ...owIssue264DataLayerApproverAdjustTest.java | 558 ++++++++++++++++++ 1 file changed, 558 insertions(+) create mode 100644 flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowIssue264DataLayerApproverAdjustTest.java diff --git a/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowIssue264DataLayerApproverAdjustTest.java b/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowIssue264DataLayerApproverAdjustTest.java new file mode 100644 index 00000000..6fa58afd --- /dev/null +++ b/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowIssue264DataLayerApproverAdjustTest.java @@ -0,0 +1,558 @@ +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.cache.FlowRuntimeScriptLocalCache; +import com.codingapi.flow.context.GatewayContext; +import com.codingapi.flow.exception.FlowNotFoundException; +import com.codingapi.flow.exception.FlowStateException; +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.FormData; +import com.codingapi.flow.form.permission.PermissionType; +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.record.FlowTodoRecord; +import com.codingapi.flow.script.factory.FlowGroovyScriptFactory; +import com.codingapi.flow.session.FlowAdvice; +import com.codingapi.flow.session.FlowSession; +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.user.User; +import com.codingapi.flow.workflow.Workflow; +import com.codingapi.flow.workflow.WorkflowBuilder; +import com.codingapi.flow.workflow.runtime.WorkflowRuntime; +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.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * issue #264 验证:**跳过服务层编排**,直接对仓储(等价于直接写 + * {@code t_flow_record} / {@code t_flow_todo_record} 两张表)增删审批人, + * 观察引擎的待办列表、审批权限与节点完成判定是否按预期变化。 + * + *

本类只做"机制可行性"验证,不代表推荐做法。用到的数据层操作与 SQL 的对应关系:

+ *
    + *
  • {@link #insertRecordRow} —— {@code INSERT INTO t_flow_record(...)}
  • + *
  • {@link #insertTodoRow} —— {@code INSERT INTO t_flow_todo_record(...)}
  • + *
  • {@link #revokeRecordRow} —— {@code UPDATE t_flow_record SET revoked = 1 WHERE id = ?}
  • + *
  • {@link #deleteTodoRow} —— {@code DELETE FROM t_flow_todo_record WHERE recordId = ?}
  • + *
+ * + *

所有用例都刻意不调用 {@code AddAuditAction} / {@code TransferAction} 等官方动作, + * 也不触发 {@code EventPusher},以复现"运维手工改库"的真实路径。

+ * + *

流程结构:A(发起) → B(多人审批) → C(c1 单人审批) → D(结束)。

+ */ +class FlowIssue264DataLayerApproverAdjustTest { + + private final MyFlowServiceFactory factory = new MyFlowServiceFactory(); + + private final User creator = 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 c1 = new User(5, "c1"); + + // ==================== 数据层操作(等价于直接写库) ==================== + + /** + * 等价于 {@code INSERT INTO t_flow_record}:复制同节点实例的一条记录行,替换审批人与审批序号。 + * + * @param template 同 {@code (fromId, nodeId)} 分组的模板记录 + * @param operator 新的审批人 + * @param nodeOrder 多人审批的顺序槽位 + * @param hidden 是否隐藏(SEQUENCE 下后序人员为 true) + * @return 尚未入库的记录对象({@code id = 0},由仓储分配,模拟数据库序列发号) + */ + private FlowRecord newRecordRow(FlowRecord template, User operator, int nodeOrder, boolean hidden) { + WorkflowRuntime runtime = factory.workflowService.getWorkflowRuntime(template.getWorkRuntimeId()); + assertNotNull(runtime, "流程运行时必须存在"); + FlowRuntimeScriptLocalCache.getInstance().set(runtime.getScripts()); + + Workflow workflow = runtime.toWorkflow(); + IFlowNode node = workflow.getFlowNode(template.getNodeId()); + IFlowAction action = node.actionManager().getActions().get(0); + + FormData formData = new FormData(workflow.getForm()); + formData.reset(template.getFormData()); + + FlowSession session = new FlowSession(factory.repositoryHolder, + operator, + factory.userGateway.get(template.getCreateOperatorId()), + factory.userGateway.get(template.getSubmitOperatorId()), + workflow, + node, + action, + formData, + template, + List.of(), + template.getWorkRuntimeId(), + FlowAdvice.nullFlowAdvice()); + + FlowRecord record = template.create(session); + // 加入同一节点实例分组(fromId 相同),并指定顺序槽位与隐藏标记 + record.resetAddAudit(template.getFromId(), nodeOrder, operator.getUserId(), hidden); + record.cleanAction(); + record.setId(0); + return record; + } + + /** 等价于 {@code INSERT INTO t_flow_record}。 */ + private FlowRecord insertRecordRow(FlowRecord template, User operator, int nodeOrder, boolean hidden) { + FlowRecord record = newRecordRow(template, operator, nodeOrder, hidden); + factory.flowRecordRepository.save(record); + return record; + } + + /** 等价于 {@code INSERT INTO t_flow_todo_record};合并模式还需配套写 {@code t_flow_todo_marge}。 */ + private FlowTodoRecord insertTodoRow(FlowRecord record) { + FlowTodoRecord todo = new FlowTodoRecord(record); + factory.flowTodoRecordRepository.save(todo); + return todo; + } + + /** 等价于 {@code UPDATE t_flow_record SET revoked = 1}。 */ + private void revokeRecordRow(FlowRecord record) { + record.revoke(); + factory.flowRecordRepository.save(record); + } + + /** 等价于 {@code DELETE FROM t_flow_todo_record WHERE recordId = ?}。 */ + private void deleteTodoRow(long recordId) { + FlowTodoRecord todo = factory.flowTodoRecordRepository.getByTodoKey(String.valueOf(recordId)); + if (todo != null) { + factory.flowTodoRecordRepository.delete(todo); + } + } + + // ==================== 用例 ==================== + + /** + * 场景 1:只插入待办行({@code t_flow_todo_record}),不插入流程记录行。 + * + *

期望验证:待办列表只按 {@code currentOperatorId} 过滤,因此列表能被"看到", + * 但审批时会因 {@code t_flow_record} 中不存在该 id 直接失败。

+ */ + @Test + void insertTodoRowOnly_visibleInTodoListButCannotApprove() { + Workflow workflow = saveWorkflow("[2]"); + startAndSubmit(workflow); + + FlowRecord b1Todo = onlyTodo(b1); + // 伪造一条指向不存在记录(id=9999)的待办行 + FlowRecord ghost = newRecordRow(b1Todo, b3, b1Todo.getNodeOrder(), false); + ghost.setId(9999L); + insertTodoRow(ghost); + + // 待办列表可见(只查 currentOperatorId) + assertEquals(1, factory.flowTodoRecordRepository.findByOperatorId(b3.getUserId()).size(), + "只插待办行,待办列表即可见"); + // 流程记录不存在 + assertTrue(factory.flowRecordRepository.findTodoByOperator(b3.getUserId()).isEmpty(), + "未插流程记录时,b3 没有真实的待办记录"); + // 点击审批直接失败(记录不存在) + String bActionId = actionIdOf(b1Todo); + assertThrows(FlowNotFoundException.class, + () -> approve(b3, 9999L, bActionId), + "只插待办行无法审批,必须同时存在 t_flow_record 行"); + } + + /** + * 场景 2:只插入流程记录行,不插入待办行。 + * + *

期望验证:待办列表中看不到(列表只读 {@code t_flow_todo_record}), + * 但只要拿到 recordId,仍然可以正常审批并推进流程。

+ */ + @Test + void insertRecordRowOnly_approvableWithoutTodoRow() { + Workflow workflow = saveWorkflow("[2]"); + startAndSubmit(workflow); + + FlowRecord b1Todo = onlyTodo(b1); + FlowRecord inserted = insertRecordRow(b1Todo, b3, b1Todo.getNodeOrder(), false); + + assertTrue(factory.flowTodoRecordRepository.findByOperatorId(b3.getUserId()).isEmpty(), + "未插待办行,b3 的待办列表为空"); + assertTrue(factory.flowRecordRepository.get(inserted.getId()).isTodo(), + "流程记录本身是待办状态"); + + // 拿到 recordId 即可审批成功,并推进到 C 节点 + approve(b3, inserted.getId()); + assertEquals(1, factory.flowTodoRecordRepository.findByOperatorId(c1.getUserId()).size(), + "只插流程记录行也能完成审批并推进流程"); + } + + /** + * 场景 3:SEQUENCE(循环审批)运行期加人。 + * + *

步骤:b1 通过后,数据层为 b3 插入一条 {@code nodeOrder = maxNodeOrder + 1} 的隐藏记录 + 待办行。

+ * + *

期望验证:

+ *
    + *
  • 待办行不看 {@code hidden},b3 会在待办列表看到但它无法审批({@code isTodo() == false});
  • + *
  • b2 通过后节点不会完成({@code maxNodeOrder} 已变为 2),且引擎自动激活 b3;
  • + *
  • b3 通过后流程才流转到 C —— 说明数据层加人确实影响完成判定,SEQUENCE 加人可闭环。
  • + *
+ */ + @Test + void sequenceInsertApproverByDataLayer_blocksCompletionUntilNewApproverPasses() { + Workflow workflow = saveWorkflow("[2,3]", + new MultiOperatorAuditStrategy(MultiOperatorAuditStrategy.Type.SEQUENCE, 0)); + startAndSubmit(workflow); + + FlowRecord b1Todo = onlyTodo(b1); + assertTrue(factory.flowTodoRecordRepository.findByOperatorId(b2.getUserId()).isEmpty(), + "SEQUENCE 下 b2 初始为隐藏,无待办行"); + + // b1 通过 -> 激活 b2 + approve(b1, b1Todo.getId()); + FlowRecord b2Todo = onlyTodo(b2); + + // 数据层加人:b3 追加到末位(hidden = true,与引擎 SEQUENCE 生成规则一致) + FlowRecord b3Row = insertRecordRow(b2Todo, b3, b2Todo.getNodeOrder() + 1, true); + insertTodoRow(b3Row); + + assertEquals(1, factory.flowTodoRecordRepository.findByOperatorId(b3.getUserId()).size(), + "待办列表不看 hidden,b3 会看到待办"); + assertTrue(!factory.flowRecordRepository.get(b3Row.getId()).isTodo(), + "但隐藏记录 isTodo() 为 false"); + assertThrows(FlowStateException.class, () -> approve(b3, b3Row.getId()), + "隐藏记录不能直接审批"); + + // b2 通过:maxNodeOrder 已变为 2,节点不应完成;同时引擎按 nodeOrder + 1 激活 b3 + approve(b2, b2Todo.getId()); + assertTrue(factory.flowTodoRecordRepository.findByOperatorId(c1.getUserId()).isEmpty(), + "数据层加人后,b2 通过不能完成 B 节点"); + assertTrue(factory.flowRecordRepository.get(b3Row.getId()).isTodo(), + "引擎的 SEQUENCE 激活逻辑会把 b3 置为可见待办"); + + // b3 通过 -> 节点达到最大序号,流程流转到 C + approve(b3, b3Row.getId()); + assertEquals(1, factory.flowTodoRecordRepository.findByOperatorId(c1.getUserId()).size(), + "b3 通过后流程才流转到 C 节点"); + } + + /** + * 场景 4:MERGE(并签,比例 1.0)运行期加人。 + * + *

期望验证:加人后 {@code total}(不同 nodeOrder 数)变大,比例分母变化, + * 原本 2/2 才完成的判定变成 2/3 不完成,需 b3 也通过才流转。

+ */ + @Test + void mergeInsertApproverByDataLayer_changesPercentDenominator() { + Workflow workflow = saveWorkflow("[2,3]", + new MultiOperatorAuditStrategy(MultiOperatorAuditStrategy.Type.MERGE, 1.0f)); + startAndSubmit(workflow); + + FlowRecord b1Todo = onlyTodo(b1); + FlowRecord b2Todo = onlyTodo(b2); + + approve(b1, b1Todo.getId()); + + // 数据层加人:并签第 3 个席位(可见) + FlowRecord b3Row = insertRecordRow(b2Todo, b3, 2, false); + insertTodoRow(b3Row); + + // b2 通过:2/3 < 1.0,节点不完成 + approve(b2, b2Todo.getId()); + assertTrue(factory.flowTodoRecordRepository.findByOperatorId(c1.getUserId()).isEmpty(), + "加人后分母变为 3,2 人通过不足以完成并签节点"); + + // b3 通过:3/3 完成 + approve(b3, b3Row.getId()); + assertEquals(1, factory.flowTodoRecordRepository.findByOperatorId(c1.getUserId()).size(), + "b3 通过后并签比例达成,流程流转到 C"); + } + + /** + * 场景 5:SEQUENCE 运行期"减人",删除中间审批人且不修复激活链。 + * + *

期望验证:b1 通过后,引擎按"最近一条更大 {@code nodeOrder}"查找下一位待审批人 + * (issue #266 的激活链路修复),被撤销的 b2 已被 {@code revoked} 过滤, + * b3 会被自动激活并产生待办 —— 数据层删除中间审批人不再需要手工维护 hidden 与待办行。

+ */ + @Test + void sequenceRevokeMiddleApproverWithoutFix_activatesNextApprover() { + Workflow workflow = saveWorkflow("[2,3,4]", + new MultiOperatorAuditStrategy(MultiOperatorAuditStrategy.Type.SEQUENCE, 0)); + startAndSubmit(workflow); + + FlowRecord b1Todo = onlyTodo(b1); + + // 数据层减人:把 nodeOrder = 1 的 b2 记录撤销 + FlowRecord b2Record = bNodeRecords(b1Todo).stream() + .filter(r -> r.getCurrentOperatorId() == b2.getUserId()) + .findFirst().orElseThrow(); + revokeRecordRow(b2Record); + deleteTodoRow(b2Record.getId()); + + // b1 通过 -> 跳过被撤销的 nodeOrder = 1,直接激活 nodeOrder = 2 的 b3 + approve(b1, b1Todo.getId()); + + assertEquals(1, factory.flowTodoRecordRepository.findByOperatorId(b3.getUserId()).size(), + "按最近一条更大 nodeOrder 激活,b3 应获得待办"); + assertEquals(1, factory.flowRecordRepository.findTodoByOperator(b3.getUserId()).size(), + "b3 的记录应被激活为有效待办"); + assertTrue(factory.flowTodoRecordRepository.findByOperatorId(c1.getUserId()).isEmpty(), + "B 节点尚未完成,不应产生 C 节点待办"); + + FlowRecord b3Record = bNodeRecords(b1Todo).stream() + .filter(r -> r.getCurrentOperatorId() == b3.getUserId()) + .findFirst().orElseThrow(); + assertTrue(factory.flowRecordRepository.get(b3Record.getId()).isTodo(), "b3 已被激活"); + + // b3 通过 -> 节点完成,流转到 C + approve(b3, b3Record.getId()); + assertEquals(1, factory.flowTodoRecordRepository.findByOperatorId(c1.getUserId()).size(), + "跳过被撤销的中间审批人后流程可以继续"); + } + + /** + * 场景 6:SEQUENCE 运行期"减人" + 手工修复激活链。 + * + *

期望验证:撤销中间人后,只要把下一位 {@code hidden = false} 并补一条待办行, + * 流程即可继续(b3 通过后 B 节点完成)。说明数据层减人可行,但必须人工维护 hidden 与待办。

+ * + *

说明:issue #266 把激活条件改为"最近一条更大 {@code nodeOrder}"后,这一步手工修复 + * 已不再必需(见场景 5);本用例保留为"运维坚持手工修链"路径的验证。

+ */ + @Test + void sequenceRevokeMiddleApproverWithHiddenFix_proceeds() { + Workflow workflow = saveWorkflow("[2,3,4]", + new MultiOperatorAuditStrategy(MultiOperatorAuditStrategy.Type.SEQUENCE, 0)); + startAndSubmit(workflow); + + FlowRecord b1Todo = onlyTodo(b1); + + FlowRecord b2Record = bNodeRecords(b1Todo).stream() + .filter(r -> r.getCurrentOperatorId() == b2.getUserId()) + .findFirst().orElseThrow(); + FlowRecord b3Record = bNodeRecords(b1Todo).stream() + .filter(r -> r.getCurrentOperatorId() == b3.getUserId()) + .findFirst().orElseThrow(); + + // 数据层减人 + 修复:撤销 b2,并手动激活 b3(UPDATE hidden = 0 + INSERT 待办行) + revokeRecordRow(b2Record); + deleteTodoRow(b2Record.getId()); + b3Record.show(); + factory.flowRecordRepository.save(b3Record); + insertTodoRow(b3Record); + + // b1 通过 -> 节点未完成(maxNodeOrder = 2),b3 可直接审批 + approve(b1, b1Todo.getId()); + assertTrue(factory.flowRecordRepository.get(b3Record.getId()).isTodo(), + "手工激活后 b3 成为有效待办"); + assertTrue(factory.flowTodoRecordRepository.findByOperatorId(c1.getUserId()).isEmpty(), + "节点尚未完成,不应产生 C 节点待办"); + + // b3 通过 -> maxNodeOrder 达到,节点完成 + approve(b3, b3Record.getId()); + assertEquals(1, factory.flowTodoRecordRepository.findByOperatorId(c1.getUserId()).size(), + "减人 + 修复激活链后流程可以继续"); + } + + /** + * 场景 7(风险):MERGE 运行期"减人",撤销最后一个未审批人。 + * + *

期望验证:节点完成判定只在"有人提交审批动作"时触发, + * 撤销最后一个待审批人后没有任何触发点,流程永久停滞在 B 节点。

+ */ + @Test + void mergeRevokeLastApprover_stallsFlowForever() { + Workflow workflow = saveWorkflow("[2,3]", + new MultiOperatorAuditStrategy(MultiOperatorAuditStrategy.Type.MERGE, 1.0f)); + startAndSubmit(workflow); + + FlowRecord b1Todo = onlyTodo(b1); + FlowRecord b2Todo = onlyTodo(b2); + + // b1 通过,1/2 未达比例 + approve(b1, b1Todo.getId()); + + // 数据层减人:撤销 b2(最后一个待审批人) + revokeRecordRow(b2Todo); + deleteTodoRow(b2Todo.getId()); + + assertTrue(factory.flowTodoRecordRepository.findByOperatorId(b2.getUserId()).isEmpty(), + "b2 的待办已消失"); + assertTrue(factory.flowTodoRecordRepository.findByOperatorId(c1.getUserId()).isEmpty(), + "C 节点永远不会产生待办"); + // 流程记录全部停留在运行中,且 B 节点再无待办 + List records = factory.flowRecordRepository.findProcessRecords(b2Todo.getProcessId()); + assertTrue(records.stream().noneMatch(FlowRecord::isTodo), + "整个流程已没有任何待办,但流程也没有结束"); + assertTrue(records.stream().noneMatch(FlowRecord::isFinish), + "流程永久停留在 B 节点(无触发点重算完成判定)"); + } + + /** + * 场景 8:只删待办行、不撤销流程记录("假减人")。 + * + *

期望验证:待办列表里看不到了,但记录仍是待办,只要拿到 recordId 依旧能审批成功, + * 甚至因此推进流程 —— 只删 {@code t_flow_todo_record} 无法真正减少审批人。

+ */ + @Test + void deleteTodoRowOnly_cannotReallyRemoveApprover() { + Workflow workflow = saveWorkflow("[2,3]", + new MultiOperatorAuditStrategy(MultiOperatorAuditStrategy.Type.MERGE, 1.0f)); + startAndSubmit(workflow); + + FlowRecord b1Todo = onlyTodo(b1); + FlowRecord b2Todo = onlyTodo(b2); + + approve(b1, b1Todo.getId()); + + // 数据层"假减人":只删待办行 + deleteTodoRow(b2Todo.getId()); + assertTrue(factory.flowTodoRecordRepository.findByOperatorId(b2.getUserId()).isEmpty(), + "b2 的待办列表已看不到"); + + // 但记录仍可审批,并因此完成并签节点 + approve(b2, b2Todo.getId()); + assertEquals(1, factory.flowTodoRecordRepository.findByOperatorId(c1.getUserId()).size(), + "只删待办行不能阻止该记录继续审批并推进流程"); + } + + // ==================== 辅助方法 ==================== + + private Workflow saveWorkflow(String operatorIds) { + return saveWorkflow(operatorIds, null); + } + + private Workflow saveWorkflow(String operatorIds, MultiOperatorAuditStrategy auditStrategy) { + for (User user : List.of(creator, b1, b2, b3, c1)) { + factory.userGateway.save(user); + } + GatewayContext.getInstance().setFlowOperatorGateway(factory.userGateway); + + FlowForm form = FlowFormBuilder.builder() + .name("issue264流程") + .code("issue264") + .addField("name", "name", DataType.STRING) + .build(); + + StartNode startNode = StartNode.builder() + .strategies(NodeStrategyBuilder.builder() + .addStrategy(permission()) + .build()) + .actions(ActionBuilder.builder() + .addAction(CustomAction.defaultAction()) + .build()) + .build(); + + NodeStrategyBuilder bStrategy = NodeStrategyBuilder.builder() + .addStrategy(permission()) + .addStrategy(new OperatorLoadStrategy( + FlowGroovyScriptFactory.createOperatorLoadScript("def run(request){return " + operatorIds + "}").getKey())); + if (auditStrategy != null) { + bStrategy.addStrategy(auditStrategy); + } + ApprovalNode bNode = ApprovalNode.builder() + .name("B审批") + .strategies(bStrategy.build()) + .build(); + + ApprovalNode cNode = ApprovalNode.builder() + .name("C审批") + .strategies(NodeStrategyBuilder.builder() + .addStrategy(permission()) + .addStrategy(new OperatorLoadStrategy( + FlowGroovyScriptFactory.createOperatorLoadScript("def run(request){return [5]}").getKey())) + .build()) + .build(); + + Workflow workflow = WorkflowBuilder.builder() + .title("issue264流程") + .code("issue264") + .createdOperator(creator) + .form(form) + .addNode(startNode) + .addNode(bNode) + .addNode(cNode) + .addNode(EndNode.builder().build()) + .build(); + factory.workflowService.saveWorkflow(workflow); + return workflow; + } + + private FormFieldPermissionStrategy permission() { + return new FormFieldPermissionStrategy(FormFieldPermissionsBuilder.builder() + .addPermission("issue264", "name", PermissionType.WRITE) + .build()); + } + + private void startAndSubmit(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(creator.getUserId()); + long recordId = factory.flowService.create(createRequest); + + FlowActionRequest actionRequest = new FlowActionRequest(); + actionRequest.setFormData(data); + actionRequest.setRecordId(recordId); + actionRequest.setAdvice(new FlowAdviceBody(startAction.id(), "同意", creator.getUserId())); + factory.flowService.action(actionRequest); + } + + private void approve(User operator, long recordId) { + FlowRecord record = factory.flowRecordRepository.get(recordId); + IFlowNode node = factory.workflowService + .getWorkflowRuntime(record.getWorkRuntimeId()) + .toWorkflow() + .getFlowNode(record.getNodeId()); + approve(operator, recordId, node.actionManager().getActions().get(0).id()); + } + + /** 记录可能不存在(待办行指向脏数据)时的审批调用:动作 id 由调用方提供。 */ + private void approve(User operator, long recordId, String actionId) { + FlowActionRequest request = new FlowActionRequest(); + request.setFormData(Map.of("name", "lorne")); + request.setRecordId(recordId); + request.setAdvice(new FlowAdviceBody(actionId, "同意", operator.getUserId())); + factory.flowService.action(request); + } + + private FlowRecord onlyTodo(User user) { + List todos = factory.flowRecordRepository.findTodoByOperator(user.getUserId()); + assertEquals(1, todos.size(), user.getName() + " 应仅有 1 条待办"); + return todos.get(0); + } + + private String actionIdOf(FlowRecord record) { + return factory.workflowService + .getWorkflowRuntime(record.getWorkRuntimeId()) + .toWorkflow() + .getFlowNode(record.getNodeId()) + .actionManager().getActions().get(0).id(); + } + + /** 按 (fromId, nodeId) 分组查询 B 节点记录:等价于 {@code select * from t_flow_record where fromId=? and nodeId=? and revoked=0}。 */ + private List bNodeRecords(FlowRecord anyBRecord) { + return factory.flowRecordRepository.findCurrentNodeRecords(anyBRecord.getFromId(), anyBRecord.getNodeId()); + } +} From 6463299d5154de380c2d47e1a7fa0b16e18e1a38 Mon Sep 17 00:00:00 2001 From: lorne <1991wangliang@gmail.com> Date: Thu, 12 Nov 2026 10:46:38 +0800 Subject: [PATCH 06/17] =?UTF-8?q?docs:=20issue=20=E5=9B=9E=E5=A4=8D?= =?UTF-8?q?=E5=BF=85=E9=A1=BB=E5=85=B3=E8=81=94=E4=BB=A3=E7=A0=81=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=EF=BC=88=E4=BB=93=E5=BA=93=20/=20=E5=88=86=E6=94=AF?= =?UTF-8?q?=20/=20=E6=8F=90=E4=BA=A4=E5=8F=B7=20/=20PR=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 核心原则新增:回复涉及代码改动时必须附「仓库 + 分支 + 提交号(SHA)」,有 PR 附链接 - 定位回复引用既有提交时同样需要附分支与提交号 - 完成回复模板与检查清单补充代码提交清单要求 - AGENTS.md 的 issue 流程摘要同步 --- AGENTS.md | 2 +- docs/conventions/workflow/github-issue.md | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cc6bc6d4..3949a874 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,7 +105,7 @@ cd flow-engine-example && mvn spring-boot:run 4. 修复需测试先行并完成本地编译/测试验证; 5. 完成后**只推送该 issue 的特性分支到 GitHub**:提交内容、推送与 issue 关联均以该特性分支为准,**不合并 `dev`**;是否合并 `dev` 由人工决定,任务完成时仅提示分支与 PR 链接; 6. 提交信息使用 `Closes #` 关联 issue;进入默认分支后自动关闭,未自动关闭时需用户确认后手动关闭; -7. **特性分支推送到 GitHub 后再回复 issue**,说明修复内容、验证结果与推送分支/提交;未推送前不得发布完成回复(定位回复不受此限)。 +7. **特性分支推送到 GitHub 后再回复 issue**,说明修复内容、验证结果与推送分支/提交;未推送前不得发布完成回复(定位回复不受此限);**回复涉及代码改动时必须附「仓库 + 分支 + 提交号(SHA) + PR 链接」,无代码改动需注明**。 ### 基本规范 diff --git a/docs/conventions/workflow/github-issue.md b/docs/conventions/workflow/github-issue.md index 4d1e15b8..ce44d6a4 100644 --- a/docs/conventions/workflow/github-issue.md +++ b/docs/conventions/workflow/github-issue.md @@ -27,6 +27,7 @@ source: 项目自有 3. **dev 只作为基线,不参与交付**:worktree 基于 `dev` 代码创建;修复提交、推送与 issue 关联全部发生在该 issue 的特性分支上。完成后**不合并 `dev`**,是否合并由人工决定,助手只在任务完成时提示分支与 PR 链接。 4. **提交即关联 issue**:提交信息使用 `Closes #` 关联;是否已真正关闭取决于是否进入默认分支,未自动关闭时由维护者确认后手动关闭。 5. **推送后再回复**:特性分支推送到 GitHub 之后,才在 issue 回复修复内容、提交与验证结果;**未推送前不得发布完成回复**。 +6. **回复必须可追溯代码**:只要回复内容涉及代码改动,就必须附上「仓库 + 分支 + 提交号(SHA)」,有 PR 时附 PR 链接;前后端都有改动时两个仓库分别列出。未涉及代码改动(纯分析、纯文档结论)时才可省略,并说明没有代码提交。 ## 标准步骤 @@ -50,7 +51,8 @@ gh issue view --repo codingapi/flow-engine \ - 问题原因(引用具体类/文件/方法); - 影响范围(哪些动作、端、版本); - 处理方案(改动点、交互变化、兼容性); -- 交付方式(基于 `dev` 的特性分支名,完成后由维护者合并)。 +- 交付方式(基于 `dev` 的特性分支名,完成后由维护者合并); +- 若本次回复引用了已经存在的代码提交(例如复用其他 issue 的修复),同样要附上对应分支与提交号,避免读者无法定位代码。 ```bash gh issue comment --repo codingapi/flow-engine --body-file /tmp/issue--analysis.md @@ -123,7 +125,14 @@ Closes # - 根因与最终方案(与步骤 3 的差异); - 变更文件/能力(新增公共 API、交互变化); - 验证结果(测试用例数、构建结果); -- 推送分支与提交号、对应 PR/评审状态; +- **代码提交清单(必填,涉及代码改动时)**:逐仓库列出 `仓库 / 分支 / 提交号(SHA) / PR 链接`,例如: + + ``` + - codingapi/flow-engine:feature/issue-- @ 0f1aadb9(PR:#123) + - codingapi/flow-frontend:feature/issue-- @ c12677f(PR:#72) + ``` + + 仅有分析结论、没有代码提交时,注明「本次无代码提交」; - 升级/兼容提示(如有行为变更)。 ## 禁止事项 @@ -145,3 +154,4 @@ Closes # - [ ] 特性分支已推送到 GitHub(`dev` 未改动) - [ ] 提交信息包含 `Closes #` - [ ] 推送后已在 issue 回复修复与验证结果 +- [ ] 回复中已附代码提交清单(仓库 / 分支 / 提交号 / PR 链接;无代码改动已注明) From ec6343fd6801e517e1dd334efe2ccc011a72fb11 Mon Sep 17 00:00:00 2001 From: lorne <1991wangliang@gmail.com> Date: Thu, 12 Nov 2026 11:08:26 +0800 Subject: [PATCH 07/17] =?UTF-8?q?fix:=20=E5=87=8F=E7=AD=BE=E5=8A=A8?= =?UTF-8?q?=E4=BD=9C=E6=8E=92=E5=9C=A8=E5=8A=A0=E7=AD=BE=E4=B9=8B=E5=90=8E?= =?UTF-8?q?=EF=BC=8C=E6=B5=8B=E8=AF=95=E6=94=B9=E7=94=A8=E6=8C=89=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E5=8F=96=E5=8A=A8=E4=BD=9C=20(#266)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ApprovalNode 默认动作顺序调整为 通过/拒绝/保存/加签/减签/转办/退回/委派 - FlowSampleServiceTest、FlowMockSampleServiceTest 中按索引取动作改为按 ActionType 查找, 避免动作列表顺序变化导致取错动作(#266 测试反馈) --- .../java/com/codingapi/flow/node/nodes/ApprovalNode.java | 5 ++--- .../codingapi/flow/service/FlowMockSampleServiceTest.java | 6 +++--- .../com/codingapi/flow/service/FlowSampleServiceTest.java | 8 ++++---- 3 files changed, 9 insertions(+), 10 deletions(-) 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 0aa8058f..6b6a0727 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 @@ -62,12 +62,11 @@ private static List defaultActions() { actions.add(RejectAction.defaultAction()); actions.add(SaveAction.defaultAction()); actions.add(AddAuditAction.defaultAction()); + // 减签紧跟加签,仅循序/并签且存在其他待审批人时可见(issue #266) + actions.add(ReduceAuditAction.defaultAction()); 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/FlowMockSampleServiceTest.java b/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowMockSampleServiceTest.java index dc612525..932bb564 100644 --- a/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowMockSampleServiceTest.java +++ b/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowMockSampleServiceTest.java @@ -1831,7 +1831,7 @@ void transfer() { transferRequest.setFormData(data); transferRequest.setRecordId(bossRecordList.get(0).getId()); - FlowAdviceBody transferAdviceBody = new FlowAdviceBody(bossActions.get(4).id(), boss.getUserId()); + FlowAdviceBody transferAdviceBody = new FlowAdviceBody(bossNode.actionManager().getActionByType(ActionType.TRANSFER.name()).id(), boss.getUserId()); transferAdviceBody.setForwardOperatorIds(List.of(3L)); transferRequest.setAdvice(transferAdviceBody); factory.flowService.action(transferRequest); @@ -1946,7 +1946,7 @@ void returnNode() { returnRequest.setFormData(data); returnRequest.setRecordId(bossRecordList.get(0).getId()); - FlowAdviceBody backAdviceBody = new FlowAdviceBody(bossActions.get(5).id(), boss.getUserId()); + FlowAdviceBody backAdviceBody = new FlowAdviceBody(bossNode.actionManager().getActionByType(ActionType.RETURN.name()).id(), boss.getUserId()); backAdviceBody.setBackNodeId(startNode.getId()); returnRequest.setAdvice(backAdviceBody); factory.flowService.action(returnRequest); @@ -2069,7 +2069,7 @@ void delegate() { delegateRequest.setFormData(data); delegateRequest.setRecordId(bossRecordList.get(0).getId()); - FlowAdviceBody delegateAdviceBody = new FlowAdviceBody(bossActions.get(6).id(), boss.getUserId()); + FlowAdviceBody delegateAdviceBody = new FlowAdviceBody(bossNode.actionManager().getActionByType(ActionType.DELEGATE.name()).id(), boss.getUserId()); delegateAdviceBody.setForwardOperatorIds(List.of(lorne.getUserId())); delegateRequest.setAdvice(delegateAdviceBody); factory.flowService.action(delegateRequest); diff --git a/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowSampleServiceTest.java b/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowSampleServiceTest.java index fec46306..d3583f1b 100644 --- a/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowSampleServiceTest.java +++ b/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowSampleServiceTest.java @@ -526,7 +526,7 @@ void returnShouldKeepHistoryAndOrder() { FlowActionRequest bReturnRequest = new FlowActionRequest(); bReturnRequest.setFormData(data); bReturnRequest.setRecordId(bRecordList.get(0).getId()); - FlowAdviceBody bReturnAdvice = new FlowAdviceBody(bActions.get(5).id(), "退回修改", bUser.getUserId()); + FlowAdviceBody bReturnAdvice = new FlowAdviceBody(bNode.actionManager().getActionByType(ActionType.RETURN.name()).id(), "退回修改", bUser.getUserId()); // 退回节点设置为 a 节点 bReturnAdvice.setBackNodeId(aNode.getId()); bReturnRequest.setAdvice(bReturnAdvice); @@ -2670,7 +2670,7 @@ void transfer() { transferRequest.setFormData(data); transferRequest.setRecordId(bossRecordList.get(0).getId()); - FlowAdviceBody transferAdviceBody = new FlowAdviceBody(bossActions.get(4).id(), boss.getUserId()); + FlowAdviceBody transferAdviceBody = new FlowAdviceBody(bossNode.actionManager().getActionByType(ActionType.TRANSFER.name()).id(), boss.getUserId()); transferAdviceBody.setForwardOperatorIds(List.of(3L)); transferRequest.setAdvice(transferAdviceBody); factory.flowService.action(transferRequest); @@ -2785,7 +2785,7 @@ void returnNode() { returnRequest.setFormData(data); returnRequest.setRecordId(bossRecordList.get(0).getId()); - FlowAdviceBody backAdviceBody = new FlowAdviceBody(bossActions.get(5).id(), boss.getUserId()); + FlowAdviceBody backAdviceBody = new FlowAdviceBody(bossNode.actionManager().getActionByType(ActionType.RETURN.name()).id(), boss.getUserId()); backAdviceBody.setBackNodeId(startNode.getId()); returnRequest.setAdvice(backAdviceBody); factory.flowService.action(returnRequest); @@ -2908,7 +2908,7 @@ void delegate() { delegateRequest.setFormData(data); delegateRequest.setRecordId(bossRecordList.get(0).getId()); - FlowAdviceBody delegateAdviceBody = new FlowAdviceBody(bossActions.get(6).id(), boss.getUserId()); + FlowAdviceBody delegateAdviceBody = new FlowAdviceBody(bossNode.actionManager().getActionByType(ActionType.DELEGATE.name()).id(), boss.getUserId()); delegateAdviceBody.setForwardOperatorIds(List.of(lorne.getUserId())); delegateRequest.setAdvice(delegateAdviceBody); factory.flowService.action(delegateRequest); From 62cc879aa3bda28797a5d2dd23aba68f9c58b0cc Mon Sep 17 00:00:00 2001 From: lorne <1991wangliang@gmail.com> Date: Thu, 12 Nov 2026 11:37:55 +0800 Subject: [PATCH 08/17] =?UTF-8?q?feat:=20=E5=8A=A0=E7=AD=BE=E5=8E=BB?= =?UTF-8?q?=E9=87=8D=E3=80=81=E5=87=8F=E7=AD=BE=E5=8D=95=E4=BA=BA=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E4=B8=8E=E5=90=88=E5=B9=B6=E5=AE=A1=E6=89=B9=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E8=83=BD=E5=8A=9B=E5=A3=B0=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 加签拒绝已在当前节点待办队列中的人员(含加签自己) - 减签在当前节点仅剩一名待审批人时拒绝 - FlowRecord 新增 isPendingApproval,统一待审批记录口径 - detail 响应新增 currentNodeOperators(当前节点待审批人,排除自己) - IFlowAction 新增 batchable,减签/自定义动作为 false,并在 detail 动作中附加标记 - 补充加签/减签/detail 相关测试与接口文档 --- docs/Integration/rest-api.md | 6 +- .../codingapi/flow/action/IFlowAction.java | 13 +++ .../flow/action/actions/AddAuditAction.java | 50 +++++++++- .../flow/action/actions/CustomAction.java | 9 ++ .../action/actions/ReduceAuditAction.java | 33 +++++-- .../exception/FlowExecutionException.java | 27 ++++++ .../codingapi/flow/node/BaseAuditNode.java | 12 +-- .../flow/pojo/response/FlowContent.java | 59 +++++++++++- .../com/codingapi/flow/record/FlowRecord.java | 11 +++ .../flow/service/FlowAddAuditServiceTest.java | 26 +++-- .../service/FlowIssue266ReduceAuditTest.java | 96 +++++++++++++++++++ flow-frontend | 2 +- 12 files changed, 312 insertions(+), 32 deletions(-) diff --git a/docs/Integration/rest-api.md b/docs/Integration/rest-api.md index 85c6b34f..c49e4b0a 100644 --- a/docs/Integration/rest-api.md +++ b/docs/Integration/rest-api.md @@ -185,11 +185,15 @@ view / code / viewTitle / nodeName / nodeId / nodeType title(节点标题) / adviceRequired / signRequired / adviceHidden form(FlowForm 元数据) / fieldPermissions(字段权限) todos(当前节点待办 Body 列表) / actions / actionList -mergeable / createOperator / currentOperator / flowState / recordState +mergeable / createOperator / currentOperator / currentNodeOperators / flowState / recordState histories(审批历史) / nodes(NodeOption 可操作节点) / revoke / urge resetSubProcess(子流程重置能力标识:当前待办位于开启重置能力、已汇聚完成的子流程的锁定合并节点上时为 true;更深层下游节点为 false) ``` +- `currentNodeOperators`:当前记录所属**节点实例**中仍待审批的人员(`id` / `name`,按人去重,排除当前操作人,含顺序审批尚未激活的隐藏记录)。减签等只能作用于当前节点其他待审批人的动作应以此字段作为候选来源,不要使用宿主的全局用户列表。 +- 该字段按单条流程记录计算;合并审批(`mergeable = true`)下多条流程实例的待审批人集合可能不同,不能跨流程合并候选人。 +- `actions` / `actionList` 中每个动作额外带有 `batchable` 标记:`true` 表示支持合并批量提交,`false` 表示只能单条记录提交(当前为 `REDUCE_AUDIT`、`CUSTOM`)。该标记只在响应中附加,不写入流程设计持久化数据;旧版本服务端无此字段时前端按 `true` 处理。 + ### `FlowRecordContent`(列表响应,字段摘要) ``` diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/action/IFlowAction.java b/flow-engine-framework/src/main/java/com/codingapi/flow/action/IFlowAction.java index f7e2aca4..7c547e58 100644 --- a/flow-engine-framework/src/main/java/com/codingapi/flow/action/IFlowAction.java +++ b/flow-engine-framework/src/main/java/com/codingapi/flow/action/IFlowAction.java @@ -49,6 +49,19 @@ public interface IFlowAction extends IMapConvertor, ICopyAbility { */ boolean show(FlowSession flowSession); + /** + * 是否支持合并批量提交。 + * + *

合并审批时同一审批人会同时持有多条流程实例的待办,前端按勾选的记录逐条提交。 + * 目标按流程实例隔离的动作(如减签,候选人来自各流程自己的当前节点待办人)不能批量, + * 否则会把 A 流程的目标错配到 B 流程。缺省视为支持。 + * + * @return true 表示支持合并批量提交 + */ + default boolean batchable() { + return true; + } + /** * 执行动作 diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/action/actions/AddAuditAction.java b/flow-engine-framework/src/main/java/com/codingapi/flow/action/actions/AddAuditAction.java index 58fe790a..2085d7a2 100644 --- a/flow-engine-framework/src/main/java/com/codingapi/flow/action/actions/AddAuditAction.java +++ b/flow-engine-framework/src/main/java/com/codingapi/flow/action/actions/AddAuditAction.java @@ -22,6 +22,9 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; /** * 加签 @@ -102,8 +105,9 @@ public void run(FlowSession flowSession) { } } } + // 已经在本节点待办队列中的人员不允许再次加签,避免同一审批人在同一节点重复产生待办 + verifyNotInTodoQueue(flowSession, auditOperators); - // 要延续流程继续的order序号 int maxNodeOrder = 0; for (FlowRecord record : currentRecords) { if (record.getNodeOrder() >= maxNodeOrder) { @@ -136,6 +140,50 @@ public void run(FlowSession flowSession) { flowEvents.forEach(EventPusher::push); } + /** + * 校验加签人员不能已经存在于当前节点的待办队列中。 + * + *

待办队列指当前节点实例中尚未办理的审批记录:包括顺序审批里尚未激活的隐藏记录 + * (它们后续会按序号激活为待办),但不包括已办、已撤销以及已结束流程的记录。 + * + * @param flowSession 当前会话 + * @param auditOperators 本次加签的人员 + */ + private void verifyNotInTodoQueue(FlowSession flowSession, List auditOperators) { + if (auditOperators == null || auditOperators.isEmpty()) { + return; + } + Set pendingOperatorIds = pendingOperatorIds(flowSession); + if (pendingOperatorIds.isEmpty()) { + return; + } + for (IFlowOperator auditOperator : auditOperators) { + if (pendingOperatorIds.contains(auditOperator.getUserId())) { + throw FlowExecutionException.addAuditOperatorAlreadyInTodo(auditOperator.getUserId()); + } + } + } + + /** + * 当前节点实例中仍有待办的人员id集合。 + * + *

仓储按 {@code (fromId, nodeId)} 查询节点记录,开始节点({@code fromId = 0})等场景可能 + * 命中其他流程实例的记录,必须按 {@code processId} 限定,避免误判其他流程的待办人员。 + */ + private Set pendingOperatorIds(FlowSession flowSession) { + FlowRecord currentRecord = flowSession.getCurrentRecord(); + List currentNodeRecords = flowSession.getCurrentNodeRecords(); + if (currentRecord == null || currentNodeRecords == null) { + return Set.of(); + } + return currentNodeRecords.stream() + .filter(Objects::nonNull) + .filter(record -> currentRecord.getProcessId().equals(record.getProcessId())) + .filter(FlowRecord::isPendingApproval) + .map(FlowRecord::getCurrentOperatorId) + .collect(Collectors.toSet()); + } + public static AddAuditAction fromMap(Map data) { AddAuditAction action = BaseAction.fromMap(data, AddAuditAction.class); String script = (String) data.get("script"); diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/action/actions/CustomAction.java b/flow-engine-framework/src/main/java/com/codingapi/flow/action/actions/CustomAction.java index 8d867e8b..5a1bbca2 100644 --- a/flow-engine-framework/src/main/java/com/codingapi/flow/action/actions/CustomAction.java +++ b/flow-engine-framework/src/main/java/com/codingapi/flow/action/actions/CustomAction.java @@ -26,6 +26,15 @@ public class CustomAction extends BaseAction { public static final String DEFAULT_TITLE = "自定义"; + /** + * 自定义动作的行为由脚本决定,可能依赖当前记录的表单/选人数据, + * 无法保证多条流程合并提交语义正确,因此不支持批量。 + */ + @Override + public boolean batchable() { + return false; + } + /** * 后端处理脚本 */ 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 index b0a58609..c2ecbec0 100644 --- 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 @@ -71,6 +71,15 @@ public List operators(FlowSession flowSession) { return script.execute(flowSession); } + /** + * 减签不支持合并批量提交:候选人与目标都是「本流程实例当前节点」的待审批人, + * 合并组内各流程的待办人集合不同,批量会把目标错配到其他流程。 + */ + @Override + public boolean batchable() { + return false; + } + @Override public void copy(IFlowAction action) { super.copy(action); @@ -120,6 +129,10 @@ public void run(FlowSession flowSession) { } List currentNodeRecords = currentNodeRecords(flowSession); + // 当前节点只剩一名待审批人时已无其他人可减,直接拒绝 + if (pendingOperatorCount(currentNodeRecords) <= 1) { + throw FlowExecutionException.reduceAuditSingleApprover(); + } List targetRecords = resolveTargetRecords(targets, currentNodeRecords, currentRecord); // 撤销后必须仍需要一次真实审批,否则节点条件即使满足也没有触发点,流程会永久卡死 @@ -161,7 +174,7 @@ private List resolveTargetRecords(List targets, throw FlowExecutionException.reduceAuditOwnRecord(); } List matchedRecords = currentNodeRecords.stream() - .filter(this::isPendingApprovalRecord) + .filter(FlowRecord::isPendingApproval) .filter(record -> record.getCurrentOperatorId() == targetOperatorId) .toList(); if (matchedRecords.isEmpty()) { @@ -186,7 +199,7 @@ private List pendingRecords(FlowSession flowSession) { return List.of(); } return currentNodeRecords(flowSession).stream() - .filter(this::isPendingApprovalRecord) + .filter(FlowRecord::isPendingApproval) .filter(record -> record.getId() != currentRecord.getId()) .toList(); } @@ -209,10 +222,18 @@ private List currentNodeRecords(FlowSession flowSession) { .toList(); } - private boolean isPendingApprovalRecord(FlowRecord record) { - return !record.isRevoked() - && record.getRecordState() == FlowRecord.SATE_RECORD_TODO - && record.getFlowState() == FlowRecord.SATE_FLOW_RUNNING; + /** + * 当前节点实例中仍有待办的人员数量(按人去重)。 + * + *

同一审批人可能持有多条待审批记录(例如加签为其生成多条记录),减签按「人」生效, + * 因此只有剩下一名待审批人时才视为「无人可减」。 + */ + private long pendingOperatorCount(List currentNodeRecords) { + return currentNodeRecords.stream() + .filter(FlowRecord::isPendingApproval) + .map(FlowRecord::getCurrentOperatorId) + .distinct() + .count(); } private boolean isSupportedMode(IFlowNode node) { 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 a23de0f8..73eab453 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 @@ -97,6 +97,20 @@ public static FlowExecutionException operatorCountExceeded(String actionType, in String.format("Operator count exceeds the max count %d of the %s action", maxOperatorCount, actionType)); } + /** + * Add audit target is already in the todo list of the current node instance + *

+ * Adding an approver who already holds a pending record would create duplicated + * todos for the same operator on the same node, so it is rejected. + * + * @param operatorId target operator id + * @return exception + */ + public static FlowExecutionException addAuditOperatorAlreadyInTodo(long operatorId) { + return new FlowExecutionException("execution.addAudit.operatorAlreadyInTodo", + String.format("Operator %d is already a pending approver of the current node", operatorId)); + } + /** * Custom action next not found * @@ -232,4 +246,17 @@ public static FlowExecutionException reduceAuditNoRemainingApproval() { return new FlowExecutionException("execution.reduceAudit.noRemainingApproval", "Reduce audit rejected: the node still requires at least one pending approval to finish"); } + + /** + * Reduce audit is not allowed when the current node has only one pending approver + *

+ * A reduce audit always removes someone else from the node, so a single pending + * approver means there is nobody left to remove. + * + * @return exception + */ + public static FlowExecutionException reduceAuditSingleApprover() { + return new FlowExecutionException("execution.reduceAudit.singleApprover", + "Reduce audit rejected: the current node has only one pending approver"); + } } 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 8e16b43c..2c19c6b9 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 @@ -181,7 +181,7 @@ public boolean requiresAuditToFinish(List records) { } MultiOperatorAuditStrategy.Type multiOperatorAuditStrategyType = this.strategyManager().getMultiOperatorAuditStrategyType(); if (multiOperatorAuditStrategyType == MultiOperatorAuditStrategy.Type.SEQUENCE) { - return records.stream().anyMatch(this::isPendingApprovalRecord); + return records.stream().anyMatch(FlowRecord::isPendingApproval); } if (multiOperatorAuditStrategyType == MultiOperatorAuditStrategy.Type.MERGE) { long total = records.stream() @@ -194,16 +194,6 @@ public boolean requiresAuditToFinish(List records) { 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/pojo/response/FlowContent.java b/flow-engine-framework/src/main/java/com/codingapi/flow/pojo/response/FlowContent.java index 52ae4874..183770ef 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 @@ -13,6 +13,8 @@ import lombok.Data; import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -146,6 +148,14 @@ public class FlowContent { */ private FlowOperator currentOperator; + /** + * 当前节点待审批人员(不含当前操作人) + * + *

减签等动作的目标必须来自当前节点实例,不能从宿主用户列表随意选取:合并审批 + * 场景下每个流程实例的待审批人集合可能不同,候选人按流程实例维度计算。 + */ + private List currentNodeOperators; + /** * 流程状态 | 运行中、已完成、异常、删除 */ @@ -217,13 +227,14 @@ public void pushCurrentNode(FlowSession flowSession) { if (currentNode != null) { ActionManager actionManager = currentNode.actionManager(); NodeStrategyManager strategyManager = currentNode.strategyManager(); - this.actions = actionManager.filterActions(flowSession).stream().map(IFlowAction::toMap).toList(); - this.actionList = actionManager.getActions().stream().map(IFlowAction::toMap).toList(); + this.actions = toActionMaps(actionManager.filterActions(flowSession)); + this.actionList = toActionMaps(actionManager.getActions()); this.adviceRequired = strategyManager.isAdviceRequired(); this.signRequired = strategyManager.isSignRequired(); this.adviceHidden = strategyManager.isAdviceHidden(); this.nodeId = currentNode.getId(); this.nodeName = currentNode.getName(); + this.currentNodeOperators = loadCurrentNodeOperators(flowSession); this.nodeType = currentNode.getType(); this.fieldPermissions = strategyManager.getFieldPermissions(); Map nodeData = currentNode.toMap(); @@ -233,6 +244,50 @@ public void pushCurrentNode(FlowSession flowSession) { } } + /** + * 输出动作配置,并附加运行期能力标记。 + * + *

{@code batchable} 表示该动作是否支持合并批量提交,由 {@link IFlowAction#batchable()} 声明。 + * 该标记只在响应中附加,不写入动作的持久化配置({@link IFlowAction#toMap()})。 + */ + private static List> toActionMaps(List actions) { + return actions.stream().map(action -> { + Map data = new HashMap<>(action.toMap()); + data.put("batchable", action.batchable()); + return data; + }).toList(); + } + + /** + * 计算当前节点实例中的待审批人员(按人去重,排除当前操作人)。 + * + *

口径与减签目标解析一致:仅取仍待审批的记录(含顺序审批中尚未激活的隐藏记录), + * 且只取同一流程实例的数据,避免仓储按 {@code (fromId, nodeId)} 查询时混入其他流程实例。 + * + *

合并审批是多条流程实例的待办聚合展示,各自的待审批人集合可能不同, + * 因此候选人员只能按单条流程记录计算,不能跨流程合并。 + */ + private List loadCurrentNodeOperators(FlowSession flowSession) { + FlowRecord currentRecord = flowSession.getCurrentRecord(); + List currentNodeRecords = flowSession.getCurrentNodeRecords(); + if (currentRecord == null || currentNodeRecords == null) { + return List.of(); + } + long currentOperatorId = currentRecord.getCurrentOperatorId(); + Map operators = new LinkedHashMap<>(); + for (FlowRecord record : currentNodeRecords) { + if (record == null || !currentRecord.getProcessId().equals(record.getProcessId())) { + continue; + } + if (!record.isPendingApproval() || record.getCurrentOperatorId() == currentOperatorId) { + continue; + } + operators.putIfAbsent(record.getCurrentOperatorId(), + new FlowOperator(record.getCurrentOperatorId(), record.getCurrentOperatorName())); + } + return List.copyOf(operators.values()); + } + public void pushWorkflow(Workflow workflow) { this.form = workflow.getForm(); this.workCode = workflow.getCode(); diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/record/FlowRecord.java b/flow-engine-framework/src/main/java/com/codingapi/flow/record/FlowRecord.java index fde0ab24..bd0fa18e 100644 --- a/flow-engine-framework/src/main/java/com/codingapi/flow/record/FlowRecord.java +++ b/flow-engine-framework/src/main/java/com/codingapi/flow/record/FlowRecord.java @@ -508,6 +508,17 @@ public boolean isDone() { return recordState == SATE_RECORD_DONE && !hidden && !revoked; } + /** + * 是否仍待审批。 + * + *

与 {@link #isTodo()} 的区别是不排除隐藏记录:顺序审批中尚未激活后续审批人的记录 + * 虽然当前不可见,但仍属于该节点的待办队列,后续会按序号激活为待办。 + * 减签目标解析、加签重复校验、节点待审批人统计都以该口径为准。 + */ + public boolean isPendingApproval() { + return recordState == SATE_RECORD_TODO && flowState == SATE_FLOW_RUNNING && !revoked; + } + /** * 判断是否已完成 diff --git a/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowAddAuditServiceTest.java b/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowAddAuditServiceTest.java index 38bd2020..65dcd833 100644 --- a/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowAddAuditServiceTest.java +++ b/flow-engine-framework/src/test/java/com/codingapi/flow/service/FlowAddAuditServiceTest.java @@ -50,10 +50,12 @@ void addAudit() { User user = new User(1, "user"); User depart = new User(2, "depart"); User boss = new User(3, "boss"); + User assistant = new User(4, "assistant"); factory.userGateway.save(user); factory.userGateway.save(boss); factory.userGateway.save(depart); + factory.userGateway.save(assistant); GatewayContext.getInstance().setFlowOperatorGateway(factory.userGateway); @@ -142,14 +144,15 @@ void addAudit() { assertEquals(1, departRecordList.size()); - List departActions = departNode.actionManager().getActions(); + IFlowAction departAddAuditAction = departNode.actionManager().getActionByType(ActionType.ADD_AUDIT.name()); + IFlowAction departPassAction = departNode.actionManager().getActionByType(ActionType.PASS.name()); FlowActionRequest addAuditRequest = new FlowActionRequest(); addAuditRequest.setFormData(data); addAuditRequest.setRecordId(departRecordList.get(0).getId()); - FlowAdviceBody addAuditAdviceBody = new FlowAdviceBody(departActions.get(3).id(), depart.getUserId()); - addAuditAdviceBody.setForwardOperatorIds(List.of(depart.getUserId())); + FlowAdviceBody addAuditAdviceBody = new FlowAdviceBody(departAddAuditAction.id(), depart.getUserId()); + addAuditAdviceBody.setForwardOperatorIds(List.of(assistant.getUserId())); addAuditRequest.setAdvice(addAuditAdviceBody); factory.flowService.action(addAuditRequest); @@ -159,17 +162,20 @@ void addAudit() { FlowActionRequest departRequest = new FlowActionRequest(); departRequest.setFormData(data); departRequest.setRecordId(departRecordList.get(0).getId()); - departRequest.setAdvice(new FlowAdviceBody(departActions.get(0).id(), "同意", depart.getUserId())); + departRequest.setAdvice(new FlowAdviceBody(departPassAction.id(), "同意", depart.getUserId())); factory.flowService.action(departRequest); departRecordList = factory.flowRecordRepository.findTodoByOperator(depart.getUserId()); - assertEquals(1, departRecordList.size()); + assertEquals(0, departRecordList.size()); - departRequest = new FlowActionRequest(); - departRequest.setFormData(data); - departRequest.setRecordId(departRecordList.get(0).getId()); - departRequest.setAdvice(new FlowAdviceBody(departActions.get(0).id(), "同意", depart.getUserId())); - factory.flowService.action(departRequest); + List assistantRecordList = factory.flowRecordRepository.findTodoByOperator(assistant.getUserId()); + assertEquals(1, assistantRecordList.size()); + + FlowActionRequest assistantRequest = new FlowActionRequest(); + assistantRequest.setFormData(data); + assistantRequest.setRecordId(assistantRecordList.get(0).getId()); + assistantRequest.setAdvice(new FlowAdviceBody(departPassAction.id(), "同意", assistant.getUserId())); + factory.flowService.action(assistantRequest); List bossRecordList = factory.flowRecordRepository.findTodoByOperator(boss.getUserId()); assertEquals(1, bossRecordList.size()); 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 index ed1ee164..90b71b6e 100644 --- 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 @@ -21,6 +21,7 @@ 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.pojo.response.FlowOperator; import com.codingapi.flow.record.FlowRecord; import com.codingapi.flow.record.FlowTodoRecord; import com.codingapi.flow.script.factory.FlowGroovyScriptFactory; @@ -273,6 +274,94 @@ void reduceActionShouldBeVisibleOnlyWhenAnotherPendingApproverExists() { "多人并签节点应展示减签按钮"); } + /** + * 测试目标:加签不允许把已经在当前节点待办队列中的人员重复加入。 + * 前置条件:B 为并签节点,b1、b2 均为待审批人。 + * 执行步骤:b1 分别加签 b2(他人)与 b1(自己)。 + * 期望断言:两次都被拒绝(execution.addAudit.operatorAlreadyInTodo),节点记录数与待办均不变化。 + */ + @Test + void addAuditToExistingPendingApprover_shouldReject() { + // given + 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/then 加签已在待办中的他人被拒绝 + FlowExecutionException exception = assertThrows(FlowExecutionException.class, + () -> addAudit(workflow, b1, b1Record, b2.getUserId())); + assertEquals("execution.addAudit.operatorAlreadyInTodo", exception.getErrCode()); + assertEquals(2, nodeRecordsOf(startRecordId, bNodeId).size(), "被拒绝的加签不应新增记录"); + assertEquals(1, todoRecordsOf(b2, bNodeId).size(), "b2 的待办应保持不变"); + + // when/then 加签自己同样被拒绝 + FlowExecutionException selfException = assertThrows(FlowExecutionException.class, + () -> addAudit(workflow, b1, b1Record, b1.getUserId())); + assertEquals("execution.addAudit.operatorAlreadyInTodo", selfException.getErrCode()); + assertEquals(2, nodeRecordsOf(startRecordId, bNodeId).size(), "被拒绝的加签不应新增记录"); + assertEquals(1, todoRecordsOf(b1, bNodeId).size(), "b1 的待办应保持不变"); + } + + /** + * 测试目标:当前节点只剩一名待审批人时不允许减签。 + * 前置条件:B 为单人审批节点,待审批人只有 b1。 + * 执行步骤:b1 尝试减签 b2。 + * 期望断言:被拒绝(execution.reduceAudit.singleApprover),b1 的待办保留。 + */ + @Test + void reduceWhenOnlyOnePendingApprover_shouldReject() { + // given + Workflow workflow = saveWorkflow("issue266-single", MultiOperatorAuditStrategy.Type.SEQUENCE, + 0f, List.of(2L), true); + String bNodeId = nodeIdByName(workflow, "B审批"); + createAndSubmit(workflow); + + FlowRecord b1Record = todoRecordOf(b1, bNodeId); + + // when/then + FlowExecutionException exception = assertThrows(FlowExecutionException.class, + () -> reduce(workflow, b1, b1Record, b2.getUserId())); + assertEquals("execution.reduceAudit.singleApprover", exception.getErrCode()); + assertEquals(1, todoRecordsOf(b1, bNodeId).size(), "b1 的待办应保留"); + } + + /** + * 测试目标:detail 返回当前节点待审批人员,作为减签选人的数据来源。 + * 前置条件:B 为顺序审批节点,b1/b2/b3。 + * 执行步骤:b1 查看 detail;b1 通过后 b2 查看 detail。 + * 期望断言:b1 看到 b2、b3(含尚未激活的隐藏记录,且不含自己);b2 只看到 b3(已办的 b1 被排除)。 + */ + @Test + void detailShouldExposeCurrentNodePendingOperators() { + // given + Workflow workflow = saveWorkflow(MultiOperatorAuditStrategy.Type.SEQUENCE, 0f, List.of(2L, 3L, 4L), false); + String bNodeId = nodeIdByName(workflow, "B审批"); + createAndSubmit(workflow); + + FlowRecord b1Record = todoRecordOf(b1, bNodeId); + + // when/then b1 视角:b2、b3 均为当前节点待审批人 + FlowContent b1Detail = factory.flowService.detail(new FlowDetailRequest(b1Record.getId(), b1.getUserId())); + assertEquals(List.of(b2.getUserId(), b3.getUserId()), + b1Detail.getCurrentNodeOperators().stream().map(FlowOperator::getId).toList()); + + // 动作能力标记:减签不支持合并批量,通过等其他动作支持 + assertEquals(false, actionMap(b1Detail, ActionType.REDUCE_AUDIT).get("batchable")); + assertEquals(true, actionMap(b1Detail, ActionType.PASS).get("batchable")); + + // when b1 通过后,b2 成为当前审批人 + pass(b1Record, workflow, b1); + + // then b2 视角:只剩 b3,b1 已办不再作为候选人 + FlowContent b2Detail = factory.flowService.detail( + new FlowDetailRequest(todoRecordOf(b2, bNodeId).getId(), b2.getUserId())); + assertEquals(List.of(b3.getUserId()), + b2Detail.getCurrentNodeOperators().stream().map(FlowOperator::getId).toList()); + assertEquals(b3.getName(), b2Detail.getCurrentNodeOperators().get(0).getName()); + } + // ------------------------------------------------------------------ // 辅助方法 // ------------------------------------------------------------------ @@ -459,4 +548,11 @@ private String nodeIdByName(Workflow workflow, String name) { private boolean containsAction(List> actions, ActionType actionType) { return actions.stream().anyMatch(action -> actionType.name().equals(action.get("type"))); } + + private Map actionMap(FlowContent detail, ActionType actionType) { + return detail.getActions().stream() + .filter(action -> actionType.name().equals(action.get("type"))) + .findFirst() + .orElseThrow(); + } } diff --git a/flow-frontend b/flow-frontend index a3a9fcce..ec7f2cbe 160000 --- a/flow-frontend +++ b/flow-frontend @@ -1 +1 @@ -Subproject commit a3a9fcce9dbaba1c5b4481159a11991ab6912d4a +Subproject commit ec7f2cbe1815c59e7971018c88901ddf177f45ca From 08760b7931f244bd10e10f0e0702992817eba885 Mon Sep 17 00:00:00 2001 From: lorne <1991wangliang@gmail.com> Date: Thu, 12 Nov 2026 11:42:02 +0800 Subject: [PATCH 09/17] =?UTF-8?q?chore:=20=E6=9B=B4=E6=96=B0=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E5=AD=90=E6=A8=A1=E5=9D=97=E6=8C=87=E9=92=88=E8=87=B3?= =?UTF-8?q?=20dev=EF=BC=88batchable=20=E4=B8=8E=E5=87=8F=E7=AD=BE=E5=80=99?= =?UTF-8?q?=E9=80=89=E4=BA=BA=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- flow-frontend | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flow-frontend b/flow-frontend index ec7f2cbe..a367fd5a 160000 --- a/flow-frontend +++ b/flow-frontend @@ -1 +1 @@ -Subproject commit ec7f2cbe1815c59e7971018c88901ddf177f45ca +Subproject commit a367fd5ab1757ea2fba0b70c2820645c623fcd75 From 483981d853bb116dae8fa8db276454050aa0f011 Mon Sep 17 00:00:00 2001 From: lorne <1991wangliang@gmail.com> Date: Thu, 12 Nov 2026 11:58:05 +0800 Subject: [PATCH 10/17] =?UTF-8?q?fix:=20=E5=90=88=E5=B9=B6=E5=AE=A1?= =?UTF-8?q?=E6=89=B9=E5=8D=95=E6=9D=A1=E8=AF=A6=E6=83=85=E6=8C=89=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E8=AE=A1=E7=AE=97=E6=8C=89=E9=92=AE=EF=BC=8C=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E8=83=BD=E5=8A=9B=E4=BB=85=E9=99=90=E5=88=B6=E5=87=8F?= =?UTF-8?q?=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CustomAction 恢复默认批量能力,避免自定义按钮在合并列表被隐藏 - 补充合并组内每条记录均可展示减签与候选人的回归测试 - 更新接口文档中的合并审批单条查看约定,同步前端子模块指针 --- docs/Integration/rest-api.md | 3 +- .../flow/action/actions/CustomAction.java | 9 ------ .../service/FlowIssue266ReduceAuditTest.java | 30 +++++++++++++++++++ flow-frontend | 2 +- 4 files changed, 33 insertions(+), 11 deletions(-) diff --git a/docs/Integration/rest-api.md b/docs/Integration/rest-api.md index c49e4b0a..678dd62a 100644 --- a/docs/Integration/rest-api.md +++ b/docs/Integration/rest-api.md @@ -192,7 +192,8 @@ resetSubProcess(子流程重置能力标识:当前待办位于开启重置 - `currentNodeOperators`:当前记录所属**节点实例**中仍待审批的人员(`id` / `name`,按人去重,排除当前操作人,含顺序审批尚未激活的隐藏记录)。减签等只能作用于当前节点其他待审批人的动作应以此字段作为候选来源,不要使用宿主的全局用户列表。 - 该字段按单条流程记录计算;合并审批(`mergeable = true`)下多条流程实例的待审批人集合可能不同,不能跨流程合并候选人。 -- `actions` / `actionList` 中每个动作额外带有 `batchable` 标记:`true` 表示支持合并批量提交,`false` 表示只能单条记录提交(当前为 `REDUCE_AUDIT`、`CUSTOM`)。该标记只在响应中附加,不写入流程设计持久化数据;旧版本服务端无此字段时前端按 `true` 处理。 +- `actions` / `actionList` 中每个动作额外带有 `batchable` 标记:`true` 表示支持合并批量提交,`false` 表示只能单条记录提交(当前为 `REDUCE_AUDIT`)。该标记只在响应中附加,不写入流程设计持久化数据;旧版本服务端无此字段时前端按 `true` 处理。 +- 合并审批(`mergeable = true`)时前端不展示 `batchable = false` 的动作;从合并列表进入**单条流程**后,按该条记录自己的 `actions` / `currentNodeOperators` 展示并提交(提交目标固定为该记录)。 ### `FlowRecordContent`(列表响应,字段摘要) diff --git a/flow-engine-framework/src/main/java/com/codingapi/flow/action/actions/CustomAction.java b/flow-engine-framework/src/main/java/com/codingapi/flow/action/actions/CustomAction.java index 5a1bbca2..8d867e8b 100644 --- a/flow-engine-framework/src/main/java/com/codingapi/flow/action/actions/CustomAction.java +++ b/flow-engine-framework/src/main/java/com/codingapi/flow/action/actions/CustomAction.java @@ -26,15 +26,6 @@ public class CustomAction extends BaseAction { public static final String DEFAULT_TITLE = "自定义"; - /** - * 自定义动作的行为由脚本决定,可能依赖当前记录的表单/选人数据, - * 无法保证多条流程合并提交语义正确,因此不支持批量。 - */ - @Override - public boolean batchable() { - return false; - } - /** * 后端处理脚本 */ 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 index 90b71b6e..1fcdcc4e 100644 --- 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 @@ -274,6 +274,36 @@ void reduceActionShouldBeVisibleOnlyWhenAnotherPendingApproverExists() { "多人并签节点应展示减签按钮"); } + /** + * 测试目标:合并审批时,合并组内每条流程记录都应单独计算减签按钮与候选人。 + * 前置条件:B 为并签节点(b1/b2),开启记录合并,并发起两条流程实例。 + * 执行步骤:分别用 b1 在两条流程里的记录查询 detail。 + * 期望断言:两条记录都展示减签按钮,候选人均只有 b2;前端进入单条流程时即可据此切换按钮。 + */ + @Test + void mergedDetailShouldExposeReduceActionForEachRecord() { + // given + Workflow workflow = saveWorkflow("issue-merge-multi", MultiOperatorAuditStrategy.Type.MERGE, + 1f, List.of(2L, 3L), true); + String bNodeId = nodeIdByName(workflow, "B审批"); + createAndSubmit(workflow); + createAndSubmit(workflow); + + List b1Todos = todoRecordsOf(b1, bNodeId); + assertEquals(2, b1Todos.size(), "两条流程的记录应合并到同一个待办分组"); + + // when/then + for (FlowRecord record : b1Todos) { + FlowContent detail = factory.flowService.detail(new FlowDetailRequest(record.getId(), b1.getUserId())); + assertTrue(detail.isMergeable(), "合并组内的记录应标记为可合并审批"); + assertTrue(containsAction(detail.getActions(), ActionType.REDUCE_AUDIT), + "合并组内每条记录都应展示减签按钮"); + assertEquals(List.of(b2.getUserId()), + detail.getCurrentNodeOperators().stream().map(FlowOperator::getId).toList(), + "减签候选人应按记录所属流程实例计算"); + } + } + /** * 测试目标:加签不允许把已经在当前节点待办队列中的人员重复加入。 * 前置条件:B 为并签节点,b1、b2 均为待审批人。 diff --git a/flow-frontend b/flow-frontend index a367fd5a..ee5f5452 160000 --- a/flow-frontend +++ b/flow-frontend @@ -1 +1 @@ -Subproject commit a367fd5ab1757ea2fba0b70c2820645c623fcd75 +Subproject commit ee5f5452a9763bdadeebaae476a637c8d7bd3b09 From 5a8888c7f2433ec3ce30bd64302c99e0d7380e67 Mon Sep 17 00:00:00 2001 From: lorne <1991wangliang@gmail.com> Date: Thu, 12 Nov 2026 12:01:03 +0800 Subject: [PATCH 11/17] =?UTF-8?q?test:=20=E8=A1=A5=E5=85=85=E6=9C=AA?= =?UTF-8?q?=E5=BC=80=E5=90=AF=E8=AE=B0=E5=BD=95=E5=90=88=E5=B9=B6=E6=97=B6?= =?UTF-8?q?=E7=9A=84=E5=87=8F=E7=AD=BE=E5=B1=95=E7=A4=BA=E7=94=A8=E4=BE=8B?= =?UTF-8?q?=E5=B9=B6=E5=90=8C=E6=AD=A5=E5=89=8D=E7=AB=AF=E5=AD=90=E6=A8=A1?= =?UTF-8?q?=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增非合并多人审批 detail 仍返回减签按钮与候选人的回归测试 - 更新 flow-frontend 子模块指针至 dev --- .../service/FlowIssue266ReduceAuditTest.java | 28 +++++++++++++++++++ flow-frontend | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) 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 index 1fcdcc4e..20eda82c 100644 --- 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 @@ -304,6 +304,34 @@ void mergedDetailShouldExposeReduceActionForEachRecord() { } } + /** + * 测试目标:未开启记录合并的多人审批,detail 仍应展示减签按钮与候选人。 + * 前置条件:B 为并签节点(b1/b2),不开启记录合并。 + * 执行步骤:用 b1 的待办查询 detail。 + * 期望断言:mergeable=false、单条 todo,但 actions 中包含减签,候选人为 b2。 + */ + @Test + void nonMergedDetailShouldExposeReduceAction() { + // given + Workflow workflow = saveWorkflow("issue266-no-merge", MultiOperatorAuditStrategy.Type.MERGE, + 1f, List.of(2L, 3L), false); + String bNodeId = nodeIdByName(workflow, "B审批"); + createAndSubmit(workflow); + + FlowRecord b1Record = todoRecordOf(b1, bNodeId); + + // when + FlowContent detail = factory.flowService.detail(new FlowDetailRequest(b1Record.getId(), b1.getUserId())); + + // then + assertFalse(detail.isMergeable(), "未开启记录合并时不应标记为可合并审批"); + assertEquals(1, detail.getTodos().size()); + assertTrue(containsAction(detail.getActions(), ActionType.REDUCE_AUDIT), + "未合并的多人审批同样应展示减签按钮"); + assertEquals(List.of(b2.getUserId()), + detail.getCurrentNodeOperators().stream().map(FlowOperator::getId).toList()); + } + /** * 测试目标:加签不允许把已经在当前节点待办队列中的人员重复加入。 * 前置条件:B 为并签节点,b1、b2 均为待审批人。 diff --git a/flow-frontend b/flow-frontend index ee5f5452..d8cb075f 160000 --- a/flow-frontend +++ b/flow-frontend @@ -1 +1 @@ -Subproject commit ee5f5452a9763bdadeebaae476a637c8d7bd3b09 +Subproject commit d8cb075ffb74810b4287a8e9ee6277f490f7edcd From c748ee79a7bd44aea9caf2084491da5eea613238 Mon Sep 17 00:00:00 2001 From: lorne <1991wangliang@gmail.com> Date: Thu, 12 Nov 2026 12:02:23 +0800 Subject: [PATCH 12/17] =?UTF-8?q?chore:=20=E5=90=8C=E6=AD=A5=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E5=AD=90=E6=A8=A1=E5=9D=97=E6=8C=87=E9=92=88=EF=BC=88?= =?UTF-8?q?PC/=E7=A7=BB=E5=8A=A8=E7=AB=AF=E5=87=8F=E7=AD=BE=E8=83=BD?= =?UTF-8?q?=E5=8A=9B=E5=AF=B9=E9=BD=90=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- flow-frontend | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flow-frontend b/flow-frontend index d8cb075f..17070e43 160000 --- a/flow-frontend +++ b/flow-frontend @@ -1 +1 @@ -Subproject commit d8cb075ffb74810b4287a8e9ee6277f490f7edcd +Subproject commit 17070e436a18369c5a57594da79a0ebda6157539 From ff417b4879a7cd101aa35a50dc42c2c64c68623d Mon Sep 17 00:00:00 2001 From: lorne <1991wangliang@gmail.com> Date: Thu, 12 Nov 2026 12:10:00 +0800 Subject: [PATCH 13/17] =?UTF-8?q?docs:=20=E5=AF=B9=E9=BD=90=E5=87=8F?= =?UTF-8?q?=E7=AD=BE/=E5=8A=A0=E7=AD=BE=E4=B8=8E=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E5=AE=A1=E6=89=B9=E6=96=87=E6=A1=A3=EF=BC=8C=E9=95=BF=E6=AE=B5?= =?UTF-8?q?=E8=90=BD=E8=A1=A8=E6=A0=BC=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 动作/事件数量由 8 更正为 9,补 REDUCE_AUDIT 与 FlowRecordReduceAuditEvent - action.md 补 batchable()、动作约束与批量能力说明 - exception.md 补减签/加签工厂方法与错误码 - rest-api.md 动作枚举、forwardOperatorIds、FlowContent 字段表格化 - 服务分工补子流程重置/结果/会话工厂;事件/会话/模拟仓储段落表格化 - 使用手册与集成手册同步减签动作与事件,同步前端子模块指针 --- docs/Integration/README.md | 4 +- docs/Integration/event-integration.md | 21 +++++----- docs/Integration/extension-points.md | 31 +++++++------- docs/Integration/mock-mode.md | 13 +++++- docs/Integration/programmatic-api.md | 27 ++++++------ docs/Integration/rest-api.md | 41 ++++++++++--------- .../flow-engine-framework/action.md | 20 ++++++++- .../flow-engine-framework/exception.md | 13 +++++- .../flow-engine-framework/flow-service.md | 3 ++ docs/capabilities/springboot/ioc.md | 2 +- docs/manual/integration-manual.html | 9 ++-- docs/manual/usage-manual.html | 16 ++++---- flow-frontend | 2 +- 13 files changed, 126 insertions(+), 76 deletions(-) diff --git a/docs/Integration/README.md b/docs/Integration/README.md index cb169277..7d770cc8 100644 --- a/docs/Integration/README.md +++ b/docs/Integration/README.md @@ -32,12 +32,12 @@ | [快速开始](./quick-start.md) | 最小集成步骤:引入依赖 → 提供 Bean → 编写流程 → 发起流程 | | [流程用户体系](./user-integration.md) | `FlowOperatorGateway` / `IFlowOperator` / `GatewayContext` / 线程缓存 / 当前登录人 | | [流程默认脚本](./script-integration.md) | 12 种脚本类型、`GroovyScriptRequest` 完整 API、`$bind`、默认脚本替换、脚本生命周期 | -| [流程事件机制](./event-integration.md) | 8 种事件、`EventPusher`、`IHandler` 订阅、异步分发管道、事务变体、推送时机 | +| [流程事件机制](./event-integration.md) | 9 种事件、`EventPusher`、`IHandler` 订阅、异步分发管道、事务变体、推送时机 | | [仓储抽象与持久化](./repository-integration.md) | `IRepositoryHolder`、12 个仓储接口(含 11 个 JPA 实现 + 需自行实现的 `NodeViewJavaScriptRepository`)、脚本仓储、锁机制 | | [Spring Boot 自动配置](./auto-configuration.md) | `AutoConfiguration` 8 个 Bean、4 个 Register、必选/可选 Bean 清单 | | [REST API](./rest-api.md) | 5 个 Controller、`mockKey`/`operatorId` 分流机制、请求/响应结构 | | [Mock 模式](./mock-mode.md) | 全内存沙箱 `MockInstance`、15 分钟过期、接入方式 | -| [节点/动作/策略扩展](./extension-points.md) | 19 种节点 × 8 种动作 × 15 种节点策略 × 2 种流程策略,扩展方式 | +| [节点/动作/策略扩展](./extension-points.md) | 19 种节点 × 9 种动作 × 15 种节点策略 × 2 种流程策略,扩展方式 | | [编程式 API](./programmatic-api.md) | `WorkflowBuilder`/`FlowFormBuilder` 构建流程、`FlowService` 服务编排、查询服务 | ## 核心架构速览 diff --git a/docs/Integration/event-integration.md b/docs/Integration/event-integration.md index 9adf1afc..68a27679 100644 --- a/docs/Integration/event-integration.md +++ b/docs/Integration/event-integration.md @@ -27,7 +27,7 @@ public interface IAsyncEvent extends IEvent {} // 异步事件标记 ← 同步/异步由 `DomainEventContext.push` 的 `instanceof` 判定:`IAsyncEvent` → 异步(线程池执行);`ISyncEvent`/裸 `IEvent` → 同步(发布线程内联执行)。 -### 1.2 八种事件 +### 1.2 九种事件 记录类事件均携带 `FlowRecord`(或当前记录)与 `boolean mock` 标记(Mock 模式为 `true`)。 @@ -40,19 +40,17 @@ public interface IAsyncEvent extends IEvent {} // 异步事件标记 ← | `FlowRecordRevokeEvent` | 撤销(被撤销的后置记录) | `FlowRecord currentRecord, boolean mock` | `getCurrentRecord()` / `isMock()` | | `FlowRecordUrgeEvent` | 催办(每条待办) | `FlowRecord, IFlowOperator urgeOperator, boolean mock` | `getFlowRecord()` / `getUrgeOperator()` / `isMock()` | | `FlowRecordDeleteEvent` | 删除未流转实例(开始节点) | `FlowRecord, boolean mock` | `getFlowRecord()` / `isMock()` | +| `FlowRecordReduceAuditEvent` | 减签(每条被撤下的记录) | `FlowRecord, long reduceOperatorId, boolean mock` | `getFlowRecord()` / `getReduceOperatorId()` / `isMock()` | | `FlowSubProcessResetEvent` | 子流程数据重置(issue #219) | `SubProcessRecord oldRecord, SubProcessRecord newRecord, long resetRecordId, IFlowOperator resetOperator, boolean mock` | `getOldRecord()` / `getNewRecord()` / `getResetRecordId()` / `getResetOperator()` / `isMock()` | **子流程重置事件订阅须知**: -- 事件提醒业务方子流程数据已被重置:`oldRecord` 为被取代的旧聚合组快照(含全部旧实例流程id), - `newRecord` 为重置后的新聚合组——继承实例沿用原流程id,重建实例为新流程id, - 重建实例的 `getSourceProcessId()` 记录其替换的旧实例流程id,据此完成旧 → 新流程id映射; -- 事件经异步线程池分发、到达顺序不保证(重置同时伴随被作废记录的 `RevokeEvent` - 与重建实例的 `StartEvent`/`TodoEvent`),订阅方应按「同一 `parentRecordId` + 节点维度下 - **最大聚合组 id 为活跃组**」做幂等覆盖式收敛,禁止增量累加; -- 事件的 `oldRecord` id 与订阅方当前活跃组 id 不一致时,说明存在丢失的重置事件, - 应通过节点记录查询(`/api/cmd/record/processNodes`)全量对账; -- 旧实例记录不会被删除(仅聚合组标记已取代),订阅方收到事件后应自行将旧流程id的数据标记失效。 +| 关注点 | 约定 | +|---|---| +| 新旧聚合组 | `oldRecord` 为被取代的旧聚合组快照(含全部旧实例流程id);`newRecord` 为新聚合组,继承实例沿用原流程id、重建实例为新流程id,重建实例的 `getSourceProcessId()` 指向被替换的旧实例 | +| 收敛方式 | 事件异步、到达顺序不保证(同时伴随 `RevokeEvent`/`StartEvent`/`TodoEvent`);按「同一 `parentRecordId` + 节点维度下最大聚合组 id 为活跃组」幂等覆盖,禁止增量累加 | +| 丢事件对账 | `oldRecord` id 与订阅方当前活跃组不一致时,视为丢事件,调用 `/api/cmd/record/processNodes` 全量对账 | +| 旧数据 | 旧实例记录不删除(仅标记聚合组已取代),订阅方需自行将旧流程id数据标记失效 | 范例(`FlowRecordUrgeEvent.java`,唯一带三个字段的事件): @@ -72,7 +70,7 @@ public class FlowRecordUrgeEvent implements IFlowEvent { - 基本信息:`getId()`、`getProcessId()`、`getWorkTitle()`、`getWorkCode()`、`getNodeId()`、`getNodeType()`、`getNodeName()`、`getTitle()`、`getFormData()`(`Map`) - 操作人:`getCurrentOperatorId/Name()`、`getSubmitOperatorId/Name()`、`getCreateOperatorId/Name()`、`getForwardOperatorId/Name()` -- 状态:`isTodo()`、`isDone()`、`isFinish()`、`isAutoDone()`、`isShow()`、`isNotEndNode()` +- 状态:`isTodo()`、`isDone()`、`isFinish()`、`isAutoDone()`、`isShow()`、`isNotEndNode()`、`isPendingApproval()`(待审批,含顺序审批未激活的隐藏记录) - 抄送标记:`isNotify()` - 动作:`getActionId()`、`getActionType()`、`getActionName()`、`getAdvice()` - 并行:`getParallelId()`、`getParallelBranchNodeId()`、`getParallelBranchTotal()` @@ -113,6 +111,7 @@ EventPusher.push(new FlowRecordStartEvent(flowRecord, session.isMock())); | `RejectAction.run()` | 当前记录 `DoneEvent` + 回退新记录 `TodoEvent` | | `ReturnAction.run()` | 当前记录 `DoneEvent` + 退回节点新记录 `TodoEvent` | | `AddAuditAction.run()` | 加签记录 `TodoEvent` | +| `ReduceAuditAction.run()` | 每条被撤下记录 `ReduceAuditEvent` | | `TransferAction.run()` | 当前记录 `DoneEvent` + 转交人新记录 `TodoEvent` | | `DelegateAction.run()` | 当前记录 `DoneEvent` + 委托人新记录 `TodoEvent` | | `EndNode.fillNewRecord()` | `FinishEvent`(流程正常完成) | diff --git a/docs/Integration/extension-points.md b/docs/Integration/extension-points.md index 2e26f61c..7d669f2f 100644 --- a/docs/Integration/extension-points.md +++ b/docs/Integration/extension-points.md @@ -37,25 +37,28 @@ SUB_PROCESS, TRIGGER | `TriggerNode` | `TRIGGER` | 触发节点(`TriggerScript` 副作用) | | `SubProcessNode` | `SUB_PROCESS` | 子流程节点 | -### 1.2 动作(8 种) +### 1.2 动作(9 种) `ActionType` 枚举(`flow-engine-framework/.../action/ActionType.java`): ``` -SAVE, PASS, REJECT, ADD_AUDIT, DELEGATE, RETURN, TRANSFER, CUSTOM -保存 通过 拒绝 加签 委派 退回 转办 自定义 +SAVE, PASS, REJECT, ADD_AUDIT, REDUCE_AUDIT, DELEGATE, RETURN, TRANSFER, CUSTOM +保存 通过 拒绝 加签 减签 委派 退回 转办 自定义 ``` -| 动作 | 特有配置 | 说明 | -|---|---|---| -| `SaveAction` | — | 仅保存表单,不流转 | -| `PassAction` | — | 通过 → 推进入下一节点;多人审批按策略自动办结/激活 | -| `RejectAction` | `ActionRejectScript` | 拒绝 → 退回指定节点/退回上级/终止流程(`TERMINATE`) | -| `AddAuditAction` | `OperatorLoadScript` + `maxOperatorCount` | 加签(追加审批人) | -| `DelegateAction` | `OperatorLoadScript` + `maxOperatorCount` | 委派(他人代审) | -| `ReturnAction` | — | 退回(目标由 `FlowAdvice.backNode` 提供) | -| `TransferAction` | `OperatorLoadScript` + `maxOperatorCount` | 转办 | -| `CustomAction` | `ActionCustomScript` + `triggerFrontEvent` | 自定义动作(脚本返回动作类型) | +| 动作 | 特有配置 | 批量提交 | 说明 | +|---|---|---|---| +| `SaveAction` | — | 支持 | 仅保存表单,不流转 | +| `PassAction` | — | 支持 | 通过 → 推进入下一节点;多人审批按策略自动办结/激活 | +| `RejectAction` | `ActionRejectScript` | 支持 | 拒绝 → 退回指定节点/退回上级/终止流程(`TERMINATE`) | +| `AddAuditAction` | `OperatorLoadScript` + `maxOperatorCount` | 支持 | 加签;目标已在本节点待办队列时拒绝 | +| `ReduceAuditAction` | `OperatorLoadScript` | **不支持** | 减签;仅循序/并签节点,仅剩 1 名待审批人时拒绝 | +| `DelegateAction` | `OperatorLoadScript` + `maxOperatorCount` | 支持 | 委派(他人代审) | +| `ReturnAction` | — | 支持 | 退回(目标由 `FlowAdvice.backNode` 提供) | +| `TransferAction` | `OperatorLoadScript` + `maxOperatorCount` | 支持 | 转办 | +| `CustomAction` | `ActionCustomScript` + `triggerFrontEvent` | 支持 | 自定义动作(脚本返回动作类型) | + +> `batchable()` 由 `IFlowAction` 声明,默认 `true`;`ReduceAuditAction` 为 `false`,detail 响应中以 `actions[].batchable` 下发。 ### 1.3 节点策略(15 种) @@ -155,7 +158,7 @@ IFlowAction#run | 子类 | 典型工厂方法与错误场景 | |---|---| | `FlowValidationException` | `fieldReadOnly`(字段只读)、`operatorCountExceeded(nodeId, max)`(超出最大可选人数)、`operatorOutOfRange`(超出可选范围)、`required` 等 | -| `FlowExecutionException` | `scriptExecutionError`、`routerNodeNotFound`、`operatorCountExceeded`、`invalidJumpTarget(nodeType)`、`errorTriggerLoop`、`subProcessLoop` 等 | +| `FlowExecutionException` | `scriptExecutionError`、`routerNodeNotFound`、`operatorCountExceeded`、`invalidJumpTarget(nodeType)`、`errorTriggerLoop`、`subProcessLoop`、`addAuditOperatorAlreadyInTodo`、`reduceAuditSingleApprover`、`reduceAuditTargetNotFound` 等 | | `FlowStateException` | `repositoryNotRegistered`、`recordAlreadyDone`、`operatorNotMatch`、`workflowAlreadyDisable`、`recordNotSupportRevoke` 等 | | `FlowNotFoundException` | `workflow(workCode)`、`record(recordId)`、`node(nodeId)`、`operator(operatorId)`、`action(actionId)` | | `FlowPermissionException` | `accessDenied(operation)` | diff --git a/docs/Integration/mock-mode.md b/docs/Integration/mock-mode.md index 5ed83256..cf2dba02 100644 --- a/docs/Integration/mock-mode.md +++ b/docs/Integration/mock-mode.md @@ -34,7 +34,18 @@ public void clear(String key); // 注销并 cancel 过期调 ### 2.3 `MockRepositoryHolder`(`mock/MockRepositoryHolder.java`) - 实现 `IRepositoryHolder`,构造时 `new` 出 9 个内存仓储(`mock/repository/` 下): - `DelayTaskRepositoryMockImpl`、`FlowRecordRepositoryMockImpl`、`FlowTodoMergeRepositoryMockImpl`、`FlowTodoRecordRepositoryMockImpl`、`ParallelBranchRepositoryMockImpl`、`SubProcessRepositoryMockImpl`、`UrgeIntervalRepositoryMockImpl`、`WorkflowRuntimeRepositoryMockImpl`、`WorkflowVersionRepositoryMockImpl`。 + +| 仓储实现 | 对应数据 | +|---|---| +| `FlowRecordRepositoryMockImpl` | 流程记录 | +| `FlowTodoRecordRepositoryMockImpl` | 待办记录 | +| `FlowTodoMergeRepositoryMockImpl` | 待办合并关系 | +| `WorkflowRuntimeRepositoryMockImpl` | 运行时快照 | +| `WorkflowVersionRepositoryMockImpl` | 流程版本 | +| `SubProcessRepositoryMockImpl` | 子流程聚合 | +| `ParallelBranchRepositoryMockImpl` | 并行分支 | +| `DelayTaskRepositoryMockImpl` | 延迟任务 | +| `UrgeIntervalRepositoryMockImpl` | 催办间隔 | - 流程设计(`WorkflowRepository`)使用**真实传入**的仓储 —— Mock 模式可复用生产已保存的流程设计。 - `operatorAssignmentCache`(HashMap,key = `processId:nodeId`)模拟「发起人设定操作人」分配。 - `createFlowActionService(session)` 传 `allowDisabledAction = true`(Mock 下允许执行停用流程的动作)。 diff --git a/docs/Integration/programmatic-api.md b/docs/Integration/programmatic-api.md index c3f23358..dc969c67 100644 --- a/docs/Integration/programmatic-api.md +++ b/docs/Integration/programmatic-api.md @@ -184,16 +184,17 @@ workflowService.saveWorkflowVersion(new WorkflowVersion(workflow), true, true); 引擎内部以不可变 `FlowSession` 传递执行上下文,业务方在脚本/事件处理中可读取: -```java -session.getCurrentOperator() // 当前审批人 -session.getCreatedOperator() // 流程创建者 -session.getSubmitOperator() // 提交人 -session.getWorkflow() // 流程设计 -session.getCurrentNode() // 当前节点 -session.getCurrentAction() // 当前动作 -session.getCurrentRecord() // 当前记录 -session.getFormData("fieldCode") // 表单字段值 -session.isMock() // 是否 Mock -session.matchNextNodes() // 匹配的下一节点列表 -session.updateSession(node) // 不可变式切换会话(内部流转用) -``` \ No newline at end of file +| 方法 | 返回 | 用途 | +|---|---|---| +| `getCurrentOperator()` | `IFlowOperator` | 当前审批人 | +| `getCreatedOperator()` | `IFlowOperator` | 流程创建者 | +| `getSubmitOperator()` | `IFlowOperator` | 提交人 | +| `getWorkflow()` | `Workflow` | 流程设计 | +| `getCurrentNode()` | `IFlowNode` | 当前节点 | +| `getCurrentAction()` | `IFlowAction` | 当前动作 | +| `getCurrentRecord()` | `FlowRecord` | 当前记录 | +| `getCurrentNodeRecords()` | `List` | 当前节点实例的全部记录(含隐藏) | +| `getFormData(code)` | `Object` | 表单字段值 | +| `isMock()` | `boolean` | 是否 Mock | +| `matchNextNodes()` | `List` | 匹配的下一节点 | +| `updateSession(node)` | `FlowSession` | 不可变式切换会话(内部流转用) | \ No newline at end of file diff --git a/docs/Integration/rest-api.md b/docs/Integration/rest-api.md index 678dd62a..9a65f134 100644 --- a/docs/Integration/rest-api.md +++ b/docs/Integration/rest-api.md @@ -26,7 +26,7 @@ operatorId 请求参数 → 存在 → 使用该 id 作为当前操作人 | GET | `/api/cmd/record/detail` | query `id` | `SingleResponse` | 流程详情(含当前节点/表单/动作/历史/待办列表) | | POST | `/api/cmd/record/processNodes` | `FlowProcessNodeRequest` | `MultiResponse` | 流程流转节点预览 | | POST | `/api/cmd/record/create` | `FlowCreateRequest` | `SingleResponse` | 发起流程,返回首条记录 id | -| POST | `/api/cmd/record/action` | `FlowActionRequest` | `SingleResponse` | 执行动作(通过/拒绝/加签/委派/退回/转办/自定义/保存) | +| POST | `/api/cmd/record/action` | `FlowActionRequest` | `SingleResponse` | 执行动作(通过/拒绝/加签/减签/委派/退回/转办/自定义/保存) | | POST | `/api/cmd/record/subProcess/reset` | `FlowSubProcessResetRequest` | `Response` | 子流程数据重置(独立接口,需子流程节点开启重置能力) | | POST | `/api/cmd/record/urge` | `IdRequest` | `Response` | 催办 | | POST | `/api/cmd/record/revoke` | `IdRequest` | `Response` | 撤销 | @@ -140,9 +140,10 @@ public interface FlowRecordQueryService { } ``` -- `actionId` 决定执行动作(PASS/REJECT/ADD_AUDIT/DELEGATE/RETURN/TRANSFER/CUSTOM/SAVE)。 -- `forwardOperatorIds`:加签/委派/转办的目标操作人。 +- `actionId` 决定执行动作(PASS/REJECT/ADD_AUDIT/REDUCE_AUDIT/DELEGATE/RETURN/TRANSFER/CUSTOM/SAVE)。 +- `forwardOperatorIds`:加签/减签/委派/转办的目标操作人。 - `backNodeId`:退回动作的目标节点(仅 Start/Approval/Handle 可退回)。 +- 减签每次只能处理一条记录,目标必须是该流程实例当前节点的其他待审批人。 ### `FlowSubProcessResetRequest`(子流程数据重置,独立接口) @@ -177,23 +178,25 @@ public interface FlowRecordQueryService { - `MANUAL_NODE_SELECT`:通过后需要选择人工节点分支。 - `OPERATOR_SELECT`:下一节点为发起人设定操作人(`INITIATOR_SELECT`),需要选择操作人(`operators` = 可选范围,`maxOperatorCount` = 最大可选人数,-1 不限制)。 -### `FlowContent`(详情响应,字段摘要) +### `FlowContent`(详情响应) -``` -recordId / processId / workId / workTitle / workDescription / createTime / workCode -view / code / viewTitle / nodeName / nodeId / nodeType -title(节点标题) / adviceRequired / signRequired / adviceHidden -form(FlowForm 元数据) / fieldPermissions(字段权限) -todos(当前节点待办 Body 列表) / actions / actionList -mergeable / createOperator / currentOperator / currentNodeOperators / flowState / recordState -histories(审批历史) / nodes(NodeOption 可操作节点) / revoke / urge -resetSubProcess(子流程重置能力标识:当前待办位于开启重置能力、已汇聚完成的子流程的锁定合并节点上时为 true;更深层下游节点为 false) -``` - -- `currentNodeOperators`:当前记录所属**节点实例**中仍待审批的人员(`id` / `name`,按人去重,排除当前操作人,含顺序审批尚未激活的隐藏记录)。减签等只能作用于当前节点其他待审批人的动作应以此字段作为候选来源,不要使用宿主的全局用户列表。 -- 该字段按单条流程记录计算;合并审批(`mergeable = true`)下多条流程实例的待审批人集合可能不同,不能跨流程合并候选人。 -- `actions` / `actionList` 中每个动作额外带有 `batchable` 标记:`true` 表示支持合并批量提交,`false` 表示只能单条记录提交(当前为 `REDUCE_AUDIT`)。该标记只在响应中附加,不写入流程设计持久化数据;旧版本服务端无此字段时前端按 `true` 处理。 -- 合并审批(`mergeable = true`)时前端不展示 `batchable = false` 的动作;从合并列表进入**单条流程**后,按该条记录自己的 `actions` / `currentNodeOperators` 展示并提交(提交目标固定为该记录)。 +| 分组 | 字段 | +|---|---| +| 标识 | `recordId` / `processId` / `workId` / `workTitle` / `workDescription` / `workCode` / `createTime` | +| 节点与视图 | `nodeId` / `nodeType` / `nodeName` / `title` / `view` / `code` / `viewTitle` | +| 表单 | `form`(FlowForm 元数据) / `fieldPermissions` | +| 意见与签名 | `adviceRequired` / `signRequired` / `adviceHidden` | +| 动作 | `actions`(当前可见) / `actionList`(全部) | +| 人员 | `createOperator` / `currentOperator` / `currentNodeOperators` | +| 待办与状态 | `todos` / `mergeable` / `flowState` / `recordState` | +| 其他 | `histories` / `nodes` / `revoke` / `urge` / `resetSubProcess` | + +| 字段/标记 | 说明 | +|---|---| +| `currentNodeOperators` | 当前节点实例仍待审批的人员(去重、排除当前操作人、含未激活隐藏记录);减签候选人来源,按单条记录计算,不可跨流程合并 | +| `actions[].batchable` | 是否支持合并批量提交(当前仅 `REDUCE_AUDIT` 为 `false`);仅响应附加、不落库;旧服务端缺省按 `true` | +| `resetSubProcess` | 当前待办位于开启重置能力且已汇聚完成的子流程锁定节点时为 `true` | +| 合并审批展示 | `mergeable && todos.length > 1` 时隐藏 `batchable=false` 的动作;进入单条流程后按该记录的 `actions` / `currentNodeOperators` 展示并提交 | ### `FlowRecordContent`(列表响应,字段摘要) diff --git a/docs/capabilities/flow-engine-framework/action.md b/docs/capabilities/flow-engine-framework/action.md index 2b7e6b9c..794bbfa3 100644 --- a/docs/capabilities/flow-engine-framework/action.md +++ b/docs/capabilities/flow-engine-framework/action.md @@ -45,11 +45,26 @@ content_hash: 0ec57b5fb719f4df594be65eda1e92ba172dad9dfb2aa8f9ec272b275e0a8321 | `TransferAction` | TRANSFER | 转办,将任务转给其他操作者 | | `ReturnAction` | RETURN | 退回,退回至上一节点 | | `DelegateAction` | DELEGATE | 委派,临时委派给其他操作者 | -| `AddAuditAction` | ADD_AUDIT | 加签,增加审批人 | -| `ReduceAuditAction` | REDUCE_AUDIT | 减签,由当前审批人把指定审批人从当前节点撤下(仅循序/并签) | +| `AddAuditAction` | ADD_AUDIT | 加签,增加审批人(已在本节点待办中的不可重复加签) | +| `ReduceAuditAction` | REDUCE_AUDIT | 减签,撤下本节点其他待审批人(仅循序/并签,不可合并批量) | | `CustomAction` | CUSTOM | 自定义动作,通过脚本定义行为 | | `SaveAction` | SAVE | 暂存,保存当前状态 | +### 动作约束 + +| 动作 | 约束 | 异常 | +|------|------|------| +| 加签 | 目标不能在当前节点待办队列中(含顺序审批未激活的隐藏记录);人数受 `maxOperatorCount` 限制 | `addAuditOperatorAlreadyInTodo`、`operatorCountExceeded`、`operatorNotInScope` | +| 减签 | 仅 `SEQUENCE`/`MERGE` 节点;当前节点至少 2 名待审批人;目标须为本节点其他待审批人 | `reduceAuditUnsupported`、`reduceAuditSingleApprover`、`reduceAuditOwnRecord`、`reduceAuditTargetNotFound`、`reduceAuditNoRemainingApproval` | + +### 批量提交能力 + +| 项 | 说明 | +|------|------| +| 声明 | `IFlowAction.batchable()`,默认 `true` | +| `false` 的动作 | `ReduceAuditAction`(候选人/目标按流程实例隔离,合并批量会错配) | +| 下发 | detail 响应 `actions[].batchable`,前端据此决定合并列表是否展示 | + ### 核心接口 `IFlowAction` 继承 `IMapConvertor`(Map 序列化)和 `ICopyAbility`(复制能力),主要方法: @@ -62,6 +77,7 @@ content_hash: 0ec57b5fb719f4df594be65eda1e92ba172dad9dfb2aa8f9ec272b275e0a8321 | `display()` | 返回 `ActionDisplay` 展示配置 | | `enable()` | 动作是否可用 | | `show(FlowSession)` | 控制动作按钮在当前节点是否展示 | +| `batchable()` | 是否支持合并批量提交,默认 `true` | | `run(FlowSession)` | 执行动作(触发流程流转) | | `generateRecords(FlowSession)` | 生成流程记录 | diff --git a/docs/capabilities/flow-engine-framework/exception.md b/docs/capabilities/flow-engine-framework/exception.md index 87f9c8fb..d9ea36de 100644 --- a/docs/capabilities/flow-engine-framework/exception.md +++ b/docs/capabilities/flow-engine-framework/exception.md @@ -43,13 +43,24 @@ FlowException(抽象基类,继承 LocaleMessageException) | 异常类 | 典型工厂方法 | |--------|-------------| -| `FlowExecutionException` | `scriptExecutionError()`, `routerNodeNotFound()`, `createRecordSizeError()`, `operatorNotInScope()` | +| `FlowExecutionException` | `scriptExecutionError()`, `routerNodeNotFound()`, `createRecordSizeError()`, `operatorNotInScope()`, `addAuditOperatorAlreadyInTodo()`, `reduceAuditSingleApprover()` | | `FlowValidationException` | 参数验证相关错误 | | `FlowStateException` | `repositoryNotRegistered()`, `recordAlreadyDone()`, `operatorNotMatch()`, `edgeConfigError()`, `recordLimitUrgeError()` | | `FlowNotFoundException` | `workflow()`, `record()`, `node()`, `operator()`, `action()` | | `FlowPermissionException` | `accessDenied()` | | `WorkflowTransferException` | `invalidSchema()`, `unsupportedSchema()`, `scriptNotFound()`, `scriptTypeUnsupported()`, `replaceTargetNotFound()` | +### 加签 / 减签错误码 + +| 工厂方法 | errCode | +|----------|---------| +| `addAuditOperatorAlreadyInTodo(operatorId)` | `execution.addAudit.operatorAlreadyInTodo` | +| `reduceAuditUnsupported()` | `execution.reduceAudit.unsupported` | +| `reduceAuditTargetNotFound(operatorId)` | `execution.reduceAudit.targetNotFound` | +| `reduceAuditOwnRecord()` | `execution.reduceAudit.ownRecord` | +| `reduceAuditNoRemainingApproval()` | `execution.reduceAudit.noRemainingApproval` | +| `reduceAuditSingleApprover()` | `execution.reduceAudit.singleApprover` | + ### 扩展新异常 继承 `FlowException`,提供构造函数和静态工厂方法。 diff --git a/docs/capabilities/flow-engine-framework/flow-service.md b/docs/capabilities/flow-engine-framework/flow-service.md index 8e0aa72a..dda55cd7 100644 --- a/docs/capabilities/flow-engine-framework/flow-service.md +++ b/docs/capabilities/flow-engine-framework/flow-service.md @@ -72,6 +72,9 @@ content_hash: 48847288af1a4551b8327e398d01a8ec4de643dce1c049b90dcdc8ad0134df3b | `FlowDelayTriggerService` | 处理延迟触发任务 | | `FlowUrgeService` | 处理催办逻辑 | | `FlowRecordSaveService` | 保存流程记录和待办合并关系 | +| `FlowSubProcessResetService` | 子流程数据重置(旧聚合组作废、实例继承/重建) | +| `FlowSubProcessResultService` | 子流程完成后回写父流程并生成待办/已办 | +| `SubProcessSessionFactory` | 子流程会话构建(按聚合组/实例定位记录) | | `OperatorAssignmentService` | 操作者分配服务 | ## 使用实例 diff --git a/docs/capabilities/springboot/ioc.md b/docs/capabilities/springboot/ioc.md index a10fb167..f3e68471 100644 --- a/docs/capabilities/springboot/ioc.md +++ b/docs/capabilities/springboot/ioc.md @@ -16,7 +16,7 @@ framework_version: "3.5.9" - **依赖注入**:容器管理 Bean 之间的依赖关系。本项目不使用 `@Autowired` / `@Inject` 注解,统一通过 Lombok `@AllArgsConstructor` 构造器注入(如 `WorkflowController`、`WorkflowService`、各 `RepositoryImpl`) - **Bean 生命周期管理**:容器统一管理 Bean 的创建、初始化和销毁 - **AOP 支持**:框架提供 `@Aspect` 横切关注点能力(本项目未使用) -- **事件发布**:本项目不使用 Spring `ApplicationEventPublisher`,事件系统为自研 `IFlowEvent` + `EventPusher`(`flow-engine-framework/event/` 下 8 种事件类,操作完成后通过 `EventPusher.push(event)` 推送) +- **事件发布**:不使用 Spring `ApplicationEventPublisher`,事件系统为自研 `IFlowEvent` + `EventPusher`(`flow-engine-framework/event/` 下 9 种事件类,操作完成后通过 `EventPusher.push(event)` 推送) - **自动配置**:通过 `@Configuration` + `@Conditional` 实现条件化 Bean 注册 ## 如何使用 diff --git a/docs/manual/integration-manual.html b/docs/manual/integration-manual.html index d97a0a68..bf5aa23d 100644 --- a/docs/manual/integration-manual.html +++ b/docs/manual/integration-manual.html @@ -284,9 +284,9 @@

5. 后端:扩展点

- + - +
扩展点说明
事件订阅实现 IHandler<E> 订阅 8 种事件:Start / Todo / Done / Finish / Urge / Revoke / Delete / SubProcessReset,自动被 Spring 扫描
事件订阅实现 IHandler<E> 订阅 9 种事件:Start / Todo / Done / Finish / Urge / Revoke / Delete / ReduceAudit / SubProcessReset,自动被 Spring 扫描
节点策略15 种策略(OperatorLoad / RecordMerge / Revoke / Router / FormFieldPermission 等)由 NodeStrategyManager 持有(工厂固定注册,不支持新增策略类型;业务方通过策略配置 + Groovy 脚本表达差异)
自定义动作8 种内置动作(PASS/REJECT/RETURN/TRANSFER/DELEGATE/ADD_AUDIT/SAVE/CUSTOM)由 FlowActionFactory 固定注册(不支持新增动作类型);业务方通过 CUSTOM 动作 + Groovy 脚本表达自定义行为
自定义动作9 种内置动作(PASS/REJECT/RETURN/TRANSFER/DELEGATE/ADD_AUDIT/REDUCE_AUDIT/SAVE/CUSTOM)由 FlowActionFactory 固定注册(不支持新增动作类型);业务方通过 CUSTOM 动作 + Groovy 脚本表达自定义行为
自定义脚本注册ScriptRegistryContext.getInstance().setRegistry(new CustomScriptRegistry()) 替换默认 Groovy 脚本
@@ -452,7 +452,7 @@

9.1 flow-pc-approval 导出

9.2 ActionFactory 动作工厂

-

三端(PC / 移动 / 设计器表单)各有一份 ActionFactory 单例,按 8 种 ActionType 注册并渲染对应 React 组件:

+

三端(PC / 移动 / 设计器表单)各有一份 ActionFactory 单例,按 9 种 ActionType 注册并渲染对应 React 组件:

// PC 端注册(packages/flow-pc/flow-pc-approval/.../action/factory.ts)
 cache.set("PASS", PassAction);
 cache.set("REJECT", RejectAction);
@@ -460,13 +460,14 @@ 

9.2 ActionFactory 动作工厂

cache.set("TRANSFER", TransferAction); cache.set("DELEGATE", DelegateAction); cache.set("ADD_AUDIT", AddAuditAction); +cache.set("REDUCE_AUDIT", ReduceAuditAction); cache.set("SAVE", SaveAction); cache.set("CUSTOM", CustomAction); // 使用:根据动作渲染按钮 const Comp = ActionFactory.getInstance().getFlowActionComponent(action);
-
扩展机制:要增加自定义审批按钮,通过 ActionFactory 注册 CUSTOM 类型组件,并在节点动作配置中启用。PC 与移动端 ActionFactory.render(action) / getFlowActionComponent(action) 各自返回对应平台组件。
+
扩展机制:要增加自定义审批按钮,通过 ActionFactory 注册 CUSTOM 类型组件,并在节点动作配置中启用。PC 与移动端 ActionFactory.render(action) / getFlowActionComponent(action) 各自返回对应平台组件。合并审批列表不展示 batchable=false 的动作(当前为减签),进入单条流程按该记录的 actions / currentNodeOperators 渲染并提交。

9.3 审批面板内部结构

flow-pc-approval 内置组件:flow-approval-actions(动作按钮区)、flow-approval-content(表单内容)、flow-approval-sider(侧边流程图/节点)、flow-approval-title、flow-node-history(节点历史轨迹)、flow-time-node(时间轴)、form-view-component(表单渲染)。

diff --git a/docs/manual/usage-manual.html b/docs/manual/usage-manual.html index abbf97ae..b40a8c47 100644 --- a/docs/manual/usage-manual.html +++ b/docs/manual/usage-manual.html @@ -75,7 +75,7 @@

目录

  • 待办中心(PC)
  • 发起流程与审批意见填写
  • 流程中心:流程详情
  • -
  • 8 种审批动作操作
  • +
  • 9 种审批动作操作
  • 用户管理
  • 移动端操作
  • 常见问题
  • @@ -89,7 +89,7 @@

    1. 系统能力总览

    流程设计

    流程列表、基本信息、流程画布编排、节点配置、表单设计、版本管理

    待办中心

    我的待办、我的已办、全部流程、发起流程,各列表点击进入详情/审批

    -
    流程中心

    流程详情、流程记录轨迹、审批意见填写、8 种审批动作

    +
    流程中心

    流程详情、流程记录轨迹、审批意见填写、9 种审批动作

    移动审批

    移动端首页、待办/已办/全部、流程申请与审批、流程详情

    用户管理

    用户与流程管理员维护

    导入导出

    流程定义 JSON 导入/导出,跨环境迁移

    @@ -108,7 +108,7 @@

    1.1 19 种节点类型

    其他NOTIFY / DELAY / SUB_PROCESS / TRIGGER抄送 / 延时 / 子流程 / 触发 -

    1.2 8 种审批动作

    +

    1.2 9 种审批动作

    @@ -116,7 +116,8 @@

    1.2 8 种审批动作

    - + +
    动作类型操作效果
    通过PASS流程继续向下流转
    退回RETURN退回到指定历史节点
    转办TRANSFER转给他人审批,自己不再处理
    委派DELEGATE委派他人审批,其完成回到自己
    加签ADD_AUDIT增加会签人
    加签ADD_AUDIT增加会签人(已在待办中的不可重复加签)
    减签REDUCE_AUDIT撤下本节点其他待审批人(不可合并批量)
    保存SAVE暂存表单,不触发流转
    自定义CUSTOM自定义按钮,脚本驱动
    @@ -245,7 +246,7 @@

    6.1.1 通用属性(全部节点)

    待办标题全部通过脚本设置待办消息的展示标题 优先级全部数字越小优先级越高;当多个分支或节点可同时执行时,按此排序 视图名称APPROVAL/HANDLE节点绑定的展示视图,用于自定义该节点的表单或界面展示 - 按钮配置(动作启用)APPROVAL/HANDLE勾选启用 8 种动作(PASS/REJECT/RETURN/TRANSFER/DELEGATE/ADD_AUDIT/SAVE/CUSTOM) + 按钮配置(动作启用)APPROVAL/HANDLE勾选启用 9 种动作(PASS/REJECT/RETURN/TRANSFER/DELEGATE/ADD_AUDIT/REDUCE_AUDIT/SAVE/CUSTOM) 表单字段权限APPROVAL/HANDLE每字段必填 / 可编辑 / 只读 / 隐藏 @@ -416,7 +417,7 @@

    11.1 流程记录轨迹

    保存顺序:saveRecords → saveTodoMargeRecords → removeTodoMergeRecords,保证三者一致;删除顺序:合并关系 → 待办 → 流程记录。
    -

    12. 8 种审批动作操作

    +

    12. 9 种审批动作操作

    审批人在审批页底部看到由节点"按钮配置"决定的可执行动作按钮:

    @@ -427,7 +428,8 @@

    12. 8 种审批动作操作

    退回 RETURN选择退回目标节点(只能退回历史 START/APPROVAL/HANDLE,不能退回当前/后续/结束节点)→ 填意见 → 退回 转办 TRANSFER选择转办人员 → 流程转给他人,自己不再处理 委派 DELEGATE选择委派人员 → 他人审批通过后流程回到自己 - 加签 ADD_AUDIT选择加签人员 → 增加会签人,会签完成后继续 + 加签 ADD_AUDIT选择加签人员 → 增加会签人,会签完成后继续;已在本节点待办中的人员不可重复加签 + 减签 REDUCE_AUDIT仅循序/并签节点可用:选择本节点其他待审批人 → 撤下其待办;当前节点只剩 1 名待审批人时不可减签,且不支持合并批量操作 保存 SAVE暂存当前表单与意见,不触发流转 自定义 CUSTOM触发节点配置的脚本/前端事件 diff --git a/flow-frontend b/flow-frontend index 17070e43..6c0f253e 160000 --- a/flow-frontend +++ b/flow-frontend @@ -1 +1 @@ -Subproject commit 17070e436a18369c5a57594da79a0ebda6157539 +Subproject commit 6c0f253ecb413282e88187d08ea618d174ad2ef1 From 94e4b9126fa5e7fa4e7b4246b3d5000e5a32f8d8 Mon Sep 17 00:00:00 2001 From: lorne <1991wangliang@gmail.com> Date: Thu, 12 Nov 2026 14:34:58 +0800 Subject: [PATCH 14/17] =?UTF-8?q?chore:=20=E5=90=8C=E6=AD=A5=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E5=AD=90=E6=A8=A1=E5=9D=97=E6=8C=87=E9=92=88=EF=BC=88?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E5=AE=A1=E6=89=B9=E8=A1=A8=E5=8D=95=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E4=BF=AE=E5=A4=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 含 flow-frontend 843e236:合并审批查看第二条流程后提交 formData 为空, 减签等动作被服务端 formData 必填校验拒绝的修复。 --- flow-frontend | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flow-frontend b/flow-frontend index 6c0f253e..843e236f 160000 --- a/flow-frontend +++ b/flow-frontend @@ -1 +1 @@ -Subproject commit 6c0f253ecb413282e88187d08ea618d174ad2ef1 +Subproject commit 843e236f05390c47e5702241f1025c5e24ee0569 From 23e2040cb134a4a55d548994c9ce27f7107afa47 Mon Sep 17 00:00:00 2001 From: lorne <1991wangliang@gmail.com> Date: Thu, 12 Nov 2026 15:00:55 +0800 Subject: [PATCH 15/17] =?UTF-8?q?chore:=20=E5=90=8C=E6=AD=A5=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E5=AD=90=E6=A8=A1=E5=9D=97=E6=8C=87=E9=92=88=EF=BC=88?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E5=AE=A1=E6=89=B9=E8=A1=A8=E5=8D=95=E8=A7=86?= =?UTF-8?q?=E5=9B=BE=E9=87=8D=E6=B8=B2=E6=9F=93=E5=B4=A9=E6=BA=83=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 含 flow-frontend d32ae63:createFormInstance 是 hook 工厂,表单实例不可用 useMemo 缓存,否则重渲染时 hook 数量变化会抛 `Cannot read properties of undefined (reading 'length')`。 --- flow-frontend | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flow-frontend b/flow-frontend index 843e236f..d32ae633 160000 --- a/flow-frontend +++ b/flow-frontend @@ -1 +1 @@ -Subproject commit 843e236f05390c47e5702241f1025c5e24ee0569 +Subproject commit d32ae6330a7fe576c6b51e3f53a6239104a3e96b From f00791a02092c70e4049a35d8c9daa99f756e33f Mon Sep 17 00:00:00 2001 From: lorne <1991wangliang@gmail.com> Date: Thu, 12 Nov 2026 15:23:16 +0800 Subject: [PATCH 16/17] 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 d32ae633..ee0daeb4 160000 --- a/flow-frontend +++ b/flow-frontend @@ -1 +1 @@ -Subproject commit d32ae6330a7fe576c6b51e3f53a6239104a3e96b +Subproject commit ee0daeb4d43a69826a6a6f1aeeae773a457d3009 From 1c13577725c59b13034e67a24d20e285df42d49e Mon Sep 17 00:00:00 2001 From: lorne <1991wangliang@gmail.com> Date: Thu, 12 Nov 2026 15:50:25 +0800 Subject: [PATCH 17/17] 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 ee0daeb4..5b767582 160000 --- a/flow-frontend +++ b/flow-frontend @@ -1 +1 @@ -Subproject commit ee0daeb4d43a69826a6a6f1aeeae773a457d3009 +Subproject commit 5b76758205ee8c38717fa0f4002b6993c356d723