merge: t20 审批中心待办已办与通过驳回撤回

This commit is contained in:
2026-10-30 20:46:27 +08:00
10 changed files with 1006 additions and 1 deletions
@@ -0,0 +1,88 @@
package com.gxwebsoft.approval.controller;
import com.gxwebsoft.approval.param.ApprovalTaskActionParam;
import com.gxwebsoft.approval.param.ApprovalTaskListParam;
import com.gxwebsoft.approval.service.ApprovalInstanceService;
import com.gxwebsoft.approval.service.ApprovalTaskService;
import com.gxwebsoft.approval.vo.ApprovalInstanceVO;
import com.gxwebsoft.approval.vo.ApprovalItemVO;
import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.BaseController;
import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.common.system.entity.User;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
/**
* 审批待办与任务动作接口(员工端与 PC 共用)。
*/
@Api(tags = "审批待办")
@RestController
@RequestMapping("/api/approval")
public class ApprovalTaskController extends BaseController {
@Resource
private ApprovalTaskService taskService;
@Resource
private ApprovalInstanceService instanceService;
@PreAuthorize("hasAuthority('approval:instance:list')")
@ApiOperation("我的待办")
@GetMapping("/todo/page")
public ApiResult<PageResult<ApprovalItemVO>> todo(ApprovalTaskListParam param) {
return success(instanceService.todo(param, getLoginUser()));
}
@PreAuthorize("hasAuthority('approval:instance:list')")
@ApiOperation("我的已办")
@GetMapping("/done/page")
public ApiResult<PageResult<ApprovalItemVO>> done(ApprovalTaskListParam param) {
return success(instanceService.done(param, getLoginUser()));
}
@PreAuthorize("hasAuthority('approval:instance:list')")
@ApiOperation("待办数量")
@GetMapping("/todo/count")
public ApiResult<Integer> todoCount() {
return success(taskService.todoCount(getLoginUser()));
}
@PreAuthorize("hasAuthority('approval:instance:approve')")
@ApiOperation("通过")
@PostMapping("/task/{id}/approve")
public ApiResult<ApprovalInstanceVO> approve(@PathVariable("id") Long id,
@RequestBody(required = false) ApprovalTaskActionParam param) {
User user = getLoginUser();
ApprovalTaskService.Result result = taskService.approve(id, param == null ? null : param.getOpinion(), user);
return success(result.getWarning() == null ? "审批成功" : result.getWarning(),
instanceService.detail(result.getInstanceId(), user));
}
@PreAuthorize("hasAuthority('approval:instance:approve')")
@ApiOperation("驳回")
@PostMapping("/task/{id}/reject")
public ApiResult<ApprovalInstanceVO> reject(@PathVariable("id") Long id,
@RequestBody(required = false) ApprovalTaskActionParam param) {
User user = getLoginUser();
ApprovalTaskService.Result result = taskService.reject(id, param == null ? null : param.getOpinion(), user);
return success("驳回成功", instanceService.detail(result.getInstanceId(), user));
}
@PreAuthorize("hasAuthority('approval:instance:submit')")
@ApiOperation("撤回")
@PostMapping("/instance/{id}/withdraw")
public ApiResult<ApprovalInstanceVO> withdraw(@PathVariable("id") Long id) {
User user = getLoginUser();
ApprovalTaskService.Result result = taskService.withdraw(id, user);
return success("撤回成功", instanceService.detail(result.getInstanceId(), user));
}
}
@@ -1,10 +1,29 @@
package com.gxwebsoft.approval.mapper; package com.gxwebsoft.approval.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.gxwebsoft.approval.entity.ApprovalTask; import com.gxwebsoft.approval.entity.ApprovalTask;
import com.gxwebsoft.approval.param.ApprovalTaskListParam;
import com.gxwebsoft.approval.support.ApprovalItemRow;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/** /**
* 审批任务Mapper。 * 审批任务Mapper。
*/ */
public interface ApprovalTaskMapper extends BaseMapper<ApprovalTask> { public interface ApprovalTaskMapper extends BaseMapper<ApprovalTask> {
/**
* 待办/已办列表:按本人任务状态取实例,天然按实例去重。
*/
List<ApprovalItemRow> selectTaskInstancePage(@Param("page") IPage<ApprovalItemRow> page,
@Param("param") ApprovalTaskListParam param,
@Param("userId") Integer userId,
@Param("taskStatus") String taskStatus);
/**
* 本人待办实例数量(同一实例多任务只算一条)。
*/
Integer countPendingInstances(@Param("userId") Integer userId);
} }
@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.gxwebsoft.approval.mapper.ApprovalTaskMapper">
<select id="selectTaskInstancePage" resultType="com.gxwebsoft.approval.support.ApprovalItemRow">
SELECT i.id,
i.biz_no,
i.doc_type,
i.status,
i.reporter_id,
i.reporter_name,
i.report_org_name,
i.accept_org_name,
i.archive_time,
i.current_node_key,
i.definition_id,
i.submit_time,
COALESCE(f.name, co.title) AS title
FROM approval_instance i
LEFT JOIN approval_completion_form f ON f.instance_id = i.id
LEFT JOIN approval_coordination_form co ON co.instance_id = i.id
WHERE i.id IN (
SELECT t.instance_id
FROM approval_task t
WHERE t.assignee_id = #{userId}
AND t.status = #{taskStatus}
)
<if test="param.status != null and param.status != ''">
AND i.status = #{param.status}
</if>
<if test="param.docType != null and param.docType != ''">
AND i.doc_type = #{param.docType}
</if>
<if test="param.keywords != null and param.keywords != ''">
AND (i.biz_no LIKE CONCAT('%', #{param.keywords}, '%')
OR f.name LIKE CONCAT('%', #{param.keywords}, '%')
OR co.title LIKE CONCAT('%', #{param.keywords}, '%')
OR f.implement_org LIKE CONCAT('%', #{param.keywords}, '%'))
</if>
ORDER BY (i.submit_time IS NULL) ASC, i.submit_time DESC, i.id DESC
</select>
<select id="countPendingInstances" resultType="java.lang.Integer">
SELECT COUNT(DISTINCT instance_id)
FROM approval_task
WHERE assignee_id = #{userId}
AND status = 'PENDING'
</select>
</mapper>
@@ -0,0 +1,17 @@
package com.gxwebsoft.approval.param;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
/**
* 任务动作参数(通过/驳回)。
*/
@Data
public class ApprovalTaskActionParam implements Serializable {
private static final long serialVersionUID = 1L;
@ApiModelProperty("处理意见;驳回必填")
private String opinion;
}
@@ -0,0 +1,20 @@
package com.gxwebsoft.approval.param;
import com.gxwebsoft.common.core.web.BaseParam;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 待办/已办列表查询参数。
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class ApprovalTaskListParam extends BaseParam {
@ApiModelProperty("实例状态")
private String status;
@ApiModelProperty("单据类型")
private String docType;
}
@@ -158,6 +158,24 @@ public class ApprovalDirectoryService {
return jdbcTemplate.query(sql, USER_MAPPER, args.toArray()); return jdbcTemplate.query(sql, USER_MAPPER, args.toArray());
} }
/** 按id批量取启用用户;停用或已删除的用户不返回(节点缺配时不生成任务)。 */
public List<ApprovalUserVO> listActiveUsersByIds(List<Integer> userIds) {
if (userIds == null || userIds.isEmpty()) {
return Collections.emptyList();
}
Integer tenantId = ApprovalTenantContext.currentTenantId();
String placeholders = String.join(",", Collections.nCopies(userIds.size(), "?"));
String sql = "SELECT u.user_id, u.username, u.nickname, u.real_name, u.organization_id, o.organization_name "
+ "FROM sys_user u "
+ "LEFT JOIN sys_organization o ON o.organization_id = u.organization_id AND o.tenant_id = ? "
+ "WHERE u.tenant_id = ? AND u.status = 0 AND u.deleted = 0 AND u.user_id IN (" + placeholders + ")";
List<Object> args = new java.util.ArrayList<>();
args.add(tenantId);
args.add(tenantId);
args.addAll(userIds);
return jdbcTemplate.query(sql, USER_MAPPER, args.toArray());
}
/** 是否持有任一角色(用于 allVisible 判断)。 */ /** 是否持有任一角色(用于 allVisible 判断)。 */
public boolean hasAnyRole(Integer userId, String... roleCodes) { public boolean hasAnyRole(Integer userId, String... roleCodes) {
if (userId == null || roleCodes == null || roleCodes.length == 0) { if (userId == null || roleCodes == null || roleCodes.length == 0) {
@@ -63,6 +63,22 @@ public class ApprovalEngineService {
return createTasks(instance, node, instance.getRound(), approvers); return createTasks(instance, node, instance.getRound(), approvers);
} }
/**
* 当前节点处理完成后推进到下一节点:更新当前节点并快照生成任务。
*
* <p>与 {@link #activateNode} 的区别:处理记录已由调用方按任务补充 taskId,
* 因此这里只更新节点与任务,不重复写处理记录。</p>
*
* @return 生成的任务数;0 表示节点缺配
*/
public int moveToNode(ApprovalInstance instance, ApprovalNode node, Integer round,
List<ApprovalApprover> approvers) {
instance.setCurrentNodeKey(node.getKey());
instance.setCurrentNodeType(node.getType());
instanceMapper.updateById(instance);
return createTasks(instance, node, round, approvers);
}
/** /**
* 对实例行加锁,串行化节点推进。 * 对实例行加锁,串行化节点推进。
*/ */
@@ -17,6 +17,7 @@ import com.gxwebsoft.approval.mapper.ApprovalRecordMapper;
import com.gxwebsoft.approval.mapper.ApprovalTaskMapper; import com.gxwebsoft.approval.mapper.ApprovalTaskMapper;
import com.gxwebsoft.approval.param.ApprovalCompletionParam; import com.gxwebsoft.approval.param.ApprovalCompletionParam;
import com.gxwebsoft.approval.param.ApprovalMineParam; import com.gxwebsoft.approval.param.ApprovalMineParam;
import com.gxwebsoft.approval.param.ApprovalTaskListParam;
import com.gxwebsoft.approval.support.ApprovalApprover; import com.gxwebsoft.approval.support.ApprovalApprover;
import com.gxwebsoft.approval.support.ApprovalItemRow; import com.gxwebsoft.approval.support.ApprovalItemRow;
import com.gxwebsoft.approval.support.ApprovalNode; import com.gxwebsoft.approval.support.ApprovalNode;
@@ -216,6 +217,32 @@ public class ApprovalInstanceService {
long size = param.getLimit() == null ? 10 : Math.max(1, param.getLimit()); long size = param.getLimit() == null ? 10 : Math.max(1, param.getLimit());
Page<ApprovalItemRow> page = new Page<>(current, size); Page<ApprovalItemRow> page = new Page<>(current, size);
List<ApprovalItemRow> rows = instanceMapper.selectMinePage(page, param, user.getUserId()); List<ApprovalItemRow> rows = instanceMapper.selectMinePage(page, param, user.getUserId());
return new PageResult<>(toItems(rows), page.getTotal());
}
/**
* 我的待办:本人 PENDING 任务对应的实例,按实例去重。
*/
public PageResult<ApprovalItemVO> todo(ApprovalTaskListParam param, User user) {
return taskPage(param, user, ApprovalConstants.TASK_PENDING);
}
/**
* 我的已办:本人 DONE 任务对应的实例,按实例去重。
*/
public PageResult<ApprovalItemVO> done(ApprovalTaskListParam param, User user) {
return taskPage(param, user, ApprovalConstants.TASK_DONE);
}
private PageResult<ApprovalItemVO> taskPage(ApprovalTaskListParam param, User user, String taskStatus) {
long current = param.getPage() == null ? 1 : Math.max(1, param.getPage());
long size = param.getLimit() == null ? 10 : Math.max(1, param.getLimit());
Page<ApprovalItemRow> page = new Page<>(current, size);
List<ApprovalItemRow> rows = taskMapper.selectTaskInstancePage(page, param, user.getUserId(), taskStatus);
return new PageResult<>(toItems(rows), page.getTotal());
}
private List<ApprovalItemVO> toItems(List<ApprovalItemRow> rows) {
Map<Long, List<ApprovalNode>> nodeCache = new HashMap<>(); Map<Long, List<ApprovalNode>> nodeCache = new HashMap<>();
List<ApprovalItemVO> list = new ArrayList<>(); List<ApprovalItemVO> list = new ArrayList<>();
for (ApprovalItemRow row : rows) { for (ApprovalItemRow row : rows) {
@@ -236,7 +263,7 @@ public class ApprovalInstanceService {
item.setCurrentNodeName(nodeName(nodeCache, row.getDefinitionId(), row.getCurrentNodeKey())); item.setCurrentNodeName(nodeName(nodeCache, row.getDefinitionId(), row.getCurrentNodeKey()));
list.add(item); list.add(item);
} }
return new PageResult<>(list, page.getTotal()); return list;
} }
private Long createDraft(ApprovalCompletionParam param, User user, ApprovalFlowDefinition definition, private Long createDraft(ApprovalCompletionParam param, User user, ApprovalFlowDefinition definition,
@@ -0,0 +1,344 @@
package com.gxwebsoft.approval.service;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.gxwebsoft.approval.constant.ApprovalConstants;
import com.gxwebsoft.approval.entity.ApprovalInstance;
import com.gxwebsoft.approval.entity.ApprovalTask;
import com.gxwebsoft.approval.mapper.ApprovalInstanceMapper;
import com.gxwebsoft.approval.mapper.ApprovalTaskMapper;
import com.gxwebsoft.approval.support.ApprovalApprover;
import com.gxwebsoft.approval.support.ApprovalNode;
import com.gxwebsoft.approval.vo.ApprovalUserVO;
import com.gxwebsoft.common.core.exception.BusinessException;
import com.gxwebsoft.common.system.entity.User;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.function.Supplier;
import java.util.stream.Collectors;
/**
* 审批任务动作:通过、驳回、撤回与节点推进。
*
* <p>跨库约束:审批人解析必须走 {@link ApprovalDirectoryService}(core 专用连接),
* 在事务外完成;写 master 的部分放进 TransactionTemplate。任务处理使用
* WHERE status='PENDING' 的条件更新,抢先后到者收到「任务已处理」。</p>
*/
@Service
public class ApprovalTaskService {
private static final String RESOLVER_REPORTER_ORG_LEADER = "reporterOrgLeader";
private static final String RESOLVER_ACCEPT_ORG_LEADER = "acceptOrgLeader";
private static final String RESOLVER_ARCHIVIST = "archivist";
private static final String RESOLVER_REPORTER = "reporter";
private static final String RESOLVER_REPORTER_AND_DISPATCH_TARGET = "reporterAndDispatchTarget";
private static final String RESOLVER_ACCEPT_ORG_MEMBERS = "acceptOrgMembers";
private static final int MAX_OPINION_LENGTH = 500;
@Resource
private ApprovalInstanceMapper instanceMapper;
@Resource
private ApprovalTaskMapper taskMapper;
@Resource
private ApprovalEngineService engineService;
@Resource
private ApprovalDirectoryService directoryService;
@Resource
private PlatformTransactionManager transactionManager;
/**
* 通过:APPROVE 节点或签,处理后取消同节点同轮其余待办并推进下一节点。
*/
public Result approve(Long taskId, String opinion, User user) {
ApprovalTask task = requireTask(taskId, user);
if (!ApprovalConstants.NODE_APPROVE.equals(task.getNodeType())) {
throw new BusinessException("当前任务不支持通过");
}
String normalized = normalizeOpinion(opinion);
ApprovalInstance instance = instanceMapper.selectById(task.getInstanceId());
if (instance == null) {
throw new BusinessException("单据不存在");
}
ApprovalNode next = engineService.nextNode(instance, task.getNodeKey());
List<ApprovalApprover> approvers =
next == null ? Collections.emptyList() : resolveApprovers(instance, next);
String warning = inTransaction(() -> doApprove(taskId, task, normalized, user, next, approvers));
return new Result(instance.getId(), warning);
}
/**
* 驳回:意见必填,同轮待办全部取消,实例回到报送人。
*/
public Result reject(Long taskId, String opinion, User user) {
ApprovalTask task = requireTask(taskId, user);
boolean rejectable = ApprovalConstants.NODE_APPROVE.equals(task.getNodeType())
|| ApprovalConstants.NODE_DISPATCH.equals(task.getNodeType());
if (!rejectable) {
throw new BusinessException("当前任务不支持驳回");
}
String required = requireOpinion(opinion);
inTransaction(() -> doReject(taskId, task, required, user));
return new Result(task.getInstanceId(), null);
}
/**
* 撤回:仅报送人,且本轮没有任何任务被处理。
*/
public Result withdraw(Long instanceId, User user) {
ApprovalInstance instance = instanceMapper.selectById(instanceId);
if (instance == null) {
throw new BusinessException("单据不存在");
}
if (user == null || !instance.getReporterId().equals(user.getUserId())) {
throw new BusinessException("无权操作该单据");
}
if (!ApprovalConstants.STATUS_PENDING.equals(instance.getStatus())) {
throw new BusinessException("当前状态不可撤回");
}
ensureNoHandledTask(instance);
inTransaction(() -> doWithdraw(instanceId, user));
return new Result(instanceId, null);
}
public Integer todoCount(User user) {
return taskMapper.countPendingInstances(user.getUserId());
}
private String doApprove(Long taskId, ApprovalTask task, String opinion, User user,
ApprovalNode next, List<ApprovalApprover> approvers) {
// 先锁实例串行化同单据的处理,再做任务条件更新,避免并发或签互相等待行锁
ApprovalInstance locked = engineService.lockInstance(task.getInstanceId());
ensureTaskHandleable(task, locked);
if (updatePendingTask(taskId, ApprovalConstants.TASK_DONE, opinion) == 0) {
throw new BusinessException("任务已处理");
}
engineService.cancelPendingTasks(locked.getId(), locked.getRound(), taskId);
engineService.addRecord(locked, taskId, task.getNodeKey(), user,
ApprovalConstants.ACTION_APPROVE, opinion);
if (next == null) {
return null;
}
int created = engineService.moveToNode(locked, next, locked.getRound(), approvers);
return created == 0 ? "当前节点缺少审批人,请联系管理员补配置" : null;
}
private void doReject(Long taskId, ApprovalTask task, String opinion, User user) {
ApprovalInstance locked = engineService.lockInstance(task.getInstanceId());
ensureTaskHandleable(task, locked);
if (updatePendingTask(taskId, ApprovalConstants.TASK_DONE, opinion) == 0) {
throw new BusinessException("任务已处理");
}
Integer round = locked.getRound();
engineService.addRecord(locked, taskId, task.getNodeKey(), user,
ApprovalConstants.ACTION_REJECT, opinion);
engineService.cancelPendingTasks(locked.getId(), round, null);
int rows = instanceMapper.update(null, new UpdateWrapper<ApprovalInstance>()
.eq("id", locked.getId())
.eq("status", ApprovalConstants.STATUS_PENDING)
.set("status", ApprovalConstants.STATUS_REJECTED)
.set("round", round + 1));
if (rows == 0) {
throw new BusinessException("单据状态已变化,请刷新后重试");
}
}
private void doWithdraw(Long instanceId, User user) {
ApprovalInstance locked = engineService.lockInstance(instanceId);
if (!ApprovalConstants.STATUS_PENDING.equals(locked.getStatus())) {
throw new BusinessException("当前状态不可撤回");
}
ensureNoHandledTask(locked);
Integer round = locked.getRound();
engineService.addRecord(locked, null, locked.getCurrentNodeKey(), user,
ApprovalConstants.ACTION_WITHDRAW, null);
engineService.cancelPendingTasks(locked.getId(), round, null);
int rows = instanceMapper.update(null, new UpdateWrapper<ApprovalInstance>()
.eq("id", instanceId)
.eq("status", ApprovalConstants.STATUS_PENDING)
.set("status", ApprovalConstants.STATUS_WITHDRAWN)
.set("round", round + 1));
if (rows == 0) {
throw new BusinessException("单据状态已变化,请刷新后重试");
}
}
/**
* 任务必须属于实例当前轮次、当前节点,否则视为已被处理或已失效。
*/
private void ensureTaskHandleable(ApprovalTask task, ApprovalInstance instance) {
if (!ApprovalConstants.STATUS_PENDING.equals(instance.getStatus())) {
throw new BusinessException("单据状态已变化,请刷新后重试");
}
if (!task.getRound().equals(instance.getRound())
|| !task.getNodeKey().equals(instance.getCurrentNodeKey())) {
throw new BusinessException("任务已处理");
}
}
private void ensureNoHandledTask(ApprovalInstance instance) {
Integer handled = taskMapper.selectCount(new LambdaQueryWrapper<ApprovalTask>()
.eq(ApprovalTask::getInstanceId, instance.getId())
.eq(ApprovalTask::getRound, instance.getRound())
.eq(ApprovalTask::getStatus, ApprovalConstants.TASK_DONE));
if (handled != null && handled > 0) {
throw new BusinessException("已有审批人处理,无法撤回");
}
}
private int updatePendingTask(Long taskId, String status, String opinion) {
UpdateWrapper<ApprovalTask> wrapper = new UpdateWrapper<ApprovalTask>()
.eq("id", taskId)
.eq("status", ApprovalConstants.TASK_PENDING)
.set("status", status)
.set("handle_time", LocalDateTime.now());
if (opinion != null) {
wrapper.set("opinion", opinion);
}
return taskMapper.update(null, wrapper);
}
private ApprovalTask requireTask(Long taskId, User user) {
if (taskId == null) {
throw new BusinessException("任务不存在");
}
ApprovalTask task = taskMapper.selectById(taskId);
if (task == null) {
throw new BusinessException("任务不存在");
}
if (user == null || !user.getUserId().equals(task.getAssigneeId())) {
throw new BusinessException("无权处理该任务");
}
return task;
}
/**
* 事务外按节点解析器解析审批人;缺配时返回空列表,由调用方提示并停留当前节点。
*/
private List<ApprovalApprover> resolveApprovers(ApprovalInstance instance, ApprovalNode node) {
String resolver = node.getResolver();
if (StrUtil.isBlank(resolver)) {
return Collections.emptyList();
}
switch (resolver) {
case RESOLVER_REPORTER_ORG_LEADER:
return roleApprovers(ApprovalConstants.departmentLeaderRoleCode(instance.getReportOrgId()));
case RESOLVER_ACCEPT_ORG_LEADER:
return roleApprovers(ApprovalConstants.departmentLeaderRoleCode(instance.getAcceptOrgId()));
case RESOLVER_ARCHIVIST:
return roleApprovers(ApprovalConstants.ROLE_ARCHIVIST);
case RESOLVER_REPORTER:
return userApprovers(Collections.singletonList(instance.getReporterId()));
case RESOLVER_REPORTER_AND_DISPATCH_TARGET:
return userApprovers(confirmCandidateIds(instance));
case RESOLVER_ACCEPT_ORG_MEMBERS:
return organizationApprovers(instance.getAcceptOrgId());
default:
return Collections.emptyList();
}
}
private List<Integer> confirmCandidateIds(ApprovalInstance instance) {
List<Integer> ids = new ArrayList<>();
if (instance.getReporterId() != null) {
ids.add(instance.getReporterId());
}
if (instance.getDispatchTargetId() != null && !ids.contains(instance.getDispatchTargetId())) {
ids.add(instance.getDispatchTargetId());
}
return ids;
}
private List<ApprovalApprover> roleApprovers(String roleCode) {
if (StrUtil.isBlank(roleCode)) {
return Collections.emptyList();
}
return toApprovers(directoryService.listRoleMembers(roleCode));
}
private List<ApprovalApprover> userApprovers(List<Integer> userIds) {
return toApprovers(directoryService.listActiveUsersByIds(userIds));
}
private List<ApprovalApprover> organizationApprovers(Integer organizationId) {
return toApprovers(directoryService.listOrganizationMembers(organizationId));
}
private List<ApprovalApprover> toApprovers(List<ApprovalUserVO> users) {
return users.stream()
.map(user -> new ApprovalApprover(user.getUserId(), displayName(user)))
.collect(Collectors.toList());
}
private String displayName(ApprovalUserVO user) {
if (StrUtil.isNotBlank(user.getRealName())) {
return user.getRealName();
}
if (StrUtil.isNotBlank(user.getNickname())) {
return user.getNickname();
}
return user.getUsername();
}
private String normalizeOpinion(String opinion) {
if (opinion == null) {
return null;
}
String trimmed = opinion.trim();
if (trimmed.isEmpty()) {
return null;
}
if (trimmed.length() > MAX_OPINION_LENGTH) {
throw new BusinessException("意见不能超过500字");
}
return trimmed;
}
private String requireOpinion(String opinion) {
String normalized = normalizeOpinion(opinion);
if (normalized == null) {
throw new BusinessException("请填写驳回意见");
}
return normalized;
}
private <T> T inTransaction(Supplier<T> action) {
return new TransactionTemplate(transactionManager).execute(status -> action.get());
}
private void inTransaction(Runnable action) {
new TransactionTemplate(transactionManager).execute(status -> {
action.run();
return null;
});
}
/**
* 动作结果:实例id + 需要透出的节点缺配提示。
*/
public static class Result {
private final Long instanceId;
private final String warning;
public Result(Long instanceId, String warning) {
this.instanceId = instanceId;
this.warning = warning;
}
public Long getInstanceId() {
return instanceId;
}
public String getWarning() {
return warning;
}
}
}
@@ -0,0 +1,406 @@
package com.gxwebsoft.approval;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.gxwebsoft.support.ApprovalTestData;
import com.gxwebsoft.support.TestAuthentication;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import javax.sql.DataSource;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
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;
/**
* 审批中心:待办/已办、通过/驳回、撤回、或签与并发处理的集成测试。
*
* <p>本类不开启测试事务:动作接口各自独立提交,便于覆盖并发条件更新;
* 数据由 {@link ApprovalTestData#cleanup()} 清理。</p>
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles({"gxtyzx-test", "test"})
class ApprovalTaskIT {
private static final int TENANT = TestAuthentication.TENANT_ID;
private static final String LIST = "approval:instance:list";
private static final String SUBMIT = "approval:instance:submit";
private static final String APPROVE = "approval:instance:approve";
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private DataSource dataSource;
private ApprovalTestData data;
private ApprovalTestData.TestUser reporter;
private ApprovalTestData.TestUser leader;
private Integer organizationId;
@BeforeEach
void setUp() {
data = new ApprovalTestData(dataSource);
organizationId = data.createOrganization("任务部门" + data.uniqueSuffix(), 136);
reporter = data.createUser("任务报送人" + data.uniqueSuffix(), organizationId);
ApprovalTestData.TestRole leaderRole =
data.createRole("dept_leader_" + organizationId, "部门负责人");
leader = data.createUser("任务负责人" + data.uniqueSuffix(), organizationId);
data.bind(leader.userId, leaderRole.roleId);
ApprovalTestData.TestRole archivistRole = data.createRole("approval_archivist", "收文员");
ApprovalTestData.TestUser archivist = data.createUser("任务收文员" + data.uniqueSuffix(), organizationId);
data.bind(archivist.userId, archivistRole.roleId);
}
@AfterEach
void tearDown() {
data.cleanup();
}
@Test
void approvesTaskAndActivatesNextConfirmNode() throws Exception {
long instanceId = createAndSubmit("通过测试");
long taskId = pendingTaskId(instanceId, leader.userId);
mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"), leader, organizationId, APPROVE)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"opinion\":\"同意办理\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.id").value((int) instanceId))
.andExpect(jsonPath("$.data.status").value("PENDING"))
.andExpect(jsonPath("$.data.currentNodeName").value("存档"))
.andExpect(jsonPath("$.data.confirms.length()").value(1))
.andExpect(jsonPath("$.data.confirms[0].userId").value(reporter.userId))
.andExpect(jsonPath("$.data.confirms[0].status").value("PENDING"));
assertThat(data.queryMaster("SELECT status FROM approval_task WHERE id = ?", String.class, taskId))
.isEqualTo("DONE");
assertThat(data.queryMaster("SELECT opinion FROM approval_task WHERE id = ?", String.class, taskId))
.isEqualTo("同意办理");
assertThat(data.queryMaster("SELECT handle_time FROM approval_task WHERE id = ?", Object.class, taskId))
.isNotNull();
assertThat(data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?", String.class, instanceId))
.isEqualTo("confirm");
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
+ "AND node_type = 'CONFIRM' AND assignee_id = ? AND status = 'PENDING'", instanceId, reporter.userId))
.isEqualTo(1);
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
+ "AND action = 'APPROVE' AND operator_id = ? AND opinion = '同意办理'", instanceId, leader.userId))
.isEqualTo(1);
// 存档人能看到待确认任务与对应权限
mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), reporter, organizationId, LIST))
.andExpect(jsonPath("$.data.myTask.nodeType").value("CONFIRM"))
.andExpect(jsonPath("$.data.permissions.canConfirm").value(true))
.andExpect(jsonPath("$.data.permissions.canApprove").value(false));
}
@Test
void requiresRejectOpinionAndResubmitsWithSameBizNo() throws Exception {
long instanceId = createAndSubmit("驳回重提");
long taskId = pendingTaskId(instanceId, leader.userId);
String bizNo = data.queryMaster("SELECT biz_no FROM approval_instance WHERE id = ?", String.class, instanceId);
mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/reject"), leader, organizationId, APPROVE)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"opinion\":\" \"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(1))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("驳回意见")));
assertThat(data.queryMaster("SELECT status FROM approval_task WHERE id = ?", String.class, taskId))
.isEqualTo("PENDING");
mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/reject"), leader, organizationId, APPROVE)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"opinion\":\"材料不完整\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.status").value("REJECTED"))
.andExpect(jsonPath("$.data.statusName").value("已驳回"))
.andExpect(jsonPath("$.data.round").value(2));
// 单据回到报送人:可修改、可重新提交
mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), reporter, organizationId, LIST))
.andExpect(jsonPath("$.data.permissions.canEdit").value(true))
.andExpect(jsonPath("$.data.permissions.canSubmit").value(true));
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? AND status = 'PENDING'",
instanceId)).isZero();
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
+ "AND action = 'REJECT' AND opinion = '材料不完整'", instanceId)).isEqualTo(1);
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, organizationId, SUBMIT))
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.status").value("PENDING"))
.andExpect(jsonPath("$.data.round").value(3))
.andExpect(jsonPath("$.data.bizNo").value(bizNo))
.andExpect(jsonPath("$.data.currentNodeName").value("报送部门负责人"));
assertThat(pendingTaskId(instanceId, leader.userId)).isPositive();
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
+ "AND action = 'RESUBMIT' AND round = 3", instanceId)).isEqualTo(1);
}
@Test
void approvingOneCountersignTaskCancelsTheRest() throws Exception {
ApprovalTestData.TestUser second =
data.createUser("第二负责人" + data.uniqueSuffix(), organizationId);
Integer leaderRoleId = data.server().queryForObject("SELECT role_id FROM sys_role WHERE role_code = ? AND tenant_id = ?",
Integer.class, "dept_leader_" + organizationId, TENANT);
data.bind(second.userId, leaderRoleId);
long instanceId = createAndSubmit("或签取消");
long firstTask = pendingTaskId(instanceId, leader.userId);
long secondTask = pendingTaskId(instanceId, second.userId);
mockMvc.perform(authed(post("/api/approval/task/" + firstTask + "/approve"), leader, organizationId, APPROVE)
.contentType(MediaType.APPLICATION_JSON).content("{}"))
.andExpect(jsonPath("$.code").value(0));
assertThat(data.queryMaster("SELECT status FROM approval_task WHERE id = ?", String.class, secondTask))
.isEqualTo("CANCELLED");
mockMvc.perform(authed(get("/api/approval/todo/page"), second, organizationId, LIST))
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.count").value(0));
mockMvc.perform(authed(post("/api/approval/task/" + secondTask + "/approve"), second, organizationId, APPROVE)
.contentType(MediaType.APPLICATION_JSON).content("{}"))
.andExpect(jsonPath("$.code").value(1))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("任务已处理")));
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'APPROVE'",
instanceId)).isEqualTo(1);
}
@Test
void concurrentApprovalOnlyHandlesTaskOnce() throws Exception {
long instanceId = createAndSubmit("并发处理");
long taskId = pendingTaskId(instanceId, leader.userId);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(2);
ExecutorService executor = Executors.newFixedThreadPool(2);
AtomicInteger success = new AtomicInteger();
AtomicInteger conflict = new AtomicInteger();
try {
for (int i = 0; i < 2; i++) {
executor.submit(() -> {
try {
start.await();
int code = mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"),
leader, organizationId, APPROVE)
.contentType(MediaType.APPLICATION_JSON).content("{\"opinion\":\"并发\"}"))
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8)
.contains("\"code\":0") ? 0 : 1;
if (code == 0) {
success.incrementAndGet();
} else {
conflict.incrementAndGet();
}
} catch (Exception e) {
conflict.incrementAndGet();
} finally {
done.countDown();
}
});
}
start.countDown();
assertThat(done.await(30, TimeUnit.SECONDS)).isTrue();
} finally {
executor.shutdownNow();
}
assertThat(success.get()).isEqualTo(1);
assertThat(conflict.get()).isEqualTo(1);
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'APPROVE'",
instanceId)).isEqualTo(1);
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? AND status = 'DONE'",
instanceId)).isEqualTo(1);
}
@Test
void allowsReporterToApproveOwnTask() throws Exception {
Integer leaderRoleId = data.server().queryForObject("SELECT role_id FROM sys_role WHERE role_code = ? AND tenant_id = ?",
Integer.class, "dept_leader_" + organizationId, TENANT);
data.bind(reporter.userId, leaderRoleId);
long instanceId = createAndSubmit("自审单据");
long taskId = pendingTaskId(instanceId, reporter.userId);
mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"),
reporter, organizationId, APPROVE))
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.status").value("PENDING"))
.andExpect(jsonPath("$.data.currentNodeName").value("存档"));
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
+ "AND action = 'APPROVE' AND operator_id = ?", instanceId, reporter.userId)).isEqualTo(1);
}
@Test
void withdrawsOnlyBeforeAnyTaskHandled() throws Exception {
long withdrawn = createAndSubmit("撤回单据");
String bizNo = data.queryMaster("SELECT biz_no FROM approval_instance WHERE id = ?", String.class, withdrawn);
mockMvc.perform(authed(post("/api/approval/instance/" + withdrawn + "/withdraw"),
reporter, organizationId, SUBMIT))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.status").value("WITHDRAWN"))
.andExpect(jsonPath("$.data.round").value(2))
.andExpect(jsonPath("$.data.bizNo").value(bizNo))
.andExpect(jsonPath("$.data.permissions.canSubmit").value(true));
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? AND status = 'PENDING'",
withdrawn)).isZero();
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'WITHDRAW'",
withdrawn)).isEqualTo(1);
// 已有处理记录的实例不能撤回
long handled = createAndSubmit("已审批撤回");
long taskId = pendingTaskId(handled, leader.userId);
mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"), leader, organizationId, APPROVE))
.andExpect(jsonPath("$.code").value(0));
mockMvc.perform(authed(post("/api/approval/instance/" + handled + "/withdraw"),
reporter, organizationId, SUBMIT))
.andExpect(jsonPath("$.code").value(1))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("已有审批人处理,无法撤回")));
assertThat(data.queryMaster("SELECT status FROM approval_instance WHERE id = ?", String.class, handled))
.isEqualTo("PENDING");
// 非报送人不能撤回
long other = createAndSubmit("他人撤回");
mockMvc.perform(authed(post("/api/approval/instance/" + other + "/withdraw"),
leader, organizationId, SUBMIT))
.andExpect(jsonPath("$.code").value(1))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权操作该单据")));
}
@Test
void listsTodoDoneAndTodoCountWithFilters() throws Exception {
long first = createAndSubmit("场馆维修A");
long second = createAndSubmit("场馆维修B");
mockMvc.perform(authed(get("/api/approval/todo/count"), leader, organizationId, LIST))
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data").value(2));
mockMvc.perform(authed(get("/api/approval/todo/page"), leader, organizationId, LIST))
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.count").value(2))
.andExpect(jsonPath("$.data.list[0].id").value((int) second))
.andExpect(jsonPath("$.data.list[0].bizNo").isNotEmpty())
.andExpect(jsonPath("$.data.list[0].status").value("PENDING"))
.andExpect(jsonPath("$.data.list[0].currentNodeName").value("报送部门负责人"))
.andExpect(jsonPath("$.data.list[0].submitTime").isNotEmpty());
mockMvc.perform(authed(get("/api/approval/todo/page").param("keywords", "B"), leader, organizationId, LIST))
.andExpect(jsonPath("$.data.count").value(1))
.andExpect(jsonPath("$.data.list[0].id").value((int) second));
mockMvc.perform(authed(get("/api/approval/todo/page").param("status", "REJECTED"), leader, organizationId, LIST))
.andExpect(jsonPath("$.data.count").value(0));
mockMvc.perform(authed(get("/api/approval/mine/page").param("keywords", "A"), reporter, organizationId, LIST))
.andExpect(jsonPath("$.data.count").value(1))
.andExpect(jsonPath("$.data.list[0].id").value((int) first));
long taskId = pendingTaskId(first, leader.userId);
mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"), leader, organizationId, APPROVE))
.andExpect(jsonPath("$.code").value(0));
mockMvc.perform(authed(get("/api/approval/todo/count"), leader, organizationId, LIST))
.andExpect(jsonPath("$.data").value(1));
mockMvc.perform(authed(get("/api/approval/done/page"), leader, organizationId, LIST))
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.count").value(1))
.andExpect(jsonPath("$.data.list[0].id").value((int) first))
.andExpect(jsonPath("$.data.list[0].status").value("PENDING"))
.andExpect(jsonPath("$.data.list[0].currentNodeName").value("存档"));
mockMvc.perform(authed(get("/api/approval/done/page").param("keywords", "B"), leader, organizationId, LIST))
.andExpect(jsonPath("$.data.count").value(0));
ApprovalTestData.TestUser outsider = data.createUser("任务路人" + data.uniqueSuffix(), organizationId);
mockMvc.perform(authed(get("/api/approval/todo/page"), outsider, organizationId, LIST))
.andExpect(jsonPath("$.data.count").value(0));
mockMvc.perform(authed(get("/api/approval/done/page"), outsider, organizationId, LIST))
.andExpect(jsonPath("$.data.count").value(0));
}
// ---------- helpers ----------
private long createAndSubmit(String name) throws Exception {
String body = "{\"name\":" + quote(name)
+ ",\"implementOrg\":\"广西体育中心实施部\",\"fee\":123.45,"
+ "\"serviceContent\":\"完成场馆设施维修与验收\"}";
String response = mockMvc.perform(authed(post("/api/approval/instance/completion"),
reporter, organizationId, SUBMIT)
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
long instanceId = objectMapper.readTree(response).path("data").asLong();
data.trackInstance(instanceId);
uploadAttachment(instanceId);
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"),
reporter, organizationId, SUBMIT))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
return instanceId;
}
private void uploadAttachment(long instanceId) throws Exception {
MockMultipartFile file = new MockMultipartFile("file", "sign.png", "image/png",
new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A});
mockMvc.perform(multipart("/api/approval/attachment/upload")
.file(file)
.param("instanceId", String.valueOf(instanceId))
.param("category", "SIGNED")
.header("tenantId", TENANT)
.with(authentication(TestAuthentication.user(reporter.userId, reporter.username,
organizationId, TENANT, SUBMIT))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
}
private long pendingTaskId(long instanceId, int userId) {
Long taskId = data.queryMaster("SELECT id FROM approval_task WHERE instance_id = ? "
+ "AND assignee_id = ? AND status = 'PENDING' LIMIT 1", Long.class, instanceId, userId);
assertThat(taskId).as("待办任务应存在").isNotNull();
return taskId;
}
private MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder,
ApprovalTestData.TestUser user, Integer org,
String... authorities) {
return builder.header("tenantId", TENANT)
.with(authentication(TestAuthentication.user(user.userId, user.username, org, TENANT, authorities)));
}
private String quote(String value) {
return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
}
}