feat(approval): 派单候选池、派单动作与双方存档 (#24)
This commit is contained in:
@@ -68,6 +68,9 @@ public final class ApprovalConstants {
|
||||
// 角色编码
|
||||
public static final String ROLE_ARCHIVIST = "approval_archivist";
|
||||
public static final String ROLE_SUPER_ADMIN = "superAdmin";
|
||||
/** 普通消费用户角色:无部门且仅有这些角色时不算派单候选。 */
|
||||
public static final String ROLE_USER = "user";
|
||||
public static final String ROLE_GUEST = "guest";
|
||||
|
||||
// 附件规则
|
||||
public static final long MAX_ATTACHMENT_SIZE = 20L * 1024 * 1024;
|
||||
|
||||
@@ -2,12 +2,14 @@ package com.gxwebsoft.approval.controller;
|
||||
|
||||
import com.gxwebsoft.approval.param.ApprovalTaskActionParam;
|
||||
import com.gxwebsoft.approval.param.ApprovalTaskAssignParam;
|
||||
import com.gxwebsoft.approval.param.ApprovalTaskDispatchParam;
|
||||
import com.gxwebsoft.approval.param.ApprovalTaskListParam;
|
||||
import com.gxwebsoft.approval.service.ApprovalInstanceService;
|
||||
import com.gxwebsoft.approval.service.ApprovalTaskService;
|
||||
import com.gxwebsoft.approval.vo.ApprovalAssignVO;
|
||||
import com.gxwebsoft.approval.vo.ApprovalInstanceVO;
|
||||
import com.gxwebsoft.approval.vo.ApprovalItemVO;
|
||||
import com.gxwebsoft.approval.vo.ApprovalUserVO;
|
||||
import com.gxwebsoft.common.core.annotation.OperationLog;
|
||||
import com.gxwebsoft.common.core.web.ApiResult;
|
||||
import com.gxwebsoft.common.core.web.BaseController;
|
||||
@@ -24,6 +26,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 审批待办与任务动作接口(员工端与 PC 共用)。
|
||||
@@ -108,6 +111,25 @@ public class ApprovalTaskController extends BaseController {
|
||||
return success("撤回成功", instanceService.detail(result.getInstanceId(), user));
|
||||
}
|
||||
|
||||
@PreAuthorize("hasAuthority('approval:instance:approve')")
|
||||
@ApiOperation("派单候选池")
|
||||
@GetMapping("/task/{id}/candidates")
|
||||
public ApiResult<List<ApprovalUserVO>> candidates(@PathVariable("id") Long id) {
|
||||
return success(taskService.candidates(id, getLoginUser()));
|
||||
}
|
||||
|
||||
@PreAuthorize("hasAuthority('approval:instance:approve')")
|
||||
@OperationLog(value = "派单审批待办", module = "审批管理")
|
||||
@ApiOperation("派单")
|
||||
@PostMapping("/task/{id}/dispatch")
|
||||
public ApiResult<ApprovalInstanceVO> dispatch(@PathVariable("id") Long id,
|
||||
@RequestBody(required = false) ApprovalTaskDispatchParam param) {
|
||||
User user = getLoginUser();
|
||||
ApprovalTaskService.Result result = taskService.dispatch(id, param, user);
|
||||
return success(result.getWarning() == null ? "派单成功" : result.getWarning(),
|
||||
instanceService.detail(result.getInstanceId(), user));
|
||||
}
|
||||
|
||||
@PreAuthorize("hasAuthority('approval:instance:assign')")
|
||||
@OperationLog(value = "改派审批待办", module = "审批管理")
|
||||
@ApiOperation("改派")
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.gxwebsoft.approval.param;
|
||||
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 派单参数。
|
||||
*/
|
||||
@Data
|
||||
public class ApprovalTaskDispatchParam implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@ApiModelProperty("被派单人用户id;必须来自候选池")
|
||||
private Integer targetUserId;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.gxwebsoft.approval.service;
|
||||
|
||||
import com.baomidou.dynamic.datasource.DynamicRoutingDataSource;
|
||||
import com.baomidou.dynamic.datasource.annotation.DS;
|
||||
import com.gxwebsoft.approval.constant.ApprovalConstants;
|
||||
import com.gxwebsoft.approval.support.ApprovalTenantContext;
|
||||
import com.gxwebsoft.approval.vo.ApprovalOrganizationVO;
|
||||
import com.gxwebsoft.approval.vo.ApprovalUserVO;
|
||||
@@ -176,6 +177,33 @@ public class ApprovalDirectoryService {
|
||||
return jdbcTemplate.query(sql, USER_MAPPER, args.toArray());
|
||||
}
|
||||
|
||||
/**
|
||||
* 派单候选池:优先受理部门启用成员;部门无成员时回退为当前租户的非普通消费用户。
|
||||
*
|
||||
* <p>回退排除「未归属组织且角色仅为 user/guest 或无角色」的账号;
|
||||
* 部门有成员时不混入回退候选。</p>
|
||||
*/
|
||||
public List<ApprovalUserVO> listDispatchCandidates(Integer acceptOrganizationId) {
|
||||
List<ApprovalUserVO> members = listOrganizationMembers(acceptOrganizationId);
|
||||
if (!members.isEmpty()) {
|
||||
return members;
|
||||
}
|
||||
Integer tenantId = ApprovalTenantContext.currentTenantId();
|
||||
return jdbcTemplate.query(
|
||||
"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 NOT ((u.organization_id IS NULL OR u.organization_id = 0) AND NOT EXISTS ("
|
||||
+ "SELECT 1 FROM sys_user_role ur "
|
||||
+ "JOIN sys_role r ON r.role_id = ur.role_id AND r.tenant_id = ? "
|
||||
+ "WHERE ur.user_id = u.user_id AND ur.tenant_id = ? AND r.deleted = 0 "
|
||||
+ "AND r.role_code NOT IN (?, ?))) "
|
||||
+ "ORDER BY u.user_id",
|
||||
USER_MAPPER, tenantId, tenantId, tenantId, tenantId,
|
||||
ApprovalConstants.ROLE_USER, ApprovalConstants.ROLE_GUEST);
|
||||
}
|
||||
|
||||
/** 是否持有任一角色(用于 allVisible 判断)。 */
|
||||
public boolean hasAnyRole(Integer userId, String... roleCodes) {
|
||||
if (userId == null || roleCodes == null || roleCodes.length == 0) {
|
||||
|
||||
@@ -566,10 +566,10 @@ public class ApprovalInstanceService {
|
||||
permissions.setCanWithdraw(done == null || done == 0);
|
||||
}
|
||||
if (myTask != null) {
|
||||
boolean approveNode = ApprovalConstants.NODE_APPROVE.equals(myTask.getNodeType())
|
||||
|| ApprovalConstants.NODE_DISPATCH.equals(myTask.getNodeType());
|
||||
boolean approveNode = ApprovalConstants.NODE_APPROVE.equals(myTask.getNodeType());
|
||||
boolean rejectableNode = approveNode || ApprovalConstants.NODE_DISPATCH.equals(myTask.getNodeType());
|
||||
permissions.setCanApprove(approveNode);
|
||||
permissions.setCanReject(approveNode);
|
||||
permissions.setCanReject(rejectableNode);
|
||||
permissions.setCanDispatch(ApprovalConstants.NODE_DISPATCH.equals(myTask.getNodeType()));
|
||||
permissions.setCanConfirm(ApprovalConstants.NODE_CONFIRM.equals(myTask.getNodeType()));
|
||||
permissions.setCanArchive(ApprovalConstants.NODE_ARCHIVE.equals(myTask.getNodeType()));
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.gxwebsoft.approval.entity.ApprovalTask;
|
||||
import com.gxwebsoft.approval.mapper.ApprovalInstanceMapper;
|
||||
import com.gxwebsoft.approval.mapper.ApprovalTaskMapper;
|
||||
import com.gxwebsoft.approval.param.ApprovalTaskAssignParam;
|
||||
import com.gxwebsoft.approval.param.ApprovalTaskDispatchParam;
|
||||
import com.gxwebsoft.approval.support.ApprovalApprover;
|
||||
import com.gxwebsoft.approval.support.ApprovalNode;
|
||||
import com.gxwebsoft.approval.vo.ApprovalAssignVO;
|
||||
@@ -175,6 +176,43 @@ public class ApprovalTaskService {
|
||||
return inTransaction(() -> doAssign(taskId, task, target.getUserId(), targetName, reason, user));
|
||||
}
|
||||
|
||||
/**
|
||||
* 派单候选池:仅派单节点、本人待办的任务可查询;优先受理部门启用成员,
|
||||
* 部门无成员时回退为租户非普通消费用户。
|
||||
*/
|
||||
public List<ApprovalUserVO> candidates(Long taskId, User user) {
|
||||
ApprovalTask task = requireDispatchTask(taskId, user);
|
||||
ApprovalInstance instance = instanceMapper.selectById(task.getInstanceId());
|
||||
if (instance == null) {
|
||||
throw new BusinessException("单据不存在");
|
||||
}
|
||||
ensureTaskHandleable(task, instance);
|
||||
return directoryService.listDispatchCandidates(instance.getAcceptOrgId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 派单:选定候选池中的 1 人即完成派单节点,激活存档节点并为报送人与被派单人生成两条
|
||||
* 存档任务。目标校验与审批人解析在事务外完成(core 库),写 master 的部分在事务内。
|
||||
*/
|
||||
public Result dispatch(Long taskId, ApprovalTaskDispatchParam param, User user) {
|
||||
if (param == null || param.getTargetUserId() == null) {
|
||||
throw new BusinessException("请选择派单对象");
|
||||
}
|
||||
ApprovalTask task = requireDispatchTask(taskId, user);
|
||||
ApprovalInstance instance = instanceMapper.selectById(task.getInstanceId());
|
||||
if (instance == null) {
|
||||
throw new BusinessException("单据不存在");
|
||||
}
|
||||
ensureNotArchived(instance);
|
||||
ApprovalUserVO target = requireDispatchTarget(instance, param.getTargetUserId());
|
||||
String targetName = displayName(target);
|
||||
ApprovalNode next = engineService.nextNode(instance, task.getNodeKey());
|
||||
List<ApprovalApprover> approvers = dispatchNextApprovers(instance, next, target);
|
||||
String warning = inTransaction(() ->
|
||||
doDispatch(taskId, task, target, targetName, user, next, approvers));
|
||||
return new Result(instance.getId(), warning);
|
||||
}
|
||||
|
||||
public Integer todoCount(User user) {
|
||||
return taskMapper.countPendingInstances(user.getUserId());
|
||||
}
|
||||
@@ -326,6 +364,94 @@ public class ApprovalTaskService {
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 事务内派单:锁实例后条件更新任务、快照派单对象、写派单记录并激活存档节点。
|
||||
*/
|
||||
private String doDispatch(Long taskId, ApprovalTask task, ApprovalUserVO target, String targetName,
|
||||
User user, ApprovalNode next, List<ApprovalApprover> approvers) {
|
||||
ApprovalInstance locked = engineService.lockInstance(task.getInstanceId());
|
||||
ensureNotArchived(locked);
|
||||
ensureTaskHandleable(task, locked);
|
||||
if (updateDispatchTask(taskId, target.getUserId(), targetName) == 0) {
|
||||
throw new BusinessException("任务已处理");
|
||||
}
|
||||
engineService.cancelPendingTasks(locked.getId(), locked.getRound(), taskId);
|
||||
int rows = instanceMapper.update(null, new UpdateWrapper<ApprovalInstance>()
|
||||
.eq("id", locked.getId())
|
||||
.eq("status", ApprovalConstants.STATUS_PENDING)
|
||||
.set("dispatch_target_id", target.getUserId())
|
||||
.set("dispatch_target_name", targetName));
|
||||
if (rows == 0) {
|
||||
throw new BusinessException("单据状态已变化,请刷新后重试");
|
||||
}
|
||||
locked.setDispatchTargetId(target.getUserId());
|
||||
locked.setDispatchTargetName(targetName);
|
||||
engineService.addRecord(locked, taskId, task.getNodeKey(), user,
|
||||
ApprovalConstants.ACTION_DISPATCH, null);
|
||||
if (next == null) {
|
||||
return "当前节点缺少审批人,请联系管理员补配置";
|
||||
}
|
||||
int created = engineService.moveToNode(locked, next, locked.getRound(), approvers);
|
||||
return created == 0 ? "当前节点缺少审批人,请联系管理员补配置" : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 派单前校验:任务存在、属于本人、是派单节点且仍待处理。
|
||||
*/
|
||||
private ApprovalTask requireDispatchTask(Long taskId, User user) {
|
||||
ApprovalTask task = requireTask(taskId, user);
|
||||
if (!ApprovalConstants.NODE_DISPATCH.equals(task.getNodeType())) {
|
||||
throw new BusinessException("当前任务不支持派单");
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* 派单目标必须来自候选池(受理部门启用成员,或部门无成员时的租户回退候选)。
|
||||
*/
|
||||
private ApprovalUserVO requireDispatchTarget(ApprovalInstance instance, Integer targetUserId) {
|
||||
for (ApprovalUserVO candidate : directoryService.listDispatchCandidates(instance.getAcceptOrgId())) {
|
||||
if (targetUserId.equals(candidate.getUserId())) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
throw new BusinessException("派单对象不在候选范围内");
|
||||
}
|
||||
|
||||
/**
|
||||
* 存档节点审批人:报送人 + 本次派单对象(实例快照尚未写入,需显式解析)。
|
||||
*/
|
||||
private List<ApprovalApprover> dispatchNextApprovers(ApprovalInstance instance, ApprovalNode next,
|
||||
ApprovalUserVO target) {
|
||||
if (next == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
if (!RESOLVER_REPORTER_AND_DISPATCH_TARGET.equals(next.getResolver())) {
|
||||
return resolveApprovers(instance, next);
|
||||
}
|
||||
List<Integer> userIds = new ArrayList<>();
|
||||
if (instance.getReporterId() != null) {
|
||||
userIds.add(instance.getReporterId());
|
||||
}
|
||||
if (!userIds.contains(target.getUserId())) {
|
||||
userIds.add(target.getUserId());
|
||||
}
|
||||
return userApprovers(userIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 派单任务条件更新:完成并快照派单对象。
|
||||
*/
|
||||
private int updateDispatchTask(Long taskId, Integer targetUserId, String targetName) {
|
||||
return taskMapper.update(null, new UpdateWrapper<ApprovalTask>()
|
||||
.eq("id", taskId)
|
||||
.eq("status", ApprovalConstants.TASK_PENDING)
|
||||
.set("status", ApprovalConstants.TASK_DONE)
|
||||
.set("handle_time", LocalDateTime.now())
|
||||
.set("dispatch_target_id", targetUserId)
|
||||
.set("dispatch_target_name", targetName));
|
||||
}
|
||||
|
||||
/**
|
||||
* 改派前的基本校验:任务存在、待处理且单据仍在审批中。
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
package com.gxwebsoft.approval;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
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.List;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 派单闭环集成测试:候选池、回退过滤、派单并发、被派单人可见性、双方存档缺一不可、
|
||||
* 归档与 PC 派单权限。
|
||||
*
|
||||
* <p>独立租户隔离角色与用户,避免与并行执行的其它审批 IT 互相污染。</p>
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles({"gxtyzx-test", "test"})
|
||||
class ApprovalDispatchIT {
|
||||
|
||||
private static final int TENANT = 10025;
|
||||
private static final String LIST = "approval:instance:list";
|
||||
private static final String SUBMIT = "approval:instance:submit";
|
||||
private static final String APPROVE = "approval:instance:approve";
|
||||
private static final String ARCHIVE = "approval:instance:archive";
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
@Autowired
|
||||
private DataSource dataSource;
|
||||
|
||||
private ApprovalTestData data;
|
||||
private Integer reportOrgId;
|
||||
private Integer acceptOrgId;
|
||||
private ApprovalTestData.TestUser reporter;
|
||||
private ApprovalTestData.TestUser reportLeader;
|
||||
private ApprovalTestData.TestUser acceptLeader;
|
||||
private ApprovalTestData.TestUser acceptMember;
|
||||
private ApprovalTestData.TestUser archivist;
|
||||
private ApprovalTestData.TestUser outsider;
|
||||
private ApprovalTestData.TestUser staffNoOrg;
|
||||
private ApprovalTestData.TestUser consumerUser;
|
||||
private ApprovalTestData.TestUser guestUser;
|
||||
private ApprovalTestData.TestUser noRoleUser;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
data = new ApprovalTestData(dataSource);
|
||||
reportOrgId = data.createOrganization("派单报送部门" + data.uniqueSuffix(), 136, TENANT);
|
||||
acceptOrgId = data.createOrganization("派单受理部门" + data.uniqueSuffix(), 136, TENANT);
|
||||
reporter = data.createUser("派单报送人" + data.uniqueSuffix(), reportOrgId, TENANT);
|
||||
|
||||
ApprovalTestData.TestRole reportLeaderRole =
|
||||
data.createRole("dept_leader_" + reportOrgId, "报送部门负责人", TENANT);
|
||||
reportLeader = data.createUser("派单报送负责人" + data.uniqueSuffix(), reportOrgId, TENANT);
|
||||
data.bindTenant(reportLeader.userId, reportLeaderRole.roleId, TENANT);
|
||||
|
||||
ApprovalTestData.TestRole acceptLeaderRole =
|
||||
data.createRole("dept_leader_" + acceptOrgId, "受理部门负责人", TENANT);
|
||||
acceptLeader = data.createUser("派单受理负责人" + data.uniqueSuffix(), acceptOrgId, TENANT);
|
||||
data.bindTenant(acceptLeader.userId, acceptLeaderRole.roleId, TENANT);
|
||||
acceptMember = data.createUser("派单受理成员" + data.uniqueSuffix(), acceptOrgId, TENANT);
|
||||
|
||||
ApprovalTestData.TestRole archivistRole = data.createRole("approval_archivist", "收文员", TENANT);
|
||||
archivist = data.createUser("派单收文员" + data.uniqueSuffix(), reportOrgId, TENANT);
|
||||
data.bindTenant(archivist.userId, archivistRole.roleId, TENANT);
|
||||
|
||||
outsider = data.createUser("派单路人" + data.uniqueSuffix(), reportOrgId, TENANT);
|
||||
|
||||
// 回退候选:无部门但持有业务角色应保留,仅有 user/guest 角色或无角色的普通消费用户被排除
|
||||
ApprovalTestData.TestRole staffRole = data.createRole("backoffice_staff", "业务管理员", TENANT);
|
||||
staffNoOrg = data.createUser("派单无部门业务员" + data.uniqueSuffix(), null, TENANT);
|
||||
data.bindTenant(staffNoOrg.userId, staffRole.roleId, TENANT);
|
||||
|
||||
ApprovalTestData.TestRole userRole = data.createRole("user", "普通用户", TENANT);
|
||||
consumerUser = data.createUser("派单消费者" + data.uniqueSuffix(), null, TENANT);
|
||||
data.bindTenant(consumerUser.userId, userRole.roleId, TENANT);
|
||||
|
||||
ApprovalTestData.TestRole guestRole = data.createRole("guest", "访客", TENANT);
|
||||
guestUser = data.createUser("派单访客" + data.uniqueSuffix(), null, TENANT);
|
||||
data.bindTenant(guestUser.userId, guestRole.roleId, TENANT);
|
||||
|
||||
// 无角色且无部门
|
||||
noRoleUser = data.createUser("派单无角色" + data.uniqueSuffix(), null, TENANT);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
data.cleanup();
|
||||
}
|
||||
|
||||
@Test
|
||||
void listsOnlyEnabledAcceptOrgMembers() throws Exception {
|
||||
long instanceId = reachDispatch("部门候选池");
|
||||
long dispatchTask = pendingTaskId(instanceId, acceptLeader.userId);
|
||||
|
||||
// 部门内停用成员不进入候选池
|
||||
ApprovalTestData.TestUser disabled =
|
||||
data.createUser("派单停用成员" + data.uniqueSuffix(), acceptOrgId, TENANT);
|
||||
data.server().update("UPDATE sys_user SET status = 1 WHERE user_id = ?", disabled.userId);
|
||||
|
||||
String response = mockMvc.perform(authed(get("/api/approval/task/" + dispatchTask + "/candidates"),
|
||||
acceptLeader, acceptOrgId, APPROVE))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.length()").value(2))
|
||||
.andExpect(jsonPath("$.data[0].userId").value(acceptLeader.userId))
|
||||
.andExpect(jsonPath("$.data[0].realName").value(acceptLeader.realName))
|
||||
.andExpect(jsonPath("$.data[0].username").value(acceptLeader.username))
|
||||
.andExpect(jsonPath("$.data[0].organizationName").value(
|
||||
data.server().queryForObject("SELECT organization_name FROM sys_organization WHERE organization_id = ?",
|
||||
String.class, acceptOrgId)))
|
||||
.andExpect(jsonPath("$.data[1].userId").value(acceptMember.userId))
|
||||
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||
|
||||
// 部门有成员时不得混入回退候选
|
||||
JsonNode candidates = objectMapper.readTree(response).path("data");
|
||||
for (JsonNode candidate : candidates) {
|
||||
assertThat(candidate.path("userId").asInt())
|
||||
.isNotEqualTo(staffNoOrg.userId)
|
||||
.isNotEqualTo(disabled.userId);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToTenantUsersExcludingConsumers() throws Exception {
|
||||
long instanceId = reachDispatch("回退候选池");
|
||||
long dispatchTask = pendingTaskId(instanceId, acceptLeader.userId);
|
||||
|
||||
// 受理部门成员全部调离后部门为空,派单待办仍在负责人手中
|
||||
data.setUserOrganization(acceptLeader.userId, reportOrgId);
|
||||
data.setUserOrganization(acceptMember.userId, reportOrgId);
|
||||
|
||||
String response = mockMvc.perform(authed(get("/api/approval/task/" + dispatchTask + "/candidates"),
|
||||
acceptLeader, reportOrgId, APPROVE))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||
|
||||
JsonNode candidates = objectMapper.readTree(response).path("data");
|
||||
List<Integer> ids = new java.util.ArrayList<>();
|
||||
candidates.forEach(node -> ids.add(node.path("userId").asInt()));
|
||||
|
||||
// 无部门但持有业务角色的账号保留(含调离受理部门后的成员)
|
||||
assertThat(ids).contains(staffNoOrg.userId, reporter.userId, acceptLeader.userId, acceptMember.userId);
|
||||
// 无部门且角色仅有 user/guest、或无角色的普通消费用户被排除
|
||||
assertThat(ids).doesNotContain(consumerUser.userId, guestUser.userId, noRoleUser.userId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void dispatchesToSelectedCandidateAndCreatesBothConfirmTasks() throws Exception {
|
||||
long instanceId = reachDispatch("派单存档");
|
||||
long dispatchTask = pendingTaskId(instanceId, acceptLeader.userId);
|
||||
long otherDispatchTask = pendingTaskId(instanceId, acceptMember.userId);
|
||||
|
||||
mockMvc.perform(authed(post("/api/approval/task/" + dispatchTask + "/dispatch"),
|
||||
acceptLeader, acceptOrgId, APPROVE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"targetUserId\":" + acceptMember.userId + "}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.status").value("PENDING"))
|
||||
.andExpect(jsonPath("$.data.currentNodeName").value("存档"))
|
||||
.andExpect(jsonPath("$.data.dispatchTargetId").value(acceptMember.userId))
|
||||
.andExpect(jsonPath("$.data.dispatchTargetName").value(acceptMember.realName))
|
||||
.andExpect(jsonPath("$.data.myTask").doesNotExist());
|
||||
|
||||
// 派单任务完成并快照派单对象,同节点其余派单待办取消
|
||||
assertThat(data.queryMaster("SELECT status FROM approval_task WHERE id = ?", String.class, dispatchTask))
|
||||
.isEqualTo("DONE");
|
||||
assertThat(data.queryMaster("SELECT dispatch_target_id FROM approval_task WHERE id = ?",
|
||||
Integer.class, dispatchTask)).isEqualTo(acceptMember.userId);
|
||||
assertThat(data.queryMaster("SELECT status FROM approval_task WHERE id = ?", String.class, otherDispatchTask))
|
||||
.isEqualTo("CANCELLED");
|
||||
assertThat(data.queryMaster("SELECT dispatch_target_id FROM approval_instance WHERE id = ?",
|
||||
Integer.class, instanceId)).isEqualTo(acceptMember.userId);
|
||||
assertThat(data.queryMaster("SELECT dispatch_target_name FROM approval_instance WHERE id = ?",
|
||||
String.class, instanceId)).isEqualTo(acceptMember.realName);
|
||||
assertThat(data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?",
|
||||
String.class, instanceId)).isEqualTo("confirm");
|
||||
|
||||
// 派单记录 + 报送人与被派单人的两条存档待办
|
||||
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
|
||||
+ "AND action = 'DISPATCH' AND operator_id = ?", instanceId, acceptLeader.userId)).isEqualTo(1);
|
||||
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
|
||||
+ "AND node_type = 'CONFIRM' AND status = 'PENDING'", instanceId)).isEqualTo(2);
|
||||
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
|
||||
+ "AND assignee_id = ? AND status = 'PENDING'", instanceId, reporter.userId)).isEqualTo(1);
|
||||
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
|
||||
+ "AND assignee_id = ? AND status = 'PENDING'", instanceId, acceptMember.userId)).isEqualTo(1);
|
||||
// 被派单人不产生审批任务
|
||||
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
|
||||
+ "AND assignee_id = ? AND node_type = 'APPROVE'", instanceId, acceptMember.userId)).isZero();
|
||||
|
||||
// 被派单人立即可见:详情、附件与待办
|
||||
mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), acceptMember, acceptOrgId, LIST))
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.dispatchTargetId").value(acceptMember.userId))
|
||||
.andExpect(jsonPath("$.data.attachments.length()").value(1))
|
||||
.andExpect(jsonPath("$.data.myTask.nodeType").value("CONFIRM"))
|
||||
.andExpect(jsonPath("$.data.permissions.canConfirm").value(true));
|
||||
mockMvc.perform(authed(get("/api/approval/todo/page"), acceptMember, acceptOrgId, LIST))
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.count").value(1))
|
||||
.andExpect(jsonPath("$.data.list[0].id").value((int) instanceId));
|
||||
mockMvc.perform(authed(get("/api/approval/todo/count"), acceptMember, acceptOrgId, LIST))
|
||||
.andExpect(jsonPath("$.data").value(1));
|
||||
|
||||
// 重复派单返回「任务已处理」
|
||||
mockMvc.perform(authed(post("/api/approval/task/" + dispatchTask + "/dispatch"),
|
||||
acceptLeader, acceptOrgId, APPROVE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"targetUserId\":" + acceptLeader.userId + "}"))
|
||||
.andExpect(jsonPath("$.code").value(1))
|
||||
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("任务已处理")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsTargetOutsideCandidatePool() throws Exception {
|
||||
long instanceId = reachDispatch("越界派单");
|
||||
long dispatchTask = pendingTaskId(instanceId, acceptLeader.userId);
|
||||
|
||||
mockMvc.perform(authed(post("/api/approval/task/" + dispatchTask + "/dispatch"),
|
||||
acceptLeader, acceptOrgId, APPROVE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"targetUserId\":" + reporter.userId + "}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(1))
|
||||
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("派单对象不在候选范围内")));
|
||||
|
||||
mockMvc.perform(authed(post("/api/approval/task/" + dispatchTask + "/dispatch"),
|
||||
acceptLeader, acceptOrgId, APPROVE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{}"))
|
||||
.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, dispatchTask))
|
||||
.isEqualTo("PENDING");
|
||||
assertThat(data.queryMaster("SELECT dispatch_target_id FROM approval_instance WHERE id = ?",
|
||||
Integer.class, instanceId)).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentDispatchOnlySucceedsOnce() throws Exception {
|
||||
long instanceId = reachDispatch("并发派单");
|
||||
long leaderTask = pendingTaskId(instanceId, acceptLeader.userId);
|
||||
long memberTask = pendingTaskId(instanceId, acceptMember.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 {
|
||||
dispatchAsync(executor, start, done, leaderTask, acceptLeader, acceptOrgId,
|
||||
acceptMember.userId, success, conflict);
|
||||
dispatchAsync(executor, start, done, memberTask, acceptMember, acceptOrgId,
|
||||
acceptLeader.userId, success, conflict);
|
||||
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 = 'DISPATCH'", instanceId)).isEqualTo(1);
|
||||
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
|
||||
+ "AND node_type = 'DISPATCH' AND status = 'DONE'", instanceId)).isEqualTo(1);
|
||||
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
|
||||
+ "AND node_type = 'DISPATCH' AND status = 'CANCELLED'", instanceId)).isEqualTo(1);
|
||||
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? "
|
||||
+ "AND node_type = 'CONFIRM' AND status = 'PENDING'", instanceId)).isEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void requiresBothConfirmsBeforeArchive() throws Exception {
|
||||
long instanceId = reachDispatch("双方存档");
|
||||
long dispatchTask = pendingTaskId(instanceId, acceptLeader.userId);
|
||||
dispatch(dispatchTask, acceptLeader, acceptOrgId, acceptMember.userId);
|
||||
|
||||
// 只有报送人存档:实例仍审批中
|
||||
mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, reporter.userId) + "/confirm"),
|
||||
reporter, reportOrgId, APPROVE))
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.status").value("PENDING"))
|
||||
.andExpect(jsonPath("$.data.currentNodeName").value("存档"))
|
||||
.andExpect(jsonPath("$.data.confirms[0].status").value("DONE"))
|
||||
.andExpect(jsonPath("$.data.confirms[1].status").value("PENDING"));
|
||||
assertThat(data.queryMaster("SELECT approve_time FROM approval_instance WHERE id = ?",
|
||||
Object.class, instanceId)).isNull();
|
||||
|
||||
// 被派单人存档后双方齐全 → 审批完成并生成归档任务
|
||||
mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, acceptMember.userId) + "/confirm"),
|
||||
acceptMember, acceptOrgId, APPROVE))
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.status").value("APPROVED"))
|
||||
.andExpect(jsonPath("$.data.statusName").value("审批完成"))
|
||||
.andExpect(jsonPath("$.data.approveTime").isNotEmpty())
|
||||
.andExpect(jsonPath("$.data.currentNodeName").value("归档"));
|
||||
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
|
||||
+ "AND action = 'CONFIRM'", instanceId)).isEqualTo(2);
|
||||
|
||||
long archiveTask = pendingTaskId(instanceId, archivist.userId);
|
||||
mockMvc.perform(authed(post("/api/approval/task/" + archiveTask + "/archive"), archivist, reportOrgId, ARCHIVE))
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.status").value("ARCHIVED"))
|
||||
.andExpect(jsonPath("$.data.statusName").value("已归档"))
|
||||
.andExpect(jsonPath("$.data.archiveTime").isNotEmpty())
|
||||
.andExpect(jsonPath("$.data.archivistId").value(archivist.userId))
|
||||
.andExpect(jsonPath("$.data.permissions.canEdit").value(false));
|
||||
|
||||
// 归档后只读
|
||||
mockMvc.perform(authed(post("/api/approval/task/" + archiveTask + "/dispatch"),
|
||||
archivist, reportOrgId, APPROVE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"targetUserId\":" + acceptMember.userId + "}"))
|
||||
.andExpect(jsonPath("$.code").value(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsCandidatesAndDispatchForNonDispatchTaskOrOutsider() throws Exception {
|
||||
long instanceId = reachDispatch("派单校验");
|
||||
long dispatchTask = pendingTaskId(instanceId, acceptLeader.userId);
|
||||
|
||||
// 非派单节点任务
|
||||
long reportTask = data.queryMaster("SELECT id FROM approval_task WHERE instance_id = ? "
|
||||
+ "AND node_type = 'APPROVE' AND assignee_id = ? LIMIT 1", Long.class, instanceId, reportLeader.userId);
|
||||
mockMvc.perform(authed(get("/api/approval/task/" + reportTask + "/candidates"), reportLeader, reportOrgId, APPROVE))
|
||||
.andExpect(jsonPath("$.code").value(1))
|
||||
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("当前任务不支持派单")));
|
||||
|
||||
// 他人任务
|
||||
mockMvc.perform(authed(get("/api/approval/task/" + dispatchTask + "/candidates"), reporter, reportOrgId, APPROVE))
|
||||
.andExpect(jsonPath("$.code").value(1))
|
||||
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权处理该任务")));
|
||||
|
||||
// PC 端与员工端使用同一权限串:缺少 approve 一律 403
|
||||
mockMvc.perform(authed(get("/api/approval/task/" + dispatchTask + "/candidates"),
|
||||
acceptLeader, acceptOrgId, LIST))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(403));
|
||||
mockMvc.perform(authed(post("/api/approval/task/" + dispatchTask + "/dispatch"),
|
||||
acceptLeader, acceptOrgId, LIST)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"targetUserId\":" + acceptMember.userId + "}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(403));
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
private void dispatchAsync(ExecutorService executor, CountDownLatch start, CountDownLatch done,
|
||||
long taskId, ApprovalTestData.TestUser actor, Integer org,
|
||||
int targetUserId, AtomicInteger success, AtomicInteger conflict) {
|
||||
executor.submit(() -> {
|
||||
try {
|
||||
start.await();
|
||||
boolean ok = mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/dispatch"),
|
||||
actor, org, APPROVE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"targetUserId\":" + targetUserId + "}"))
|
||||
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8)
|
||||
.contains("\"code\":0");
|
||||
if (ok) {
|
||||
success.incrementAndGet();
|
||||
} else {
|
||||
conflict.incrementAndGet();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
conflict.incrementAndGet();
|
||||
} finally {
|
||||
done.countDown();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void dispatch(long taskId, ApprovalTestData.TestUser actor, Integer org, int targetUserId)
|
||||
throws Exception {
|
||||
mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/dispatch"), actor, org, APPROVE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"targetUserId\":" + targetUserId + "}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建并推进内部协调单到派单节点:提交 → 报送负责人通过 → 受理负责人通过。
|
||||
*/
|
||||
private long reachDispatch(String title) throws Exception {
|
||||
String body = "{\"acceptOrgId\":" + acceptOrgId
|
||||
+ ",\"title\":" + quote(title) + ",\"content\":\"协调内容\"}";
|
||||
String response = mockMvc.perform(authed(post("/api/approval/instance/coordination"),
|
||||
reporter, reportOrgId, 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, reportOrgId, SUBMIT))
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
|
||||
mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, reportLeader.userId) + "/approve"),
|
||||
reportLeader, reportOrgId, APPROVE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"opinion\":\"同意协调\"}"))
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.currentNodeName").value("受理部门负责人"));
|
||||
|
||||
mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, acceptLeader.userId) + "/approve"),
|
||||
acceptLeader, acceptOrgId, APPROVE)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"opinion\":\"同意派单\"}"))
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.currentNodeName").value("派单"))
|
||||
.andExpect(jsonPath("$.data.permissions.canDispatch").value(true));
|
||||
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,
|
||||
reportOrgId, 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