From 0974a56294324bc36bd242bc05cd3b733b97ebe8 Mon Sep 17 00:00:00 2001 From: PIGCLOUD Date: Fri, 30 Oct 2026 17:10:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(flow):=20=E4=BC=98=E5=8C=96=E6=8B=92?= =?UTF-8?q?=E7=BB=9D=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=A4=9A=E5=AE=9E=E4=BE=8B=E7=BB=93=E6=9D=9F=E5=90=8E?= =?UTF-8?q?=E9=87=8D=E5=AE=9A=E5=90=91=E5=88=B0=E5=8F=91=E8=B5=B7=E4=BA=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 更新 ApproveServiceTask 中拒绝处理逻辑,支持多实例结束后当前执行流尚未落库的情况 - 增加对 CommandContextUtil 的使用,确保执行流迁移的正确性 - 添加 ModelUtilMultiMatchTests 中的单元测试,验证拒绝到发起人的逻辑 (cherry picked from commit 9e37864b02b14a2c980b74e01e126b5d38d10b09) --- .../support/tasks/ApproveServiceTask.java | 48 ++++++++++--- .../utils/ModelUtilMultiMatchTests.java | 72 +++++++++++++++++-- 2 files changed, 105 insertions(+), 15 deletions(-) diff --git a/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/support/tasks/ApproveServiceTask.java b/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/support/tasks/ApproveServiceTask.java index aa41fdc95..e017d0e09 100644 --- a/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/support/tasks/ApproveServiceTask.java +++ b/pigx-flow/pigx-flow-biz/src/main/java/com/pig4cloud/pigx/flow/support/tasks/ApproveServiceTask.java @@ -1,17 +1,25 @@ package com.pig4cloud.pigx.flow.support.tasks; +import cn.hutool.core.convert.Convert; import cn.hutool.core.util.StrUtil; import cn.hutool.extra.spring.SpringUtil; +import com.pig4cloud.pigx.flow.constant.NodeStatusEnum; import com.pig4cloud.pigx.flow.constant.ProcessInstanceConstant; import com.pig4cloud.pigx.flow.dto.Node; import com.pig4cloud.pigx.flow.dto.Refuse; +import com.pig4cloud.pigx.flow.entity.ProcessNodeRecord; +import com.pig4cloud.pigx.flow.entity.ProcessNodeRecordAssignUser; import com.pig4cloud.pigx.flow.service.IProcessNodeDataService; +import com.pig4cloud.pigx.flow.service.IProcessNodeRecordService; +import com.pig4cloud.pigx.flow.service.IProcessNodeRecordAssignUserService; import org.flowable.engine.RuntimeService; import org.flowable.engine.delegate.DelegateExecution; import org.flowable.engine.delegate.JavaDelegate; import org.flowable.engine.impl.persistence.entity.ExecutionEntityImpl; +import org.flowable.engine.impl.util.CommandContextUtil; import org.flowable.engine.runtime.Execution; +import java.util.Date; import java.util.List; /** @@ -68,7 +76,8 @@ public class ApproveServiceTask implements JavaDelegate { /** * 处理任务被拒绝时的流程重定向逻辑 *

- * 根据节点配置的拒绝策略,将流程重定向到指定节点或结束节点。 支持的拒绝策略: 1. TO_NODE:跳转到指定节点 2. 其他:直接结束流程 + * 根据节点配置跳转到指定节点、发起人或结束节点。TO_START 会收回实例全部执行流,并结束被收回节点的本轮审批记录; + * 与引擎状态迁移共用当前审批事务,不覆盖已经完成的审批历史。 * @param processDefinitionId 流程定义 ID * @param nodeId 当前节点标识符 * @param currentActivityId 当前活动标识符 @@ -97,17 +106,36 @@ public class ApproveServiceTask implements JavaDelegate { // 根据拒绝设置重定向到特定节点 stateChangeBuilder.moveActivityIdTo(currentActivityId, refuseConfig.getNodeId()).changeState(); } - else if (StrUtil.equals(rejectionHandler, "TO_START")) { - // 驳回到发起人:设置驳回标识变量,跳转到 root 节点 - // 并行、多选分支内驳回时,兄弟分支执行流需一并收回,否则重新提交后汇聚网关会多出令牌 - runtimeService.setVariable(processInstanceId, "rejectToStarter", true); - List executionIds = runtimeService.createExecutionQuery() - .parentId(processInstanceId) - .list() + else if (StrUtil.equals(rejectionHandler, ProcessInstanceConstant.USER_TASK_REFUSE_TYPE_TO_START)) { + // 多实例结束后当前执行流尚未落库,必须使用包含命令缓存的执行树,并排除已删除的旧 scope。 + // 按活动迁移会合并为一个 root;按执行流迁移会为每条分支各建一个 root 待办。 + List activityIds = CommandContextUtil.getExecutionEntityManager() + .findChildExecutionsByProcessInstanceId(processInstanceId) .stream() - .map(Execution::getId) + .filter(child -> !child.isDeleted() && StrUtil.equals(child.getParentId(), processInstanceId)) + .map(Execution::getActivityId) + .distinct() .toList(); - stateChangeBuilder.moveExecutionsToSingleActivityId(executionIds, "root").changeState(); + runtimeService.setVariable(processInstanceId, ProcessInstanceConstant.REJECT_TO_STARTER_VAR, true); + + // 先结束本轮活动记录,避免重提复用被收回的节点及参与人;迁移新建的 root 不应被关闭。 + Date now = new Date(); + SpringUtil.getBean(IProcessNodeRecordService.class) + .lambdaUpdate() + .set(ProcessNodeRecord::getStatus, NodeStatusEnum.YJS.getCode()) + .set(ProcessNodeRecord::getEndTime, now) + .eq(ProcessNodeRecord::getProcessInstanceId, processInstanceId) + .eq(ProcessNodeRecord::getStatus, NodeStatusEnum.JXZ.getCode()) + .update(new ProcessNodeRecord()); + SpringUtil.getBean(IProcessNodeRecordAssignUserService.class) + .lambdaUpdate() + .set(ProcessNodeRecordAssignUser::getStatus, ProcessInstanceConstant.ASSIGN_USER_STATUS_WITHDRAW) + .set(ProcessNodeRecordAssignUser::getEndTime, Convert.toLocalDateTime(now)) + .set(ProcessNodeRecordAssignUser::getTaskType, "WITHDRAW") + .eq(ProcessNodeRecordAssignUser::getProcessInstanceId, processInstanceId) + .eq(ProcessNodeRecordAssignUser::getStatus, NodeStatusEnum.JXZ.getCode()) + .update(new ProcessNodeRecordAssignUser()); + stateChangeBuilder.moveActivityIdsToSingleActivityId(activityIds, "root").changeState(); } else { // 默认行为:重定向到结束节点 diff --git a/pigx-flow/pigx-flow-biz/src/test/java/com/pig4cloud/pigx/flow/support/utils/ModelUtilMultiMatchTests.java b/pigx-flow/pigx-flow-biz/src/test/java/com/pig4cloud/pigx/flow/support/utils/ModelUtilMultiMatchTests.java index 8c101f98f..633594297 100644 --- a/pigx-flow/pigx-flow-biz/src/test/java/com/pig4cloud/pigx/flow/support/utils/ModelUtilMultiMatchTests.java +++ b/pigx-flow/pigx-flow-biz/src/test/java/com/pig4cloud/pigx/flow/support/utils/ModelUtilMultiMatchTests.java @@ -1,10 +1,15 @@ package com.pig4cloud.pigx.flow.support.utils; import cn.hutool.extra.spring.SpringUtil; +import com.baomidou.mybatisplus.extension.conditions.update.LambdaUpdateChainWrapper; +import com.pig4cloud.pigx.flow.entity.ProcessNodeRecord; +import com.pig4cloud.pigx.flow.entity.ProcessNodeRecordAssignUser; import com.pig4cloud.pigx.common.core.util.R; import com.pig4cloud.pigx.flow.dto.Node; import com.pig4cloud.pigx.flow.dto.Refuse; import com.pig4cloud.pigx.flow.service.IProcessNodeDataService; +import com.pig4cloud.pigx.flow.service.IProcessNodeRecordService; +import com.pig4cloud.pigx.flow.service.IProcessNodeRecordAssignUserService; import org.flowable.bpmn.model.BpmnModel; import org.flowable.bpmn.model.ExclusiveGateway; import org.flowable.bpmn.model.InclusiveGateway; @@ -17,6 +22,7 @@ import org.h2.jdbcx.JdbcDataSource; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.stubbing.Answer; import org.springframework.context.ApplicationContext; import org.springframework.context.support.GenericApplicationContext; import org.springframework.jdbc.datasource.DataSourceTransactionManager; @@ -25,6 +31,7 @@ import org.flowable.spring.SpringProcessEngineConfiguration; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; @@ -65,6 +72,18 @@ class ModelUtilMultiMatchTests { context = new GenericApplicationContext(); context.getBeanFactory().registerSingleton("runtimeService", engine.getRuntimeService()); context.getBeanFactory().registerSingleton("processNodeDataService", nodes); + // set/eq 泛型返回值擦除为 Object,RETURNS_SELF 不会返回自身;链式调用返回 mock,终结的 update 返回 true + Answer chain = invocation -> "update".equals(invocation.getMethod().getName()) ? true + : invocation.getMock(); + IProcessNodeRecordService records = mock(IProcessNodeRecordService.class); + LambdaUpdateChainWrapper recordUpdate = mock(LambdaUpdateChainWrapper.class, chain); + when(records.lambdaUpdate()).thenReturn(recordUpdate); + IProcessNodeRecordAssignUserService assignments = mock(IProcessNodeRecordAssignUserService.class); + LambdaUpdateChainWrapper assignmentUpdate = mock(LambdaUpdateChainWrapper.class, + chain); + when(assignments.lambdaUpdate()).thenReturn(assignmentUpdate); + context.getBeanFactory().registerSingleton("processNodeRecordService", records); + context.getBeanFactory().registerSingleton("processNodeRecordAssignUserService", assignments); context.refresh(); new SpringUtil().setApplicationContext(context); } @@ -83,8 +102,8 @@ class ModelUtilMultiMatchTests { assertThat(model.getMainProcess().getFlowElement("gw")).isInstanceOf(InclusiveGateway.class); assertThat(model.getMainProcess().getFlowElement("gw_merge_gateway")).isInstanceOf(InclusiveGateway.class); - BpmnModel single = ModelUtil.buildBpmnModel(tree(false, approval("a"), approval("b"), approval("other")), - "f", "f"); + BpmnModel single = ModelUtil.buildBpmnModel(tree(false, approval("a"), approval("b"), approval("other")), "f", + "f"); assertThat(single.getMainProcess().getFlowElement("gw")).isInstanceOf(ExclusiveGateway.class); assertThat(single.getMainProcess().getFlowElement("gw_merge_gateway")).isInstanceOf(ExclusiveGateway.class); } @@ -156,6 +175,45 @@ class ModelUtilMultiMatchTests { engine.getTaskService().complete(task(instance, "root").getId(), Map.of("a_approve_condition", true)); assertThat(activeTaskKeys(instance)).containsExactlyInAnyOrder("a", "b"); complete(instance, "a"); + assertThat(activeTaskKeys(instance)).containsExactly("b"); + complete(instance, "b"); + assertThat(activeTaskKeys(instance)).containsExactly("after"); + } + + @Test + void rejectToStarterOnSinglePathStopsAtRoot() { + Refuse refuse = new Refuse(); + refuse.setHandler("TO_START"); + Node rejectConfig = new Node(); + rejectConfig.setRefuse(refuse); + when(nodes.getPublishedNode(anyString(), eq("a"))).thenReturn(R.ok(rejectConfig)); + Node root = tree(false, approval("x"), approval("y"), approval("z")); + Node a = approval("a"); + a.setChildren(approval("after")); + root.setChildren(a); + ProcessInstance instance = submit(root, 200, "it"); + + engine.getTaskService().complete(task(instance, "a").getId(), Map.of("a_approve_condition", false)); + assertThat(activeTaskKeys(instance)).containsExactly("root"); + } + + @Test + void rejectToStarterAfterSiblingWaitsAtJoin() { + Refuse refuse = new Refuse(); + refuse.setHandler("TO_START"); + Node rejectConfig = new Node(); + rejectConfig.setRefuse(refuse); + when(nodes.getPublishedNode(anyString(), eq("a"))).thenReturn(R.ok(rejectConfig)); + ProcessInstance instance = submit(tree(true, approval("a"), approval("b"), approval("other")), 200, "it"); + complete(instance, "b"); + + engine.getTaskService().complete(task(instance, "a").getId(), Map.of("a_approve_condition", false)); + assertThat(activeTaskKeys(instance)).containsExactly("root"); + + engine.getTaskService().complete(task(instance, "root").getId(), Map.of("a_approve_condition", true)); + assertThat(activeTaskKeys(instance)).containsExactlyInAnyOrder("a", "b"); + complete(instance, "a"); + assertThat(activeTaskKeys(instance)).containsExactly("b"); complete(instance, "b"); assertThat(activeTaskKeys(instance)).containsExactly("after"); } @@ -178,6 +236,7 @@ class ModelUtilMultiMatchTests { engine.getTaskService().complete(task(instance, "root").getId(), Map.of("a_approve_condition", true)); complete(instance, "a"); + assertThat(activeTaskKeys(instance)).containsExactly("b"); complete(instance, "b"); assertThat(activeTaskKeys(instance)).containsExactly("after"); } @@ -223,7 +282,7 @@ class ModelUtilMultiMatchTests { } /** - * 部署后发起并提交发起人节点;剥离监听器和多实例,只保留真实网关、连线和审批服务任务。 + * 部署后发起并提交发起人节点;保留多实例执行树,隔离人员解析和业务监听器。 */ private ProcessInstance submit(Node root, int amount, String dept) { BpmnModel model = ModelUtil.buildBpmnModel(root, "f", "f"); @@ -231,13 +290,16 @@ class ModelUtilMultiMatchTests { for (var element : model.getMainProcess().getFlowElements()) { if (element instanceof UserTask task) { task.setTaskListeners(new ArrayList<>()); - task.setLoopCharacteristics(null); + if (Objects.nonNull(task.getLoopCharacteristics())) { + task.getLoopCharacteristics().setInputDataItem("${assignees}"); + task.getLoopCharacteristics().setCompletionCondition("${nrOfCompletedInstances == nrOfInstances}"); + } task.setAssignee("9000"); } } engine.getRepositoryService().createDeployment().addBpmnModel("f.bpmn20.xml", model).deploy(); ProcessInstance instance = engine.getRuntimeService() - .startProcessInstanceByKey("f", Map.of("amount", amount, "dept", dept)); + .startProcessInstanceByKey("f", Map.of("amount", amount, "dept", dept, "assignees", List.of("9000"))); complete(instance, "root"); return instance; }