Merge branch 'feat/approval-phase1' into feat/approval-t21
This commit is contained in:
@@ -34,7 +34,7 @@ public class ApprovalDirectoryController extends BaseController {
|
||||
return success(directoryService.listOrganizations());
|
||||
}
|
||||
|
||||
@PreAuthorize("hasAuthority('approval:instance:assign')")
|
||||
@PreAuthorize("hasAnyAuthority('approval:instance:list', 'approval:instance:assign')")
|
||||
@ApiOperation("启用用户搜索")
|
||||
@GetMapping("/users")
|
||||
public ApiResult<List<ApprovalUserVO>> users(
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.gxwebsoft.approval.controller;
|
||||
|
||||
import com.gxwebsoft.approval.param.ApprovalQueryParam;
|
||||
import com.gxwebsoft.approval.service.ApprovalQueryService;
|
||||
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 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.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
|
||||
/**
|
||||
* PC 单据查询接口。
|
||||
*/
|
||||
@Api(tags = "审批单据查询")
|
||||
@RestController
|
||||
@RequestMapping("/api/approval")
|
||||
public class ApprovalQueryController extends BaseController {
|
||||
|
||||
@Resource
|
||||
private ApprovalQueryService queryService;
|
||||
|
||||
@PreAuthorize("hasAuthority('approval:instance:list')")
|
||||
@ApiOperation("PC 单据查询分页")
|
||||
@GetMapping("/instance/page")
|
||||
public ApiResult<PageResult<ApprovalItemVO>> page(ApprovalQueryParam param) {
|
||||
return success(queryService.page(param, getLoginUser()));
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
package com.gxwebsoft.approval.controller;
|
||||
|
||||
import com.gxwebsoft.approval.param.ApprovalTaskActionParam;
|
||||
import com.gxwebsoft.approval.param.ApprovalTaskAssignParam;
|
||||
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.common.core.annotation.OperationLog;
|
||||
import com.gxwebsoft.common.core.web.ApiResult;
|
||||
import com.gxwebsoft.common.core.web.BaseController;
|
||||
import com.gxwebsoft.common.core.web.PageResult;
|
||||
@@ -104,4 +107,13 @@ public class ApprovalTaskController extends BaseController {
|
||||
ApprovalTaskService.Result result = taskService.withdraw(id, user);
|
||||
return success("撤回成功", instanceService.detail(result.getInstanceId(), user));
|
||||
}
|
||||
|
||||
@PreAuthorize("hasAuthority('approval:instance:assign')")
|
||||
@OperationLog(value = "改派审批待办", module = "审批管理")
|
||||
@ApiOperation("改派")
|
||||
@PostMapping("/task/{id}/assign")
|
||||
public ApiResult<ApprovalAssignVO> assign(@PathVariable("id") Long id,
|
||||
@RequestBody(required = false) ApprovalTaskAssignParam param) {
|
||||
return success("改派成功", taskService.assign(id, param, getLoginUser()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.gxwebsoft.approval.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.gxwebsoft.approval.param.ApprovalQueryParam;
|
||||
import com.gxwebsoft.approval.support.ApprovalItemRow;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* PC 单据查询Mapper:两类单据联表单,数据权限条件与详情一致。
|
||||
*/
|
||||
public interface ApprovalQueryMapper {
|
||||
|
||||
/**
|
||||
* 单据查询分页,默认提交时间倒序、同秒 id 倒序。
|
||||
*/
|
||||
List<ApprovalItemRow> selectInstancePage(@Param("page") IPage<ApprovalItemRow> page,
|
||||
@Param("param") ApprovalQueryParam param,
|
||||
@Param("userId") Integer userId,
|
||||
@Param("allVisible") Integer allVisible);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.gxwebsoft.approval.mapper.ApprovalQueryMapper">
|
||||
|
||||
<!-- PC 单据查询:数据权限条件与详情保持一致(草稿仅发起人可见) -->
|
||||
<select id="selectInstancePage" resultType="com.gxwebsoft.approval.support.ApprovalItemRow">
|
||||
SELECT i.id,
|
||||
i.biz_no,
|
||||
i.doc_type,
|
||||
i.status,
|
||||
i.reporter_id,
|
||||
i.reporter_name,
|
||||
i.report_org_name,
|
||||
i.accept_org_name,
|
||||
i.archive_time,
|
||||
i.current_node_key,
|
||||
i.definition_id,
|
||||
i.submit_time,
|
||||
COALESCE(f.name, co.title) AS title
|
||||
FROM approval_instance i
|
||||
LEFT JOIN approval_completion_form f ON f.instance_id = i.id
|
||||
LEFT JOIN approval_coordination_form co ON co.instance_id = i.id
|
||||
WHERE (
|
||||
i.reporter_id = #{userId}
|
||||
OR (i.status != 'DRAFT' AND (
|
||||
#{allVisible} = 1
|
||||
OR EXISTS (SELECT 1 FROM approval_task t WHERE t.instance_id = i.id AND t.assignee_id = #{userId})
|
||||
OR EXISTS (SELECT 1 FROM approval_record r WHERE r.instance_id = i.id AND r.operator_id = #{userId})
|
||||
OR i.dispatch_target_id = #{userId}
|
||||
))
|
||||
)
|
||||
<if test="param.docType != null and param.docType != ''">
|
||||
AND i.doc_type = #{param.docType}
|
||||
</if>
|
||||
<if test="param.bizNo != null and param.bizNo != ''">
|
||||
AND i.biz_no LIKE CONCAT('%', #{param.bizNo}, '%')
|
||||
</if>
|
||||
<if test="param.reportOrgId != null">
|
||||
AND i.report_org_id = #{param.reportOrgId}
|
||||
</if>
|
||||
<if test="param.acceptOrgId != null">
|
||||
AND i.accept_org_id = #{param.acceptOrgId}
|
||||
</if>
|
||||
<if test="param.reporterId != null">
|
||||
AND i.reporter_id = #{param.reporterId}
|
||||
</if>
|
||||
<if test="param.status != null and param.status != ''">
|
||||
AND i.status = #{param.status}
|
||||
</if>
|
||||
<if test="param.keyword != null and param.keyword != ''">
|
||||
AND (i.biz_no LIKE CONCAT('%', #{param.keyword}, '%')
|
||||
OR f.name LIKE CONCAT('%', #{param.keyword}, '%')
|
||||
OR co.title LIKE CONCAT('%', #{param.keyword}, '%')
|
||||
OR f.implement_org LIKE CONCAT('%', #{param.keyword}, '%'))
|
||||
</if>
|
||||
<if test="param.submitTimeStart != null and param.submitTimeStart != ''">
|
||||
AND i.submit_time >= #{param.submitTimeStart}
|
||||
</if>
|
||||
<if test="param.submitTimeEnd != null and param.submitTimeEnd != ''">
|
||||
AND i.submit_time <= #{param.submitTimeEnd}
|
||||
</if>
|
||||
<if test="param.archiveTimeStart != null and param.archiveTimeStart != ''">
|
||||
AND i.archive_time >= #{param.archiveTimeStart}
|
||||
</if>
|
||||
<if test="param.archiveTimeEnd != null and param.archiveTimeEnd != ''">
|
||||
AND i.archive_time <= #{param.archiveTimeEnd}
|
||||
</if>
|
||||
ORDER BY (i.submit_time IS NULL) ASC, i.submit_time DESC, i.id DESC
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.gxwebsoft.approval.param;
|
||||
|
||||
import com.gxwebsoft.common.core.web.BaseParam;
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/**
|
||||
* PC 单据查询参数:两类单据统一检索,含归档单据。
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@ApiModel(description = "审批单据查询参数")
|
||||
public class ApprovalQueryParam extends BaseParam {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@ApiModelProperty("单据类型 completion/coordination")
|
||||
private String docType;
|
||||
|
||||
@ApiModelProperty("单号")
|
||||
private String bizNo;
|
||||
|
||||
@ApiModelProperty("关键词:单号、名称或标题、实施单位")
|
||||
private String keyword;
|
||||
|
||||
@ApiModelProperty("报送部门id")
|
||||
private Integer reportOrgId;
|
||||
|
||||
@ApiModelProperty("受理部门id")
|
||||
private Integer acceptOrgId;
|
||||
|
||||
@ApiModelProperty("发起人id")
|
||||
private Integer reporterId;
|
||||
|
||||
@ApiModelProperty("单据状态")
|
||||
private String status;
|
||||
|
||||
@ApiModelProperty("提交时间起,yyyy-MM-dd HH:mm:ss")
|
||||
private String submitTimeStart;
|
||||
|
||||
@ApiModelProperty("提交时间止,yyyy-MM-dd HH:mm:ss")
|
||||
private String submitTimeEnd;
|
||||
|
||||
@ApiModelProperty("归档时间起,yyyy-MM-dd HH:mm:ss")
|
||||
private String archiveTimeStart;
|
||||
|
||||
@ApiModelProperty("归档时间止,yyyy-MM-dd HH:mm:ss")
|
||||
private String archiveTimeEnd;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.gxwebsoft.approval.param;
|
||||
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 待办改派参数。
|
||||
*/
|
||||
@Data
|
||||
public class ApprovalTaskAssignParam implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@ApiModelProperty("改派对象用户id")
|
||||
private Integer targetUserId;
|
||||
|
||||
@ApiModelProperty("改派原因;必填")
|
||||
private String reason;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.gxwebsoft.approval.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.gxwebsoft.approval.constant.ApprovalConstants;
|
||||
import com.gxwebsoft.approval.mapper.ApprovalQueryMapper;
|
||||
import com.gxwebsoft.approval.param.ApprovalQueryParam;
|
||||
import com.gxwebsoft.approval.support.ApprovalItemRow;
|
||||
import com.gxwebsoft.approval.support.ApprovalNode;
|
||||
import com.gxwebsoft.approval.vo.ApprovalItemVO;
|
||||
import com.gxwebsoft.common.core.web.PageResult;
|
||||
import com.gxwebsoft.common.system.entity.User;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* PC 单据查询服务:两类单据统一分页检索,列表与详情共用同一套数据权限条件。
|
||||
*
|
||||
* <p>跨库约束:allVisible 角色判定走 core 专用连接,本方法不开启事务。</p>
|
||||
*/
|
||||
@Service
|
||||
public class ApprovalQueryService {
|
||||
|
||||
private static final long DEFAULT_LIMIT = 10L;
|
||||
|
||||
@Resource
|
||||
private ApprovalQueryMapper queryMapper;
|
||||
|
||||
@Resource
|
||||
private ApprovalFlowService flowService;
|
||||
|
||||
@Resource
|
||||
private ApprovalDirectoryService directoryService;
|
||||
|
||||
/**
|
||||
* 单据查询分页,默认提交时间倒序、同秒按 id 倒序。
|
||||
*/
|
||||
public PageResult<ApprovalItemVO> page(ApprovalQueryParam param, User user) {
|
||||
long current = param.getPage() == null ? 1 : Math.max(1, param.getPage());
|
||||
long size = param.getLimit() == null ? DEFAULT_LIMIT : Math.max(1, param.getLimit());
|
||||
int allVisible = directoryService.hasAnyRole(user.getUserId(),
|
||||
ApprovalConstants.ROLE_SUPER_ADMIN, ApprovalConstants.ROLE_ARCHIVIST) ? 1 : 0;
|
||||
Page<ApprovalItemRow> page = new Page<>(current, size);
|
||||
List<ApprovalItemRow> rows = queryMapper.selectInstancePage(page, param, user.getUserId(), allVisible);
|
||||
Map<Long, List<ApprovalNode>> nodeCache = new HashMap<>();
|
||||
List<ApprovalItemVO> list = new ArrayList<>(rows.size());
|
||||
for (ApprovalItemRow row : rows) {
|
||||
list.add(toItem(row, nodeCache));
|
||||
}
|
||||
return new PageResult<>(list, page.getTotal());
|
||||
}
|
||||
|
||||
private ApprovalItemVO toItem(ApprovalItemRow row, Map<Long, List<ApprovalNode>> nodeCache) {
|
||||
ApprovalItemVO item = new ApprovalItemVO();
|
||||
item.setId(row.getId());
|
||||
item.setBizNo(row.getBizNo());
|
||||
item.setDocType(row.getDocType());
|
||||
item.setDocTypeName(ApprovalConstants.docTypeName(row.getDocType()));
|
||||
item.setStatus(row.getStatus());
|
||||
item.setStatusName(ApprovalConstants.statusName(row.getStatus()));
|
||||
item.setTitle(row.getTitle());
|
||||
item.setSubmitTime(row.getSubmitTime());
|
||||
item.setReporterId(row.getReporterId());
|
||||
item.setReporterName(row.getReporterName());
|
||||
item.setReportOrgName(row.getReportOrgName());
|
||||
item.setAcceptOrgName(row.getAcceptOrgName());
|
||||
item.setArchiveTime(row.getArchiveTime());
|
||||
item.setCurrentNodeName(nodeName(nodeCache, row.getDefinitionId(), row.getCurrentNodeKey()));
|
||||
return item;
|
||||
}
|
||||
|
||||
private String nodeName(Map<Long, List<ApprovalNode>> cache, Long definitionId, String nodeKey) {
|
||||
if (definitionId == null || nodeKey == null) {
|
||||
return null;
|
||||
}
|
||||
List<ApprovalNode> nodes = cache.computeIfAbsent(definitionId, flowService::parseNodes);
|
||||
return nodes.stream()
|
||||
.filter(node -> nodeKey.equals(node.getKey()))
|
||||
.map(ApprovalNode::getName)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,10 @@ 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.param.ApprovalTaskAssignParam;
|
||||
import com.gxwebsoft.approval.support.ApprovalApprover;
|
||||
import com.gxwebsoft.approval.support.ApprovalNode;
|
||||
import com.gxwebsoft.approval.vo.ApprovalAssignVO;
|
||||
import com.gxwebsoft.approval.vo.ApprovalUserVO;
|
||||
import com.gxwebsoft.common.core.exception.BusinessException;
|
||||
import com.gxwebsoft.common.system.entity.User;
|
||||
@@ -151,6 +153,28 @@ public class ApprovalTaskService {
|
||||
return new Result(instanceId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 改派:管理员把待处理任务换给同租户其他启用用户,原因必填并写 ASSIGN 记录。
|
||||
*
|
||||
* <p>改派不改变任务状态,只替换处理人;操作人不必是任务当前处理人。</p>
|
||||
*/
|
||||
public ApprovalAssignVO assign(Long taskId, ApprovalTaskAssignParam param, User user) {
|
||||
if (param == null || param.getTargetUserId() == null) {
|
||||
throw new BusinessException("请选择改派对象");
|
||||
}
|
||||
String reason = requireAssignReason(param.getReason());
|
||||
ApprovalTask task = requireAssignableTask(taskId);
|
||||
// 事务外解析目标用户:启用、未删除且属于当前租户(@DS("server") 查询显式带租户)
|
||||
List<ApprovalUserVO> targets =
|
||||
directoryService.listActiveUsersByIds(Collections.singletonList(param.getTargetUserId()));
|
||||
if (targets.isEmpty()) {
|
||||
throw new BusinessException("改派对象不存在或已停用");
|
||||
}
|
||||
ApprovalUserVO target = targets.get(0);
|
||||
String targetName = displayName(target);
|
||||
return inTransaction(() -> doAssign(taskId, task, target.getUserId(), targetName, reason, user));
|
||||
}
|
||||
|
||||
public Integer todoCount(User user) {
|
||||
return taskMapper.countPendingInstances(user.getUserId());
|
||||
}
|
||||
@@ -274,6 +298,58 @@ public class ApprovalTaskService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 事务内改派:锁实例后条件更新处理人,并追加 ASSIGN 记录。
|
||||
*/
|
||||
private ApprovalAssignVO doAssign(Long taskId, ApprovalTask task, Integer targetUserId,
|
||||
String targetName, String reason, User user) {
|
||||
ApprovalInstance locked = engineService.lockInstance(task.getInstanceId());
|
||||
ensureTaskHandleable(task, locked);
|
||||
int rows = taskMapper.update(null, new UpdateWrapper<ApprovalTask>()
|
||||
.eq("id", taskId)
|
||||
.eq("status", ApprovalConstants.TASK_PENDING)
|
||||
.set("assignee_id", targetUserId)
|
||||
.set("assignee_name", targetName));
|
||||
if (rows == 0) {
|
||||
throw new BusinessException("任务已处理");
|
||||
}
|
||||
engineService.addRecord(locked, taskId, task.getNodeKey(), user,
|
||||
ApprovalConstants.ACTION_ASSIGN, reason);
|
||||
ApprovalAssignVO vo = new ApprovalAssignVO();
|
||||
vo.setInstanceId(locked.getId());
|
||||
vo.setTaskId(taskId);
|
||||
vo.setOriginalAssigneeId(task.getAssigneeId());
|
||||
vo.setOriginalAssigneeName(task.getAssigneeName());
|
||||
vo.setAssigneeId(targetUserId);
|
||||
vo.setAssigneeName(targetName);
|
||||
vo.setReason(reason);
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 改派前的基本校验:任务存在、待处理且单据仍在审批中。
|
||||
*/
|
||||
private ApprovalTask requireAssignableTask(Long taskId) {
|
||||
if (taskId == null) {
|
||||
throw new BusinessException("任务不存在");
|
||||
}
|
||||
ApprovalTask task = taskMapper.selectById(taskId);
|
||||
if (task == null) {
|
||||
throw new BusinessException("任务不存在");
|
||||
}
|
||||
if (!ApprovalConstants.TASK_PENDING.equals(task.getStatus())) {
|
||||
throw new BusinessException("任务已处理");
|
||||
}
|
||||
ApprovalInstance instance = instanceMapper.selectById(task.getInstanceId());
|
||||
if (instance == null) {
|
||||
throw new BusinessException("单据不存在");
|
||||
}
|
||||
if (!ApprovalConstants.STATUS_PENDING.equals(instance.getStatus())) {
|
||||
throw new BusinessException("单据不在审批中,无法改派");
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务必须属于实例当前轮次、当前节点,否则视为已被处理或已失效。
|
||||
*/
|
||||
@@ -433,6 +509,14 @@ public class ApprovalTaskService {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String requireAssignReason(String reason) {
|
||||
String normalized = normalizeOpinion(reason);
|
||||
if (normalized == null) {
|
||||
throw new BusinessException("请填写改派原因");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private <T> T inTransaction(Supplier<T> action) {
|
||||
return new TransactionTemplate(transactionManager).execute(status -> action.get());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.gxwebsoft.approval.vo;
|
||||
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 改派结果:原处理人、新处理人与原因,同时用于操作日志留痕。
|
||||
*/
|
||||
@Data
|
||||
@ApiModel(description = "改派结果")
|
||||
public class ApprovalAssignVO implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private Long instanceId;
|
||||
|
||||
private Long taskId;
|
||||
|
||||
private Integer originalAssigneeId;
|
||||
|
||||
private String originalAssigneeName;
|
||||
|
||||
private Integer assigneeId;
|
||||
|
||||
private String assigneeName;
|
||||
|
||||
private String reason;
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
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.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
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>覆盖:改派成功与待办归属、目标用户校验、非待办任务、原因必填、改派权限 403、
|
||||
* ASSIGN 处理记录、操作日志以及改派候选用户查询。</p>
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles({"gxtyzx-test", "test"})
|
||||
class ApprovalPcTodoIT {
|
||||
|
||||
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";
|
||||
private static final String ASSIGN = "approval:instance:assign";
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
@Autowired
|
||||
private DataSource dataSource;
|
||||
|
||||
private ApprovalTestData data;
|
||||
private ApprovalTestData.TestUser reporter;
|
||||
private ApprovalTestData.TestUser leader;
|
||||
private ApprovalTestData.TestUser admin;
|
||||
private Integer organizationId;
|
||||
private final List<String> assignUrls = new ArrayList<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
data = new ApprovalTestData(dataSource);
|
||||
organizationId = data.createOrganization("PC待办部门" + data.uniqueSuffix(), 136);
|
||||
reporter = data.createUser("PC报送人" + data.uniqueSuffix(), organizationId);
|
||||
ApprovalTestData.TestRole leaderRole =
|
||||
data.createRole("dept_leader_" + organizationId, "部门负责人");
|
||||
leader = data.createUser("PC负责人" + data.uniqueSuffix(), organizationId);
|
||||
data.bind(leader.userId, leaderRole.roleId);
|
||||
ApprovalTestData.TestRole archivistRole = data.createRole("approval_archivist", "收文员");
|
||||
ApprovalTestData.TestUser archivist = data.createUser("PC收文员" + data.uniqueSuffix(), organizationId);
|
||||
data.bind(archivist.userId, archivistRole.roleId);
|
||||
admin = data.createUser("PC管理员" + data.uniqueSuffix(), organizationId);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
for (String url : assignUrls) {
|
||||
data.server().update("DELETE FROM sys_operation_record WHERE user_id = ? AND url = ?",
|
||||
admin.userId, url);
|
||||
}
|
||||
assignUrls.clear();
|
||||
data.cleanup();
|
||||
}
|
||||
|
||||
@Test
|
||||
void assignsPendingTaskAndMovesTodoToTargetUser() throws Exception {
|
||||
long instanceId = createAndSubmit("改派单据");
|
||||
long taskId = pendingTaskId(instanceId, leader.userId);
|
||||
ApprovalTestData.TestUser target = data.createUser("改派目标" + data.uniqueSuffix(), organizationId);
|
||||
String reason = "原处理人休假" + data.uniqueSuffix();
|
||||
|
||||
mockMvc.perform(assign(taskId, admin, ASSIGN, target.userId, reason))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.instanceId").value((int) instanceId))
|
||||
.andExpect(jsonPath("$.data.taskId").value((int) taskId))
|
||||
.andExpect(jsonPath("$.data.originalAssigneeId").value(leader.userId))
|
||||
.andExpect(jsonPath("$.data.assigneeId").value(target.userId))
|
||||
.andExpect(jsonPath("$.data.assigneeName").value(target.realName))
|
||||
.andExpect(jsonPath("$.data.reason").value(reason));
|
||||
|
||||
// 任务仍为待处理,归属已换人
|
||||
assertThat(data.queryMaster("SELECT status FROM approval_task WHERE id = ?", String.class, taskId))
|
||||
.isEqualTo("PENDING");
|
||||
assertThat(data.queryMaster("SELECT assignee_id FROM approval_task WHERE id = ?", Integer.class, taskId))
|
||||
.isEqualTo(target.userId);
|
||||
assertThat(data.queryMaster("SELECT assignee_name FROM approval_task WHERE id = ?", String.class, taskId))
|
||||
.isEqualTo(target.realName);
|
||||
|
||||
// 处理记录留痕:改派人与原因
|
||||
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? "
|
||||
+ "AND action = 'ASSIGN' AND operator_id = ? AND opinion = ?", instanceId, admin.userId, reason))
|
||||
.isEqualTo(1);
|
||||
|
||||
// 原处理人待办消失、新处理人待办出现
|
||||
mockMvc.perform(authed(get("/api/approval/todo/page"), leader, organizationId, LIST))
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.count").value(0));
|
||||
mockMvc.perform(authed(get("/api/approval/todo/page"), target, organizationId, LIST))
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.count").value(1))
|
||||
.andExpect(jsonPath("$.data.list[0].id").value((int) instanceId))
|
||||
.andExpect(jsonPath("$.data.list[0].currentNodeName").value("报送部门负责人"));
|
||||
|
||||
// 新处理人可正常处理改派后的任务
|
||||
mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"), target, organizationId, APPROVE)
|
||||
.contentType(MediaType.APPLICATION_JSON).content("{\"opinion\":\"接办\"}"))
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.currentNodeName").value("存档"));
|
||||
|
||||
// 原处理人已无权处理该任务
|
||||
long second = createAndSubmit("改派后原处理人");
|
||||
long secondTask = pendingTaskId(second, leader.userId);
|
||||
mockMvc.perform(assign(secondTask, admin, ASSIGN, target.userId, "转办" + data.uniqueSuffix()))
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
mockMvc.perform(authed(post("/api/approval/task/" + secondTask + "/approve"), leader, organizationId, APPROVE)
|
||||
.contentType(MediaType.APPLICATION_JSON).content("{}"))
|
||||
.andExpect(jsonPath("$.code").value(1))
|
||||
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权处理该任务")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAssignToUnknownOrInactiveUser() throws Exception {
|
||||
long instanceId = createAndSubmit("改派无效目标");
|
||||
long taskId = pendingTaskId(instanceId, leader.userId);
|
||||
|
||||
ApprovalTestData.TestUser inactive = data.createUser("停用目标" + data.uniqueSuffix(), organizationId);
|
||||
data.server().update("UPDATE sys_user SET status = 1 WHERE user_id = ?", inactive.userId);
|
||||
|
||||
mockMvc.perform(assign(taskId, admin, ASSIGN, inactive.userId, "停用目标改派"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(1))
|
||||
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("改派对象不存在或已停用")));
|
||||
|
||||
mockMvc.perform(assign(taskId, admin, ASSIGN, 99999999, "不存在用户改派"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(1))
|
||||
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("改派对象不存在或已停用")));
|
||||
|
||||
assertThat(data.queryMaster("SELECT assignee_id FROM approval_task WHERE id = ?", Integer.class, taskId))
|
||||
.isEqualTo(leader.userId);
|
||||
assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'ASSIGN'",
|
||||
instanceId)).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void requiresAssignReason() throws Exception {
|
||||
long instanceId = createAndSubmit("改派无原因");
|
||||
long taskId = pendingTaskId(instanceId, leader.userId);
|
||||
ApprovalTestData.TestUser target = data.createUser("改派目标" + data.uniqueSuffix(), organizationId);
|
||||
|
||||
mockMvc.perform(assign(taskId, admin, ASSIGN, target.userId, " "))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(1))
|
||||
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("请填写改派原因")));
|
||||
|
||||
assertThat(data.queryMaster("SELECT assignee_id FROM approval_task WHERE id = ?", Integer.class, taskId))
|
||||
.isEqualTo(leader.userId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAssignForHandledTask() throws Exception {
|
||||
long instanceId = createAndSubmit("改派已处理任务");
|
||||
long taskId = pendingTaskId(instanceId, leader.userId);
|
||||
ApprovalTestData.TestUser target = data.createUser("改派目标" + data.uniqueSuffix(), organizationId);
|
||||
|
||||
mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"), leader, organizationId, APPROVE)
|
||||
.contentType(MediaType.APPLICATION_JSON).content("{}"))
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
|
||||
mockMvc.perform(assign(taskId, admin, ASSIGN, target.userId, "已处理后改派"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(1))
|
||||
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("任务已处理")));
|
||||
|
||||
assertThat(data.queryMaster("SELECT assignee_id FROM approval_task WHERE id = ?", Integer.class, taskId))
|
||||
.isEqualTo(leader.userId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAssignWithoutAuthority() throws Exception {
|
||||
long instanceId = createAndSubmit("无权限改派");
|
||||
long taskId = pendingTaskId(instanceId, leader.userId);
|
||||
ApprovalTestData.TestUser target = data.createUser("改派目标" + data.uniqueSuffix(), organizationId);
|
||||
|
||||
mockMvc.perform(assign(taskId, admin, APPROVE, target.userId, "越权改派"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(403));
|
||||
|
||||
assertThat(data.queryMaster("SELECT assignee_id FROM approval_task WHERE id = ?", Integer.class, taskId))
|
||||
.isEqualTo(leader.userId);
|
||||
|
||||
mockMvc.perform(authed(get("/api/approval/directory/users").param("keywords", "PC"),
|
||||
admin, organizationId, APPROVE))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(403));
|
||||
}
|
||||
|
||||
@Test
|
||||
void writesOperationLogWithOriginalAndNewAssignee() throws Exception {
|
||||
long instanceId = createAndSubmit("改派操作日志");
|
||||
long taskId = pendingTaskId(instanceId, leader.userId);
|
||||
ApprovalTestData.TestUser target = data.createUser("改派目标" + data.uniqueSuffix(), organizationId);
|
||||
String reason = "操作日志原因" + data.uniqueSuffix();
|
||||
String url = "/api/approval/task/" + taskId + "/assign";
|
||||
assignUrls.add(url);
|
||||
|
||||
mockMvc.perform(assign(taskId, admin, ASSIGN, target.userId, reason))
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
|
||||
JsonNode record = awaitOperationRecord(url, reason);
|
||||
assertThat(record).as("改派操作日志应写入 sys_operation_record").isNotNull();
|
||||
assertThat(record.path("userId").asInt()).isEqualTo(admin.userId);
|
||||
assertThat(record.path("description").asText()).contains("改派");
|
||||
assertThat(record.path("params").asText()).contains(reason);
|
||||
assertThat(record.path("result").asText())
|
||||
.contains(leader.realName)
|
||||
.contains(target.realName)
|
||||
.contains(reason);
|
||||
}
|
||||
|
||||
@Test
|
||||
void listsOnlyActiveUsersForAssignCandidates() throws Exception {
|
||||
ApprovalTestData.TestUser active = data.createUser("候选人" + data.uniqueSuffix(), organizationId);
|
||||
ApprovalTestData.TestUser inactive = data.createUser("停用候选人" + data.uniqueSuffix(), organizationId);
|
||||
data.server().update("UPDATE sys_user SET status = 1 WHERE user_id = ?", inactive.userId);
|
||||
|
||||
String body = mockMvc.perform(authed(get("/api/approval/directory/users")
|
||||
.param("keywords", active.realName), admin, organizationId, ASSIGN))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||
JsonNode users = objectMapper.readTree(body).path("data");
|
||||
assertThat(users.isArray()).isTrue();
|
||||
assertThat(users.findValuesAsText("userId")).contains(String.valueOf(active.userId));
|
||||
assertThat(users.findValuesAsText("userId")).doesNotContain(String.valueOf(inactive.userId));
|
||||
JsonNode matched = null;
|
||||
for (JsonNode user : users) {
|
||||
if (user.path("userId").asInt() == active.userId) {
|
||||
matched = user;
|
||||
}
|
||||
}
|
||||
assertThat(matched).isNotNull();
|
||||
assertThat(matched.path("realName").asText()).isEqualTo(active.realName);
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
private MockHttpServletRequestBuilder assign(long taskId, ApprovalTestData.TestUser operator,
|
||||
String authority, int targetUserId, String reason)
|
||||
throws Exception {
|
||||
return authed(post("/api/approval/task/" + taskId + "/assign"), operator, organizationId, authority)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"targetUserId\":" + targetUserId + ",\"reason\":" + quote(reason) + "}");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** 操作日志异步写入,最多等待 2 秒。 */
|
||||
private JsonNode awaitOperationRecord(String url, String paramsMark) throws Exception {
|
||||
long deadline = System.currentTimeMillis() + 2000;
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
List<java.util.Map<String, Object>> rows = data.server().queryForList(
|
||||
"SELECT user_id AS userId, description, params, result FROM sys_operation_record "
|
||||
+ "WHERE url = ? AND params LIKE ? ORDER BY id DESC LIMIT 1",
|
||||
url, "%" + paramsMark + "%");
|
||||
if (!rows.isEmpty()) {
|
||||
return objectMapper.valueToTree(rows.get(0));
|
||||
}
|
||||
Thread.sleep(100);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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("\"", "\\\"") + "\"";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
package com.gxwebsoft.approval;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.gxwebsoft.approval.constant.ApprovalConstants;
|
||||
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 org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
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 单据查询:筛选、关键词、排序、归档检索与数据权限的集成测试。
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles({"gxtyzx-test", "test"})
|
||||
@Transactional
|
||||
class ApprovalQueryIT {
|
||||
|
||||
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 DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
@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;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
data = new ApprovalTestData(dataSource);
|
||||
String suffix = data.uniqueSuffix();
|
||||
reportOrgId = data.createOrganization("报送部门" + suffix, 136);
|
||||
acceptOrgId = data.createOrganization("受理部门" + suffix, 136);
|
||||
reporter = data.createUser("报送人" + suffix, reportOrgId);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
data.cleanup();
|
||||
}
|
||||
|
||||
@Test
|
||||
void filtersByTypeKeywordOrganizationReporterAndStatus() throws Exception {
|
||||
long submitted = submitCompletion("场馆维修实施");
|
||||
long coordination = seedCoordination("场馆维修协调", "PENDING", LocalDateTime.now().minusHours(1), null);
|
||||
long draft = createCompletionDraft("场馆维护草稿", "广西体育中心保障部");
|
||||
|
||||
// 单据类型 + 关键词命中实施单位
|
||||
assertThat(queryIds("?docType=completion&keyword=实施部")).containsExactly(submitted);
|
||||
// 关键词命中协调单标题
|
||||
assertThat(queryIds("?docType=coordination&keyword=维修协调")).containsExactly(coordination);
|
||||
// 关键词命中单号
|
||||
String bizNo = data.queryMaster("SELECT biz_no FROM approval_instance WHERE id = ?", String.class, submitted);
|
||||
assertThat(queryIds("?keyword=" + bizNo)).containsExactly(submitted);
|
||||
// 报送部门:两类单据都按报送部门过滤,其它部门不命中
|
||||
assertThat(queryIds("?reportOrgId=" + reportOrgId))
|
||||
.containsExactlyInAnyOrder(submitted, coordination, draft);
|
||||
assertThat(queryIds("?reportOrgId=" + acceptOrgId)).isEmpty();
|
||||
// 受理部门
|
||||
assertThat(queryIds("?acceptOrgId=" + acceptOrgId)).containsExactly(coordination);
|
||||
// 发起人
|
||||
assertThat(queryIds("?reporterId=" + reporter.userId)).containsExactlyInAnyOrder(submitted, coordination, draft);
|
||||
// 状态
|
||||
assertThat(queryIds("?status=PENDING")).containsExactlyInAnyOrder(submitted, coordination);
|
||||
assertThat(queryIds("?status=DRAFT")).containsExactly(draft);
|
||||
// 总数与分页总数一致
|
||||
assertThat(queryCount("?keyword=实施部")).isEqualTo(1);
|
||||
// 单号精确筛选
|
||||
assertThat(queryIds("?bizNo=" + bizNo)).containsExactly(submitted);
|
||||
// 提交时间范围
|
||||
assertThat(queryIds("?submitTimeStart=" + format(LocalDateTime.now().minusHours(2))
|
||||
+ "&submitTimeEnd=" + format(LocalDateTime.now().plusMinutes(1))))
|
||||
.containsExactlyInAnyOrder(submitted, coordination);
|
||||
assertThat(queryIds("?submitTimeEnd=" + format(LocalDateTime.now().minusDays(1)))).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void ordersBySubmitTimeDescThenIdDescAndKeepsPaginationStable() throws Exception {
|
||||
LocalDateTime sameSecond = LocalDateTime.now().withNano(0).minusMinutes(10);
|
||||
long first = seedCoordination("同秒一", "PENDING", sameSecond, null);
|
||||
long second = seedCoordination("同秒二", "PENDING", sameSecond, null);
|
||||
long third = seedCoordination("同秒三", "PENDING", sameSecond, null);
|
||||
long older = seedCoordination("更早", "PENDING", sameSecond.minusDays(1), null);
|
||||
long draft = createCompletionDraft("无提交时间草稿");
|
||||
|
||||
List<Long> page1 = queryIds("?limit=2&page=1");
|
||||
List<Long> page2 = queryIds("?limit=2&page=2");
|
||||
List<Long> page3 = queryIds("?limit=2&page=3");
|
||||
|
||||
assertThat(page1).containsExactly(third, second);
|
||||
assertThat(page2).containsExactly(first, older);
|
||||
assertThat(page3).containsExactly(draft);
|
||||
}
|
||||
|
||||
@Test
|
||||
void includesArchivedInstancesAndFiltersByArchiveTime() throws Exception {
|
||||
LocalDateTime archivedAt = LocalDateTime.now().withNano(0).minusDays(3);
|
||||
long archived = seedCoordination("已归档协调单", "ARCHIVED", archivedAt.minusDays(1), archivedAt);
|
||||
long pending = seedCoordination("在途协调单", "PENDING", archivedAt.minusDays(1), null);
|
||||
|
||||
assertThat(queryIds("?status=ARCHIVED")).containsExactly(archived);
|
||||
assertThat(queryIds("?archiveTimeStart=" + format(archivedAt.minusHours(1))
|
||||
+ "&archiveTimeEnd=" + format(archivedAt.plusHours(1)))).containsExactly(archived);
|
||||
assertThat(queryIds("?archiveTimeStart=" + format(archivedAt.plusDays(1)))).isEmpty();
|
||||
assertThat(queryIds("")).containsExactlyInAnyOrder(archived, pending);
|
||||
}
|
||||
|
||||
@Test
|
||||
void limitsListByDataPermission() throws Exception {
|
||||
long draft = createCompletionDraft("仅发起人可见草稿");
|
||||
long pending = seedCoordination("待处理协调单", "PENDING", LocalDateTime.now().minusHours(1), null);
|
||||
|
||||
ApprovalTestData.TestUser outsider = data.createUser("无关人员" + data.uniqueSuffix(), acceptOrgId);
|
||||
ApprovalTestData.TestUser assignee = data.createUser("任务处理人" + data.uniqueSuffix(), acceptOrgId);
|
||||
ApprovalTestData.TestUser operator = data.createUser("记录操作人" + data.uniqueSuffix(), acceptOrgId);
|
||||
ApprovalTestData.TestUser dispatched = data.createUser("被派单人" + data.uniqueSuffix(), acceptOrgId);
|
||||
ApprovalTestData.TestUser archivist = data.createUser("收文员" + data.uniqueSuffix(), acceptOrgId);
|
||||
|
||||
data.master().update("INSERT INTO approval_task (tenant_id, instance_id, node_key, node_type, round, "
|
||||
+ "task_type, assignee_id, status) VALUES (?, ?, 'reportLeader', 'APPROVE', 1, 'APPROVE', ?, 'PENDING')",
|
||||
TENANT, pending, assignee.userId);
|
||||
data.master().update("INSERT INTO approval_record (tenant_id, instance_id, operator_id, action, round) "
|
||||
+ "VALUES (?, ?, ?, 'SUBMIT', 1)", TENANT, pending, operator.userId);
|
||||
data.master().update("UPDATE approval_instance SET dispatch_target_id = ?, dispatch_target_name = ? WHERE id = ?",
|
||||
dispatched.userId, dispatched.realName, pending);
|
||||
ApprovalTestData.TestRole archivistRole = data.createRole(ApprovalConstants.ROLE_ARCHIVIST, "收文员");
|
||||
data.bind(archivist.userId, archivistRole.roleId);
|
||||
|
||||
// 发起人可见自己的草稿与在途单据
|
||||
assertThat(queryIdsAs(reporter, "")).containsExactlyInAnyOrder(draft, pending);
|
||||
// 参与人:有任务、有处理记录、被派单
|
||||
assertThat(queryIdsAs(assignee, "")).containsExactly(pending);
|
||||
assertThat(queryIdsAs(operator, "")).containsExactly(pending);
|
||||
assertThat(queryIdsAs(dispatched, "")).containsExactly(pending);
|
||||
// 无关人员看不到他人单据
|
||||
assertThat(queryIdsAs(outsider, "")).isEmpty();
|
||||
assertThat(queryIdsAs(outsider, "?status=PENDING")).isEmpty();
|
||||
// 收文员看全租户,但草稿仍只属于发起人
|
||||
assertThat(queryIdsAs(archivist, "")).contains(pending).doesNotContain(draft);
|
||||
assertThat(queryIdsAs(archivist, "?status=DRAFT")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDetailOfInstanceOutsideDataPermission() throws Exception {
|
||||
long draft = createCompletionDraft("他人草稿");
|
||||
ApprovalTestData.TestUser outsider = data.createUser("越权人员" + data.uniqueSuffix(), acceptOrgId);
|
||||
|
||||
mockMvc.perform(authed(get("/api/approval/instance/" + draft), outsider, LIST))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(1))
|
||||
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权查看该单据")));
|
||||
|
||||
mockMvc.perform(authed(get("/api/approval/instance/" + draft), reporter, LIST))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.id").value((int) draft))
|
||||
.andExpect(jsonPath("$.data.docTypeName").value("业务完成确认单"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void allowsInstanceQueryUsersToSearchDirectoryUsers() throws Exception {
|
||||
mockMvc.perform(authed(get("/api/approval/directory/users?keywords=" + reporter.username), reporter, LIST))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data[0].userId").value(reporter.userId));
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
private List<Long> queryIds(String query) throws Exception {
|
||||
return queryIdsAs(reporter, query);
|
||||
}
|
||||
|
||||
private List<Long> queryIdsAs(ApprovalTestData.TestUser user, String query) throws Exception {
|
||||
String response = mockMvc.perform(authed(get("/api/approval/instance/page" + query), user, LIST))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||
JsonNode node = objectMapper.readTree(response).path("data");
|
||||
List<Long> ids = new ArrayList<>();
|
||||
for (JsonNode item : node.path("list")) {
|
||||
ids.add(item.path("id").asLong());
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private long queryCount(String query) throws Exception {
|
||||
String response = mockMvc.perform(authed(get("/api/approval/instance/page" + query), reporter, LIST))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||
return objectMapper.readTree(response).path("data").path("count").asLong();
|
||||
}
|
||||
|
||||
private long createCompletionDraft(String name) throws Exception {
|
||||
return createCompletionDraft(name, "广西体育中心实施部");
|
||||
}
|
||||
|
||||
private long createCompletionDraft(String name, String implementOrg) throws Exception {
|
||||
String body = "{\"name\":" + quote(name) + ",\"implementOrg\":" + quote(implementOrg) + ","
|
||||
+ "\"fee\":100.00,\"serviceContent\":\"完成场馆设施维修与验收\"}";
|
||||
String response = mockMvc.perform(authed(post("/api/approval/instance/completion"), reporter, SUBMIT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||
long id = objectMapper.readTree(response).path("data").asLong();
|
||||
data.trackInstance(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
private long submitCompletion(String name) throws Exception {
|
||||
grantDepartmentLeader();
|
||||
grantArchivist();
|
||||
long instanceId = createCompletionDraft(name);
|
||||
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));
|
||||
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, SUBMIT))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
assertThat(data.queryMaster("SELECT status FROM approval_instance WHERE id = ?", String.class, instanceId))
|
||||
.isEqualTo("PENDING");
|
||||
return instanceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接落库一条协调单实例:T23 的表单/提交流程尚未合入,查询侧只需完整数据行。
|
||||
*/
|
||||
private long seedCoordination(String title, String status, LocalDateTime submitTime, LocalDateTime archiveTime) {
|
||||
String bizNo = "XT" + System.nanoTime();
|
||||
data.master().update("INSERT INTO approval_instance (tenant_id, biz_no, flow_code, definition_id, "
|
||||
+ "definition_version, doc_type, biz_id, status, round, reporter_id, reporter_name, report_org_id, "
|
||||
+ "report_org_name, accept_org_id, accept_org_name, current_node_key, current_node_type, submit_time, "
|
||||
+ "archive_time, archivist_id, archivist_name) "
|
||||
+ "VALUES (?, ?, 'coordination_internal', ?, 1, 'coordination', 0, ?, 1, ?, ?, ?, ?, ?, ?, "
|
||||
+ "'reportLeader', 'APPROVE', ?, ?, ?, ?)",
|
||||
TENANT, bizNo, coordinationDefinitionId(), status, reporter.userId, reporter.realName,
|
||||
reportOrgId, orgName(reportOrgId), acceptOrgId, orgName(acceptOrgId),
|
||||
Timestamp.valueOf(submitTime),
|
||||
archiveTime == null ? null : Timestamp.valueOf(archiveTime),
|
||||
archiveTime == null ? null : reporter.userId,
|
||||
archiveTime == null ? null : reporter.realName);
|
||||
Long instanceId = data.queryMaster("SELECT id FROM approval_instance WHERE tenant_id = ? AND biz_no = ?",
|
||||
Long.class, TENANT, bizNo);
|
||||
data.trackInstance(instanceId);
|
||||
data.master().update("INSERT INTO approval_coordination_form (tenant_id, instance_id, accept_org_id, "
|
||||
+ "accept_org_name, title, content) VALUES (?, ?, ?, ?, ?, '协调内容')",
|
||||
TENANT, instanceId, acceptOrgId, orgName(acceptOrgId), title);
|
||||
Long formId = data.queryMaster("SELECT id FROM approval_coordination_form WHERE instance_id = ?",
|
||||
Long.class, instanceId);
|
||||
data.master().update("UPDATE approval_instance SET biz_id = ? WHERE id = ?", formId, instanceId);
|
||||
return instanceId;
|
||||
}
|
||||
|
||||
private long coordinationDefinitionId() {
|
||||
List<Long> ids = data.master().queryForList("SELECT id FROM approval_flow_definition WHERE tenant_id = ? "
|
||||
+ "AND flow_code = 'coordination_internal' AND version = 1 LIMIT 1", Long.class, TENANT);
|
||||
if (ids.isEmpty()) {
|
||||
data.master().update("INSERT INTO approval_flow_definition (tenant_id, flow_code, version, name, nodes_json) "
|
||||
+ "VALUES (?, 'coordination_internal', 1, '内部协调单', ?)",
|
||||
TENANT, ApprovalConstants.COORDINATION_NODES_JSON);
|
||||
ids = data.master().queryForList("SELECT id FROM approval_flow_definition WHERE tenant_id = ? "
|
||||
+ "AND flow_code = 'coordination_internal' AND version = 1 LIMIT 1", Long.class, TENANT);
|
||||
}
|
||||
return ids.isEmpty() ? 0L : ids.get(0);
|
||||
}
|
||||
|
||||
private String format(LocalDateTime time) {
|
||||
return TIME_FORMAT.format(time.withNano(0));
|
||||
}
|
||||
|
||||
private String orgName(Integer organizationId) {
|
||||
return data.server().queryForObject("SELECT organization_name FROM sys_organization WHERE organization_id = ?",
|
||||
String.class, organizationId);
|
||||
}
|
||||
|
||||
private void grantDepartmentLeader() {
|
||||
ApprovalTestData.TestRole role = data.createRole("dept_leader_" + reportOrgId, "部门负责人");
|
||||
ApprovalTestData.TestUser leader = data.createUser("负责人" + data.uniqueSuffix(), reportOrgId);
|
||||
data.bind(leader.userId, role.roleId);
|
||||
}
|
||||
|
||||
private void grantArchivist() {
|
||||
ApprovalTestData.TestRole role = data.createRole(ApprovalConstants.ROLE_ARCHIVIST, "收文员");
|
||||
ApprovalTestData.TestUser archivist = data.createUser("收文员" + data.uniqueSuffix(), reportOrgId);
|
||||
data.bind(archivist.userId, role.roleId);
|
||||
}
|
||||
|
||||
private MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder,
|
||||
ApprovalTestData.TestUser user, String... authorities) {
|
||||
return builder.header("tenantId", TENANT)
|
||||
.with(authentication(TestAuthentication.user(user.userId, user.username,
|
||||
reportOrgId, TENANT, authorities)));
|
||||
}
|
||||
|
||||
private String quote(String value) {
|
||||
return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user