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] =?UTF-8?q?feat(approval):=20=E5=AD=98=E6=A1=A3=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E4=B8=8E=E5=BD=92=E6=A1=A3=E9=97=AD=E7=8E=AF=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("\"", "\\\"") + "\""; + } +}