diff --git a/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java b/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java new file mode 100644 index 0000000..58af23e --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java @@ -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> todo(ApprovalTaskListParam param) { + return success(instanceService.todo(param, getLoginUser())); + } + + @PreAuthorize("hasAuthority('approval:instance:list')") + @ApiOperation("我的已办") + @GetMapping("/done/page") + public ApiResult> done(ApprovalTaskListParam param) { + return success(instanceService.done(param, getLoginUser())); + } + + @PreAuthorize("hasAuthority('approval:instance:list')") + @ApiOperation("待办数量") + @GetMapping("/todo/count") + public ApiResult todoCount() { + return success(taskService.todoCount(getLoginUser())); + } + + @PreAuthorize("hasAuthority('approval:instance:approve')") + @ApiOperation("通过") + @PostMapping("/task/{id}/approve") + public ApiResult 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 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 withdraw(@PathVariable("id") Long id) { + User user = getLoginUser(); + ApprovalTaskService.Result result = taskService.withdraw(id, user); + return success("撤回成功", instanceService.detail(result.getInstanceId(), user)); + } +} diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalTaskMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalTaskMapper.java index 9326221..ece5152 100644 --- a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalTaskMapper.java +++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalTaskMapper.java @@ -1,10 +1,29 @@ package com.gxwebsoft.approval.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.core.metadata.IPage; 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。 */ public interface ApprovalTaskMapper extends BaseMapper { + + /** + * 待办/已办列表:按本人任务状态取实例,天然按实例去重。 + */ + List selectTaskInstancePage(@Param("page") IPage page, + @Param("param") ApprovalTaskListParam param, + @Param("userId") Integer userId, + @Param("taskStatus") String taskStatus); + + /** + * 本人待办实例数量(同一实例多任务只算一条)。 + */ + Integer countPendingInstances(@Param("userId") Integer userId); } diff --git a/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalTaskMapper.xml b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalTaskMapper.xml new file mode 100644 index 0000000..cedbc0b --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalTaskMapper.xml @@ -0,0 +1,50 @@ + + + + + + + + + diff --git a/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskActionParam.java b/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskActionParam.java new file mode 100644 index 0000000..5f64d3e --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskActionParam.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 ApprovalTaskActionParam implements Serializable { + private static final long serialVersionUID = 1L; + + @ApiModelProperty("处理意见;驳回必填") + private String opinion; +} diff --git a/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskListParam.java b/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskListParam.java new file mode 100644 index 0000000..8bb757b --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskListParam.java @@ -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; +} diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalDirectoryService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalDirectoryService.java index c032296..132d6f9 100644 --- a/src/main/java/com/gxwebsoft/approval/service/ApprovalDirectoryService.java +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalDirectoryService.java @@ -158,6 +158,24 @@ public class ApprovalDirectoryService { return jdbcTemplate.query(sql, USER_MAPPER, args.toArray()); } + /** 按id批量取启用用户;停用或已删除的用户不返回(节点缺配时不生成任务)。 */ + public List listActiveUsersByIds(List 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 args = new java.util.ArrayList<>(); + args.add(tenantId); + args.add(tenantId); + args.addAll(userIds); + return jdbcTemplate.query(sql, USER_MAPPER, args.toArray()); + } + /** 是否持有任一角色(用于 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/ApprovalEngineService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalEngineService.java index 4711d23..7d9b789 100644 --- a/src/main/java/com/gxwebsoft/approval/service/ApprovalEngineService.java +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalEngineService.java @@ -63,6 +63,22 @@ public class ApprovalEngineService { return createTasks(instance, node, instance.getRound(), approvers); } + /** + * 当前节点处理完成后推进到下一节点:更新当前节点并快照生成任务。 + * + *

与 {@link #activateNode} 的区别:处理记录已由调用方按任务补充 taskId, + * 因此这里只更新节点与任务,不重复写处理记录。

+ * + * @return 生成的任务数;0 表示节点缺配 + */ + public int moveToNode(ApprovalInstance instance, ApprovalNode node, Integer round, + List approvers) { + instance.setCurrentNodeKey(node.getKey()); + instance.setCurrentNodeType(node.getType()); + instanceMapper.updateById(instance); + return createTasks(instance, node, round, approvers); + } + /** * 对实例行加锁,串行化节点推进。 */ diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java index 7142dc9..d2b6af0 100644 --- a/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java @@ -17,6 +17,7 @@ import com.gxwebsoft.approval.mapper.ApprovalRecordMapper; import com.gxwebsoft.approval.mapper.ApprovalTaskMapper; import com.gxwebsoft.approval.param.ApprovalCompletionParam; import com.gxwebsoft.approval.param.ApprovalMineParam; +import com.gxwebsoft.approval.param.ApprovalTaskListParam; import com.gxwebsoft.approval.support.ApprovalApprover; import com.gxwebsoft.approval.support.ApprovalItemRow; import com.gxwebsoft.approval.support.ApprovalNode; @@ -216,6 +217,32 @@ public class ApprovalInstanceService { long size = param.getLimit() == null ? 10 : Math.max(1, param.getLimit()); Page page = new Page<>(current, size); List rows = instanceMapper.selectMinePage(page, param, user.getUserId()); + return new PageResult<>(toItems(rows), page.getTotal()); + } + + /** + * 我的待办:本人 PENDING 任务对应的实例,按实例去重。 + */ + public PageResult todo(ApprovalTaskListParam param, User user) { + return taskPage(param, user, ApprovalConstants.TASK_PENDING); + } + + /** + * 我的已办:本人 DONE 任务对应的实例,按实例去重。 + */ + public PageResult done(ApprovalTaskListParam param, User user) { + return taskPage(param, user, ApprovalConstants.TASK_DONE); + } + + private PageResult 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 page = new Page<>(current, size); + List rows = taskMapper.selectTaskInstancePage(page, param, user.getUserId(), taskStatus); + return new PageResult<>(toItems(rows), page.getTotal()); + } + + private List toItems(List rows) { Map> nodeCache = new HashMap<>(); List list = new ArrayList<>(); for (ApprovalItemRow row : rows) { @@ -236,7 +263,7 @@ public class ApprovalInstanceService { item.setCurrentNodeName(nodeName(nodeCache, row.getDefinitionId(), row.getCurrentNodeKey())); list.add(item); } - return new PageResult<>(list, page.getTotal()); + return list; } private Long createDraft(ApprovalCompletionParam param, User user, ApprovalFlowDefinition definition, diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java new file mode 100644 index 0000000..0cf213d --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java @@ -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; + +/** + * 审批任务动作:通过、驳回、撤回与节点推进。 + * + *

跨库约束:审批人解析必须走 {@link ApprovalDirectoryService}(core 专用连接), + * 在事务外完成;写 master 的部分放进 TransactionTemplate。任务处理使用 + * WHERE status='PENDING' 的条件更新,抢先后到者收到「任务已处理」。

+ */ +@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 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 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() + .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() + .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() + .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 wrapper = new UpdateWrapper() + .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 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 confirmCandidateIds(ApprovalInstance instance) { + List 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 roleApprovers(String roleCode) { + if (StrUtil.isBlank(roleCode)) { + return Collections.emptyList(); + } + return toApprovers(directoryService.listRoleMembers(roleCode)); + } + + private List userApprovers(List userIds) { + return toApprovers(directoryService.listActiveUsersByIds(userIds)); + } + + private List organizationApprovers(Integer organizationId) { + return toApprovers(directoryService.listOrganizationMembers(organizationId)); + } + + private List toApprovers(List 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 inTransaction(Supplier 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; + } + } +} diff --git a/src/test/java/com/gxwebsoft/approval/ApprovalTaskIT.java b/src/test/java/com/gxwebsoft/approval/ApprovalTaskIT.java new file mode 100644 index 0000000..4dfc907 --- /dev/null +++ b/src/test/java/com/gxwebsoft/approval/ApprovalTaskIT.java @@ -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; + +/** + * 审批中心:待办/已办、通过/驳回、撤回、或签与并发处理的集成测试。 + * + *

本类不开启测试事务:动作接口各自独立提交,便于覆盖并发条件更新; + * 数据由 {@link ApprovalTestData#cleanup()} 清理。

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