feat(flow): 新增驳回到发起人功能

- 在ModelUtil重构开始节点结构,添加root UserTask节点
- 新增RootTaskCreateListener处理驳回到发起人场景
- 新增RootTaskHandler解析发起人执行人
- 在ApproveServiceTask添加TO_START驳回处理逻辑
- 新增ProcessInstanceRecordVo包含驳回状态信息
- 在TaskService添加resubmitTask接口处理重新提交
- 在ProcessTaskController添加resubmitTask接口
This commit is contained in:
PIGCLOUD committed 2025-12-28 11:25:38 +08:00
1 parent 0ad5864476
commit 16a7f9309f
10 files changed
+377 -8

No files matched your search

@@ -50,6 +50,16 @@ public class ProcessInstanceConstant {
*/
public static final String USER_TASK_REFUSE_TYPE_TO_NODE = "TO_NODE";
/**
* 拒绝处理策略:驳回到发起人 审批人拒绝后,流程跳转回发起人节点,发起人可重新编辑提交
*/
public static final String USER_TASK_REFUSE_TYPE_TO_START = "TO_START";
/**
* 流程变量:驳回到发起人标识 用于标记流程实例是否处于驳回待重新提交状态
*/
public static final String REJECT_TO_STARTER_VAR = "rejectToStarter";
/**
* 多人审批模式:会签 所有审批人都必须同意才能通过,任何一人拒绝则不通过
*/
@@ -0,0 +1,38 @@
package com.pig4cloud.pigx.flow.vo;
import com.pig4cloud.pigx.flow.entity.ProcessInstanceRecord;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 流程实例记录视图对象
* <p>
* 继承自ProcessInstanceRecord实体类,用于扩展查询返回的额外信息。
* 包含流程实例的驳回状态等运行时信息。
* </p>
*
* @author pigx
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class ProcessInstanceRecordVo extends ProcessInstanceRecord {
/**
* 是否驳回到发起人状态
* <p>
* true表示流程已被驳回到发起人,等待发起人重新编辑提交;
* false或null表示流程正常运行中
* </p>
*/
private Boolean rejectToStarter;
/**
* 重新提交的任务ID
* <p>
* 当rejectToStarter为true时,此字段保存发起人节点(root)的任务ID,
* 用于前端调用重新提交接口
* </p>
*/
private String resubmitTaskId;
}
@@ -159,4 +159,23 @@ public class ProcessTaskController {
return taskService.back(taskParamDto);
}
/**
* 重新提交任务(驳回到发起人后)
* <p>
* 当流程被驳回到发起人后,发起人可使用此接口重新编辑表单并提交。
* 与completeTask不同:
* 1. 不需要填写审批意见
* 2. 会自动清理驳回标识
* 3. 提交后流程继续到下一个审批节点
* </p>
* @param taskParamDto 重新提交参数,包含:
* - taskId: 发起人节点的任务ID
* - formData: 更新后的表单数据
* @return R 统一响应对象,操作成功返回提交结果
*/
@PostMapping("resubmitTask")
public R resubmitTask(@RequestBody TaskParamDto taskParamDto) {
return taskService.resubmitTask(taskParamDto);
}
}
@@ -106,4 +106,18 @@ public interface ITaskService {
*/
R queryTaskData();
/**
* 重新提交任务(驳回到发起人后)
* <p>
* 当流程被驳回到发起人时,发起人使用此方法重新编辑表单并提交。
* 与普通completeTask不同:
* 1. 不需要填写审批意见
* 2. 会清理 rejectToStarter 流程变量
* 3. 完成后流程继续到下一个审批节点
* </p>
* @param taskParamDto 任务参数,包含taskId和更新后的formData
* @return 操作结果
*/
R resubmitTask(TaskParamDto taskParamDto);
}
@@ -29,6 +29,7 @@ import com.pig4cloud.pigx.flow.support.utils.NodeUtil;
import com.pig4cloud.pigx.flow.vo.NodeFormatParamVo;
import com.pig4cloud.pigx.flow.vo.NodeVo;
import com.pig4cloud.pigx.flow.vo.ProcessCopyVo;
import com.pig4cloud.pigx.flow.vo.ProcessInstanceRecordVo;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
@@ -364,7 +365,39 @@ public class ProcessInstanceServiceImpl implements IProcessInstanceService {
Page<ProcessInstanceRecord> instanceRecordPage = processInstanceRecordService.lambdaQuery().eq(ProcessInstanceRecord::getUserId, userId).eq(ProcessInstanceRecord::getStatus, taskQueryParamDto.getStatus()).between(ArrayUtil.isNotEmpty(taskQueryParamDto.getTaskTime()), ProcessInstanceRecord::getCreateTime, ArrayUtil.isNotEmpty(taskQueryParamDto.getTaskTime()) ? taskQueryParamDto.getTaskTime()[0] : null, ArrayUtil.isNotEmpty(taskQueryParamDto.getTaskTime()) ? taskQueryParamDto.getTaskTime()[1] : null).orderByDesc(ProcessInstanceRecord::getCreateTime).page(new Page<>(taskQueryParamDto.getCurrent(), taskQueryParamDto.getSize()));
return R.ok(instanceRecordPage);
// 转换为 VO 并添加驳回状态信息
List<ProcessInstanceRecordVo> voList = BeanUtil.copyToList(instanceRecordPage.getRecords(), ProcessInstanceRecordVo.class);
for (ProcessInstanceRecordVo vo : voList) {
try {
// 从 Flowable 变量中获取 rejectToStarter 状态
Object rejectVar = runtimeService.getVariable(
vo.getProcessInstanceId(),
"rejectToStarter"
);
vo.setRejectToStarter(Boolean.TRUE.equals(rejectVar));
// 如果是驳回状态,获取 root 任务 ID 用于重新提交
if (Boolean.TRUE.equals(vo.getRejectToStarter())) {
Task task = taskService.createTaskQuery()
.processInstanceId(vo.getProcessInstanceId())
.taskDefinitionKey("root")
.singleResult();
if (task != null) {
vo.setResubmitTaskId(task.getId());
}
}
} catch (Exception e) {
// 流程可能已结束,设置为 false
vo.setRejectToStarter(false);
}
}
// 构建返回的分页对象
Page<ProcessInstanceRecordVo> resultPage = new Page<>(instanceRecordPage.getCurrent(), instanceRecordPage.getSize(), instanceRecordPage.getTotal());
resultPage.setRecords(voList);
return R.ok(resultPage);
}
/**
@@ -10,6 +10,7 @@ import com.pig4cloud.pigx.common.core.util.R;
import com.pig4cloud.pigx.common.data.tenant.TenantContextHolder;
import com.pig4cloud.pigx.common.security.util.SecurityUtils;
import com.pig4cloud.pigx.flow.constant.NodeStatusEnum;
import com.pig4cloud.pigx.flow.constant.ProcessInstanceConstant;
import com.pig4cloud.pigx.flow.dto.IndexPageStatistics;
import com.pig4cloud.pigx.flow.dto.Node;
import com.pig4cloud.pigx.flow.dto.TaskParamDto;
@@ -483,6 +484,79 @@ public class TaskServiceImpl implements ITaskService {
return flowableEngineService.back(taskParamDto);
}
/**
* 重新提交任务(驳回到发起人后)
* <p>
* 当流程被驳回到发起人时,发起人使用此方法重新编辑表单并提交。
* 该方法会:
* 1. 验证任务存在且属于当前用户
* 2. 验证任务是 root 节点任务
* 3. 更新表单数据
* 4. 清理 rejectToStarter 流程变量
* 5. 完成任务,流程继续到下一个审批节点
* </p>
*
* @param taskParamDto 任务参数,包含taskId和更新后的formData
* @return R 操作结果
*/
@Override
@SneakyThrows
@Transactional(rollbackFor = Exception.class)
public R resubmitTask(TaskParamDto taskParamDto) {
long userId = SecurityUtils.getUser().getId();
// 1. 获取任务并验证
Task task = taskService.createTaskQuery()
.taskId(taskParamDto.getTaskId())
.taskTenantId(TenantContextHolder.getTenantId().toString())
.taskAssignee(String.valueOf(userId))
.singleResult();
if (task == null) {
return R.failed("任务不存在或无权限操作");
}
// 2. 验证是 root 任务(发起人节点)
if (!StrUtil.equals(task.getTaskDefinitionKey(), "root")) {
return R.failed("非重新提交任务,无法操作");
}
String processInstanceId = task.getProcessInstanceId();
// 3. 更新表单数据
if (CollUtil.isNotEmpty(taskParamDto.getFormData())) {
runtimeService.setVariables(task.getExecutionId(), taskParamDto.getFormData());
// 同步更新到 ProcessInstanceRecord
ProcessInstanceRecord processInstanceRecord = processInstanceRecordService.lambdaQuery()
.eq(ProcessInstanceRecord::getProcessInstanceId, processInstanceId)
.one();
if (processInstanceRecord != null) {
Map<String, Object> existingFormData = new HashMap<>();
if (StrUtil.isNotBlank(processInstanceRecord.getFormData())) {
existingFormData = objectMapper.readValue(processInstanceRecord.getFormData(),
new TypeReference<Map<String, Object>>() {});
}
existingFormData.putAll(taskParamDto.getFormData());
String updatedFormData = objectMapper.writeValueAsString(existingFormData);
processInstanceRecordService.lambdaUpdate()
.set(ProcessInstanceRecord::getFormData, updatedFormData)
.eq(ProcessInstanceRecord::getProcessInstanceId, processInstanceId)
.update();
}
}
// 4. 清理 rejectToStarter 变量
runtimeService.removeVariable(processInstanceId, "rejectToStarter");
// 5. 完成任务,流程继续
taskService.complete(taskParamDto.getTaskId());
return R.ok("重新提交成功");
}
/**
* 递归获取所有需要停止的子流程实例ID列表
* <p>
@@ -0,0 +1,66 @@
package com.pig4cloud.pigx.flow.support.listeners;
import cn.hutool.extra.spring.SpringUtil;
import lombok.extern.slf4j.Slf4j;
import org.flowable.engine.RuntimeService;
import org.flowable.engine.TaskService;
import org.flowable.task.service.delegate.DelegateTask;
import org.flowable.task.service.delegate.TaskListener;
/**
* 发起人任务创建监听器
* <p>
* 用于处理发起人节点(root节点)的任务创建事件。
* 该监听器的核心职责是区分"正常发起流程"和"驳回到发起人"两种场景:
* <ul>
* <li>正常发起流程:自动完成root任务,让流程继续到第一个审批节点</li>
* <li>驳回到发起人:保留任务,让发起人可以在待办任务中看到并处理</li>
* </ul>
* </p>
* <p>
* 判断逻辑:
* 通过检查流程变量 rejectToStarter 是否为 true 来判断是否是驳回场景。
* 该变量在 ApproveServiceTask 处理驳回到发起人时设置。
* </p>
*
* @author pigx
* @see com.pig4cloud.pigx.flow.support.tasks.ApproveServiceTask
*/
@Slf4j
public class RootTaskCreateListener implements TaskListener {
/**
* 任务创建事件处理方法
* <p>
* 当root任务被创建时触发。根据是否是驳回场景决定是否自动完成任务。
* </p>
*
* @param delegateTask 委派的任务对象
*/
@Override
public void notify(DelegateTask delegateTask) {
RuntimeService runtimeService = SpringUtil.getBean(RuntimeService.class);
TaskService taskService = SpringUtil.getBean(TaskService.class);
String processInstanceId = delegateTask.getProcessInstanceId();
String taskId = delegateTask.getId();
String assignee = delegateTask.getAssignee();
log.debug("发起人任务创建 - 任务ID: {}, 执行人: {}, 流程实例ID: {}", taskId, assignee, processInstanceId);
// 检查是否是驳回场景
Object rejectToStarter = runtimeService.getVariable(processInstanceId, "rejectToStarter");
if (!Boolean.TRUE.equals(rejectToStarter)) {
// 非驳回场景:自动完成 root 任务,让流程继续到下一个节点
log.debug("非驳回场景,自动完成发起人任务 - 任务ID: {}", taskId);
taskService.complete(taskId);
}
else {
// 驳回场景:保留任务给发起人处理
// 发起人可以在待办任务列表中看到这个任务,并进行编辑重新提交
log.info("驳回场景,保留任务给发起人 - 任务ID: {}, 执行人: {}", taskId, assignee);
}
}
}
@@ -0,0 +1,67 @@
package com.pig4cloud.pigx.flow.support.node;
import cn.hutool.extra.spring.SpringUtil;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.pig4cloud.pigx.flow.constant.ProcessInstanceConstant;
import com.pig4cloud.pigx.flow.dto.NodeUser;
import lombok.extern.slf4j.Slf4j;
import org.flowable.engine.delegate.DelegateExecution;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 发起人任务处理器
* <p>
* 用于处理驳回到发起人场景下,发起人任务的执行人解析。
* 该处理器作为Spring Bean注入,在BPMN模型中通过EL表达式调用。
* </p>
* <p>
* 使用方式:
* 在UserTask的assignee属性中配置:${rootTaskHandler.resolveAssignee(execution)}
* </p>
*
* @author pigx
*/
@Component("rootTaskHandler")
@Slf4j
public class RootTaskHandler {
/**
* 解析发起人作为任务执行人
* <p>
* 从流程变量中获取发起人信息(root变量),返回发起人的用户ID作为任务执行人。
* 该方法在任务创建时由Flowable引擎通过EL表达式调用。
* </p>
*
* @param execution 流程执行上下文,包含流程变量等信息
* @return 发起人用户ID字符串,如果未找到则返回默认空执行人标识
*/
public String resolveAssignee(DelegateExecution execution) {
// 从流程变量中获取发起人信息
Object rootUserObj = execution.getVariable("root");
if (rootUserObj == null) {
log.warn("未找到发起人信息,流程实例ID: {}", execution.getProcessInstanceId());
return ProcessInstanceConstant.DEFAULT_EMPTY_ASSIGN.toString();
}
ObjectMapper objectMapper = SpringUtil.getBean(ObjectMapper.class);
try {
// 发起人信息存储为List<NodeUser>格式
List<NodeUser> nodeUsers = objectMapper.readValue(objectMapper.writeValueAsString(rootUserObj),
new TypeReference<List<NodeUser>>() {
});
if (!nodeUsers.isEmpty()) {
Long userId = nodeUsers.get(0).getId();
log.debug("解析发起人成功,用户ID: {}", userId);
return userId.toString();
}
}
catch (Exception e) {
log.error("解析发起人信息失败,流程实例ID: {}", execution.getProcessInstanceId(), e);
}
return ProcessInstanceConstant.DEFAULT_EMPTY_ASSIGN.toString();
}
}
@@ -105,6 +105,11 @@ public class ApproveServiceTask implements JavaDelegate {
// 根据拒绝设置重定向到特定节点
stateChangeBuilder.moveActivityIdTo(currentActivityId, refuseConfig.getNodeId()).changeState();
}
else if (StrUtil.equals(rejectionHandler, "TO_START")) {
// 驳回到发起人:设置驳回标识变量,跳转到 root 节点
runtimeService.setVariable(processInstanceId, "rejectToStarter", true);
stateChangeBuilder.moveActivityIdTo(currentActivityId, "root").changeState();
}
else {
// 默认行为:重定向到结束节点
stateChangeBuilder.moveActivityIdTo(currentActivityId, "end").changeState();
@@ -13,6 +13,7 @@ import com.pig4cloud.pigx.flow.service.IProcessNodeDataService;
import com.pig4cloud.pigx.flow.support.expression.condition.NodeExpressionStrategyFactory;
import com.pig4cloud.pigx.flow.support.listeners.ApprovalCreateListener;
import com.pig4cloud.pigx.flow.support.listeners.FlowProcessEventListener;
import com.pig4cloud.pigx.flow.support.listeners.RootTaskCreateListener;
import com.pig4cloud.pigx.flow.support.tasks.ApproveServiceTask;
import com.pig4cloud.pigx.flow.support.tasks.CopyServiceTask;
import lombok.extern.slf4j.Slf4j;
@@ -352,22 +353,53 @@ public class ModelUtil {
/**
* 构建开始节点
* <p>
* 创建BPMN流程的开始事件节点。
* 每个流程必须有且只有一个开始节点。
* 创建BPMN流程的开始事件节点和发起人任务节点。
* 流程结构:StartEvent(start) → UserTask(root) → 第一个审批节点
* </p>
* <p>
* 发起人任务节点的作用:
* <ul>
* <li>正常发起流程时,通过RootTaskCreateListener自动完成,流程继续到下一个节点</li>
* <li>驳回到发起人时,任务保留给发起人,发起人可以在待办任务中编辑并重新提交</li>
* </ul>
* </p>
*
* @param node 前端传输的开始节点数据
* @return 包含开始事件的流程元素列表
* @return 包含开始事件和发起人任务的流程元素列表
*/
private static List<FlowElement> buildStartNode(Node node) {
List<FlowElement> flowElementList = new ArrayList<>();
// 1. 创建真正的开始事件(ID 为 start)
StartEvent startEvent = new StartEvent();
startEvent.setId(node.getId());
startEvent.setName(node.getName());
startEvent.setId("start");
startEvent.setName("开始");
flowElementList.add(startEvent);
// 2. 创建发起人任务节点(ID 保持为 root)
// 配置任务监听器,用于处理驳回场景
FlowableListener createListener = new FlowableListener();
createListener.setImplementation(RootTaskCreateListener.class.getCanonicalName());
createListener.setImplementationType("class");
createListener.setEvent("create");
UserTask rootTask = new UserTask();
rootTask.setId(node.getId()); // 保持 "root" 作为 ID
rootTask.setName(node.getName());
// 动态获取发起人作为执行人
rootTask.setAssignee("${rootTaskHandler.resolveAssignee(execution)}");
List<FlowableListener> taskListeners = new ArrayList<>();
taskListeners.add(createListener);
rootTask.setTaskListeners(taskListeners);
flowElementList.add(rootTask);
// 3. 更新节点的 head/tail 信息
node.setHeadId("start"); // 头部是 StartEvent
node.setTailId(node.getId()); // 尾部是 root UserTask
return flowElementList;
}
@@ -655,7 +687,11 @@ public class ModelUtil {
/**
* 创建节点内部连接线
* <p>
* 创建节点内部的连接线,例如审批节点与其后续服务任务之间的连接。
* 创建节点内部的连接线,例如:
* <ul>
* <li>开始节点:start → root(StartEvent到发起人UserTask)</li>
* <li>审批节点:审批任务 → 服务任务</li>
* </ul>
*
* @param node 节点数据
* @param flowId 流程定义ID
@@ -673,6 +709,13 @@ public class ModelUtil {
return sequenceFlowList;
}
// 开始节点:创建 start → root 的连线
if (node.getType() == NodeTypeEnum.ROOT.getValue().intValue()) {
SequenceFlow sequenceFlow = buildSingleSequenceFlow("start", nodeId, null, "start->root");
sequenceFlowList.add(sequenceFlow);
}
// 审批节点:创建审批任务到服务任务的连线
if (node.getType() == NodeTypeEnum.APPROVAL.getValue().intValue()) {
String gatewayId = StrUtil.format("approve_service_task_{}", nodeId);