From 7db8097c8445179127296f212730cb7c0e947852 Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Fri, 30 Oct 2026 21:03:07 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(approval):=20PC=20=E5=BE=85=E5=8A=9E?= =?UTF-8?q?=E6=94=B9=E6=B4=BE=E6=8E=A5=E5=8F=A3=E4=B8=8E=E9=9B=86=E6=88=90?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=20(#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 POST /api/approval/task/{id}/assign:权限 approval:instance:assign, 任务须 PENDING、目标同租户启用用户、原因必填;事务内锁实例后条件更新 处理人并写 ASSIGN 记录(opinion=原因),@OperationLog 记录原/新处理人与原因 - 新增 ApprovalTaskAssignParam / ApprovalAssignVO - 新增 ApprovalPcTodoIT:改派成功与归属变化、无效目标、非待办任务、原因必填、 无权限 403、ASSIGN 记录、操作日志、改派候选用户 --- .../controller/ApprovalTaskController.java | 12 + .../param/ApprovalTaskAssignParam.java | 20 + .../approval/service/ApprovalTaskService.java | 84 +++++ .../approval/vo/ApprovalAssignVO.java | 29 ++ .../gxwebsoft/approval/ApprovalPcTodoIT.java | 349 ++++++++++++++++++ 5 files changed, 494 insertions(+) create mode 100644 src/main/java/com/gxwebsoft/approval/param/ApprovalTaskAssignParam.java create mode 100644 src/main/java/com/gxwebsoft/approval/vo/ApprovalAssignVO.java create mode 100644 src/test/java/com/gxwebsoft/approval/ApprovalPcTodoIT.java 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/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/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("\"", "\\\"") + "\""; + } +} From 7f26fafba3d399167ca0434ff2f87eb244720ef7 Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Fri, 30 Oct 2026 21:07:35 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(approval):=20=E5=86=85=E9=83=A8?= =?UTF-8?q?=E5=8D=8F=E8=B0=83=E5=8D=95=E8=A1=A8=E5=8D=95=E3=80=81=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=E9=A2=84=E6=A3=80=E4=B8=8E=E5=8F=8C=E9=83=A8=E9=97=A8?= =?UTF-8?q?=E8=B4=9F=E8=B4=A3=E4=BA=BA=E5=AE=A1=E6=89=B9=20(#23)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ApprovalInstanceController.java | 9 + .../mapper/xml/ApprovalInstanceMapper.xml | 2 +- .../param/ApprovalCoordinationParam.java | 31 + .../service/ApprovalInstanceService.java | 170 +++++- .../approval/ApprovalCoordinationIT.java | 568 ++++++++++++++++++ 5 files changed, 775 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/gxwebsoft/approval/param/ApprovalCoordinationParam.java create mode 100644 src/test/java/com/gxwebsoft/approval/ApprovalCoordinationIT.java diff --git a/src/main/java/com/gxwebsoft/approval/controller/ApprovalInstanceController.java b/src/main/java/com/gxwebsoft/approval/controller/ApprovalInstanceController.java index 586a594..cf3b993 100644 --- a/src/main/java/com/gxwebsoft/approval/controller/ApprovalInstanceController.java +++ b/src/main/java/com/gxwebsoft/approval/controller/ApprovalInstanceController.java @@ -1,6 +1,7 @@ package com.gxwebsoft.approval.controller; import com.gxwebsoft.approval.param.ApprovalCompletionParam; +import com.gxwebsoft.approval.param.ApprovalCoordinationParam; import com.gxwebsoft.approval.param.ApprovalMineParam; import com.gxwebsoft.approval.service.ApprovalInstanceService; import com.gxwebsoft.approval.vo.ApprovalInstanceVO; @@ -40,6 +41,14 @@ public class ApprovalInstanceController extends BaseController { return success("保存成功", instanceService.saveCompletion(param, user)); } + @PreAuthorize("hasAuthority('approval:instance:submit')") + @ApiOperation("新建或保存内部协调单草稿") + @PostMapping("/instance/coordination") + public ApiResult saveCoordination(@RequestBody ApprovalCoordinationParam param) { + User user = getLoginUser(); + return success("保存成功", instanceService.saveCoordination(param, user)); + } + @PreAuthorize("hasAuthority('approval:instance:submit')") @ApiOperation("提交或重提") @PostMapping("/instance/{id}/submit") diff --git a/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalInstanceMapper.xml b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalInstanceMapper.xml index 61cdb83..e934317 100644 --- a/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalInstanceMapper.xml +++ b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalInstanceMapper.xml @@ -32,7 +32,7 @@ i.reporter_id, i.reporter_name, i.report_org_name, - i.accept_org_name, + COALESCE(i.accept_org_name, co.accept_org_name) AS accept_org_name, i.archive_time, i.current_node_key, i.definition_id, diff --git a/src/main/java/com/gxwebsoft/approval/param/ApprovalCoordinationParam.java b/src/main/java/com/gxwebsoft/approval/param/ApprovalCoordinationParam.java new file mode 100644 index 0000000..bb9ab53 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/param/ApprovalCoordinationParam.java @@ -0,0 +1,31 @@ +package com.gxwebsoft.approval.param; + +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; + +import java.io.Serializable; + +/** + * 内部协调单草稿参数;id 为审批实例id,更新草稿时传入。 + * + *

报送部门与经办人由服务端按登录态带出,不在参数中接收。

+ */ +@Data +public class ApprovalCoordinationParam implements Serializable { + private static final long serialVersionUID = 1L; + + @ApiModelProperty("审批实例id(更新草稿时传入)") + private Long id; + + @ApiModelProperty("受理部门id,必须是启用组织") + private Integer acceptOrgId; + + @ApiModelProperty("标题") + private String title; + + @ApiModelProperty("内容") + private String content; + + @ApiModelProperty("备注") + private String remark; +} diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java index d2b6af0..fc709b0 100644 --- a/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java @@ -16,6 +16,7 @@ import com.gxwebsoft.approval.mapper.ApprovalInstanceMapper; import com.gxwebsoft.approval.mapper.ApprovalRecordMapper; import com.gxwebsoft.approval.mapper.ApprovalTaskMapper; import com.gxwebsoft.approval.param.ApprovalCompletionParam; +import com.gxwebsoft.approval.param.ApprovalCoordinationParam; import com.gxwebsoft.approval.param.ApprovalMineParam; import com.gxwebsoft.approval.param.ApprovalTaskListParam; import com.gxwebsoft.approval.support.ApprovalApprover; @@ -99,6 +100,30 @@ public class ApprovalInstanceService { return inTransaction(() -> createDraft(param, user, definition, organizationId, organizationName)); } + /** + * 新建或更新内部协调单草稿,返回实例id。 + * + *

报送部门与经办人取登录态;受理部门必须是启用组织,保存时即校验。

+ */ + public Long saveCoordination(ApprovalCoordinationParam param, User user) { + validateCoordination(param); + ApprovalOrganizationVO acceptOrganization = directoryService.getOrganization(param.getAcceptOrgId()); + if (acceptOrganization == null) { + throw new BusinessException("受理部门不存在或已停用"); + } + Integer organizationId = user.getOrganizationId(); + ApprovalOrganizationVO organization = directoryService.getOrganization(organizationId); + String organizationName = organization == null ? null : organization.getOrganizationName(); + + if (param.getId() != null) { + return inTransaction(() -> + updateCoordinationDraft(param, user, organizationId, organizationName, acceptOrganization)); + } + ApprovalFlowDefinition definition = flowService.getOrCreate(ApprovalConstants.FLOW_COORDINATION_INTERNAL); + return inTransaction(() -> + createCoordinationDraft(param, user, definition, organizationId, organizationName, acceptOrganization)); + } + /** * 提交或重提,返回节点缺配提示;正常提交返回 null。 */ @@ -113,7 +138,14 @@ public class ApprovalInstanceService { if (!ApprovalConstants.EDITABLE_STATUSES.contains(instance.getStatus())) { throw new BusinessException(submitBlockedMessage(instance.getStatus())); } - if (completionForm(instanceId) == null) { + boolean coordination = ApprovalConstants.DOC_COORDINATION.equals(instance.getDocType()); + ApprovalCoordinationForm coordinationForm = null; + if (coordination) { + coordinationForm = coordinationForm(instanceId); + if (coordinationForm == null) { + throw new BusinessException("单据内容不存在"); + } + } else if (completionForm(instanceId) == null) { throw new BusinessException("单据内容不存在"); } @@ -130,6 +162,10 @@ public class ApprovalInstanceService { if (leaders.isEmpty()) { throw new BusinessException("报送部门负责人角色缺配"); } + ApprovalOrganizationVO acceptOrganization = null; + if (coordination) { + acceptOrganization = preflightAcceptOrganization(coordinationForm); + } if (directoryService.listRoleMembers(ApprovalConstants.ROLE_ARCHIVIST).isEmpty()) { throw new BusinessException("收文员角色缺配"); } @@ -141,11 +177,30 @@ public class ApprovalInstanceService { .map(this::toApprover) .collect(Collectors.toList()); - return inTransaction(() -> doSubmit(instanceId, user, organization, approvers)); + ApprovalOrganizationVO acceptOrg = acceptOrganization; + return inTransaction(() -> doSubmit(instanceId, user, organization, acceptOrg, approvers)); + } + + /** + * 协调单提交预检:受理部门负责人角色、受理部门停用。 + */ + private ApprovalOrganizationVO preflightAcceptOrganization(ApprovalCoordinationForm form) { + Integer acceptOrgId = form.getAcceptOrgId(); + if (acceptOrgId == null || acceptOrgId <= 0) { + throw new BusinessException("请选择受理部门"); + } + if (directoryService.listRoleMembers(ApprovalConstants.departmentLeaderRoleCode(acceptOrgId)).isEmpty()) { + throw new BusinessException("受理部门负责人角色缺配"); + } + ApprovalOrganizationVO acceptOrganization = directoryService.getOrganization(acceptOrgId); + if (acceptOrganization == null) { + throw new BusinessException("受理部门已停用"); + } + return acceptOrganization; } private String doSubmit(Long instanceId, User user, ApprovalOrganizationVO organization, - List approvers) { + ApprovalOrganizationVO acceptOrganization, List approvers) { ApprovalInstance locked = engineService.lockInstance(instanceId); if (!locked.getReporterId().equals(user.getUserId()) || !ApprovalConstants.EDITABLE_STATUSES.contains(locked.getStatus())) { @@ -154,8 +209,15 @@ public class ApprovalInstanceService { boolean firstSubmit = StrUtil.isBlank(locked.getBizNo()); locked.setReportOrgId(organization.getOrganizationId()); locked.setReportOrgName(organization.getOrganizationName()); + if (acceptOrganization != null) { + locked.setAcceptOrgId(acceptOrganization.getOrganizationId()); + locked.setAcceptOrgName(acceptOrganization.getOrganizationName()); + } if (firstSubmit) { - locked.setBizNo(noService.nextNo(ApprovalConstants.NO_PREFIX_COMPLETION)); + boolean coordination = ApprovalConstants.DOC_COORDINATION.equals(locked.getDocType()); + locked.setBizNo(noService.nextNo(coordination + ? ApprovalConstants.NO_PREFIX_COORDINATION + : ApprovalConstants.NO_PREFIX_COMPLETION)); } locked.setStatus(ApprovalConstants.STATUS_PENDING); locked.setRound((locked.getRound() == null ? 0 : locked.getRound()) + 1); @@ -323,6 +385,79 @@ public class ApprovalInstanceService { return instance.getId(); } + private Long createCoordinationDraft(ApprovalCoordinationParam param, User user, + ApprovalFlowDefinition definition, + Integer organizationId, String organizationName, + ApprovalOrganizationVO acceptOrganization) { + ApprovalInstance instance = new ApprovalInstance(); + instance.setFlowCode(ApprovalConstants.FLOW_COORDINATION_INTERNAL); + instance.setDefinitionId(definition.getId()); + instance.setDefinitionVersion(definition.getVersion()); + instance.setDocType(ApprovalConstants.DOC_COORDINATION); + instance.setBizId(0L); + instance.setStatus(ApprovalConstants.STATUS_DRAFT); + instance.setRound(0); + instance.setReporterId(user.getUserId()); + instance.setReporterName(displayName(user)); + instance.setReportOrgId(organizationId); + instance.setReportOrgName(organizationName); + instanceMapper.insert(instance); + + ApprovalCoordinationForm form = new ApprovalCoordinationForm(); + form.setInstanceId(instance.getId()); + form.setAcceptOrgId(acceptOrganization.getOrganizationId()); + form.setAcceptOrgName(acceptOrganization.getOrganizationName()); + form.setTitle(param.getTitle().trim()); + form.setContent(param.getContent().trim()); + form.setRemark(normalizeRemark(param.getRemark())); + coordinationFormMapper.insert(form); + + instance.setBizId(form.getId()); + instanceMapper.updateById(instance); + return instance.getId(); + } + + private Long updateCoordinationDraft(ApprovalCoordinationParam param, User user, + Integer organizationId, String organizationName, + ApprovalOrganizationVO acceptOrganization) { + ApprovalInstance instance = instanceMapper.selectById(param.getId()); + if (instance == null) { + throw new BusinessException("单据不存在"); + } + if (!ApprovalConstants.DOC_COORDINATION.equals(instance.getDocType())) { + throw new BusinessException("单据类型不匹配"); + } + if (!instance.getReporterId().equals(user.getUserId())) { + throw new BusinessException("无权操作该单据"); + } + if (!ApprovalConstants.EDITABLE_STATUSES.contains(instance.getStatus())) { + throw new BusinessException("当前状态不可修改"); + } + ApprovalCoordinationForm form = coordinationForm(instance.getId()); + if (form == null) { + throw new BusinessException("单据内容不存在"); + } + form.setAcceptOrgId(acceptOrganization.getOrganizationId()); + form.setAcceptOrgName(acceptOrganization.getOrganizationName()); + form.setTitle(param.getTitle().trim()); + form.setContent(param.getContent().trim()); + form.setRemark(normalizeRemark(param.getRemark())); + coordinationFormMapper.updateById(form); + + instance.setReportOrgId(organizationId); + instance.setReportOrgName(organizationName); + instanceMapper.updateById(instance); + return instance.getId(); + } + + private String normalizeRemark(String remark) { + if (remark == null) { + return null; + } + String trimmed = remark.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + private ApprovalInstanceVO baseVO(ApprovalInstance instance) { ApprovalInstanceVO vo = new ApprovalInstanceVO(); vo.setId(instance.getId()); @@ -502,6 +637,33 @@ public class ApprovalInstanceService { } } + private void validateCoordination(ApprovalCoordinationParam param) { + if (param == null) { + throw new BusinessException("参数不能为空"); + } + if (param.getAcceptOrgId() == null || param.getAcceptOrgId() <= 0) { + throw new BusinessException("请选择受理部门"); + } + String title = param.getTitle() == null ? "" : param.getTitle().trim(); + if (title.isEmpty()) { + throw new BusinessException("标题不能为空"); + } + if (title.length() > 100) { + throw new BusinessException("标题不能超过100字"); + } + String content = param.getContent() == null ? "" : param.getContent().trim(); + if (content.isEmpty()) { + throw new BusinessException("内容不能为空"); + } + if (content.length() > 2000) { + throw new BusinessException("内容不能超过2000字"); + } + String remark = normalizeRemark(param.getRemark()); + if (remark != null && remark.length() > 500) { + throw new BusinessException("备注不能超过500字"); + } + } + private String submitBlockedMessage(String status) { if (ApprovalConstants.STATUS_PENDING.equals(status)) { return "单据正在审批中,不能重复提交"; diff --git a/src/test/java/com/gxwebsoft/approval/ApprovalCoordinationIT.java b/src/test/java/com/gxwebsoft/approval/ApprovalCoordinationIT.java new file mode 100644 index 0000000..4ffdf2a --- /dev/null +++ b/src/test/java/com/gxwebsoft/approval/ApprovalCoordinationIT.java @@ -0,0 +1,568 @@ +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; + +/** + * 内部协调单:草稿、字段校验、提交预检、部门快照与双节点审批的集成测试。 + * + *

不开启测试事务:提交与审批动作各自提交,便于验证跨库解析与节点推进; + * 数据由 {@link ApprovalTestData#cleanup()} 清理。

+ */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles({"gxtyzx-test", "test"}) +class ApprovalCoordinationIT { + + private static final int TENANT = TestAuthentication.TENANT_ID; + private static final String LIST = "approval:instance:list"; + private static final String SUBMIT = "approval:instance:submit"; + private static final String APPROVE = "approval:instance:approve"; + + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @Autowired + private DataSource dataSource; + + private ApprovalTestData data; + private ApprovalTestData.TestUser reporter; + private Integer reportOrgId; + private Integer acceptOrgId; + + @BeforeEach + void setUp() { + data = new ApprovalTestData(dataSource); + reportOrgId = data.createOrganization("协调报送部门" + data.uniqueSuffix(), 136); + acceptOrgId = data.createOrganization("协调受理部门" + data.uniqueSuffix(), 136); + reporter = data.createUser("协调报送人" + data.uniqueSuffix(), reportOrgId); + } + + @AfterEach + void tearDown() { + data.cleanup(); + } + + @Test + void createsDraftWithServerSideReportFields() throws Exception { + long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "设备借用协调", + "借用体育馆音响设备一天", "活动结束后归还"); + + assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT"); + assertThat(bizNo(instanceId)).isNull(); + assertThat(data.queryMaster("SELECT doc_type FROM approval_instance WHERE id = ?", String.class, instanceId)) + .isEqualTo("coordination"); + assertThat(data.queryMaster("SELECT flow_code FROM approval_instance WHERE id = ?", String.class, instanceId)) + .isEqualTo("coordination_internal"); + assertThat(data.queryMaster("SELECT reporter_id FROM approval_instance WHERE id = ?", Integer.class, instanceId)) + .isEqualTo(reporter.userId); + assertThat(data.queryMaster("SELECT reporter_name FROM approval_instance WHERE id = ?", String.class, instanceId)) + .isEqualTo(reporter.username); + assertThat(data.queryMaster("SELECT report_org_id FROM approval_instance WHERE id = ?", Integer.class, instanceId)) + .isEqualTo(reportOrgId); + assertThat(data.queryMaster("SELECT report_org_name FROM approval_instance WHERE id = ?", String.class, instanceId)) + .isNotBlank(); + // 受理部门在提交时才写入实例快照 + assertThat(data.queryMaster("SELECT accept_org_id FROM approval_instance WHERE id = ?", Integer.class, instanceId)) + .isNull(); + assertThat(data.queryMaster("SELECT accept_org_id FROM approval_coordination_form WHERE instance_id = ?", + Integer.class, instanceId)).isEqualTo(acceptOrgId); + assertThat(data.queryMaster("SELECT title FROM approval_coordination_form WHERE instance_id = ?", + String.class, instanceId)).isEqualTo("设备借用协调"); + assertThat(data.queryMaster("SELECT remark FROM approval_coordination_form WHERE instance_id = ?", + String.class, instanceId)).isEqualTo("活动结束后归还"); + + mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), reporter, reportOrgId, LIST)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.docType").value("coordination")) + .andExpect(jsonPath("$.data.docTypeName").value("内部协调单")) + .andExpect(jsonPath("$.data.statusName").value("未提交")) + .andExpect(jsonPath("$.data.title").value("设备借用协调")) + .andExpect(jsonPath("$.data.content").value("借用体育馆音响设备一天")) + .andExpect(jsonPath("$.data.remark").value("活动结束后归还")) + .andExpect(jsonPath("$.data.acceptOrgName").isNotEmpty()) + .andExpect(jsonPath("$.data.permissions.canEdit").value(true)) + .andExpect(jsonPath("$.data.bizNo").doesNotExist()); + + mockMvc.perform(authed(get("/api/approval/mine/page"), reporter, reportOrgId, LIST)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.list[0].id").value((int) instanceId)) + .andExpect(jsonPath("$.data.list[0].docTypeName").value("内部协调单")) + .andExpect(jsonPath("$.data.list[0].title").value("设备借用协调")) + .andExpect(jsonPath("$.data.list[0].acceptOrgName").isNotEmpty()) + .andExpect(jsonPath("$.data.list[0].statusName").value("未提交")); + + // 流程定义按租户幂等,节点顺序固定;一期不产生外部协调流程 + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_flow_definition " + + "WHERE tenant_id = ? AND flow_code = 'coordination_internal' AND version = 1", TENANT)).isEqualTo(1); + String nodes = data.queryMaster("SELECT nodes_json FROM approval_flow_definition " + + "WHERE tenant_id = ? AND flow_code = 'coordination_internal' AND version = 1", String.class, TENANT); + assertThat(nodes).contains("reportLeader").contains("acceptLeader").contains("dispatch") + .contains("confirm").contains("archive"); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_flow_definition " + + "WHERE tenant_id = ? AND flow_code = 'coordination_external'", TENANT)).isZero(); + } + + @Test + void updatesDraftOnlyForOwnerInEditableState() throws Exception { + long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "原标题", "原内容", null); + Integer otherAcceptOrg = data.createOrganization("备用受理部门" + data.uniqueSuffix(), 136); + + mockMvc.perform(authed(post("/api/approval/instance/coordination"), reporter, reportOrgId, SUBMIT) + .contentType(MediaType.APPLICATION_JSON) + .content(body(otherAcceptOrg, "新标题", "新内容", "补充说明", instanceId))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data").value((int) instanceId)); + + assertThat(data.queryMaster("SELECT title FROM approval_coordination_form WHERE instance_id = ?", + String.class, instanceId)).isEqualTo("新标题"); + assertThat(data.queryMaster("SELECT accept_org_id FROM approval_coordination_form WHERE instance_id = ?", + Integer.class, instanceId)).isEqualTo(otherAcceptOrg); + assertThat(data.queryMaster("SELECT remark FROM approval_coordination_form WHERE instance_id = ?", + String.class, instanceId)).isEqualTo("补充说明"); + + ApprovalTestData.TestUser outsider = data.createUser("协调路人" + data.uniqueSuffix(), reportOrgId); + mockMvc.perform(authed(post("/api/approval/instance/coordination"), outsider, reportOrgId, SUBMIT) + .contentType(MediaType.APPLICATION_JSON) + .content(body(otherAcceptOrg, "越权标题", "内容", null, instanceId))) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权操作该单据"))); + + // 提交后不可再修改 + grantDepartmentLeader(reportOrgId, 1); + grantDepartmentLeader(otherAcceptOrg, 1); + grantArchivist(); + uploadAttachment(instanceId, reporter, reportOrgId); + submitCoordination(instanceId, reporter, reportOrgId); + + mockMvc.perform(authed(post("/api/approval/instance/coordination"), reporter, reportOrgId, SUBMIT) + .contentType(MediaType.APPLICATION_JSON) + .content(body(acceptOrgId, "提交后改标题", "内容", null, instanceId))) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("不可修改"))); + } + + @Test + void validatesCoordinationFields() throws Exception { + expectSaveFailure(body(null, "标题", "内容", null, null), "请选择受理部门"); + expectSaveFailure(body(acceptOrgId, "", "内容", null, null), "标题不能为空"); + expectSaveFailure(body(acceptOrgId, repeat("标", 101), "内容", null, null), "标题不能超过100字"); + expectSaveFailure(body(acceptOrgId, "标题", "", null, null), "内容不能为空"); + expectSaveFailure(body(acceptOrgId, "标题", repeat("内", 2001), null, null), "内容不能超过2000字"); + expectSaveFailure(body(acceptOrgId, "标题", "内容", repeat("备", 501), null), "备注不能超过500字"); + + Integer disabledOrg = data.createOrganization("停用受理部门" + data.uniqueSuffix(), 136); + data.disableOrganization(disabledOrg); + expectSaveFailure(body(disabledOrg, "标题", "内容", null, null), "受理部门不存在或已停用"); + + // 备注为可选字段,留空保存成功 + long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "无备注", "内容", " "); + assertThat(data.queryMaster("SELECT remark FROM approval_coordination_form WHERE instance_id = ?", + String.class, instanceId)).isNull(); + } + + @Test + void rejectsSubmitWhenReportLeaderRoleMissing() throws Exception { + long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "缺报送负责人", "内容", null); + uploadAttachment(instanceId, reporter, reportOrgId); + + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), + reporter, reportOrgId, SUBMIT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("报送部门负责人角色缺配"))); + + assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT"); + assertThat(bizNo(instanceId)).isNull(); + } + + @Test + void rejectsSubmitWhenAcceptLeaderRoleMissing() throws Exception { + grantDepartmentLeader(reportOrgId, 1); + grantArchivist(); + long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "缺受理负责人", "内容", null); + uploadAttachment(instanceId, reporter, reportOrgId); + + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), + reporter, reportOrgId, SUBMIT)) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("受理部门负责人角色缺配"))); + + assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT"); + assertThat(bizNo(instanceId)).isNull(); + } + + @Test + void rejectsSubmitWhenAcceptOrganizationDisabled() throws Exception { + grantDepartmentLeader(reportOrgId, 1); + grantDepartmentLeader(acceptOrgId, 1); + grantArchivist(); + long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "受理部门停用", "内容", null); + uploadAttachment(instanceId, reporter, reportOrgId); + data.disableOrganization(acceptOrgId); + + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), + reporter, reportOrgId, SUBMIT)) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("受理部门已停用"))); + + assertThat(instanceStatus(instanceId)).isEqualTo("DRAFT"); + assertThat(bizNo(instanceId)).isNull(); + assertThat(data.queryMaster("SELECT accept_org_id FROM approval_instance WHERE id = ?", + Integer.class, instanceId)).isNull(); + } + + @Test + void submitsWithXtBizNoAndSnapshotsBothOrganizations() throws Exception { + ApprovalTestData.TestUser reportLeader = grantDepartmentLeader(reportOrgId, 1).get(0); + grantDepartmentLeader(acceptOrgId, 1); + grantArchivist(); + long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "提交协调单", "协调内容", "备注内容"); + uploadAttachment(instanceId, reporter, reportOrgId); + + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), + reporter, reportOrgId, SUBMIT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("PENDING")) + .andExpect(jsonPath("$.data.statusName").value("审批中")) + .andExpect(jsonPath("$.data.docTypeName").value("内部协调单")) + .andExpect(jsonPath("$.data.currentNodeName").value("报送部门负责人")) + .andExpect(jsonPath("$.data.acceptOrgName").isNotEmpty()) + .andExpect(jsonPath("$.data.attachments.length()").value(1)) + .andExpect(jsonPath("$.data.records[0].action").value("SUBMIT")) + .andExpect(jsonPath("$.data.permissions.canEdit").value(false)) + .andExpect(jsonPath("$.data.permissions.canSubmit").value(false)); + + String bizNo = bizNo(instanceId); + assertThat(bizNo).matches("XT\\d{8}-\\d{4}"); + assertThat(bizNo.substring(2, 10)).isEqualTo(todayFromDatabase()); + + assertThat(data.queryMaster("SELECT round FROM approval_instance WHERE id = ?", Integer.class, instanceId)) + .isEqualTo(1); + assertThat(data.queryMaster("SELECT submit_time FROM approval_instance WHERE id = ?", Object.class, instanceId)) + .isNotNull(); + assertThat(data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?", + String.class, instanceId)).isEqualTo("reportLeader"); + assertThat(data.queryMaster("SELECT current_node_type FROM approval_instance WHERE id = ?", + String.class, instanceId)).isEqualTo("APPROVE"); + assertThat(data.queryMaster("SELECT report_org_id FROM approval_instance WHERE id = ?", + Integer.class, instanceId)).isEqualTo(reportOrgId); + assertThat(data.queryMaster("SELECT accept_org_id FROM approval_instance WHERE id = ?", + Integer.class, instanceId)).isEqualTo(acceptOrgId); + assertThat(data.queryMaster("SELECT accept_org_name FROM approval_instance WHERE id = ?", + String.class, instanceId)) + .isEqualTo(data.server().queryForObject( + "SELECT organization_name FROM sys_organization WHERE organization_id = ?", + String.class, acceptOrgId)); + + assertThat(pendingTaskIds(instanceId)).containsExactly(reportLeader.userId); + } + + @Test + void approvesReportThenAcceptLeaderAndReachesDispatch() throws Exception { + ApprovalTestData.TestUser reportLeader = grantDepartmentLeader(reportOrgId, 1).get(0); + ApprovalTestData.TestUser acceptLeader = grantDepartmentLeader(acceptOrgId, 1).get(0); + grantArchivist(); + ApprovalTestData.TestUser acceptMember = data.createUser("受理部门成员" + data.uniqueSuffix(), acceptOrgId); + long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "双节点审批", "内容", null); + uploadAttachment(instanceId, reporter, reportOrgId); + submitCoordination(instanceId, reporter, reportOrgId); + + mockMvc.perform(authed(get("/api/approval/todo/page"), reportLeader, reportOrgId, LIST)) + .andExpect(jsonPath("$.data.count").value(1)) + .andExpect(jsonPath("$.data.list[0].currentNodeName").value("报送部门负责人")); + + long reportTask = pendingTaskId(instanceId, reportLeader.userId); + mockMvc.perform(authed(post("/api/approval/task/" + reportTask + "/approve"), + reportLeader, reportOrgId, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"opinion\":\"同意协调\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.currentNodeName").value("受理部门负责人")) + .andExpect(jsonPath("$.data.myTask").doesNotExist()); + + assertThat(data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?", + String.class, instanceId)).isEqualTo("acceptLeader"); + assertThat(pendingTaskIds(instanceId)).containsExactly(acceptLeader.userId); + + mockMvc.perform(authed(get("/api/approval/todo/page"), acceptLeader, acceptOrgId, LIST)) + .andExpect(jsonPath("$.data.count").value(1)) + .andExpect(jsonPath("$.data.list[0].id").value((int) instanceId)); + + long acceptTask = pendingTaskId(instanceId, acceptLeader.userId); + mockMvc.perform(authed(post("/api/approval/task/" + acceptTask + "/approve"), + acceptLeader, acceptOrgId, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"opinion\":\"可以协调\"}")) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("PENDING")) + .andExpect(jsonPath("$.data.currentNodeName").value("派单")); + + assertThat(data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?", + String.class, instanceId)).isEqualTo("dispatch"); + assertThat(data.queryMaster("SELECT current_node_type FROM approval_instance WHERE id = ?", + String.class, instanceId)).isEqualTo("DISPATCH"); + assertThat(pendingTaskIds(instanceId)).contains(acceptMember.userId); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? " + + "AND action = 'APPROVE'", instanceId)).isEqualTo(2); + assertThat(data.queryMaster("SELECT opinion FROM approval_task WHERE id = ?", String.class, reportTask)) + .isEqualTo("同意协调"); + } + + @Test + void allowsSamePersonToHandleBothApprovalNodes() throws Exception { + ApprovalTestData.TestUser both = data.createUser("双部门负责人" + data.uniqueSuffix(), reportOrgId); + ApprovalTestData.TestRole reportRole = data.createRole("dept_leader_" + reportOrgId, "部门负责人"); + ApprovalTestData.TestRole acceptRole = data.createRole("dept_leader_" + acceptOrgId, "部门负责人"); + data.bind(both.userId, reportRole.roleId); + data.bind(both.userId, acceptRole.roleId); + grantArchivist(); + long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "同人连做", "内容", null); + uploadAttachment(instanceId, reporter, reportOrgId); + submitCoordination(instanceId, reporter, reportOrgId); + + long firstTask = pendingTaskId(instanceId, both.userId); + mockMvc.perform(authed(post("/api/approval/task/" + firstTask + "/approve"), both, reportOrgId, APPROVE) + .contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.currentNodeName").value("受理部门负责人")); + + long secondTask = pendingTaskId(instanceId, both.userId); + assertThat(secondTask).isNotEqualTo(firstTask); + mockMvc.perform(authed(post("/api/approval/task/" + secondTask + "/approve"), both, reportOrgId, APPROVE) + .contentType(MediaType.APPLICATION_JSON).content("{}")) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.currentNodeName").value("派单")); + + assertThat(data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?", + String.class, instanceId)).isEqualTo("dispatch"); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? " + + "AND action = 'APPROVE' AND operator_id = ?", instanceId, both.userId)).isEqualTo(2); + } + + @Test + void rejectsThenResubmitsKeepingBizNo() throws Exception { + ApprovalTestData.TestUser reportLeader = grantDepartmentLeader(reportOrgId, 1).get(0); + grantDepartmentLeader(acceptOrgId, 1); + grantArchivist(); + long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "驳回重提协调", "内容", null); + uploadAttachment(instanceId, reporter, reportOrgId); + submitCoordination(instanceId, reporter, reportOrgId); + String bizNo = bizNo(instanceId); + + long taskId = pendingTaskId(instanceId, reportLeader.userId); + mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/reject"), + reportLeader, reportOrgId, APPROVE) + .contentType(MediaType.APPLICATION_JSON).content("{\"opinion\":\" \"}")) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("驳回意见"))); + + mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/reject"), + reportLeader, reportOrgId, APPROVE) + .contentType(MediaType.APPLICATION_JSON).content("{\"opinion\":\"协调范围不清\"}")) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("REJECTED")) + .andExpect(jsonPath("$.data.statusName").value("已驳回")) + .andExpect(jsonPath("$.data.round").value(2)); + + mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), reporter, reportOrgId, LIST)) + .andExpect(jsonPath("$.data.permissions.canEdit").value(true)) + .andExpect(jsonPath("$.data.permissions.canSubmit").value(true)) + .andExpect(jsonPath("$.data.bizNo").value(bizNo)); + + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), + reporter, reportOrgId, SUBMIT)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("PENDING")) + .andExpect(jsonPath("$.data.round").value(3)) + .andExpect(jsonPath("$.data.bizNo").value(bizNo)) + .andExpect(jsonPath("$.data.currentNodeName").value("报送部门负责人")); + + assertThat(pendingTaskId(instanceId, reportLeader.userId)).isPositive(); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? " + + "AND action = 'RESUBMIT' AND round = 3", instanceId)).isEqualTo(1); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? " + + "AND action = 'REJECT' AND opinion = '协调范围不清'", instanceId)).isEqualTo(1); + } + + @Test + void withdrawsAndResubmitsBeforeAnyApproval() throws Exception { + grantDepartmentLeader(reportOrgId, 1); + grantDepartmentLeader(acceptOrgId, 1); + grantArchivist(); + long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "撤回协调", "内容", null); + uploadAttachment(instanceId, reporter, reportOrgId); + submitCoordination(instanceId, reporter, reportOrgId); + String bizNo = bizNo(instanceId); + + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/withdraw"), + reporter, reportOrgId, SUBMIT)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("WITHDRAWN")) + .andExpect(jsonPath("$.data.round").value(2)) + .andExpect(jsonPath("$.data.bizNo").value(bizNo)); + + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), + reporter, reportOrgId, SUBMIT)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("PENDING")) + .andExpect(jsonPath("$.data.round").value(3)) + .andExpect(jsonPath("$.data.bizNo").value(bizNo)) + .andExpect(jsonPath("$.data.currentNodeName").value("报送部门负责人")); + } + + @Test + void hidesDraftFromOutsider() throws Exception { + long instanceId = saveCoordination(reporter, reportOrgId, acceptOrgId, "草稿保密", "内容", null); + ApprovalTestData.TestUser outsider = data.createUser("协调外人" + data.uniqueSuffix(), reportOrgId); + + mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), outsider, reportOrgId, LIST)) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权查看该单据"))); + } + + // ---------- helpers ---------- + + private long saveCoordination(ApprovalTestData.TestUser user, Integer reportOrg, Integer acceptOrg, + String title, String content, String remark) throws Exception { + String response = mockMvc.perform(authed(post("/api/approval/instance/coordination"), + user, reportOrg, SUBMIT) + .contentType(MediaType.APPLICATION_JSON) + .content(body(acceptOrg, title, content, remark, null))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + JsonNode node = objectMapper.readTree(response); + long id = node.path("data").asLong(); + data.trackInstance(id); + return id; + } + + private void expectSaveFailure(String body, String expectedMessage) throws Exception { + mockMvc.perform(authed(post("/api/approval/instance/coordination"), reporter, reportOrgId, SUBMIT) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString(expectedMessage))); + } + + private void submitCoordination(long instanceId, ApprovalTestData.TestUser user, Integer org) throws Exception { + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), user, org, SUBMIT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } + + private void uploadAttachment(long instanceId, ApprovalTestData.TestUser user, Integer org) 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(user.userId, user.username, org, TENANT, SUBMIT)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } + + private List grantDepartmentLeader(int orgId, int memberCount) { + ApprovalTestData.TestRole role = data.createRole("dept_leader_" + orgId, "部门负责人"); + List members = new ArrayList<>(); + for (int i = 0; i < memberCount; i++) { + ApprovalTestData.TestUser member = data.createUser("协调负责人" + data.uniqueSuffix(), orgId); + data.bind(member.userId, role.roleId); + members.add(member); + } + return members; + } + + private void grantArchivist() { + ApprovalTestData.TestRole role = data.createRole("approval_archivist", "收文员"); + ApprovalTestData.TestUser member = data.createUser("协调收文员" + data.uniqueSuffix(), reportOrgId); + data.bind(member.userId, role.roleId); + } + + private long pendingTaskId(long instanceId, int userId) { + Long taskId = data.queryMaster("SELECT id FROM approval_task WHERE instance_id = ? " + + "AND assignee_id = ? AND status = 'PENDING' LIMIT 1", Long.class, instanceId, userId); + assertThat(taskId).as("待办任务应存在").isNotNull(); + return taskId; + } + + private List pendingTaskIds(long instanceId) { + return data.master().queryForList("SELECT assignee_id FROM approval_task " + + "WHERE instance_id = ? AND status = 'PENDING' ORDER BY id", Integer.class, instanceId); + } + + 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 body(Integer acceptOrgId, String title, String content, String remark, Long id) { + StringBuilder sb = new StringBuilder("{"); + if (id != null) { + sb.append("\"id\":").append(id).append(','); + } + sb.append("\"acceptOrgId\":").append(acceptOrgId == null ? "null" : acceptOrgId).append(',') + .append("\"title\":").append(quote(title)).append(',') + .append("\"content\":").append(quote(content)).append(',') + .append("\"remark\":").append(remark == null ? "null" : quote(remark)) + .append('}'); + return sb.toString(); + } + + private String quote(String value) { + return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } + + private String repeat(String value, int count) { + return value.repeat(count); + } + + private String instanceStatus(long instanceId) { + return data.queryMaster("SELECT status FROM approval_instance WHERE id = ?", String.class, instanceId); + } + + private String bizNo(long instanceId) { + return data.queryMaster("SELECT biz_no FROM approval_instance WHERE id = ?", String.class, instanceId); + } + + private String todayFromDatabase() { + return data.queryMaster("SELECT DATE_FORMAT(CURDATE(), '%Y%m%d')", String.class); + } +} From a336e207476d9b076cccd6a65a8be7cd960ebacf Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Fri, 30 Oct 2026 21:09:05 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat(approval):=20=E5=AD=98=E6=A1=A3?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E4=B8=8E=E5=BD=92=E6=A1=A3=E9=97=AD=E7=8E=AF?= =?UTF-8?q?=20(#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 POST /task/{id}/confirm(存档确认)与 /task/{id}/archive(归档)端点 - CONFIRM 全员完成后实例进入 APPROVED 并激活归档节点;缺配时停留审批中并提示 - ARCHIVE 或签,实例进入 ARCHIVED,记录归档人与时间,其余待办取消 - 归档后提交/撤回/编辑/附件/存档动作统一中文拒绝 - ApprovalConfirmArchiveIT 覆盖全员/部分存档、或签、只读、缺配阻断与全租户可见 --- .../approval/constant/ApprovalConstants.java | 7 + .../controller/ApprovalTaskController.java | 19 + .../service/ApprovalAttachmentService.java | 8 +- .../service/ApprovalInstanceService.java | 2 +- .../approval/service/ApprovalTaskService.java | 123 ++++++ .../approval/ApprovalConfirmArchiveIT.java | 367 ++++++++++++++++++ 6 files changed, 522 insertions(+), 4 deletions(-) create mode 100644 src/test/java/com/gxwebsoft/approval/ApprovalConfirmArchiveIT.java diff --git a/src/main/java/com/gxwebsoft/approval/constant/ApprovalConstants.java b/src/main/java/com/gxwebsoft/approval/constant/ApprovalConstants.java index 3ea09af..e27ff0e 100644 --- a/src/main/java/com/gxwebsoft/approval/constant/ApprovalConstants.java +++ b/src/main/java/com/gxwebsoft/approval/constant/ApprovalConstants.java @@ -163,6 +163,13 @@ public final class ApprovalConstants { return CATEGORY_NAMES.getOrDefault(category, category); } + /** + * 归档后单据为只读终态:任何写动作统一返回该提示。 + */ + public static String writeBlockedMessage(String status) { + return STATUS_ARCHIVED.equals(status) ? "单据已归档,不能修改" : "当前状态不可修改"; + } + public static String departmentLeaderRoleCode(Integer organizationId) { return organizationId == null ? null : "dept_leader_" + organizationId; } diff --git a/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java b/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java index 58af23e..c062d8c 100644 --- a/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java +++ b/src/main/java/com/gxwebsoft/approval/controller/ApprovalTaskController.java @@ -77,6 +77,25 @@ public class ApprovalTaskController extends BaseController { return success("驳回成功", instanceService.detail(result.getInstanceId(), user)); } + @PreAuthorize("hasAuthority('approval:instance:approve')") + @ApiOperation("存档确认") + @PostMapping("/task/{id}/confirm") + public ApiResult confirm(@PathVariable("id") Long id) { + User user = getLoginUser(); + ApprovalTaskService.Result result = taskService.confirm(id, user); + return success(result.getWarning() == null ? "存档成功" : result.getWarning(), + instanceService.detail(result.getInstanceId(), user)); + } + + @PreAuthorize("hasAuthority('approval:instance:archive')") + @ApiOperation("归档") + @PostMapping("/task/{id}/archive") + public ApiResult archive(@PathVariable("id") Long id) { + User user = getLoginUser(); + ApprovalTaskService.Result result = taskService.archive(id, user); + return success("归档成功", instanceService.detail(result.getInstanceId(), user)); + } + @PreAuthorize("hasAuthority('approval:instance:submit')") @ApiOperation("撤回") @PostMapping("/instance/{id}/withdraw") diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalAttachmentService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalAttachmentService.java index 7a3dbfa..a48089f 100644 --- a/src/main/java/com/gxwebsoft/approval/service/ApprovalAttachmentService.java +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalAttachmentService.java @@ -162,9 +162,11 @@ public class ApprovalAttachmentService { } private void checkEditable(ApprovalInstance instance, User user) { - if (user == null || !instance.getReporterId().equals(user.getUserId()) - || !ApprovalConstants.EDITABLE_STATUSES.contains(instance.getStatus())) { - throw new BusinessException("当前状态不可修改"); + if (user == null || !instance.getReporterId().equals(user.getUserId())) { + throw new BusinessException("无权操作该单据"); + } + if (!ApprovalConstants.EDITABLE_STATUSES.contains(instance.getStatus())) { + throw new BusinessException(ApprovalConstants.writeBlockedMessage(instance.getStatus())); } } diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java index d2b6af0..325a5be 100644 --- a/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java @@ -305,7 +305,7 @@ public class ApprovalInstanceService { throw new BusinessException("无权操作该单据"); } if (!ApprovalConstants.EDITABLE_STATUSES.contains(instance.getStatus())) { - throw new BusinessException("当前状态不可修改"); + throw new BusinessException(ApprovalConstants.writeBlockedMessage(instance.getStatus())); } ApprovalCompletionForm form = completionForm(instance.getId()); if (form == 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..6bc98d5 100644 --- a/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java @@ -75,6 +75,45 @@ public class ApprovalTaskService { return new Result(instance.getId(), warning); } + /** + * 存档确认:CONFIRM 任务只能确认(无意见),同节点同轮全部完成后实例进入审批完成, + * 并激活归档节点;归档节点缺配时实例停在审批中并提示管理员补配置。 + */ + public Result confirm(Long taskId, User user) { + ApprovalTask task = requireTask(taskId, user); + if (!ApprovalConstants.NODE_CONFIRM.equals(task.getNodeType())) { + throw new BusinessException("当前任务不支持存档确认"); + } + ApprovalInstance instance = instanceMapper.selectById(task.getInstanceId()); + if (instance == null) { + throw new BusinessException("单据不存在"); + } + ensureNotArchived(instance); + ApprovalNode next = engineService.nextNode(instance, task.getNodeKey()); + List approvers = + next == null ? Collections.emptyList() : resolveApprovers(instance, next); + + String warning = inTransaction(() -> doConfirm(taskId, task, user, next, approvers)); + return new Result(instance.getId(), warning); + } + + /** + * 归档:ARCHIVE 任务或签,处理后取消同节点其余待办,实例进入只读终态。 + */ + public Result archive(Long taskId, User user) { + ApprovalTask task = requireTask(taskId, user); + if (!ApprovalConstants.NODE_ARCHIVE.equals(task.getNodeType())) { + throw new BusinessException("当前任务不支持归档"); + } + ApprovalInstance instance = instanceMapper.selectById(task.getInstanceId()); + if (instance == null) { + throw new BusinessException("单据不存在"); + } + ensureNotArchived(instance); + inTransaction(() -> doArchive(taskId, task, user)); + return new Result(instance.getId(), null); + } + /** * 驳回:意见必填,同轮待办全部取消,实例回到报送人。 */ @@ -101,6 +140,9 @@ public class ApprovalTaskService { if (user == null || !instance.getReporterId().equals(user.getUserId())) { throw new BusinessException("无权操作该单据"); } + if (ApprovalConstants.STATUS_ARCHIVED.equals(instance.getStatus())) { + throw new BusinessException("单据已归档,不能修改"); + } if (!ApprovalConstants.STATUS_PENDING.equals(instance.getStatus())) { throw new BusinessException("当前状态不可撤回"); } @@ -131,6 +173,67 @@ public class ApprovalTaskService { return created == 0 ? "当前节点缺少审批人,请联系管理员补配置" : null; } + private String doConfirm(Long taskId, ApprovalTask task, User user, + ApprovalNode next, List approvers) { + ApprovalInstance locked = engineService.lockInstance(task.getInstanceId()); + ensureNotArchived(locked); + ensureTaskHandleable(task, locked); + if (updatePendingTask(taskId, ApprovalConstants.TASK_DONE, null) == 0) { + throw new BusinessException("任务已处理"); + } + engineService.addRecord(locked, taskId, task.getNodeKey(), user, + ApprovalConstants.ACTION_CONFIRM, null); + Integer remaining = taskMapper.selectCount(new LambdaQueryWrapper() + .eq(ApprovalTask::getInstanceId, locked.getId()) + .eq(ApprovalTask::getRound, locked.getRound()) + .eq(ApprovalTask::getNodeKey, task.getNodeKey()) + .eq(ApprovalTask::getStatus, ApprovalConstants.TASK_PENDING)); + if (remaining != null && remaining > 0) { + return null; + } + if (next == null) { + return "当前节点缺少审批人,请联系管理员补配置"; + } + int created = engineService.moveToNode(locked, next, locked.getRound(), approvers); + if (created == 0) { + return "当前节点缺少审批人,请联系管理员补配置"; + } + int rows = instanceMapper.update(null, new UpdateWrapper() + .eq("id", locked.getId()) + .eq("status", ApprovalConstants.STATUS_PENDING) + .set("status", ApprovalConstants.STATUS_APPROVED) + .set("approve_time", LocalDateTime.now())); + if (rows == 0) { + throw new BusinessException("单据状态已变化,请刷新后重试"); + } + return null; + } + + private void doArchive(Long taskId, ApprovalTask task, User user) { + ApprovalInstance locked = engineService.lockInstance(task.getInstanceId()); + ensureNotArchived(locked); + if (!ApprovalConstants.STATUS_APPROVED.equals(locked.getStatus())) { + throw new BusinessException("单据状态已变化,请刷新后重试"); + } + ensureTaskCurrent(task, locked); + if (updatePendingTask(taskId, ApprovalConstants.TASK_DONE, null) == 0) { + throw new BusinessException("任务已处理"); + } + engineService.cancelPendingTasks(locked.getId(), locked.getRound(), taskId); + engineService.addRecord(locked, taskId, task.getNodeKey(), user, + ApprovalConstants.ACTION_ARCHIVE, null); + int rows = instanceMapper.update(null, new UpdateWrapper() + .eq("id", locked.getId()) + .eq("status", ApprovalConstants.STATUS_APPROVED) + .set("status", ApprovalConstants.STATUS_ARCHIVED) + .set("archive_time", LocalDateTime.now()) + .set("archivist_id", user.getUserId()) + .set("archivist_name", displayName(user))); + if (rows == 0) { + throw new BusinessException("单据状态已变化,请刷新后重试"); + } + } + private void doReject(Long taskId, ApprovalTask task, String opinion, User user) { ApprovalInstance locked = engineService.lockInstance(task.getInstanceId()); ensureTaskHandleable(task, locked); @@ -178,12 +281,22 @@ public class ApprovalTaskService { if (!ApprovalConstants.STATUS_PENDING.equals(instance.getStatus())) { throw new BusinessException("单据状态已变化,请刷新后重试"); } + ensureTaskCurrent(task, instance); + } + + private void ensureTaskCurrent(ApprovalTask task, ApprovalInstance instance) { if (!task.getRound().equals(instance.getRound()) || !task.getNodeKey().equals(instance.getCurrentNodeKey())) { throw new BusinessException("任务已处理"); } } + private void ensureNotArchived(ApprovalInstance instance) { + if (ApprovalConstants.STATUS_ARCHIVED.equals(instance.getStatus())) { + throw new BusinessException("单据已归档,不能修改"); + } + } + private void ensureNoHandledTask(ApprovalInstance instance) { Integer handled = taskMapper.selectCount(new LambdaQueryWrapper() .eq(ApprovalTask::getInstanceId, instance.getId()) @@ -288,6 +401,16 @@ public class ApprovalTaskService { return user.getUsername(); } + private String displayName(User user) { + if (StrUtil.isNotBlank(user.getRealName())) { + return user.getRealName(); + } + if (StrUtil.isNotBlank(user.getNickname())) { + return user.getNickname(); + } + return user.getUsername(); + } + private String normalizeOpinion(String opinion) { if (opinion == null) { return null; diff --git a/src/test/java/com/gxwebsoft/approval/ApprovalConfirmArchiveIT.java b/src/test/java/com/gxwebsoft/approval/ApprovalConfirmArchiveIT.java new file mode 100644 index 0000000..c89dc95 --- /dev/null +++ b/src/test/java/com/gxwebsoft/approval/ApprovalConfirmArchiveIT.java @@ -0,0 +1,367 @@ +package com.gxwebsoft.approval; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gxwebsoft.support.ApprovalTestData; +import com.gxwebsoft.support.TestAuthentication; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +import javax.sql.DataSource; +import java.nio.charset.StandardCharsets; + +import 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.delete; +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; + +/** + * 存档确认与归档闭环集成测试:全员存档、部分存档、归档或签、归档后只读、 + * 归档缺配阻断、收文员与 superAdmin 全租户可见。 + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles({"gxtyzx-test", "test"}) +class ApprovalConfirmArchiveIT { + + /** + * 独立租户:收文员解析按 (tenant_id, role_code) 全局匹配,使用专属租户可避免 + * 与并行执行的其它审批测试类互相污染角色成员。 + */ + private static final int TENANT = 10024; + private static final String LIST = "approval:instance:list"; + private static final String SUBMIT = "approval:instance:submit"; + private static final String APPROVE = "approval:instance:approve"; + private static final String ARCHIVE = "approval:instance:archive"; + + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @Autowired + private DataSource dataSource; + + private ApprovalTestData data; + private ApprovalTestData.TestUser reporter; + private ApprovalTestData.TestUser leader; + private ApprovalTestData.TestUser archivist; + private ApprovalTestData.TestUser secondArchivist; + private ApprovalTestData.TestUser superAdmin; + private ApprovalTestData.TestUser outsider; + private Integer organizationId; + private Integer archivistRoleId; + + @BeforeEach + void setUp() { + data = new ApprovalTestData(dataSource); + organizationId = data.createOrganization("归档部门" + data.uniqueSuffix(), 136, TENANT); + reporter = data.createUser("归档报送人" + data.uniqueSuffix(), organizationId, TENANT); + ApprovalTestData.TestRole leaderRole = + data.createRole("dept_leader_" + organizationId, "部门负责人", TENANT); + leader = data.createUser("归档负责人" + data.uniqueSuffix(), organizationId, TENANT); + data.bindTenant(leader.userId, leaderRole.roleId, TENANT); + + ApprovalTestData.TestRole archivistRole = data.createRole("approval_archivist", "收文员", TENANT); + archivistRoleId = archivistRole.roleId; + archivist = data.createUser("归档收文员" + data.uniqueSuffix(), organizationId, TENANT); + secondArchivist = data.createUser("归档收文员B" + data.uniqueSuffix(), organizationId, TENANT); + data.bindTenant(archivist.userId, archivistRoleId, TENANT); + data.bindTenant(secondArchivist.userId, archivistRoleId, TENANT); + + ApprovalTestData.TestRole superAdminRole = data.createRole("superAdmin", "超级管理员", TENANT); + superAdmin = data.createUser("归档超管" + data.uniqueSuffix(), organizationId, TENANT); + data.bindTenant(superAdmin.userId, superAdminRole.roleId, TENANT); + + outsider = data.createUser("归档路人" + data.uniqueSuffix(), organizationId, TENANT); + } + + @AfterEach + void tearDown() { + data.cleanup(); + } + + @Test + void confirmsOnlyAfterAllConfirmTasksDone() throws Exception { + long instanceId = createAndSubmit("全员存档"); + approveByLeader(instanceId); + long reporterTask = pendingTaskId(instanceId, reporter.userId); + + // 存档节点缺配前先验证:CONFIRM 任务不能驳回,也不需要意见 + mockMvc.perform(authed(post("/api/approval/task/" + reporterTask + "/reject"), + reporter, organizationId, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"opinion\":\"不同意\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("不支持驳回"))); + + // 追加一名并存档人(协调单场景:报送人 + 派单对象) + ApprovalTestData.TestUser secondConfirmer = data.createUser("归档存档人B" + data.uniqueSuffix(), organizationId); + long secondTask = insertConfirmTask(instanceId, secondConfirmer); + + mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), secondConfirmer, organizationId, LIST)) + .andExpect(jsonPath("$.data.confirms.length()").value(2)) + .andExpect(jsonPath("$.data.confirms[1].status").value("PENDING")) + .andExpect(jsonPath("$.data.confirms[1].statusName").value("待确认")) + .andExpect(jsonPath("$.data.permissions.canConfirm").value(true)); + + // 部分存档:仍等待其他人 + mockMvc.perform(authed(post("/api/approval/task/" + reporterTask + "/confirm"), reporter, organizationId, APPROVE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("PENDING")) + .andExpect(jsonPath("$.data.currentNodeName").value("存档")) + .andExpect(jsonPath("$.data.confirms[0].status").value("DONE")) + .andExpect(jsonPath("$.data.confirms[0].statusName").value("已确认")) + .andExpect(jsonPath("$.data.confirms[0].handleTime").isNotEmpty()) + .andExpect(jsonPath("$.data.confirms[1].status").value("PENDING")); + assertThat(data.queryMaster("SELECT approve_time FROM approval_instance WHERE id = ?", + Object.class, instanceId)).isNull(); + + // 全员存档完成 → APPROVED,并为全部收文员生成或签归档任务 + mockMvc.perform(authed(post("/api/approval/task/" + secondTask + "/confirm"), + secondConfirmer, organizationId, APPROVE)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("APPROVED")) + .andExpect(jsonPath("$.data.statusName").value("审批完成")) + .andExpect(jsonPath("$.data.approveTime").isNotEmpty()) + .andExpect(jsonPath("$.data.currentNodeName").value("归档")) + .andExpect(jsonPath("$.data.confirms[1].status").value("DONE")); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? " + + "AND node_type = 'ARCHIVE' AND status = 'PENDING'", instanceId)).isEqualTo(2); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'CONFIRM'", + instanceId)).isEqualTo(2); + // 存档记录不带意见 + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? " + + "AND action = 'CONFIRM' AND opinion IS NOT NULL", instanceId)).isZero(); + } + + @Test + void archivesOnceAndCancelsOtherArchiveTasks() throws Exception { + long instanceId = createAndSubmit("归档或签"); + approveByLeader(instanceId); + confirmReporterTask(instanceId); + + long firstArchive = pendingTaskId(instanceId, archivist.userId); + long secondArchive = pendingTaskId(instanceId, secondArchivist.userId); + + mockMvc.perform(authed(post("/api/approval/task/" + firstArchive + "/archive"), archivist, organizationId, ARCHIVE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("ARCHIVED")) + .andExpect(jsonPath("$.data.statusName").value("已归档")) + .andExpect(jsonPath("$.data.archiveTime").isNotEmpty()) + .andExpect(jsonPath("$.data.archivistId").value(archivist.userId)) + .andExpect(jsonPath("$.data.archivistName").value(archivist.username)); + + assertThat(data.queryMaster("SELECT status FROM approval_task WHERE id = ?", String.class, secondArchive)) + .isEqualTo("CANCELLED"); + assertThat(data.queryMaster("SELECT handle_time FROM approval_task WHERE id = ?", Object.class, firstArchive)) + .isNotNull(); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'ARCHIVE'", + instanceId)).isEqualTo(1); + + mockMvc.perform(authed(post("/api/approval/task/" + secondArchive + "/archive"), + secondArchivist, organizationId, ARCHIVE)) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("已归档"))); + } + + @Test + void rejectsEveryWriteAfterArchive() throws Exception { + long instanceId = createAndSubmit("归档只读"); + long attachmentId = data.queryMaster("SELECT id FROM approval_attachment WHERE instance_id = ? LIMIT 1", + Long.class, instanceId); + approveByLeader(instanceId); + long confirmTask = pendingTaskId(instanceId, reporter.userId); + confirmReporterTask(instanceId); + long archiveTask = pendingTaskId(instanceId, archivist.userId); + + mockMvc.perform(authed(post("/api/approval/task/" + archiveTask + "/archive"), archivist, organizationId, ARCHIVE)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("ARCHIVED")) + .andExpect(jsonPath("$.data.permissions.canEdit").value(false)) + .andExpect(jsonPath("$.data.permissions.canSubmit").value(false)) + .andExpect(jsonPath("$.data.permissions.canWithdraw").value(false)) + .andExpect(jsonPath("$.data.permissions.canConfirm").value(false)) + .andExpect(jsonPath("$.data.permissions.canArchive").value(false)) + .andExpect(jsonPath("$.data.myTask").doesNotExist()); + + String archived = "单据已归档,不能修改"; + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, organizationId, SUBMIT)) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(archived)); + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/withdraw"), reporter, organizationId, SUBMIT)) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(archived)); + mockMvc.perform(authed(post("/api/approval/instance/completion"), reporter, organizationId, SUBMIT) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"id\":" + instanceId + ",\"name\":\"改归档单据\"," + + "\"implementOrg\":\"广西体育中心实施部\",\"fee\":1.00," + + "\"serviceContent\":\"尝试修改已归档单据\"}")) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(archived)); + mockMvc.perform(authed(delete("/api/approval/attachment/" + attachmentId), reporter, organizationId, SUBMIT)) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(archived)); + mockMvc.perform(authed(post("/api/approval/task/" + confirmTask + "/confirm"), reporter, organizationId, APPROVE)) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(archived)); + + assertThat(data.queryMaster("SELECT status FROM approval_instance WHERE id = ?", String.class, instanceId)) + .isEqualTo("ARCHIVED"); + } + + @Test + void blocksWhenArchiveNodeHasNoApprover() throws Exception { + long instanceId = createAndSubmit("归档缺配"); + approveByLeader(instanceId); + + // 提交后收文员角色被清空,存档完成时归档节点缺配 + data.server().update("DELETE FROM sys_user_role WHERE role_id = ?", archivistRoleId); + + mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, reporter.userId) + "/confirm"), + reporter, organizationId, APPROVE)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("缺少审批人"))) + .andExpect(jsonPath("$.data.status").value("PENDING")) + .andExpect(jsonPath("$.data.currentNodeName").value("归档")) + .andExpect(jsonPath("$.data.approveTime").doesNotExist()); + + assertThat(data.queryMaster("SELECT status FROM approval_instance WHERE id = ?", String.class, instanceId)) + .isEqualTo("PENDING"); + assertThat(data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?", + String.class, instanceId)).isEqualTo("archive"); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? " + + "AND node_type = 'ARCHIVE'", instanceId)).isZero(); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? AND action = 'CONFIRM'", + instanceId)).isEqualTo(1); + } + + @Test + void letsArchivistAndSuperAdminViewAllTenantInstances() throws Exception { + long instanceId = createAndSubmit("全租户可见"); + approveByLeader(instanceId); + confirmReporterTask(instanceId); + long archiveTask = pendingTaskId(instanceId, archivist.userId); + mockMvc.perform(authed(post("/api/approval/task/" + archiveTask + "/archive"), archivist, organizationId, ARCHIVE)) + .andExpect(jsonPath("$.code").value(0)); + + // 收文员与 superAdmin 非参与人也可见完整详情(含归档人/时间与附件) + ApprovalTestData.TestUser otherArchivist = + data.createUser("归档外聘收文员" + data.uniqueSuffix(), organizationId, TENANT); + data.bindTenant(otherArchivist.userId, archivistRoleId, TENANT); + mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), otherArchivist, organizationId, LIST)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("ARCHIVED")) + .andExpect(jsonPath("$.data.archivistName").value(archivist.username)) + .andExpect(jsonPath("$.data.attachments.length()").value(1)) + .andExpect(jsonPath("$.data.records.length()").isNotEmpty()); + mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), superAdmin, organizationId, LIST)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.id").value((int) instanceId)); + + // 普通参与人之外的用户仍被拒绝 + mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), outsider, organizationId, LIST)) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权查看该单据"))); + // 参与人(报送人)可见 + mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), reporter, organizationId, LIST)) + .andExpect(jsonPath("$.code").value(0)); + } + + // ---------- helpers ---------- + + private long createAndSubmit(String name) throws Exception { + String body = "{\"name\":" + quote(name) + + ",\"implementOrg\":\"广西体育中心实施部\",\"fee\":123.45," + + "\"serviceContent\":\"完成场馆设施维修与验收\"}"; + String response = mockMvc.perform(authed(post("/api/approval/instance/completion"), + reporter, organizationId, SUBMIT) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + long instanceId = objectMapper.readTree(response).path("data").asLong(); + data.trackInstance(instanceId); + + uploadAttachment(instanceId); + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), + reporter, organizationId, SUBMIT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + return instanceId; + } + + private void approveByLeader(long instanceId) throws Exception { + mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, leader.userId) + "/approve"), + leader, organizationId, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"opinion\":\"同意办理\"}")) + .andExpect(jsonPath("$.code").value(0)); + } + + private void confirmReporterTask(long instanceId) throws Exception { + mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, reporter.userId) + "/confirm"), + reporter, organizationId, APPROVE)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("APPROVED")); + } + + private long insertConfirmTask(long instanceId, ApprovalTestData.TestUser assignee) { + Integer round = data.queryMaster("SELECT round FROM approval_instance WHERE id = ?", Integer.class, instanceId); + data.master().update("INSERT INTO approval_task (tenant_id, instance_id, node_key, node_type, round, " + + "task_type, assignee_id, assignee_name, status) VALUES (?, ?, 'confirm', 'CONFIRM', ?, 'CONFIRM', ?, ?, 'PENDING')", + TENANT, instanceId, round, assignee.userId, assignee.realName); + Long taskId = data.queryMaster("SELECT id FROM approval_task WHERE instance_id = ? AND assignee_id = ? " + + "AND status = 'PENDING'", Long.class, instanceId, assignee.userId); + assertThat(taskId).as("并存档任务应存在").isNotNull(); + return taskId; + } + + private void uploadAttachment(long instanceId) throws Exception { + MockMultipartFile file = new MockMultipartFile("file", "sign.png", "image/png", + new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}); + mockMvc.perform(multipart("/api/approval/attachment/upload") + .file(file) + .param("instanceId", String.valueOf(instanceId)) + .param("category", "SIGNED") + .header("tenantId", TENANT) + .with(authentication(TestAuthentication.user(reporter.userId, reporter.username, + organizationId, TENANT, SUBMIT)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } + + private long pendingTaskId(long instanceId, int userId) { + Long taskId = data.queryMaster("SELECT id FROM approval_task WHERE instance_id = ? " + + "AND assignee_id = ? AND status = 'PENDING' LIMIT 1", Long.class, instanceId, userId); + assertThat(taskId).as("待办任务应存在").isNotNull(); + return taskId; + } + + private MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder, + ApprovalTestData.TestUser user, Integer org, + String... authorities) { + return builder.header("tenantId", TENANT) + .with(authentication(TestAuthentication.user(user.userId, user.username, org, TENANT, authorities))); + } + + private String quote(String value) { + return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } +} From 8224e6380a5e0b4b12c1880d052db96deaeb6a8f Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Fri, 30 Oct 2026 21:11:51 +0800 Subject: [PATCH 4/4] =?UTF-8?q?test(approval):=20=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E5=8D=8F=E8=B0=83=E5=8D=95=E4=BC=AA=E9=80=A0=E6=8A=A5=E9=80=81?= =?UTF-8?q?=E9=83=A8=E9=97=A8=E5=AD=97=E6=AE=B5=E8=A2=AB=E5=BF=BD=E7=95=A5?= =?UTF-8?q?=20(#23)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../approval/ApprovalCoordinationIT.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/test/java/com/gxwebsoft/approval/ApprovalCoordinationIT.java b/src/test/java/com/gxwebsoft/approval/ApprovalCoordinationIT.java index 4ffdf2a..867c788 100644 --- a/src/test/java/com/gxwebsoft/approval/ApprovalCoordinationIT.java +++ b/src/test/java/com/gxwebsoft/approval/ApprovalCoordinationIT.java @@ -129,6 +129,23 @@ class ApprovalCoordinationIT { .contains("confirm").contains("archive"); assertThat(data.countMaster("SELECT COUNT(*) FROM approval_flow_definition " + "WHERE tenant_id = ? AND flow_code = 'coordination_external'", TENANT)).isZero(); + + // 报送部门与经办人只取登录态,客户端伪造字段被忽略 + ApprovalTestData.TestUser other = data.createUser("他人" + data.uniqueSuffix(), acceptOrgId); + String forged = "{\"acceptOrgId\":" + acceptOrgId + ",\"title\":\"伪造部门\",\"content\":\"内容\"," + + "\"reporterId\":" + other.userId + ",\"reportOrgId\":" + acceptOrgId + "}"; + String forgedResponse = mockMvc.perform(authed(post("/api/approval/instance/coordination"), + reporter, reportOrgId, SUBMIT) + .contentType(MediaType.APPLICATION_JSON) + .content(forged)) + .andExpect(jsonPath("$.code").value(0)) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + long forgedId = objectMapper.readTree(forgedResponse).path("data").asLong(); + data.trackInstance(forgedId); + assertThat(data.queryMaster("SELECT reporter_id FROM approval_instance WHERE id = ?", + Integer.class, forgedId)).isEqualTo(reporter.userId); + assertThat(data.queryMaster("SELECT report_org_id FROM approval_instance WHERE id = ?", + Integer.class, forgedId)).isEqualTo(reportOrgId); } @Test