diff --git a/src/main/java/com/gxwebsoft/approval/constant/ApprovalConstants.java b/src/main/java/com/gxwebsoft/approval/constant/ApprovalConstants.java index 335f79f..71a0330 100644 --- a/src/main/java/com/gxwebsoft/approval/constant/ApprovalConstants.java +++ b/src/main/java/com/gxwebsoft/approval/constant/ApprovalConstants.java @@ -40,6 +40,9 @@ public final class ApprovalConstants { public static final String NODE_CONFIRM = "CONFIRM"; public static final String NODE_ARCHIVE = "ARCHIVE"; + /** 提交环节:链首只读节点,不产生待办(ADR 0016)。 */ + public static final String NODE_SUBMIT = "SUBMIT"; + // 动作 public static final String ACTION_SUBMIT = "SUBMIT"; public static final String ACTION_RESUBMIT = "RESUBMIT"; diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalAttachmentService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalAttachmentService.java index 4ffbbe9..fc32c1d 100644 --- a/src/main/java/com/gxwebsoft/approval/service/ApprovalAttachmentService.java +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalAttachmentService.java @@ -5,8 +5,10 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.gxwebsoft.approval.constant.ApprovalConstants; import com.gxwebsoft.approval.entity.ApprovalAttachment; import com.gxwebsoft.approval.entity.ApprovalInstance; +import com.gxwebsoft.approval.entity.ApprovalTask; import com.gxwebsoft.approval.mapper.ApprovalAttachmentMapper; import com.gxwebsoft.approval.mapper.ApprovalInstanceMapper; +import com.gxwebsoft.approval.mapper.ApprovalTaskMapper; import com.gxwebsoft.approval.vo.ApprovalAttachmentVO; import com.gxwebsoft.approval.vo.ApprovalUserVO; import com.gxwebsoft.common.core.config.ConfigProperties; @@ -42,6 +44,8 @@ public class ApprovalAttachmentService { @Resource private ApprovalInstanceMapper instanceMapper; @Resource + private ApprovalTaskMapper taskMapper; + @Resource private ApprovalAttachmentMapper attachmentMapper; @Resource private ApprovalFileService fileService; @@ -86,6 +90,17 @@ public class ApprovalAttachmentService { return count == null ? 0 : count; } + /** + * 指定时刻之后、指定用户上传的附件数:节点级「本次必须新传」判定用(ADR 0016)。 + */ + public int countUploadedAfter(Long instanceId, Integer uploaderId, java.time.LocalDateTime after) { + Integer count = attachmentMapper.selectCount(new LambdaQueryWrapper() + .eq(ApprovalAttachment::getInstanceId, instanceId) + .eq(ApprovalAttachment::getUploaderId, uploaderId) + .gt(after != null, ApprovalAttachment::getCreateTime, after)); + return count == null ? 0 : count; + } + /** 指定分类的附件数量(提交预检按用途校验必传,ADR 0005)。 */ public int countCategory(Long instanceId, String category) { if (StrUtil.isBlank(category)) { @@ -169,17 +184,37 @@ public class ApprovalAttachmentService { if (instance == null) { throw new BusinessException("单据不存在"); } - checkEditable(instance, user); + // 本人上传的件可自行撤销(审批人补件场景,ADR 0016);其余仍按报送人规则 + if (!user.getUserId().equals(attachment.getUploaderId())) { + checkEditable(instance, user); + } attachmentMapper.deleteById(attachmentId); } private void checkEditable(ApprovalInstance instance, User user) { - if (user == null || !instance.getReporterId().equals(user.getUserId())) { + if (user == null) { throw new BusinessException("无权操作该单据"); } - if (!ApprovalConstants.EDITABLE_STATUSES.contains(instance.getStatus())) { - throw new BusinessException(ApprovalConstants.writeBlockedMessage(instance.getStatus())); + if (instance.getReporterId().equals(user.getUserId())) { + if (!ApprovalConstants.EDITABLE_STATUSES.contains(instance.getStatus())) { + throw new BusinessException(ApprovalConstants.writeBlockedMessage(instance.getStatus())); + } + return; } + // 审批中:当前待办处理人可以补传附件(ADR 0016) + if (ApprovalConstants.STATUS_PENDING.equals(instance.getStatus()) + && hasPendingTask(instance.getId(), user.getUserId())) { + return; + } + throw new BusinessException("无权操作该单据"); + } + + private boolean hasPendingTask(Long instanceId, Integer userId) { + Integer count = taskMapper.selectCount(new LambdaQueryWrapper() + .eq(ApprovalTask::getInstanceId, instanceId) + .eq(ApprovalTask::getAssigneeId, userId) + .eq(ApprovalTask::getStatus, ApprovalConstants.TASK_PENDING)); + return count != null && count > 0; } private void checkExtensionAndContentType(String extension, String contentType) { diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalDocumentService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalDocumentService.java index d2d0cc8..387e2f0 100644 --- a/src/main/java/com/gxwebsoft/approval/service/ApprovalDocumentService.java +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalDocumentService.java @@ -77,6 +77,16 @@ public class ApprovalDocumentService { createDraft(type, param, user, definition, organizationId, organizationName, prepared)); } + /** 链首提交环节是否要求附件;没有提交环节时沿用代码声明(ADR 0016)。 */ + private boolean submitAttachmentRequired(List nodes) { + for (ApprovalNode node : nodes) { + if (ApprovalConstants.NODE_SUBMIT.equals(node.getType())) { + return node.isAttachmentRequired(); + } + } + return true; + } + /** * 提交预检:按单据声明校验必传附件分类,再逐节点逐组解析审批人(ADR 0003/0005)。 * @@ -85,14 +95,21 @@ public class ApprovalDocumentService { public void preflight(ApprovalInstance instance, List nodes) { ApprovalDocumentType type = documentRegistry.find(instance.getFlowCode()); String requiredCategory = type == null ? null : type.requiredAttachmentCategory(); - if (requiredCategory != null) { - if (attachmentService.countCategory(instance.getId(), requiredCategory) == 0) { - throw new BusinessException("请至少上传一张" + ApprovalConstants.categoryName(requiredCategory)); + // 链首提交环节可关闭整单必传(ADR 0016);没有提交环节的存量链沿用代码声明 + if (submitAttachmentRequired(nodes)) { + if (requiredCategory != null) { + if (attachmentService.countCategory(instance.getId(), requiredCategory) == 0) { + throw new BusinessException("请至少上传一张" + ApprovalConstants.categoryName(requiredCategory)); + } + } else if (attachmentService.count(instance.getId()) == 0) { + throw new BusinessException("请至少上传一张附件"); } - } else if (attachmentService.count(instance.getId()) == 0) { - throw new BusinessException("请至少上传一张附件"); } - String missing = approverResolver.preflight(instance, nodes); + // 提交环节不产生待办,解析审批人时跳过(ADR 0016) + List processable = nodes.stream() + .filter(node -> !ApprovalConstants.NODE_SUBMIT.equals(node.getType())) + .collect(java.util.stream.Collectors.toList()); + String missing = approverResolver.preflight(instance, processable); if (missing != null) { throw new BusinessException(missing); } diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalEngineService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalEngineService.java index 4d372c7..933bef5 100644 --- a/src/main/java/com/gxwebsoft/approval/service/ApprovalEngineService.java +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalEngineService.java @@ -49,10 +49,12 @@ public class ApprovalEngineService { public int startFlow(ApprovalInstance instance, List approvers, User operator, String action) { List nodes = flowService.parseNodes(instance.getDefinitionId()); - if (nodes.isEmpty()) { + // 链首的提交环节不产生待办(ADR 0016) + ApprovalNode first = ApprovalNode.firstProcessable(nodes); + if (first == null) { throw new BusinessException("审批流程未配置节点"); } - return activateNode(instance, nodes.get(0), approvers, operator, action, null); + return activateNode(instance, first, approvers, operator, action, null); } /** diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java index 6286a65..a78f0d2 100644 --- a/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalInstanceService.java @@ -150,12 +150,13 @@ public class ApprovalInstanceService { List nodes = flowService.parseNodes(instance.getDefinitionId()); // 附件分类与逐节点逐组审批人解析(ADR 0003 / 0005),必须在事务外完成 documentService.preflight(instance, nodes); - if (nodes.isEmpty()) { + ApprovalNode firstProcessable = ApprovalNode.firstProcessable(nodes); + if (firstProcessable == null) { throw new BusinessException("审批流程未配置节点"); } // 首节点审批人同样在事务外解析:TransactionTemplate 内 @DS("server") 不生效 ApprovalApproverResolver.Resolution firstNode = - approverResolver.resolve(instance, nodes.get(0)); + approverResolver.resolve(instance, firstProcessable); return inTransaction(() -> doSubmit(instanceId, user, organization, resolvedAcceptOrg, type, firstNode)); diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalNodeValidator.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalNodeValidator.java index e9d6307..cf20185 100644 --- a/src/main/java/com/gxwebsoft/approval/service/ApprovalNodeValidator.java +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalNodeValidator.java @@ -27,7 +27,8 @@ public class ApprovalNodeValidator { /** 节点对象允许的字段名。新增节点配置字段时两处同步:ApprovalNode 与此处。 */ private static final List NODE_FIELDS = List.of( - "key", "type", "resolver", "orgCode", "name", "groups", "writableFields"); + "key", "type", "resolver", "orgCode", "name", "groups", "writableFields", + "attachmentRequired"); private static final List GROUP_FIELDS = List.of( "key", "name", "resolver", "orgCode"); @@ -39,7 +40,8 @@ public class ApprovalNodeValidator { ApprovalConstants.NODE_COUNTERSIGN, ApprovalConstants.NODE_DISPATCH, ApprovalConstants.NODE_CONFIRM, - ApprovalConstants.NODE_ARCHIVE); + ApprovalConstants.NODE_ARCHIVE, + ApprovalConstants.NODE_SUBMIT); /** 按固定组织编码取负责人的解析器,必须配 orgCode。 */ private static final List ORG_RESOLVERS = List.of( @@ -191,7 +193,9 @@ public class ApprovalNodeValidator { if (ApprovalConstants.NODE_DISPATCH.equals(nodeType)) { dispatchCount++; } - if (ApprovalConstants.NODE_COUNTERSIGN.equals(nodeType)) { + if (ApprovalConstants.NODE_SUBMIT.equals(nodeType)) { + checkSubmitNode(where, i, node); + } else if (ApprovalConstants.NODE_COUNTERSIGN.equals(nodeType)) { checkGroups(where, node); } else { if (node.getGroups() != null && !node.getGroups().isEmpty()) { @@ -199,6 +203,9 @@ public class ApprovalNodeValidator { } checkResolver(where, node.getResolver(), node.getOrgCode()); } + if (ApprovalConstants.NODE_DISPATCH.equals(nodeType) && node.isAttachmentRequired()) { + throw new BusinessException(where + "派单节点不能要求上传附件"); + } checkWritableFields(where, nodeType, node, fieldKeys); } if (approveCount == 0) { @@ -218,6 +225,22 @@ public class ApprovalNodeValidator { } } + /** 提交环节只能位于链首,且不配审批人、会签组与回填字段(ADR 0016)。 */ + private void checkSubmitNode(String where, int index, ApprovalNode node) { + if (index != 0) { + throw new BusinessException("提交环节只能位于流程首位"); + } + if (!isBlank(node.getResolver()) || !isBlank(node.getOrgCode())) { + throw new BusinessException("提交环节不需要配置审批人解析器"); + } + if (node.getGroups() != null && !node.getGroups().isEmpty()) { + throw new BusinessException("提交环节不能配置会签组"); + } + if (node.getWritableFields() != null && !node.getWritableFields().isEmpty()) { + throw new BusinessException("提交环节不能配置回填字段"); + } + } + private void checkGroups(String where, ApprovalNode node) { List groups = node.getGroups(); if (groups == null || groups.isEmpty()) { diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java index d2fe08b..a968b88 100644 --- a/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java @@ -62,8 +62,29 @@ public class ApprovalTaskService { @Resource private ApprovalDocumentRegistry documentRegistry; @Resource + private ApprovalFlowService flowService; + @Resource + private ApprovalAttachmentService attachmentService; + @Resource private PlatformTransactionManager transactionManager; + /** + * 节点级附件要求(ADR 0016):节点声明必须附件时,处理人本次必须新传过一张。 + * + *

「本次」以当前待办生成时刻为基线,驳回重提或派单换人后待办重建,基线随之刷新。

+ */ + private void requireNodeAttachment(ApprovalInstance instance, ApprovalTask task, User user) { + ApprovalNode node = flowService.findNode(instance.getDefinitionId(), task.getNodeKey()); + if (node == null || !node.isAttachmentRequired()) { + return; + } + if (attachmentService.countUploadedAfter(instance.getId(), user.getUserId(), + task.getCreateTime()) > 0) { + return; + } + throw new BusinessException("本节点处理前请先上传附件"); + } + /** * 通过:APPROVE 节点或签、COUNTERSIGN 节点组内或签。 * @@ -81,6 +102,7 @@ public class ApprovalTaskService { if (instance == null) { throw new BusinessException("单据不存在"); } + requireNodeAttachment(instance, task, user); ApprovalDocumentType documentType = documentRegistry.find(instance.getFlowCode()); // 回填字段在事务外校验(可能涉及唯一性查询与业务表读取) List changes = writeFields(instance, documentType, task, fields, true, user); @@ -142,6 +164,7 @@ public class ApprovalTaskService { throw new BusinessException("单据不存在"); } ensureNotArchived(instance); + requireNodeAttachment(instance, task, user); // ADR 0006:CONFIRM 节点也可声明可写字段(如设施价格表的运营管理部存档人) ApprovalDocumentType documentType = documentRegistry.find(instance.getFlowCode()); List changes = writeFields(instance, documentType, task, fields, true, user); @@ -167,6 +190,7 @@ public class ApprovalTaskService { throw new BusinessException("单据不存在"); } ensureNotArchived(instance); + requireNodeAttachment(instance, task, user); inTransaction(() -> doArchive(taskId, task, user)); return new Result(instance.getId(), null); } diff --git a/src/main/java/com/gxwebsoft/approval/support/ApprovalNode.java b/src/main/java/com/gxwebsoft/approval/support/ApprovalNode.java index 350de93..1307c48 100644 --- a/src/main/java/com/gxwebsoft/approval/support/ApprovalNode.java +++ b/src/main/java/com/gxwebsoft/approval/support/ApprovalNode.java @@ -41,6 +41,26 @@ public class ApprovalNode { /** 可写字段(APPROVE/COUNTERSIGN/CONFIRM 可声明);为空表示不可回填。 */ private List writableFields; + /** + * 处理人通过前是否必须本次新传附件(ADR 0016)。 + * + *

SUBMIT 节点上表示整单提交时是否必须上传附件;缺省 false,存量链不受影响。

+ */ + private boolean attachmentRequired; + + /** 链首的提交环节不产生待办,取首个可处理节点时跳过它。 */ + public static ApprovalNode firstProcessable(List nodes) { + if (nodes == null) { + return null; + } + for (ApprovalNode node : nodes) { + if (!com.gxwebsoft.approval.constant.ApprovalConstants.NODE_SUBMIT.equals(node.getType())) { + return node; + } + } + return null; + } + /** 会签组定义:组标识、显示名、解析器与可选固定组织编码。 */ @Data @JsonIgnoreProperties(ignoreUnknown = true) diff --git a/src/test/java/com/gxwebsoft/approval/ApprovalAttachmentConfigIT.java b/src/test/java/com/gxwebsoft/approval/ApprovalAttachmentConfigIT.java new file mode 100644 index 0000000..982490c --- /dev/null +++ b/src/test/java/com/gxwebsoft/approval/ApprovalAttachmentConfigIT.java @@ -0,0 +1,217 @@ +package com.gxwebsoft.approval; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gxwebsoft.support.ApprovalTestData; +import com.gxwebsoft.support.TestAuthentication; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +import javax.sql.DataSource; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.Statement; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +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; + +/** + * 附件要求可配置(ADR 0016):提交环节关闭必传、提交环节要求附件、审批节点要求本次新传。 + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles({"gxtyzx-test", "test"}) +class ApprovalAttachmentConfigIT { + + /** 独立租户,避免与其它审批测试类的收文员角色互相污染。 */ + private static final int TENANT = 10025; + + private static final String FLOW = "completion"; + + 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 FLOW_LIST = "approval:flow:list"; + + private static final String FLOW_PUBLISH = "approval:flow:publish"; + + @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 superAdmin; + private Integer organizationId; + private long maxDefinitionId; + + @BeforeEach + void setUp() throws Exception { + data = new ApprovalTestData(dataSource); + organizationId = data.createOrganization("附件部门" + data.uniqueSuffix(), 136, TENANT); + reporter = data.createUser("附件报送人" + data.uniqueSuffix(), organizationId, TENANT); + ApprovalTestData.TestRole leaderRole = + data.createRole(data.leaderRoleCode(organizationId), "部门负责人", TENANT); + leader = data.createUser("附件负责人" + data.uniqueSuffix(), organizationId, TENANT); + data.bindTenant(leader.userId, leaderRole.roleId, TENANT); + ApprovalTestData.TestRole archivistRole = data.createRole("approval_archivist", "收文员", TENANT); + ApprovalTestData.TestUser archivist = + data.createUser("附件收文员" + data.uniqueSuffix(), organizationId, TENANT); + data.bindTenant(archivist.userId, archivistRole.roleId, TENANT); + ApprovalTestData.TestRole superAdminRole = data.createRole("superAdmin", "超级管理员", TENANT); + superAdmin = data.createUser("附件超管" + data.uniqueSuffix(), organizationId, TENANT); + data.bindTenant(superAdmin.userId, superAdminRole.roleId, TENANT); + try (Connection connection = dataSource.getConnection(); + Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery( + "SELECT COALESCE(MAX(id), 0) FROM approval_flow_definition WHERE flow_code = '" + + FLOW + "' AND tenant_id = " + TENANT)) { + resultSet.next(); + maxDefinitionId = resultSet.getLong(1); + } + } + + @AfterEach + void tearDown() throws Exception { + data.cleanup(); + try (Connection connection = dataSource.getConnection(); + Statement statement = connection.createStatement()) { + statement.executeUpdate("DELETE FROM approval_flow_definition WHERE flow_code = '" + + FLOW + "' AND tenant_id = " + TENANT + " AND id > " + maxDefinitionId); + } + } + + @Test + void submitWithoutAttachmentPassesWhenSubmitStageAllows() throws Exception { + publish(chain(false, false)); + long instanceId = createDraft(); + submit(instanceId).andExpect(jsonPath("$.code").value(0)); + } + + @Test + void submitWithoutAttachmentBlockedWhenSubmitStageRequires() throws Exception { + publish(chain(true, false)); + long instanceId = createDraft(); + submit(instanceId) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(containsString("请至少上传一张"))); + } + + @Test + void approverMustUploadOnNodeThatRequiresIt() throws Exception { + publish(chain(false, true)); + long instanceId = createDraft(); + submit(instanceId).andExpect(jsonPath("$.code").value(0)); + + // 未上传:节点拦住 + mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, leader.userId) + + "/approve"), leader, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"opinion\":\"同意\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value("本节点处理前请先上传附件")); + + // 处理人补传后可继续 + uploadAs(leader, instanceId); + mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, leader.userId) + + "/approve"), leader, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"opinion\":\"同意\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } + + private String chain(boolean submitRequired, boolean leaderRequired) { + return "[{\"key\":\"submit\",\"type\":\"SUBMIT\",\"name\":\"提交\"," + + "\"attachmentRequired\":" + submitRequired + "}," + + "{\"key\":\"deptLeader\",\"type\":\"APPROVE\",\"name\":\"部门负责人\"," + + "\"resolver\":\"reporterOrgLeader\",\"attachmentRequired\":" + leaderRequired + "}," + + "{\"key\":\"confirm\",\"type\":\"CONFIRM\",\"name\":\"存档\"," + + "\"resolver\":\"reporter\"}," + + "{\"key\":\"archive\",\"type\":\"ARCHIVE\",\"name\":\"归档\"," + + "\"resolver\":\"archivist\"}]"; + } + + private void publish(String nodes) throws Exception { + mockMvc.perform(authed(post("/api/approval/flow/publish"), superAdmin, FLOW_LIST, FLOW_PUBLISH) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"flowCode\":\"" + FLOW + "\",\"nodes\":" + nodes + "}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } + + private long createDraft() throws Exception { + String body = "{\"name\":\"附件要求" + data.uniqueSuffix() + + "\",\"implementOrg\":\"广西体育中心实施部\",\"fee\":123.45," + + "\"serviceContent\":\"完成场馆设施维修与验收\"}"; + String response = mockMvc.perform(authed(post("/api/approval/instance/completion"), + reporter, SUBMIT) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + long instanceId = objectMapper.readTree(response).path("data").asLong(); + data.trackInstance(instanceId); + return instanceId; + } + + private org.springframework.test.web.servlet.ResultActions submit(long instanceId) throws Exception { + return mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), + reporter, SUBMIT)) + .andExpect(status().isOk()); + } + + private void uploadAs(ApprovalTestData.TestUser user, long instanceId) throws Exception { + MockMultipartFile file = new MockMultipartFile("file", "voucher.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", "OTHER") + .header("tenantId", TENANT) + .with(authentication(TestAuthentication.user(user.userId, user.username, + organizationId, TENANT, LIST, SUBMIT, APPROVE)))) + .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, + String... authorities) { + return builder.header("tenantId", TENANT) + .with(authentication(TestAuthentication.user(user.userId, user.username, + organizationId, TENANT, authorities))); + } +}