feat(flow): support dynamic countersign add-sign

This commit is contained in:
PIGCLOUD committed 2026-08-01 13:32:20 +08:00
1 parent 5931a5d6d7
commit 2b6a4d2e85
32 files changed
+1916 -37

No files matched your search

@@ -105,6 +105,30 @@ public class ProcessInstanceConstant {
*/
public static final int MULTIPLE_MODE_ALL_SORT = 3;
/** 原始流程分配。 */
public static final String ASSIGNMENT_ORIGIN_ORIGINAL = "ORIGINAL";
/** 动态加签分配。 */
public static final String ASSIGNMENT_ORIGIN_ADD_SIGN = "ADD_SIGN";
/** 执行变量:分配来源。 */
public static final String VAR_ASSIGNMENT_ORIGIN = "assignmentOrigin";
/** 执行变量:本份任务的最初审批人。 */
public static final String VAR_INITIAL_USER_ID = "initialUserId";
/** 执行变量:本轮节点记录。 */
public static final String VAR_NODE_RECORD_ID = "nodeRecordId";
/** 执行变量:加签审计记录。 */
public static final String VAR_ADD_SIGN_RECORD_ID = "addSignRecordId";
/** 执行变量:加签发起人。 */
public static final String VAR_ADDED_BY_USER_ID = "addedByUserId";
/** 任务变量:本次分配操作类型。 */
public static final String VAR_ASSIGNMENT_OPERATION = "assignmentOperation";
/**
* 任务分配类型 定义审批节点的执行人分配方式
*/
@@ -0,0 +1,19 @@
package com.pig4cloud.pigx.flow.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import java.util.List;
/**
* 动态加签结果。
*/
@Data
@AllArgsConstructor
public class AddSignResultDto {
private Long recordId;
private List<AddSignTaskSummaryDto> tasks;
}
@@ -0,0 +1,22 @@
package com.pig4cloud.pigx.flow.dto;
import lombok.Data;
import java.util.List;
/**
* 动态加签请求。
*/
@Data
public class AddSignTaskDto {
/** 当前审批任务 ID。 */
private String taskId;
/** 本批次新增的审批人。 */
private List<Long> targetUserIdList;
/** 加签原因。 */
private String reason;
}
@@ -0,0 +1,17 @@
package com.pig4cloud.pigx.flow.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
/**
* 动态加签后新建任务摘要。
*/
@Data
@AllArgsConstructor
public class AddSignTaskSummaryDto {
private String taskId;
private Long userId;
}
@@ -85,6 +85,11 @@ public class Node {
*/
private Integer multipleMode;
/**
* 是否允许当前并行会签节点的原始审批人动态加签
*/
private Boolean allowAddSign;
/**
* 部门主管级别 当assignedType为LEADER时,指定查找第几级主管
*/
@@ -117,4 +117,16 @@ public class ProcessNodeRecordAssignUserParamDto {
*/
private String taskType;
/** 本轮节点执行记录 ID。 */
private Long nodeRecordId;
/** 分配来源:ORIGINAL 或 ADD_SIGN。 */
private String assignmentOrigin;
/** 本份待办最初分配的用户。 */
private Long initialUserId;
/** 动态加签批次记录 ID。 */
private Long addSignRecordId;
}
@@ -158,6 +158,22 @@ public class ProcessNodeRecordAssignUser {
@TableField("local_data")
private String localData;
/** 本轮节点执行记录 ID。 */
@TableField("node_record_id")
private Long nodeRecordId;
/** 分配来源:ORIGINAL 或 ADD_SIGN。 */
@TableField("assignment_origin")
private String assignmentOrigin;
/** 本份待办最初分配的用户。 */
@TableField("initial_user_id")
private Long initialUserId;
/** 动态加签批次记录 ID。 */
@TableField("add_sign_record_id")
private Long addSignRecordId;
/**
* 主键ID
* <p>
@@ -0,0 +1,69 @@
package com.pig4cloud.pigx.flow.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import com.pig4cloud.pigx.common.core.util.TenantTable;
import lombok.Getter;
import lombok.Setter;
import lombok.experimental.Accessors;
import java.time.LocalDateTime;
/**
* 动态加签批次审计记录。
*/
@Getter
@Setter
@TenantTable
@Accessors(chain = true)
@TableName("process_task_add_sign_record")
public class ProcessTaskAddSignRecord {
@TableId(value = "id", type = IdType.ASSIGN_ID)
private Long id;
@TableField("flow_id")
private String flowId;
@TableField("process_instance_id")
private String processInstanceId;
@TableField("node_record_id")
private Long nodeRecordId;
@TableField("node_id")
private String nodeId;
@TableField("source_task_id")
private String sourceTaskId;
@TableField("added_by_user_id")
private Long addedByUserId;
@TableField("reason")
private String reason;
@TableField("target_user_ids")
private String targetUserIds;
@TableField("participant_user_ids_before")
private String participantUserIdsBefore;
@TableField("participant_user_ids_after")
private String participantUserIdsAfter;
@TableLogic
@TableField(fill = FieldFill.INSERT)
private String delFlag;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
}
@@ -0,0 +1,24 @@
package com.pig4cloud.pigx.flow.vo;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/**
* 流程进度中的动态加签事件。
*/
@Data
public class AddSignRecordVo {
private Long id;
private String addedByName;
private List<String> targetUserNames;
private String reason;
private LocalDateTime createTime;
}
@@ -30,6 +30,11 @@ public class NodeVo {
*/
private List<UserVo> userVoList;
/**
* 当前节点的动态加签批次事件
*/
private List<AddSignRecordVo> addSignRecordList;
/**
* 节点占位提示
* <p>
@@ -1,6 +1,7 @@
package com.pig4cloud.pigx.flow.controller;
import com.pig4cloud.pigx.common.core.util.R;
import com.pig4cloud.pigx.flow.dto.AddSignTaskDto;
import com.pig4cloud.pigx.flow.dto.TaskParamDto;
import com.pig4cloud.pigx.flow.service.ITaskService;
import lombok.RequiredArgsConstructor;
@@ -93,6 +94,16 @@ public class ProcessTaskController {
}
/**
* 向当前并行会签节点动态追加审批人。
* @param addSignTaskDto 当前任务、目标用户及加签原因
* @return 加签批次和新增任务摘要
*/
@PostMapping("addSign")
public R addSign(@RequestBody AddSignTaskDto addSignTaskDto) {
return taskService.addSign(addSignTaskDto);
}
/**
* 加签完成任务
* <p>
@@ -3,6 +3,8 @@ package com.pig4cloud.pigx.flow.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.pig4cloud.pigx.flow.entity.ProcessNodeRecord;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
/**
* 流程节点记录数据访问接口
@@ -17,4 +19,40 @@ import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ProcessNodeRecordMapper extends BaseMapper<ProcessNodeRecord> {
/**
* 锁定当前活动节点,串行化加签与任务完成。
*/
@Select("""
SELECT *
FROM process_node_record
WHERE process_instance_id = #{processInstanceId}
AND node_id = #{nodeId}
AND status = 1
AND tenant_id = #{tenantId}
AND del_flag = '0'
ORDER BY id DESC
LIMIT 1
FOR UPDATE
""")
ProcessNodeRecord selectActiveForUpdate(@Param("processInstanceId") String processInstanceId,
@Param("nodeId") String nodeId, @Param("tenantId") Long tenantId);
/**
* 按任务已绑定的本轮节点记录加锁,避免误锁同节点的其他执行记录。
*/
@Select("""
SELECT *
FROM process_node_record
WHERE id = #{nodeRecordId}
AND process_instance_id = #{processInstanceId}
AND node_id = #{nodeId}
AND status = 1
AND tenant_id = #{tenantId}
AND del_flag = '0'
FOR UPDATE
""")
ProcessNodeRecord selectActiveByIdForUpdate(@Param("nodeRecordId") Long nodeRecordId,
@Param("processInstanceId") String processInstanceId, @Param("nodeId") String nodeId,
@Param("tenantId") Long tenantId);
}
@@ -0,0 +1,12 @@
package com.pig4cloud.pigx.flow.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.pig4cloud.pigx.flow.entity.ProcessTaskAddSignRecord;
import org.apache.ibatis.annotations.Mapper;
/**
* 动态加签批次审计数据访问接口。
*/
@Mapper
public interface ProcessTaskAddSignRecordMapper extends BaseMapper<ProcessTaskAddSignRecord> {
}
@@ -1,6 +1,7 @@
package com.pig4cloud.pigx.flow.service;
import com.pig4cloud.pigx.common.core.util.R;
import com.pig4cloud.pigx.flow.dto.AddSignTaskDto;
import com.pig4cloud.pigx.flow.dto.TaskParamDto;
/**
@@ -57,6 +58,13 @@ public interface ITaskService {
*/
R delegateTask(TaskParamDto taskParamDto);
/**
* 向运行中的并行会签节点动态追加审批人。
* @param addSignTaskDto 当前任务、目标用户及加签原因
* @return 加签批次和新增任务摘要
*/
R addSign(AddSignTaskDto addSignTaskDto);
/**
* 加签完成任务
* <p>
@@ -5,10 +5,14 @@ import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.pig4cloud.pigx.common.core.util.R;
import com.pig4cloud.pigx.flow.constant.NodeStatusEnum;
import com.pig4cloud.pigx.flow.constant.ProcessInstanceConstant;
import com.pig4cloud.pigx.flow.dto.ProcessNodeRecordAssignUserParamDto;
import com.pig4cloud.pigx.flow.entity.ProcessNodeRecord;
import com.pig4cloud.pigx.flow.entity.ProcessNodeRecordAssignUser;
import com.pig4cloud.pigx.flow.mapper.ProcessNodeRecordAssignUserMapper;
import com.pig4cloud.pigx.flow.service.IProcessNodeRecordAssignUserService;
import com.pig4cloud.pigx.flow.service.IProcessNodeRecordService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
@@ -29,10 +33,13 @@ import java.time.LocalDateTime;
* @since 2023-05-10
*/
@Service
@RequiredArgsConstructor
public class ProcessNodeRecordAssignUserServiceImpl
extends ServiceImpl<ProcessNodeRecordAssignUserMapper, ProcessNodeRecordAssignUser>
implements IProcessNodeRecordAssignUserService {
private final IProcessNodeRecordService processNodeRecordService;
/**
* 设置执行人
* <p>
@@ -53,36 +60,100 @@ public class ProcessNodeRecordAssignUserServiceImpl
*/
@Override
public R addAssignUser(ProcessNodeRecordAssignUserParamDto processNodeRecordAssignUserParamDto) {
// 如果有审批意见,说明是转办、委托等操作,需要更新上一条记录
if (StrUtil.isNotBlank(processNodeRecordAssignUserParamDto.getApproveDesc())) {
ProcessNodeRecordAssignUser processNodeRecordAssignUser = this.lambdaQuery()
ProcessNodeRecordAssignUser previousAssignUser = this.lambdaQuery()
.eq(ProcessNodeRecordAssignUser::getTaskId, processNodeRecordAssignUserParamDto.getTaskId())
.orderByDesc(ProcessNodeRecordAssignUser::getId)
.last("limit 1")
.one();
if (processNodeRecordAssignUser != null) {
// 更新上一个执行人的记录为已完成
processNodeRecordAssignUser.setApproveDesc(processNodeRecordAssignUserParamDto.getApproveDesc());
processNodeRecordAssignUser.setTaskType(processNodeRecordAssignUserParamDto.getTaskType());
processNodeRecordAssignUser.setStatus(NodeStatusEnum.YJS.getCode());
processNodeRecordAssignUser.setEndTime(LocalDateTime.now());
this.updateById(processNodeRecordAssignUser);
}
Long nodeRecordId = processNodeRecordAssignUserParamDto.getNodeRecordId();
if (previousAssignUser != null) {
nodeRecordId = previousAssignUser.getNodeRecordId();
}
if (nodeRecordId == null) {
ProcessNodeRecord nodeRecord = processNodeRecordService.lambdaQuery()
.eq(ProcessNodeRecord::getProcessInstanceId,
processNodeRecordAssignUserParamDto.getProcessInstanceId())
.eq(ProcessNodeRecord::getNodeId, processNodeRecordAssignUserParamDto.getNodeId())
.eq(ProcessNodeRecord::getStatus, NodeStatusEnum.JXZ.getCode())
.orderByDesc(ProcessNodeRecord::getId)
.last("limit 1")
.one();
if (nodeRecord != null) {
nodeRecordId = nodeRecord.getId();
}
}
ProcessNodeRecordAssignUser existingParticipant = null;
if (nodeRecordId != null) {
existingParticipant = this.lambdaQuery()
.eq(ProcessNodeRecordAssignUser::getNodeRecordId, nodeRecordId)
.eq(ProcessNodeRecordAssignUser::getUserId, processNodeRecordAssignUserParamDto.getUserId())
.one();
if (existingParticipant != null && (previousAssignUser == null
|| !processNodeRecordAssignUserParamDto.getUserId().equals(previousAssignUser.getUserId()))) {
if (previousAssignUser != null
&& StrUtil.equals("RESOLVED", processNodeRecordAssignUserParamDto.getTaskType())) {
finishAssignment(previousAssignUser, processNodeRecordAssignUserParamDto);
existingParticipant.setExecutionId(processNodeRecordAssignUserParamDto.getExecutionId());
existingParticipant.setStatus(NodeStatusEnum.JXZ.getCode());
existingParticipant.setStartTime(LocalDateTime.now());
existingParticipant.setEndTime(null);
existingParticipant.setApproveDesc(StrUtil.EMPTY);
existingParticipant.setTaskType("RESOLVED");
this.updateById(existingParticipant);
return R.ok();
}
return R.failed("用户已参与当前会签");
}
}
if (previousAssignUser != null
&& previousAssignUser.getUserId().equals(processNodeRecordAssignUserParamDto.getUserId())
&& previousAssignUser.getStatus().equals(NodeStatusEnum.JXZ.getCode())) {
return R.ok();
}
String assignmentOrigin = processNodeRecordAssignUserParamDto.getAssignmentOrigin();
Long initialUserId = processNodeRecordAssignUserParamDto.getInitialUserId();
Long addSignRecordId = processNodeRecordAssignUserParamDto.getAddSignRecordId();
if (previousAssignUser != null) {
assignmentOrigin = previousAssignUser.getAssignmentOrigin();
initialUserId = previousAssignUser.getInitialUserId();
addSignRecordId = previousAssignUser.getAddSignRecordId();
finishAssignment(previousAssignUser, processNodeRecordAssignUserParamDto);
}
assignmentOrigin = StrUtil.blankToDefault(assignmentOrigin,
ProcessInstanceConstant.ASSIGNMENT_ORIGIN_ORIGINAL);
initialUserId = initialUserId == null ? processNodeRecordAssignUserParamDto.getUserId() : initialUserId;
// 创建新的执行人记录
ProcessNodeRecordAssignUser processNodeRecordAssignUser = BeanUtil
.copyProperties(processNodeRecordAssignUserParamDto, ProcessNodeRecordAssignUser.class);
processNodeRecordAssignUser.setNodeRecordId(nodeRecordId);
processNodeRecordAssignUser.setAssignmentOrigin(assignmentOrigin);
processNodeRecordAssignUser.setInitialUserId(initialUserId);
processNodeRecordAssignUser.setAddSignRecordId(addSignRecordId);
processNodeRecordAssignUser.setStartTime(LocalDateTime.now());
processNodeRecordAssignUser.setStatus(NodeStatusEnum.JXZ.getCode()); // 进行中状态
processNodeRecordAssignUser.setApproveDesc("");
processNodeRecordAssignUser.setTaskType("");
processNodeRecordAssignUser.setApproveDesc(StrUtil.EMPTY);
processNodeRecordAssignUser.setTaskType(ProcessInstanceConstant.ASSIGNMENT_ORIGIN_ADD_SIGN
.equals(assignmentOrigin) ? "ADD_SIGN" : StrUtil.EMPTY);
this.save(processNodeRecordAssignUser);
return R.ok();
}
private void finishAssignment(ProcessNodeRecordAssignUser assignment,
ProcessNodeRecordAssignUserParamDto paramDto) {
assignment.setApproveDesc(paramDto.getApproveDesc());
assignment.setTaskType(StrUtil.blankToDefault(paramDto.getTaskType(), "TRANSFER"));
assignment.setStatus(NodeStatusEnum.YJS.getCode());
assignment.setEndTime(LocalDateTime.now());
this.updateById(assignment);
}
/**
* 任务完成通知
* <p>
@@ -116,6 +187,10 @@ public class ProcessNodeRecordAssignUserServiceImpl
.one();
// 更新记录为已完成状态
if (processNodeRecordAssignUser == null) {
return R.failed("未找到活动任务执行人记录");
}
processNodeRecordAssignUser.setStatus(NodeStatusEnum.YJS.getCode());
processNodeRecordAssignUser.setApproveDesc(processNodeRecordAssignUserParamDto.getApproveDesc());
processNodeRecordAssignUser.setEndTime(LocalDateTime.now());
@@ -48,6 +48,16 @@ public class ProcessNodeRecordServiceImpl extends ServiceImpl<ProcessNodeRecordM
*/
@Override
public R start(ProcessNodeRecordParamDto processNodeRecordParamDto) {
ProcessNodeRecord activeRecord = this.lambdaQuery()
.eq(ProcessNodeRecord::getProcessInstanceId, processNodeRecordParamDto.getProcessInstanceId())
.eq(ProcessNodeRecord::getNodeId, processNodeRecordParamDto.getNodeId())
.eq(ProcessNodeRecord::getStatus, NodeStatusEnum.JXZ.getCode())
.orderByAsc(ProcessNodeRecord::getId)
.last("limit 1")
.one();
if (activeRecord != null) {
return R.ok(activeRecord.getId());
}
ProcessNodeRecord processNodeRecord = BeanUtil.copyProperties(processNodeRecordParamDto,
ProcessNodeRecord.class);
@@ -55,7 +65,7 @@ public class ProcessNodeRecordServiceImpl extends ServiceImpl<ProcessNodeRecordM
processNodeRecord.setStatus(NodeStatusEnum.JXZ.getCode());
this.save(processNodeRecord);
return R.ok();
return R.ok(processNodeRecord.getId());
}
/**
@@ -6,23 +6,25 @@ import cn.hutool.core.lang.Dict;
import cn.hutool.core.util.StrUtil;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.pig4cloud.pigx.admin.api.entity.SysUser;
import com.pig4cloud.pigx.admin.api.feign.RemoteUserService;
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;
import com.pig4cloud.pigx.flow.dto.TaskResultDto;
import com.pig4cloud.pigx.flow.dto.*;
import com.pig4cloud.pigx.flow.entity.*;
import com.pig4cloud.pigx.flow.entity.Process;
import com.pig4cloud.pigx.flow.mapper.ProcessNodeRecordMapper;
import com.pig4cloud.pigx.flow.mapper.ProcessTaskAddSignRecordMapper;
import com.pig4cloud.pigx.flow.service.*;
import com.pig4cloud.pigx.flow.support.utils.NodeUtil;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.flowable.common.engine.api.FlowableException;
import org.flowable.common.engine.api.FlowableOptimisticLockingException;
import org.flowable.engine.HistoryService;
import org.flowable.engine.RuntimeService;
import org.flowable.engine.TaskService;
@@ -36,6 +38,7 @@ import org.flowable.task.api.history.HistoricTaskInstance;
import org.flowable.variable.api.history.HistoricVariableInstance;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.interceptor.TransactionAspectSupport;
import java.util.*;
@@ -78,6 +81,12 @@ public class TaskServiceImpl implements ITaskService {
private final ObjectMapper objectMapper;
private final RemoteUserService remoteUserService;
private final ProcessNodeRecordMapper processNodeRecordMapper;
private final ProcessTaskAddSignRecordMapper processTaskAddSignRecordMapper;
/**
* 查询首页数据看板
* <p>
@@ -274,6 +283,18 @@ public class TaskServiceImpl implements ITaskService {
Node node = nodeDataService.getNodeData(taskResultDto.getFlowId(), taskResultDto.getNodeId()).getData();
Map<String, String> formPerms = node.getFormPerms();
ProcessNodeRecord activeNodeRecord = null;
List<Long> participantUserIds = Collections.emptyList();
boolean canAddSign = false;
if (Boolean.TRUE.equals(currentTask) && task.isPresent()) {
ProcessNodeRecordAssignUser currentAssignment = findActiveAssignment(task.get().getId(), userId);
activeNodeRecord = findActiveNodeRecord(task.get(), currentAssignment);
if (activeNodeRecord != null) {
participantUserIds = listParticipantUserIds(activeNodeRecord.getId());
canAddSign = canAddSign(task.get(), node, activeNodeRecord, currentAssignment, userId);
}
}
Process oaForms = processService.getByFlowId(taskResultDto.getFlowId());
if (oaForms == null) {
return R.failed("流程不存在");
@@ -287,6 +308,8 @@ public class TaskServiceImpl implements ITaskService {
.set("process", oaForms.getProcess())
.set("delegateAgain", taskResultDto.getDelegate())
.set("delegationTask", StrUtil.equals(taskResultDto.getDelegationState(), "PENDING"))
.set("canAddSign", canAddSign)
.set("participantUserIds", participantUserIds)
.set("formItems", oaForms.getFormItems())
.set("formData", paramMap)
.set("formConfig", oaForms.getFormConfig())
@@ -305,17 +328,31 @@ public class TaskServiceImpl implements ITaskService {
* @return R 操作结果
*/
@Override
@Transactional(rollbackFor = Exception.class)
public R completeTask(TaskParamDto taskParamDto) {
long userId = SecurityUtils.getUser().getId();
taskParamDto.setUserId(String.valueOf(userId));
Task task = taskService.createTaskQuery()
.taskId(taskParamDto.getTaskId())
.taskTenantId(TenantContextHolder.getTenantId().toString())
.taskAssignee(String.valueOf(userId))
.singleResult();
if (task == null) {
return R.failed("当前任务已完成或无权限操作");
}
ProcessNodeRecordAssignUser currentAssignment = findActiveAssignment(task.getId(), userId);
ProcessNodeRecord nodeRecord = lockActiveNodeRecord(task, currentAssignment, TenantContextHolder.getTenantId());
if (nodeRecord == null) {
return R.failed("当前审批节点已结束,无法操作");
}
Map<String, Object> taskLocalParamMap = taskParamDto.getTaskLocalParamMap();
if (CollUtil.isNotEmpty(taskLocalParamMap)) {
taskService.setVariablesLocal(taskParamDto.getTaskId(), taskLocalParamMap);
}
// 始终获取 processInstanceId,确保触发器终止时可用
Task task = taskService.createTaskQuery().taskId(taskParamDto.getTaskId()).singleResult();
taskParamDto.setProcessInstanceId(task.getProcessInstanceId());
if (CollUtil.isNotEmpty(taskParamDto.getFormData())) {
@@ -400,6 +437,152 @@ public class TaskServiceImpl implements ITaskService {
return R.failed("触发器调用失败: " + errorMsg);
}
/**
* 向运行中的并行会签节点动态追加审批人。
*/
@Override
@SneakyThrows
@Transactional(rollbackFor = Exception.class)
public R addSign(AddSignTaskDto addSignTaskDto) {
if (addSignTaskDto == null || StrUtil.isBlank(addSignTaskDto.getTaskId())) {
return R.failed("任务ID不能为空");
}
List<Long> requestedUserIds = addSignTaskDto.getTargetUserIdList();
if (CollUtil.isEmpty(requestedUserIds)) {
return R.failed("请选择加签人员");
}
if (requestedUserIds.stream().anyMatch(Objects::isNull)) {
return R.failed("加签人员不能为空");
}
LinkedHashSet<Long> targetUserIds = new LinkedHashSet<>(requestedUserIds);
if (targetUserIds.size() != requestedUserIds.size()) {
return R.failed("加签人员不能重复");
}
String reason = StrUtil.trim(addSignTaskDto.getReason());
if (reason == null || reason.length() < 2 || reason.length() > 100) {
return R.failed("加签原因需为 2~100 字");
}
long userId = SecurityUtils.getUser().getId();
Long tenantId = TenantContextHolder.getTenantId();
Task sourceTask = taskService.createTaskQuery()
.taskId(addSignTaskDto.getTaskId())
.taskTenantId(tenantId.toString())
.taskAssignee(String.valueOf(userId))
.singleResult();
if (sourceTask == null) {
return R.failed("当前任务已完成或已转办");
}
ProcessNodeRecordAssignUser sourceAssignment = findActiveAssignment(sourceTask.getId(), userId);
ProcessNodeRecord nodeRecord = lockActiveNodeRecord(sourceTask, sourceAssignment, tenantId);
if (nodeRecord == null) {
return R.failed("当前审批节点已结束,无法加签");
}
String flowId = NodeUtil.getFlowId(sourceTask.getProcessDefinitionId());
Node node = nodeDataService.getNodeData(flowId, sourceTask.getTaskDefinitionKey()).getData();
if (!isParallelCountersign(node)) {
return R.failed("仅并行会签节点支持加签");
}
if (!Boolean.TRUE.equals(node.getAllowAddSign())) {
return R.failed("当前节点未开启加签");
}
if (isProcessStarter(sourceTask.getProcessInstanceId(), userId)) {
return R.failed("流程发起人不能发起加签");
}
if (!isOriginalApprover(sourceTask, sourceAssignment, userId)) {
return R.failed("只有未完成审批的原始审批人可以加签");
}
List<Long> participantUserIds = listParticipantUserIds(nodeRecord.getId());
List<SysUser> targetUsers = queryValidTenantUsers(new ArrayList<>(targetUserIds), tenantId);
if (targetUsers.size() != targetUserIds.size()) {
return R.failed("加签用户不存在或不属于当前租户");
}
Map<Long, SysUser> targetUserMap = new HashMap<>();
for (SysUser targetUser : targetUsers) {
targetUserMap.put(targetUser.getUserId(), targetUser);
}
for (Long targetUserId : targetUserIds) {
if (participantUserIds.contains(targetUserId)) {
SysUser duplicateUser = targetUserMap.get(targetUserId);
String userName = duplicateUser == null ? targetUserId.toString()
: StrUtil.blankToDefault(duplicateUser.getName(), duplicateUser.getUsername());
return R.failed(StrUtil.format("用户已参与当前会签:{}", userName));
}
}
ProcessTaskAddSignRecord addSignRecord = new ProcessTaskAddSignRecord()
.setFlowId(flowId)
.setProcessInstanceId(sourceTask.getProcessInstanceId())
.setNodeRecordId(nodeRecord.getId())
.setNodeId(sourceTask.getTaskDefinitionKey())
.setSourceTaskId(sourceTask.getId())
.setAddedByUserId(userId)
.setReason(reason)
.setTargetUserIds(objectMapper.writeValueAsString(targetUserIds))
.setParticipantUserIdsBefore(objectMapper.writeValueAsString(participantUserIds));
processTaskAddSignRecordMapper.insert(addSignRecord);
List<AddSignTaskSummaryDto> taskSummaries = new ArrayList<>();
try {
for (Long targetUserId : targetUserIds) {
Map<String, Object> executionVariables = new HashMap<>();
executionVariables.put(StrUtil.format("{}_assignee_temp", sourceTask.getTaskDefinitionKey()),
targetUserId);
executionVariables.put(ProcessInstanceConstant.VAR_ASSIGNMENT_ORIGIN,
ProcessInstanceConstant.ASSIGNMENT_ORIGIN_ADD_SIGN);
executionVariables.put(ProcessInstanceConstant.VAR_INITIAL_USER_ID, targetUserId);
executionVariables.put(ProcessInstanceConstant.VAR_NODE_RECORD_ID, nodeRecord.getId());
executionVariables.put(ProcessInstanceConstant.VAR_ADD_SIGN_RECORD_ID, addSignRecord.getId());
executionVariables.put(ProcessInstanceConstant.VAR_ADDED_BY_USER_ID, userId);
executionVariables.put("addSignReason", reason);
// Flowable 8 允许以流程实例 ID 为搜索根,递归定位当前活动的多实例根执行。
runtimeService.addMultiInstanceExecution(sourceTask.getTaskDefinitionKey(),
sourceTask.getProcessInstanceId(), executionVariables);
Task newTask = taskService.createTaskQuery()
.processInstanceId(sourceTask.getProcessInstanceId())
.taskDefinitionKey(sourceTask.getTaskDefinitionKey())
.taskAssignee(targetUserId.toString())
.active()
.singleResult();
if (newTask == null) {
return rollbackFailed("新增会签任务失败,请刷新后重试");
}
boolean assignmentRecorded = processNodeRecordAssignUserService.lambdaQuery()
.eq(ProcessNodeRecordAssignUser::getNodeRecordId, nodeRecord.getId())
.eq(ProcessNodeRecordAssignUser::getTaskId, newTask.getId())
.eq(ProcessNodeRecordAssignUser::getUserId, targetUserId)
.eq(ProcessNodeRecordAssignUser::getAssignmentOrigin,
ProcessInstanceConstant.ASSIGNMENT_ORIGIN_ADD_SIGN)
.exists();
if (!assignmentRecorded) {
return rollbackFailed("新增会签任务记录失败,请刷新后重试");
}
taskSummaries.add(new AddSignTaskSummaryDto(newTask.getId(), targetUserId));
}
}
catch (FlowableOptimisticLockingException exception) {
log.warn("动态加签发生并发冲突: taskId={}", sourceTask.getId(), exception);
return rollbackFailed("节点状态已变化,请刷新后重试");
}
catch (FlowableException exception) {
log.warn("动态加签时多实例节点已不可用: taskId={}", sourceTask.getId(), exception);
return rollbackFailed("当前审批节点已结束,无法加签");
}
LinkedHashSet<Long> participantUserIdsAfter = new LinkedHashSet<>(participantUserIds);
participantUserIdsAfter.addAll(targetUserIds);
addSignRecord.setParticipantUserIdsAfter(objectMapper.writeValueAsString(participantUserIdsAfter));
processTaskAddSignRecordMapper.updateById(addSignRecord);
return R.ok(new AddSignResultDto(addSignRecord.getId(), taskSummaries));
}
/**
* 前加签
* <p>
@@ -412,9 +595,59 @@ public class TaskServiceImpl implements ITaskService {
@Transactional
@Override
public R delegateTask(TaskParamDto taskParamDto) {
taskParamDto.setUserId(SecurityUtils.getUser().getId().toString());
return flowableEngineService.delegateTask(taskParamDto);
}
if (taskParamDto == null || StrUtil.isBlank(taskParamDto.getTaskId())) {
return R.failed("任务ID不能为空");
}
if (taskParamDto.getTargetUserIdList() == null || taskParamDto.getTargetUserIdList().size() != 1
|| taskParamDto.getTargetUserIdList().get(0) == null) {
return R.failed("委托仅支持选择一名用户");
}
long userId = SecurityUtils.getUser().getId();
Long tenantId = TenantContextHolder.getTenantId();
Task task = taskService.createTaskQuery()
.taskId(taskParamDto.getTaskId())
.taskTenantId(tenantId.toString())
.taskAssignee(String.valueOf(userId))
.singleResult();
if (task == null) {
return R.failed("当前任务已完成或无权限委托");
}
Long targetUserId = taskParamDto.getTargetUserIdList().get(0);
ProcessNodeRecordAssignUser sourceAssignment = findActiveAssignment(task.getId(), userId);
ProcessNodeRecord nodeRecord = lockActiveNodeRecord(task, sourceAssignment, tenantId);
if (nodeRecord == null) {
return R.failed("当前审批节点已结束,无法委托");
}
if (listParticipantUserIds(nodeRecord.getId()).contains(targetUserId)) {
return R.failed("委托目标已参与当前会签");
}
if (queryValidTenantUsers(taskParamDto.getTargetUserIdList(), tenantId).size() != 1) {
return R.failed("委托用户不存在、不属于当前租户或已被锁定");
}
Map<String, Object> safeLocalVariables = new HashMap<>();
if (CollUtil.isNotEmpty(taskParamDto.getTaskLocalParamMap())) {
safeLocalVariables.putAll(taskParamDto.getTaskLocalParamMap());
}
List.of(ProcessInstanceConstant.VAR_ASSIGNMENT_ORIGIN, ProcessInstanceConstant.VAR_INITIAL_USER_ID,
ProcessInstanceConstant.VAR_NODE_RECORD_ID, ProcessInstanceConstant.VAR_ADD_SIGN_RECORD_ID,
ProcessInstanceConstant.VAR_ADDED_BY_USER_ID, ProcessInstanceConstant.VAR_ASSIGNMENT_OPERATION)
.forEach(safeLocalVariables::remove);
taskParamDto.setTaskLocalParamMap(safeLocalVariables);
taskParamDto.setUserId(String.valueOf(userId));
R result = flowableEngineService.delegateTask(taskParamDto);
if (result == null) {
return rollbackFailed("委托任务失败,请刷新后重试");
}
if (!result.isOk()) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
return result;
}
if (findActiveAssignment(task.getId(), targetUserId) == null) {
return rollbackFailed("委托任务记录失败,请刷新后重试");
}
return result;
}
/**
* 加签完成任务
@@ -440,23 +673,74 @@ public class TaskServiceImpl implements ITaskService {
* @return R 操作结果
*/
@Override
@Transactional(rollbackFor = Exception.class)
public R setAssignee(TaskParamDto taskParamDto) {
if (taskParamDto == null || StrUtil.isBlank(taskParamDto.getTaskId())) {
return R.failed("任务ID不能为空");
}
if (taskParamDto.getTargetUserIdList() == null || taskParamDto.getTargetUserIdList().size() != 1
|| taskParamDto.getTargetUserIdList().get(0) == null) {
return R.failed("转办仅支持选择一名用户");
}
long userId = SecurityUtils.getUser().getId();
Long tenantId = TenantContextHolder.getTenantId();
Long targetUserId = taskParamDto.getTargetUserIdList().get(0);
Task task = taskService.createTaskQuery()
.taskId(taskParamDto.getTaskId())
.taskTenantId(tenantId.toString())
.taskAssignee(String.valueOf(userId))
.includeTaskLocalVariables()
.singleResult();
if (task == null) {
return R.failed("当前任务已完成或已转办");
}
ProcessNodeRecordAssignUser sourceAssignment = findActiveAssignment(task.getId(), userId);
ProcessNodeRecord nodeRecord = lockActiveNodeRecord(task, sourceAssignment, tenantId);
if (nodeRecord == null) {
return R.failed("当前审批节点已结束,无法转办");
}
if (listParticipantUserIds(nodeRecord.getId()).contains(targetUserId)) {
return R.failed("转办目标已参与当前会签");
}
if (queryValidTenantUsers(List.of(targetUserId), tenantId).size() != 1) {
return R.failed("转办用户不存在或不属于当前租户");
}
if (sourceAssignment == null) {
return R.failed("未找到当前任务的执行人记录");
}
Map<String, Object> taskLocalParamMap = taskParamDto.getTaskLocalParamMap();
if (CollUtil.isNotEmpty(taskLocalParamMap)) {
taskService.setVariablesLocal(taskParamDto.getTaskId(), taskLocalParamMap);
}
taskService.setVariableLocal(task.getId(), ProcessInstanceConstant.VAR_NODE_RECORD_ID, nodeRecord.getId());
taskService.setVariableLocal(task.getId(), ProcessInstanceConstant.VAR_ASSIGNMENT_ORIGIN,
sourceAssignment.getAssignmentOrigin());
taskService.setVariableLocal(task.getId(), ProcessInstanceConstant.VAR_INITIAL_USER_ID,
sourceAssignment.getInitialUserId());
if (sourceAssignment.getAddSignRecordId() != null) {
taskService.setVariableLocal(task.getId(), ProcessInstanceConstant.VAR_ADD_SIGN_RECORD_ID,
sourceAssignment.getAddSignRecordId());
}
taskService.setVariableLocal(task.getId(), ProcessInstanceConstant.VAR_ASSIGNMENT_OPERATION, "TRANSFER");
// 设置任务的 owner 为当前任务的 assignee
Task task = taskService.createTaskQuery()
.taskId(taskParamDto.getTaskId())
.includeTaskLocalVariables()
.singleResult();
taskService.setOwner(taskParamDto.getTaskId(), task.getAssignee());
// 设置任务的 assignee 为目标用户 (转办用户)
for (Long targetUserId : taskParamDto.getTargetUserIdList()) {
taskService.setAssignee(taskParamDto.getTaskId(), targetUserId.toString());
}
taskService.setAssignee(taskParamDto.getTaskId(), targetUserId.toString());
boolean assignmentRecorded = processNodeRecordAssignUserService.lambdaQuery()
.eq(ProcessNodeRecordAssignUser::getNodeRecordId, nodeRecord.getId())
.eq(ProcessNodeRecordAssignUser::getTaskId, task.getId())
.eq(ProcessNodeRecordAssignUser::getUserId, targetUserId)
.eq(ProcessNodeRecordAssignUser::getStatus, NodeStatusEnum.JXZ.getCode())
.exists();
if (!assignmentRecorded) {
return rollbackFailed("转办任务记录失败,请刷新后重试");
}
// 更新表单
if (CollUtil.isNotEmpty(taskParamDto.getFormData())) {
@@ -624,6 +908,122 @@ public class TaskServiceImpl implements ITaskService {
return R.ok("重新提交成功");
}
private ProcessNodeRecord findActiveNodeRecord(Task task, ProcessNodeRecordAssignUser assignment) {
if (task == null || assignment == null || assignment.getNodeRecordId() == null) {
return null;
}
ProcessNodeRecord nodeRecord = processNodeRecordService.getById(assignment.getNodeRecordId());
if (nodeRecord == null || !Objects.equals(nodeRecord.getStatus(), NodeStatusEnum.JXZ.getCode())
|| !Objects.equals(nodeRecord.getProcessInstanceId(), task.getProcessInstanceId())
|| !Objects.equals(nodeRecord.getNodeId(), task.getTaskDefinitionKey())) {
return null;
}
return nodeRecord;
}
private ProcessNodeRecord lockActiveNodeRecord(Task task, ProcessNodeRecordAssignUser assignment, Long tenantId) {
if (task == null || assignment == null || assignment.getNodeRecordId() == null) {
return null;
}
return processNodeRecordMapper.selectActiveByIdForUpdate(assignment.getNodeRecordId(),
task.getProcessInstanceId(), task.getTaskDefinitionKey(), tenantId);
}
private List<Long> listParticipantUserIds(Long nodeRecordId) {
if (nodeRecordId == null) {
return Collections.emptyList();
}
return processNodeRecordAssignUserService.lambdaQuery()
.eq(ProcessNodeRecordAssignUser::getNodeRecordId, nodeRecordId)
.orderByAsc(ProcessNodeRecordAssignUser::getId)
.list()
.stream()
.map(ProcessNodeRecordAssignUser::getUserId)
.filter(Objects::nonNull)
.distinct()
.toList();
}
private ProcessNodeRecordAssignUser findActiveAssignment(String taskId, long userId) {
return processNodeRecordAssignUserService.lambdaQuery()
.eq(ProcessNodeRecordAssignUser::getTaskId, taskId)
.eq(ProcessNodeRecordAssignUser::getUserId, userId)
.eq(ProcessNodeRecordAssignUser::getStatus, NodeStatusEnum.JXZ.getCode())
.orderByDesc(ProcessNodeRecordAssignUser::getId)
.last("limit 1")
.one();
}
private boolean canAddSign(Task task, Node node, ProcessNodeRecord nodeRecord,
ProcessNodeRecordAssignUser sourceAssignment, long userId) {
if (task == null || node == null || nodeRecord == null || !isParallelCountersign(node)
|| !Boolean.TRUE.equals(node.getAllowAddSign())
|| isProcessStarter(task.getProcessInstanceId(), userId)) {
return false;
}
return isOriginalApprover(task, sourceAssignment, userId);
}
private boolean isParallelCountersign(Node node) {
if (node == null || !Objects.equals(node.getMultipleMode(), ProcessInstanceConstant.MULTIPLE_MODE_AL_SAME)) {
return false;
}
Integer assignedType = node.getAssignedType();
if (assignedType == null) {
return false;
}
return switch (assignedType) {
case ProcessInstanceConstant.AssignedTypeClass.USER -> CollUtil.size(node.getNodeUserList()) > 1;
case ProcessInstanceConstant.AssignedTypeClass.LEADER, ProcessInstanceConstant.AssignedTypeClass.ROLE -> true;
case ProcessInstanceConstant.AssignedTypeClass.SELF_SELECT,
ProcessInstanceConstant.AssignedTypeClass.FORM_USER -> Boolean.TRUE.equals(node.getMultiple());
case 7 -> node.getDeptLeaderLevel() != null && node.getDeptLeaderLevel() > 1;
default -> false;
};
}
private boolean isProcessStarter(String processInstanceId, long userId) {
return processInstanceRecordService.lambdaQuery()
.eq(ProcessInstanceRecord::getProcessInstanceId, processInstanceId)
.eq(ProcessInstanceRecord::getUserId, userId)
.exists();
}
private boolean isOriginalApprover(Task task, ProcessNodeRecordAssignUser assignment, long userId) {
return assignment != null
&& ProcessInstanceConstant.ASSIGNMENT_ORIGIN_ORIGINAL.equals(assignment.getAssignmentOrigin())
&& Objects.equals(assignment.getInitialUserId(), userId)
&& Objects.equals(assignment.getUserId(), userId)
&& Objects.equals(task.getAssignee(), String.valueOf(userId));
}
private List<SysUser> queryValidTenantUsers(List<Long> userIds, Long tenantId) {
try {
R<List<SysUser>> response = remoteUserService.getUserById(userIds);
if (response == null || !response.isOk() || response.getData() == null) {
return Collections.emptyList();
}
Set<Long> requestedIds = new HashSet<>(userIds);
return response.getData()
.stream()
.filter(Objects::nonNull)
.filter(user -> requestedIds.contains(user.getUserId()))
.filter(user -> Objects.equals(user.getTenantId(), tenantId))
.filter(user -> !StrUtil.equals("1", user.getDelFlag()))
.filter(user -> StrUtil.equals("0", user.getLockFlag()))
.toList();
}
catch (Exception exception) {
log.warn("校验工作流目标用户失败: userIds={}", userIds, exception);
return Collections.emptyList();
}
}
private R rollbackFailed(String message) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
return R.failed(message);
}
/**
* 递归获取所有需要停止的子流程实例ID列表
* <p>
@@ -7,6 +7,7 @@ import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.pig4cloud.pigx.common.security.util.SecurityUtils;
import com.pig4cloud.pigx.flow.dto.*;
import com.pig4cloud.pigx.flow.constant.ProcessInstanceConstant;
import com.pig4cloud.pigx.flow.service.IProcessNodeDataService;
import com.pig4cloud.pigx.flow.service.IRemoteService;
import com.pig4cloud.pigx.flow.support.utils.NodeUtil;
@@ -262,12 +263,23 @@ public class FlowProcessEventListener implements FlowableEventListener {
processNodeRecordAssignUserParamDto.setUserId(Long.parseLong(task.getAssignee()));
processNodeRecordAssignUserParamDto.setTaskId(task.getId());
processNodeRecordAssignUserParamDto.setNodeName(task.getName());
processNodeRecordAssignUserParamDto.setNodeRecordId(
Convert.toLong(task.getVariable(ProcessInstanceConstant.VAR_NODE_RECORD_ID), null));
processNodeRecordAssignUserParamDto.setAssignmentOrigin(
Convert.toStr(task.getVariable(ProcessInstanceConstant.VAR_ASSIGNMENT_ORIGIN)));
processNodeRecordAssignUserParamDto.setInitialUserId(
Convert.toLong(task.getVariable(ProcessInstanceConstant.VAR_INITIAL_USER_ID), null));
processNodeRecordAssignUserParamDto.setAddSignRecordId(
Convert.toLong(task.getVariable(ProcessInstanceConstant.VAR_ADD_SIGN_RECORD_ID), null));
// 根据委派状态设置任务类型
String assignmentOperation = Convert
.toStr(task.getVariableLocal(ProcessInstanceConstant.VAR_ASSIGNMENT_OPERATION));
processNodeRecordAssignUserParamDto
.setTaskType(StrUtil.equals(DelegationState.PENDING.toString(), delegationStateString) ? "DELEGATION"
: (StrUtil.equals(DelegationState.RESOLVED.toString(), delegationStateString) ? "RESOLVED"
: ""));
.setTaskType(StrUtil.isNotBlank(assignmentOperation) ? assignmentOperation
: (StrUtil.equals(DelegationState.PENDING.toString(), delegationStateString) ? "DELEGATION"
: (StrUtil.equals(DelegationState.RESOLVED.toString(), delegationStateString) ? "RESOLVED"
: StrUtil.EMPTY)));
processNodeRecordAssignUserParamDto.setApproveDesc(Convert.toStr(task.getVariableLocal("approveDesc")));
processNodeRecordAssignUserParamDto.setExecutionId(task.getExecutionId());
@@ -346,4 +358,4 @@ public class FlowProcessEventListener implements FlowableEventListener {
return null;
}
}
}
@@ -145,7 +145,7 @@ public class MultiInstanceHandler {
if (multipleMode == ProcessInstanceConstant.MULTIPLE_MODE_AL_SAME) {
return BigDecimal.valueOf(nrOfCompletedInstances * 100L)
.compareTo(BigDecimal.valueOf(nrOfCompletedInstances).multiply(modePercentage)) > 0;
.compareTo(BigDecimal.valueOf(nrOfInstances).multiply(modePercentage)) >= 0;
}
return nrOfCompletedInstances == nrOfInstances;
@@ -18,10 +18,13 @@ import com.pig4cloud.pigx.flow.dto.Node;
import com.pig4cloud.pigx.flow.dto.NodeUser;
import com.pig4cloud.pigx.flow.entity.ProcessInstanceRecord;
import com.pig4cloud.pigx.flow.entity.ProcessNodeRecordAssignUser;
import com.pig4cloud.pigx.flow.entity.ProcessTaskAddSignRecord;
import com.pig4cloud.pigx.flow.mapper.ProcessTaskAddSignRecordMapper;
import com.pig4cloud.pigx.flow.service.IProcessInstanceRecordService;
import com.pig4cloud.pigx.flow.service.IProcessNodeRecordAssignUserService;
import com.pig4cloud.pigx.flow.service.IRemoteService;
import com.pig4cloud.pigx.flow.vo.NodeVo;
import com.pig4cloud.pigx.flow.vo.AddSignRecordVo;
import com.pig4cloud.pigx.flow.vo.UserVo;
import lombok.SneakyThrows;
@@ -98,6 +101,17 @@ public class NodeFormatUtil {
if (StrUtil.isNotBlank(processInstanceId)) {
IProcessNodeRecordAssignUserService processNodeRecordAssignUserService = SpringUtil.getBean(IProcessNodeRecordAssignUserService.class);
List<ProcessNodeRecordAssignUser> processNodeRecordAssignUserList = processNodeRecordAssignUserService.lambdaQuery().eq(ProcessNodeRecordAssignUser::getNodeId, node.getId()).eq(ProcessNodeRecordAssignUser::getProcessInstanceId, processInstanceId).orderByAsc(ProcessNodeRecordAssignUser::getCreateTime).list();
ProcessTaskAddSignRecordMapper addSignRecordMapper = SpringUtil
.getBean(ProcessTaskAddSignRecordMapper.class);
List<AddSignRecordVo> addSignRecordList = addSignRecordMapper.selectList(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<ProcessTaskAddSignRecord>()
.eq(ProcessTaskAddSignRecord::getProcessInstanceId, processInstanceId)
.eq(ProcessTaskAddSignRecord::getNodeId, node.getId())
.orderByAsc(ProcessTaskAddSignRecord::getCreateTime))
.stream()
.map(record -> buildAddSignRecord(record, objectMapper()))
.toList();
nodeVo.setAddSignRecordList(addSignRecordList);
Map<String, List<ProcessNodeRecordAssignUser>> map = processNodeRecordAssignUserList.stream().collect(Collectors.groupingBy(ProcessNodeRecordAssignUser::getTaskId));
for (Map.Entry<String, List<ProcessNodeRecordAssignUser>> entry : map.entrySet()) {
@@ -111,7 +125,9 @@ public class NodeFormatUtil {
userVo.setShowTime(w.getEndTime());
userVo.setApproveDesc(w.getApproveDesc());
userVo.setStatus(w.getStatus());
userVo.setOperType(w.getTaskType());
userVo.setOperType(ProcessInstanceConstant.ASSIGNMENT_ORIGIN_ADD_SIGN
.equals(w.getAssignmentOrigin()) ? ProcessInstanceConstant.ASSIGNMENT_ORIGIN_ADD_SIGN
: w.getTaskType());
return userVo;
}).filter(Objects::nonNull).toList();
userVoList.addAll(collect);
@@ -237,6 +253,28 @@ public class NodeFormatUtil {
return list;
}
@SneakyThrows
private static AddSignRecordVo buildAddSignRecord(ProcessTaskAddSignRecord record, ObjectMapper objectMapper) {
AddSignRecordVo recordVo = new AddSignRecordVo();
recordVo.setId(record.getId());
UserVo addedBy = buildUser(record.getAddedByUserId());
recordVo.setAddedByName(addedBy == null ? String.valueOf(record.getAddedByUserId()) : addedBy.getName());
List<Long> targetUserIds = objectMapper.readValue(record.getTargetUserIds(), new TypeReference<>() {
});
recordVo.setTargetUserNames(targetUserIds.stream()
.map(NodeFormatUtil::buildUser)
.filter(Objects::nonNull)
.map(UserVo::getName)
.toList());
recordVo.setReason(record.getReason());
recordVo.setCreateTime(record.getCreateTime());
return recordVo;
}
private static ObjectMapper objectMapper() {
return SpringUtil.getBean(ObjectMapper.class);
}
/**
* 根据流程实例ID构建发起人用户信息
* <p>
@@ -0,0 +1,49 @@
package com.pig4cloud.pigx.flow.controller;
import com.pig4cloud.pigx.common.core.util.R;
import com.pig4cloud.pigx.flow.dto.AddSignTaskDto;
import com.pig4cloud.pigx.flow.service.ITaskService;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class ProcessTaskControllerTests {
@Test
void originalApproverCanRequestBatchAddSignThroughPublicApi() throws Exception {
ITaskService taskService = mock(ITaskService.class);
when(taskService.addSign(org.mockito.ArgumentMatchers.any(AddSignTaskDto.class))).thenReturn(R.ok());
MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new ProcessTaskController(taskService)).build();
mockMvc.perform(post("/task/addSign")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"taskId": "12501",
"targetUserIdList": [31, 32],
"reason": "需要法务和财务共同确认"
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
ArgumentCaptor<AddSignTaskDto> captor = ArgumentCaptor.forClass(AddSignTaskDto.class);
verify(taskService).addSign(captor.capture());
assertThat(captor.getValue().getTaskId()).isEqualTo("12501");
assertThat(captor.getValue().getTargetUserIdList()).containsExactly(31L, 32L);
assertThat(captor.getValue().getReason()).isEqualTo("需要法务和财务共同确认");
}
}
@@ -0,0 +1,67 @@
package com.pig4cloud.pigx.flow.service.impl;
import com.pig4cloud.pigx.common.core.util.R;
import com.pig4cloud.pigx.flow.constant.NodeStatusEnum;
import com.pig4cloud.pigx.flow.dto.ProcessNodeRecordParamDto;
import com.pig4cloud.pigx.flow.entity.ProcessNodeRecord;
import com.baomidou.mybatisplus.core.toolkit.support.SFunction;
import com.baomidou.mybatisplus.extension.conditions.query.LambdaQueryChainWrapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.junit.jupiter.api.extension.ExtendWith;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ProcessNodeRecordServiceImplTests {
@Mock
private LambdaQueryChainWrapper<ProcessNodeRecord> activeRecordQuery;
private ProcessNodeRecordServiceImpl service;
@BeforeEach
void setUp() {
service = spy(new ProcessNodeRecordServiceImpl());
doReturn(activeRecordQuery).when(service).lambdaQuery();
when(activeRecordQuery.eq(any(SFunction.class), any())).thenReturn(activeRecordQuery);
when(activeRecordQuery.orderByAsc(any(SFunction.class))).thenReturn(activeRecordQuery);
when(activeRecordQuery.last(anyString())).thenReturn(activeRecordQuery);
}
@Test
void startReusesActiveRecordForSameProcessNodeRound() {
ProcessNodeRecord existing = new ProcessNodeRecord()
.setId(101L)
.setProcessInstanceId("process-1")
.setNodeId("approve-1")
.setStatus(NodeStatusEnum.JXZ.getCode());
when(activeRecordQuery.one()).thenReturn(existing);
R result = service.start(request());
assertThat(result.getCode()).isZero();
assertThat(result.getData()).isEqualTo(101L);
verify(service, never()).save(any(ProcessNodeRecord.class));
}
private ProcessNodeRecordParamDto request() {
ProcessNodeRecordParamDto request = new ProcessNodeRecordParamDto();
request.setFlowId("flow-1");
request.setProcessInstanceId("process-1");
request.setNodeId("approve-1");
request.setNodeName("会签审批");
request.setExecutionId("execution-2");
return request;
}
}
@@ -0,0 +1,138 @@
package com.pig4cloud.pigx.flow.service.impl;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.extension.conditions.query.LambdaQueryChainWrapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.pig4cloud.pigx.admin.api.entity.SysUser;
import com.pig4cloud.pigx.admin.api.feign.RemoteUserService;
import com.pig4cloud.pigx.common.core.util.R;
import com.pig4cloud.pigx.common.data.tenant.TenantContextHolder;
import com.pig4cloud.pigx.common.security.service.PigxUser;
import com.pig4cloud.pigx.flow.constant.NodeStatusEnum;
import com.pig4cloud.pigx.flow.constant.ProcessInstanceConstant;
import com.pig4cloud.pigx.flow.dto.TaskParamDto;
import com.pig4cloud.pigx.flow.entity.ProcessNodeRecord;
import com.pig4cloud.pigx.flow.entity.ProcessNodeRecordAssignUser;
import com.pig4cloud.pigx.flow.mapper.ProcessNodeRecordMapper;
import com.pig4cloud.pigx.flow.mapper.ProcessTaskAddSignRecordMapper;
import com.pig4cloud.pigx.flow.service.IFlowableEngineService;
import com.pig4cloud.pigx.flow.service.IProcessCopyService;
import com.pig4cloud.pigx.flow.service.IProcessInstanceRecordService;
import com.pig4cloud.pigx.flow.service.IProcessNodeDataService;
import com.pig4cloud.pigx.flow.service.IProcessNodeRecordAssignUserService;
import com.pig4cloud.pigx.flow.service.IProcessNodeRecordService;
import com.pig4cloud.pigx.flow.service.IProcessService;
import org.flowable.engine.HistoryService;
import org.flowable.engine.RuntimeService;
import org.flowable.engine.TaskService;
import org.flowable.task.api.Task;
import org.flowable.task.api.TaskQuery;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Answers;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class TaskServiceDelegateParticipationTests {
private static final long USER_ID = 11L;
private static final long TARGET_USER_ID = 31L;
private static final long TENANT_ID = 1L;
private static final long NODE_RECORD_ID = 101L;
private static final String TASK_ID = "task-1";
private static final String PROCESS_INSTANCE_ID = "process-1";
private static final String NODE_ID = "approve-1";
@Mock private IProcessService processService;
@Mock private IProcessCopyService processCopyService;
@Mock private IProcessNodeDataService nodeDataService;
@Mock private IProcessNodeRecordAssignUserService processNodeRecordAssignUserService;
@Mock private IProcessInstanceRecordService processInstanceRecordService;
@Mock private IProcessNodeRecordService processNodeRecordService;
@Mock private IFlowableEngineService flowableEngineService;
@Mock private TaskService taskService;
@Mock private HistoryService historyService;
@Mock private RuntimeService runtimeService;
@Mock private ObjectMapper objectMapper;
@Mock private RemoteUserService remoteUserService;
@Mock private ProcessNodeRecordMapper processNodeRecordMapper;
@Mock private ProcessTaskAddSignRecordMapper processTaskAddSignRecordMapper;
@Mock(answer = Answers.RETURNS_SELF) private TaskQuery taskQuery;
@Mock private Task task;
@Mock private BaseMapper<ProcessNodeRecordAssignUser> assignmentBaseMapper;
@InjectMocks private TaskServiceImpl taskServiceImpl;
@BeforeEach
void setCurrentUserAndTenant() {
PigxUser user = new PigxUser(USER_ID, "approver", List.of(), null, List.of(), List.of(), null, null,
"审批人", "审批人", null, TENANT_ID, "password", true, true, "sys", true,
LocalDateTime.now(), true, List.of());
SecurityContextHolder.getContext()
.setAuthentication(UsernamePasswordAuthenticationToken.authenticated(user, "password", List.of()));
TenantContextHolder.setTenantId(TENANT_ID);
}
@AfterEach
void clearCurrentUserAndTenant() {
SecurityContextHolder.clearContext();
TenantContextHolder.clear();
}
@Test
void delegateRejectsUserAlreadyParticipatingInCurrentRound() {
when(taskService.createTaskQuery()).thenReturn(taskQuery);
when(taskQuery.singleResult()).thenReturn(task);
when(task.getId()).thenReturn(TASK_ID);
when(task.getProcessInstanceId()).thenReturn(PROCESS_INSTANCE_ID);
when(task.getTaskDefinitionKey()).thenReturn(NODE_ID);
when(processNodeRecordAssignUserService.lambdaQuery())
.thenAnswer(invocation -> new LambdaQueryChainWrapper<>(assignmentBaseMapper));
when(assignmentBaseMapper.selectOne(any())).thenReturn(originalAssignment());
when(assignmentBaseMapper.selectList(any())).thenReturn(List.of(originalAssignment(), targetAssignment()));
when(processNodeRecordMapper.selectActiveByIdForUpdate(NODE_RECORD_ID, PROCESS_INSTANCE_ID, NODE_ID, TENANT_ID))
.thenReturn(new ProcessNodeRecord().setId(NODE_RECORD_ID));
TaskParamDto request = new TaskParamDto();
request.setTaskId(TASK_ID);
request.setTargetUserIdList(List.of(TARGET_USER_ID));
R result = taskServiceImpl.delegateTask(request);
assertThat(result.getCode()).isEqualTo(1);
assertThat(result.getMsg()).isEqualTo("委托目标已参与当前会签");
verify(flowableEngineService, never()).delegateTask(any(TaskParamDto.class));
}
private ProcessNodeRecordAssignUser originalAssignment() {
return new ProcessNodeRecordAssignUser()
.setNodeRecordId(NODE_RECORD_ID)
.setTaskId(TASK_ID)
.setUserId(USER_ID)
.setInitialUserId(USER_ID)
.setAssignmentOrigin(ProcessInstanceConstant.ASSIGNMENT_ORIGIN_ORIGINAL)
.setStatus(NodeStatusEnum.JXZ.getCode());
}
private ProcessNodeRecordAssignUser targetAssignment() {
return new ProcessNodeRecordAssignUser()
.setNodeRecordId(NODE_RECORD_ID)
.setTaskId("task-2")
.setUserId(TARGET_USER_ID)
.setStatus(NodeStatusEnum.JXZ.getCode());
}
}
@@ -0,0 +1,405 @@
package com.pig4cloud.pigx.flow.service.impl;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.extension.conditions.query.LambdaQueryChainWrapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.pig4cloud.pigx.admin.api.entity.SysUser;
import com.pig4cloud.pigx.admin.api.feign.RemoteUserService;
import com.pig4cloud.pigx.common.core.util.R;
import com.pig4cloud.pigx.common.data.tenant.TenantContextHolder;
import com.pig4cloud.pigx.common.security.service.PigxUser;
import com.pig4cloud.pigx.flow.constant.NodeStatusEnum;
import com.pig4cloud.pigx.flow.constant.ProcessInstanceConstant;
import com.pig4cloud.pigx.flow.dto.AddSignResultDto;
import com.pig4cloud.pigx.flow.dto.AddSignTaskDto;
import com.pig4cloud.pigx.flow.dto.Node;
import com.pig4cloud.pigx.flow.entity.ProcessInstanceRecord;
import com.pig4cloud.pigx.flow.entity.ProcessNodeRecord;
import com.pig4cloud.pigx.flow.entity.ProcessNodeRecordAssignUser;
import com.pig4cloud.pigx.flow.entity.ProcessTaskAddSignRecord;
import com.pig4cloud.pigx.flow.mapper.ProcessNodeRecordMapper;
import com.pig4cloud.pigx.flow.mapper.ProcessTaskAddSignRecordMapper;
import com.pig4cloud.pigx.flow.service.IFlowableEngineService;
import com.pig4cloud.pigx.flow.service.IProcessCopyService;
import com.pig4cloud.pigx.flow.service.IProcessInstanceRecordService;
import com.pig4cloud.pigx.flow.service.IProcessNodeDataService;
import com.pig4cloud.pigx.flow.service.IProcessNodeRecordAssignUserService;
import com.pig4cloud.pigx.flow.service.IProcessNodeRecordService;
import com.pig4cloud.pigx.flow.service.IProcessService;
import com.pig4cloud.pigx.flow.service.ITaskService;
import org.flowable.common.engine.api.FlowableOptimisticLockingException;
import org.flowable.engine.HistoryService;
import org.flowable.engine.RuntimeService;
import org.flowable.engine.TaskService;
import org.flowable.engine.runtime.Execution;
import org.flowable.task.api.Task;
import org.flowable.task.api.TaskQuery;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Answers;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import org.springframework.transaction.support.AbstractPlatformTransactionManager;
import org.springframework.transaction.support.DefaultTransactionStatus;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class TaskServiceImplTests {
private static final long USER_ID = 11L;
private static final long TENANT_ID = 1L;
private static final long TARGET_USER_ID = 31L;
private static final long NODE_RECORD_ID = 101L;
private static final long ADD_SIGN_RECORD_ID = 901L;
private static final String TASK_ID = "12501";
private static final String ADDED_TASK_ID = "12502";
private static final String FLOW_ID = "flow-1";
private static final String PROCESS_DEFINITION_ID = FLOW_ID + ":1:deployment";
private static final String PROCESS_INSTANCE_ID = "process-1";
private static final String NODE_ID = "approve-1";
@Mock
private IProcessService processService;
@Mock
private IProcessCopyService processCopyService;
@Mock
private IProcessNodeDataService nodeDataService;
@Mock
private IProcessNodeRecordAssignUserService processNodeRecordAssignUserService;
@Mock
private IProcessInstanceRecordService processInstanceRecordService;
@Mock
private IProcessNodeRecordService processNodeRecordService;
@Mock
private IFlowableEngineService flowableEngineService;
@Mock
private TaskService taskService;
@Mock
private HistoryService historyService;
@Mock
private RuntimeService runtimeService;
@Spy
private ObjectMapper objectMapper = new ObjectMapper();
@Mock
private RemoteUserService remoteUserService;
@Mock
private ProcessNodeRecordMapper processNodeRecordMapper;
@Mock
private ProcessTaskAddSignRecordMapper processTaskAddSignRecordMapper;
@Mock(answer = Answers.RETURNS_SELF)
private TaskQuery taskQuery;
@Mock
private BaseMapper<ProcessInstanceRecord> processInstanceBaseMapper;
@Mock
private BaseMapper<ProcessNodeRecordAssignUser> assignmentBaseMapper;
@Mock
private Task sourceTask;
@Mock
private Task addedTask;
@Mock
private Execution addedExecution;
@InjectMocks
private TaskServiceImpl taskServiceImpl;
@BeforeEach
void setCurrentUserAndTenant() {
PigxUser user = new PigxUser(USER_ID, "approver", List.of(), null, List.of(), List.of(), null, null,
"审批人", "审批人", null, TENANT_ID, "password", true, true, "sys", true,
LocalDateTime.now(), true, List.of());
SecurityContextHolder.getContext()
.setAuthentication(UsernamePasswordAuthenticationToken.authenticated(user, "password", List.of()));
TenantContextHolder.setTenantId(TENANT_ID);
}
@AfterEach
void clearCurrentUserAndTenant() {
SecurityContextHolder.clearContext();
TenantContextHolder.clear();
}
@Test
void addSignRejectsEmptyTargetBatch() {
AddSignTaskDto request = request(List.of(), "需要共同确认");
R result = taskServiceImpl.addSign(request);
assertThat(result.getCode()).isEqualTo(1);
assertThat(result.getMsg()).isEqualTo("请选择加签人员");
}
@Test
void addSignRejectsDuplicateTargetUsers() {
AddSignTaskDto request = request(List.of(31L, 31L), "需要共同确认");
R result = taskServiceImpl.addSign(request);
assertThat(result.getCode()).isEqualTo(1);
assertThat(result.getMsg()).isEqualTo("加签人员不能重复");
}
@Test
void addSignRejectsReasonOutsideLengthLimit() {
AddSignTaskDto request = request(List.of(31L), "短");
R result = taskServiceImpl.addSign(request);
assertThat(result.getCode()).isEqualTo(1);
assertThat(result.getMsg()).isEqualTo("加签原因需为 2~100 字");
}
@Test
void addSignCreatesTaskAndAuditSnapshotForEligibleApprover() {
prepareTaskAndNode(addedTask);
prepareOriginalApprover();
prepareTargetValidation(List.of(originalAssignment()), validUser(TARGET_USER_ID, TENANT_ID));
prepareAuditInsert();
when(runtimeService.addMultiInstanceExecution(eq(NODE_ID), eq(PROCESS_INSTANCE_ID), anyMap()))
.thenReturn(addedExecution);
when(assignmentBaseMapper.selectCount(any())).thenReturn(1L);
R result = taskServiceImpl.addSign(request(List.of(TARGET_USER_ID), "需要共同确认"));
assertThat(result.getCode()).isZero();
assertThat(result.getData()).isInstanceOf(AddSignResultDto.class);
AddSignResultDto addSignResult = (AddSignResultDto) result.getData();
assertThat(addSignResult.getRecordId()).isEqualTo(ADD_SIGN_RECORD_ID);
assertThat(addSignResult.getTasks()).hasSize(1);
assertThat(addSignResult.getTasks().get(0).getTaskId()).isEqualTo(ADDED_TASK_ID);
assertThat(addSignResult.getTasks().get(0).getUserId()).isEqualTo(TARGET_USER_ID);
ArgumentCaptor<ProcessTaskAddSignRecord> recordCaptor = ArgumentCaptor
.forClass(ProcessTaskAddSignRecord.class);
verify(processTaskAddSignRecordMapper).insert(recordCaptor.capture());
ProcessTaskAddSignRecord record = recordCaptor.getValue();
assertThat(record.getSourceTaskId()).isEqualTo(TASK_ID);
assertThat(record.getAddedByUserId()).isEqualTo(USER_ID);
assertThat(record.getTargetUserIds()).isEqualTo("[31]");
assertThat(record.getParticipantUserIdsBefore()).isEqualTo("[11]");
assertThat(record.getParticipantUserIdsAfter()).isEqualTo("[11,31]");
verify(processTaskAddSignRecordMapper).updateById(record);
verify(processNodeRecordMapper).selectActiveByIdForUpdate(NODE_RECORD_ID, PROCESS_INSTANCE_ID, NODE_ID,
TENANT_ID);
verify(runtimeService).addMultiInstanceExecution(eq(NODE_ID), eq(PROCESS_INSTANCE_ID),
argThat(variables -> Long.valueOf(TARGET_USER_ID).equals(variables.get(NODE_ID + "_assignee_temp"))
&& Long.valueOf(ADD_SIGN_RECORD_ID)
.equals(variables.get(ProcessInstanceConstant.VAR_ADD_SIGN_RECORD_ID))));
}
@Test
void addSignRejectsProcessStarterEvenWhenStarterOwnsTask() {
prepareTaskAndNode(null);
prepareActiveAssignment();
when(processInstanceRecordService.lambdaQuery())
.thenAnswer(invocation -> new LambdaQueryChainWrapper<>(processInstanceBaseMapper));
when(processInstanceBaseMapper.selectCount(any())).thenReturn(1L);
R result = taskServiceImpl.addSign(request(List.of(TARGET_USER_ID), "需要共同确认"));
assertThat(result.getCode()).isEqualTo(1);
assertThat(result.getMsg()).isEqualTo("流程发起人不能发起加签");
verify(processTaskAddSignRecordMapper, never()).insert(any(ProcessTaskAddSignRecord.class));
}
@Test
void addSignRejectsTargetUserFromAnotherTenant() {
prepareTaskAndNode(null);
prepareOriginalApprover();
prepareTargetValidation(List.of(originalAssignment()), validUser(TARGET_USER_ID, TENANT_ID + 1));
R result = taskServiceImpl.addSign(request(List.of(TARGET_USER_ID), "需要共同确认"));
assertThat(result.getCode()).isEqualTo(1);
assertThat(result.getMsg()).isEqualTo("加签用户不存在或不属于当前租户");
verify(processTaskAddSignRecordMapper, never()).insert(any(ProcessTaskAddSignRecord.class));
}
@Test
void addSignRejectsUserAlreadyParticipatingInCurrentCountersign() {
prepareTaskAndNode(null);
prepareOriginalApprover();
ProcessNodeRecordAssignUser existingParticipant = new ProcessNodeRecordAssignUser()
.setNodeRecordId(NODE_RECORD_ID)
.setUserId(TARGET_USER_ID);
prepareTargetValidation(List.of(originalAssignment(), existingParticipant),
validUser(TARGET_USER_ID, TENANT_ID));
R result = taskServiceImpl.addSign(request(List.of(TARGET_USER_ID), "需要共同确认"));
assertThat(result.getCode()).isEqualTo(1);
assertThat(result.getMsg()).isEqualTo("用户已参与当前会签:目标审批人");
verify(processTaskAddSignRecordMapper, never()).insert(any(ProcessTaskAddSignRecord.class));
}
@Test
void addSignRollsBackAuditWhenFlowableReportsConcurrentChange() {
prepareTaskAndNode(null);
prepareOriginalApprover();
prepareTargetValidation(List.of(originalAssignment()), validUser(TARGET_USER_ID, TENANT_ID));
prepareAuditInsert();
when(runtimeService.addMultiInstanceExecution(anyString(), anyString(), anyMap()))
.thenThrow(new FlowableOptimisticLockingException("conflict"));
RecordingTransactionManager transactionManager = new RecordingTransactionManager();
R result = transactionalService(transactionManager)
.addSign(request(List.of(TARGET_USER_ID), "需要共同确认"));
assertThat(result.getCode()).isEqualTo(1);
assertThat(result.getMsg()).isEqualTo("节点状态已变化,请刷新后重试");
assertThat(transactionManager.rolledBack).isTrue();
assertThat(transactionManager.committed).isFalse();
verify(processTaskAddSignRecordMapper).insert(any(ProcessTaskAddSignRecord.class));
verify(processTaskAddSignRecordMapper, never()).updateById(any(ProcessTaskAddSignRecord.class));
}
private void prepareTaskAndNode(Task taskCreatedAfterAddSign) {
when(taskService.createTaskQuery()).thenReturn(taskQuery);
if (taskCreatedAfterAddSign == null) {
when(taskQuery.singleResult()).thenReturn(sourceTask);
}
else {
when(taskQuery.singleResult()).thenReturn(sourceTask, taskCreatedAfterAddSign);
}
when(sourceTask.getProcessInstanceId()).thenReturn(PROCESS_INSTANCE_ID);
when(sourceTask.getTaskDefinitionKey()).thenReturn(NODE_ID);
when(sourceTask.getProcessDefinitionId()).thenReturn(PROCESS_DEFINITION_ID);
if (taskCreatedAfterAddSign != null) {
when(taskCreatedAfterAddSign.getId()).thenReturn(ADDED_TASK_ID);
}
ProcessNodeRecord nodeRecord = new ProcessNodeRecord().setId(NODE_RECORD_ID);
when(processNodeRecordMapper.selectActiveByIdForUpdate(NODE_RECORD_ID, PROCESS_INSTANCE_ID, NODE_ID,
TENANT_ID))
.thenReturn(nodeRecord);
Node node = new Node();
node.setAssignedType(ProcessInstanceConstant.AssignedTypeClass.ROLE);
node.setMultipleMode(ProcessInstanceConstant.MULTIPLE_MODE_AL_SAME);
node.setAllowAddSign(true);
when(nodeDataService.getNodeData(FLOW_ID, NODE_ID)).thenReturn(R.ok(node));
}
private void prepareOriginalApprover() {
prepareActiveAssignment();
when(sourceTask.getAssignee()).thenReturn(String.valueOf(USER_ID));
when(processInstanceRecordService.lambdaQuery())
.thenAnswer(invocation -> new LambdaQueryChainWrapper<>(processInstanceBaseMapper));
}
private void prepareActiveAssignment() {
when(sourceTask.getId()).thenReturn(TASK_ID);
when(processNodeRecordAssignUserService.lambdaQuery())
.thenAnswer(invocation -> new LambdaQueryChainWrapper<>(assignmentBaseMapper));
when(assignmentBaseMapper.selectOne(any())).thenReturn(originalAssignment());
}
private void prepareTargetValidation(List<ProcessNodeRecordAssignUser> participants, SysUser targetUser) {
when(assignmentBaseMapper.selectList(any())).thenReturn(participants);
when(remoteUserService.getUserById(List.of(TARGET_USER_ID))).thenReturn(R.ok(List.of(targetUser)));
}
private void prepareAuditInsert() {
when(processTaskAddSignRecordMapper.insert(any(ProcessTaskAddSignRecord.class))).thenAnswer(invocation -> {
ProcessTaskAddSignRecord record = invocation.getArgument(0);
record.setId(ADD_SIGN_RECORD_ID);
return 1;
});
}
private ProcessNodeRecordAssignUser originalAssignment() {
return new ProcessNodeRecordAssignUser()
.setNodeRecordId(NODE_RECORD_ID)
.setTaskId(TASK_ID)
.setUserId(USER_ID)
.setInitialUserId(USER_ID)
.setAssignmentOrigin(ProcessInstanceConstant.ASSIGNMENT_ORIGIN_ORIGINAL)
.setStatus(NodeStatusEnum.JXZ.getCode());
}
private SysUser validUser(long userId, long tenantId) {
SysUser user = new SysUser();
user.setUserId(userId);
user.setUsername("target");
user.setName("目标审批人");
user.setTenantId(tenantId);
user.setDelFlag("0");
user.setLockFlag("0");
return user;
}
private ITaskService transactionalService(RecordingTransactionManager transactionManager) {
ProxyFactory proxyFactory = new ProxyFactory(taskServiceImpl);
proxyFactory.addInterface(ITaskService.class);
proxyFactory.addAdvice(new TransactionInterceptor(transactionManager,
new AnnotationTransactionAttributeSource()));
return (ITaskService) proxyFactory.getProxy();
}
private AddSignTaskDto request(List<Long> userIds, String reason) {
AddSignTaskDto request = new AddSignTaskDto();
request.setTaskId(TASK_ID);
request.setTargetUserIdList(userIds);
request.setReason(reason);
return request;
}
private static final class RecordingTransactionManager extends AbstractPlatformTransactionManager {
private boolean committed;
private boolean rolledBack;
@Override
protected Object doGetTransaction() {
return new Object();
}
@Override
protected void doBegin(Object transaction, TransactionDefinition definition) {
}
@Override
protected void doCommit(DefaultTransactionStatus status) {
committed = true;
}
@Override
protected void doRollback(DefaultTransactionStatus status) {
rolledBack = true;
}
}
}
@@ -0,0 +1,40 @@
package com.pig4cloud.pigx.flow.support.node;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.pig4cloud.pigx.common.core.util.R;
import com.pig4cloud.pigx.flow.constant.ProcessInstanceConstant;
import com.pig4cloud.pigx.flow.dto.Node;
import com.pig4cloud.pigx.flow.service.IProcessNodeDataService;
import org.flowable.engine.impl.persistence.entity.ExecutionEntityImpl;
import org.junit.jupiter.api.Test;
import java.util.Collections;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class MultiInstanceHandlerTests {
@Test
void countersignWaitsForDynamicallyExpandedInstanceCount() {
IProcessNodeDataService nodeDataService = mock(IProcessNodeDataService.class);
Node node = new Node();
node.setMultipleMode(ProcessInstanceConstant.MULTIPLE_MODE_AL_SAME);
when(nodeDataService.getNodeData("flow-1", "approve-1")).thenReturn(R.ok(node));
MultiInstanceHandler handler = new MultiInstanceHandler(nodeDataService, Collections.emptyMap(),
new ObjectMapper());
ExecutionEntityImpl execution = mock(ExecutionEntityImpl.class);
when(execution.getProcessDefinitionKey()).thenReturn("flow-1");
when(execution.getCurrentActivityId()).thenReturn("approve-1");
when(execution.getVariable("approve-1_approve_condition")).thenReturn(true);
when(execution.getVariable("nrOfInstances")).thenReturn(3);
when(execution.getVariable("nrOfCompletedInstances")).thenReturn(2);
assertThat(handler.completionCondition(execution)).isFalse();
when(execution.getVariable("nrOfCompletedInstances")).thenReturn(3);
assertThat(handler.completionCondition(execution)).isTrue();
}
}