diff --git a/src/main/java/com/gxwebsoft/approval/controller/ApprovalDirectoryController.java b/src/main/java/com/gxwebsoft/approval/controller/ApprovalDirectoryController.java index 192eb34..8819b12 100644 --- a/src/main/java/com/gxwebsoft/approval/controller/ApprovalDirectoryController.java +++ b/src/main/java/com/gxwebsoft/approval/controller/ApprovalDirectoryController.java @@ -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> users( diff --git a/src/main/java/com/gxwebsoft/approval/controller/ApprovalQueryController.java b/src/main/java/com/gxwebsoft/approval/controller/ApprovalQueryController.java new file mode 100644 index 0000000..80b26b6 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/controller/ApprovalQueryController.java @@ -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> page(ApprovalQueryParam param) { + return success(queryService.page(param, getLoginUser())); + } +} diff --git a/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java b/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java index 58af23e..6d75442 100644 --- a/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java +++ b/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java @@ -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; @@ -85,4 +88,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 assign(@PathVariable("id") Long id, + @RequestBody(required = false) ApprovalTaskAssignParam param) { + return success("改派成功", taskService.assign(id, param, getLoginUser())); + } } diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalQueryMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalQueryMapper.java new file mode 100644 index 0000000..e3ae64c --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalQueryMapper.java @@ -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 selectInstancePage(@Param("page") IPage page, + @Param("param") ApprovalQueryParam param, + @Param("userId") Integer userId, + @Param("allVisible") Integer allVisible); +} diff --git a/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalQueryMapper.xml b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalQueryMapper.xml new file mode 100644 index 0000000..1b13427 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalQueryMapper.xml @@ -0,0 +1,71 @@ + + + + + + + + diff --git a/src/main/java/com/gxwebsoft/approval/param/ApprovalQueryParam.java b/src/main/java/com/gxwebsoft/approval/param/ApprovalQueryParam.java new file mode 100644 index 0000000..3e87857 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/param/ApprovalQueryParam.java @@ -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; +} diff --git a/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskAssignParam.java b/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskAssignParam.java new file mode 100644 index 0000000..a826e08 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/param/ApprovalTaskAssignParam.java @@ -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; +} diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalQueryService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalQueryService.java new file mode 100644 index 0000000..1ab4609 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalQueryService.java @@ -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 单据查询服务:两类单据统一分页检索,列表与详情共用同一套数据权限条件。 + * + *

跨库约束:allVisible 角色判定走 core 专用连接,本方法不开启事务。

+ */ +@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 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 page = new Page<>(current, size); + List rows = queryMapper.selectInstancePage(page, param, user.getUserId(), allVisible); + Map> nodeCache = new HashMap<>(); + List 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> 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> cache, Long definitionId, String nodeKey) { + if (definitionId == null || nodeKey == null) { + return null; + } + List nodes = cache.computeIfAbsent(definitionId, flowService::parseNodes); + return nodes.stream() + .filter(node -> nodeKey.equals(node.getKey())) + .map(ApprovalNode::getName) + .findFirst() + .orElse(null); + } +} diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java index 0cf213d..a6a1a2f 100644 --- a/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java @@ -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; @@ -109,6 +111,28 @@ public class ApprovalTaskService { return new Result(instanceId, null); } + /** + * 改派:管理员把待处理任务换给同租户其他启用用户,原因必填并写 ASSIGN 记录。 + * + *

改派不改变任务状态,只替换处理人;操作人不必是任务当前处理人。

+ */ + 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 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()); } @@ -171,6 +195,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() + .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; + } + /** * 任务必须属于实例当前轮次、当前节点,否则视为已被处理或已失效。 */ @@ -310,6 +386,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 inTransaction(Supplier action) { return new TransactionTemplate(transactionManager).execute(status -> action.get()); } diff --git a/src/main/java/com/gxwebsoft/approval/vo/ApprovalAssignVO.java b/src/main/java/com/gxwebsoft/approval/vo/ApprovalAssignVO.java new file mode 100644 index 0000000..718c9d3 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/vo/ApprovalAssignVO.java @@ -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; +} diff --git a/src/test/java/com/gxwebsoft/approval/ApprovalPcTodoIT.java b/src/test/java/com/gxwebsoft/approval/ApprovalPcTodoIT.java new file mode 100644 index 0000000..26e43fd --- /dev/null +++ b/src/test/java/com/gxwebsoft/approval/ApprovalPcTodoIT.java @@ -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 待办审批与改派的端到端行为。 + * + *

覆盖:改派成功与待办归属、目标用户校验、非待办任务、原因必填、改派权限 403、 + * ASSIGN 处理记录、操作日志以及改派候选用户查询。

+ */ +@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 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> 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("\"", "\\\"") + "\""; + } +} diff --git a/src/test/java/com/gxwebsoft/approval/ApprovalQueryIT.java b/src/test/java/com/gxwebsoft/approval/ApprovalQueryIT.java new file mode 100644 index 0000000..37c38ef --- /dev/null +++ b/src/test/java/com/gxwebsoft/approval/ApprovalQueryIT.java @@ -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 page1 = queryIds("?limit=2&page=1"); + List page2 = queryIds("?limit=2&page=2"); + List 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 queryIds(String query) throws Exception { + return queryIdsAs(reporter, query); + } + + private List 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 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 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("\"", "\\\"") + "\""; + } +}