merge: t20 审批中心待办已办与通过驳回撤回
This commit is contained in:
@@ -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("\"", "\\\"") + "\"";
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user