diff --git a/src/main/java/com/gxwebsoft/approval/constant/ApprovalConstants.java b/src/main/java/com/gxwebsoft/approval/constant/ApprovalConstants.java index e27ff0e..ed5bbaf 100644 --- a/src/main/java/com/gxwebsoft/approval/constant/ApprovalConstants.java +++ b/src/main/java/com/gxwebsoft/approval/constant/ApprovalConstants.java @@ -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; diff --git a/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java b/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java index f6c4fa5..8301b34 100644 --- a/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java +++ b/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java @@ -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> 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 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("改派") diff --git a/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskDispatchParam.java b/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskDispatchParam.java new file mode 100644 index 0000000..3b062f4 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskDispatchParam.java @@ -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; +} diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalDirectoryService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalDirectoryService.java index 132d6f9..ce9e400 100644 --- a/src/main/java/com/gxwebsoft/approval/service/ApprovalDirectoryService.java +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalDirectoryService.java @@ -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()); } + /** + * 派单候选池:优先受理部门启用成员;部门无成员时回退为当前租户的非普通消费用户。 + * + *

回退排除「未归属组织且角色仅为 user/guest 或无角色」的账号; + * 部门有成员时不混入回退候选。

+ */ + public List listDispatchCandidates(Integer acceptOrganizationId) { + List 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) { diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java index da03752..7868be4 100644 --- a/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java @@ -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())); diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java index 504d349..381dd26 100644 --- a/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java @@ -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 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 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 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() + .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 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 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() + .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)); + } + /** * 改派前的基本校验:任务存在、待处理且单据仍在审批中。 */ diff --git a/src/test/java/com/gxwebsoft/approval/ApprovalDispatchIT.java b/src/test/java/com/gxwebsoft/approval/ApprovalDispatchIT.java new file mode 100644 index 0000000..139a9ec --- /dev/null +++ b/src/test/java/com/gxwebsoft/approval/ApprovalDispatchIT.java @@ -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 派单权限。 + * + *

独立租户隔离角色与用户,避免与并行执行的其它审批 IT 互相污染。

+ */ +@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 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("\"", "\\\"") + "\""; + } +}