feat: 附件要求可配置——提交环节节点 + 节点级本次新传校验(ADR 0016)

This commit is contained in:
2026-11-01 15:47:44 +08:00
parent d670b26140
commit 8a4cdd556e
9 changed files with 359 additions and 17 deletions
@@ -40,6 +40,9 @@ public final class ApprovalConstants {
public static final String NODE_CONFIRM = "CONFIRM"; public static final String NODE_CONFIRM = "CONFIRM";
public static final String NODE_ARCHIVE = "ARCHIVE"; 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_SUBMIT = "SUBMIT";
public static final String ACTION_RESUBMIT = "RESUBMIT"; public static final String ACTION_RESUBMIT = "RESUBMIT";
@@ -5,8 +5,10 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.gxwebsoft.approval.constant.ApprovalConstants; import com.gxwebsoft.approval.constant.ApprovalConstants;
import com.gxwebsoft.approval.entity.ApprovalAttachment; import com.gxwebsoft.approval.entity.ApprovalAttachment;
import com.gxwebsoft.approval.entity.ApprovalInstance; import com.gxwebsoft.approval.entity.ApprovalInstance;
import com.gxwebsoft.approval.entity.ApprovalTask;
import com.gxwebsoft.approval.mapper.ApprovalAttachmentMapper; import com.gxwebsoft.approval.mapper.ApprovalAttachmentMapper;
import com.gxwebsoft.approval.mapper.ApprovalInstanceMapper; import com.gxwebsoft.approval.mapper.ApprovalInstanceMapper;
import com.gxwebsoft.approval.mapper.ApprovalTaskMapper;
import com.gxwebsoft.approval.vo.ApprovalAttachmentVO; import com.gxwebsoft.approval.vo.ApprovalAttachmentVO;
import com.gxwebsoft.approval.vo.ApprovalUserVO; import com.gxwebsoft.approval.vo.ApprovalUserVO;
import com.gxwebsoft.common.core.config.ConfigProperties; import com.gxwebsoft.common.core.config.ConfigProperties;
@@ -42,6 +44,8 @@ public class ApprovalAttachmentService {
@Resource @Resource
private ApprovalInstanceMapper instanceMapper; private ApprovalInstanceMapper instanceMapper;
@Resource @Resource
private ApprovalTaskMapper taskMapper;
@Resource
private ApprovalAttachmentMapper attachmentMapper; private ApprovalAttachmentMapper attachmentMapper;
@Resource @Resource
private ApprovalFileService fileService; private ApprovalFileService fileService;
@@ -86,6 +90,17 @@ public class ApprovalAttachmentService {
return count == null ? 0 : count; return count == null ? 0 : count;
} }
/**
* 指定时刻之后、指定用户上传的附件数:节点级「本次必须新传」判定用(ADR 0016)。
*/
public int countUploadedAfter(Long instanceId, Integer uploaderId, java.time.LocalDateTime after) {
Integer count = attachmentMapper.selectCount(new LambdaQueryWrapper<ApprovalAttachment>()
.eq(ApprovalAttachment::getInstanceId, instanceId)
.eq(ApprovalAttachment::getUploaderId, uploaderId)
.gt(after != null, ApprovalAttachment::getCreateTime, after));
return count == null ? 0 : count;
}
/** 指定分类的附件数量(提交预检按用途校验必传,ADR 0005)。 */ /** 指定分类的附件数量(提交预检按用途校验必传,ADR 0005)。 */
public int countCategory(Long instanceId, String category) { public int countCategory(Long instanceId, String category) {
if (StrUtil.isBlank(category)) { if (StrUtil.isBlank(category)) {
@@ -169,17 +184,37 @@ public class ApprovalAttachmentService {
if (instance == null) { if (instance == null) {
throw new BusinessException("单据不存在"); throw new BusinessException("单据不存在");
} }
checkEditable(instance, user); // 本人上传的件可自行撤销(审批人补件场景,ADR 0016);其余仍按报送人规则
if (!user.getUserId().equals(attachment.getUploaderId())) {
checkEditable(instance, user);
}
attachmentMapper.deleteById(attachmentId); attachmentMapper.deleteById(attachmentId);
} }
private void checkEditable(ApprovalInstance instance, User user) { private void checkEditable(ApprovalInstance instance, User user) {
if (user == null || !instance.getReporterId().equals(user.getUserId())) { if (user == null) {
throw new BusinessException("无权操作该单据"); throw new BusinessException("无权操作该单据");
} }
if (!ApprovalConstants.EDITABLE_STATUSES.contains(instance.getStatus())) { if (instance.getReporterId().equals(user.getUserId())) {
throw new BusinessException(ApprovalConstants.writeBlockedMessage(instance.getStatus())); 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<ApprovalTask>()
.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) { private void checkExtensionAndContentType(String extension, String contentType) {
@@ -77,6 +77,16 @@ public class ApprovalDocumentService {
createDraft(type, param, user, definition, organizationId, organizationName, prepared)); createDraft(type, param, user, definition, organizationId, organizationName, prepared));
} }
/** 链首提交环节是否要求附件;没有提交环节时沿用代码声明(ADR 0016)。 */
private boolean submitAttachmentRequired(List<ApprovalNode> nodes) {
for (ApprovalNode node : nodes) {
if (ApprovalConstants.NODE_SUBMIT.equals(node.getType())) {
return node.isAttachmentRequired();
}
}
return true;
}
/** /**
* 提交预检:按单据声明校验必传附件分类,再逐节点逐组解析审批人(ADR 0003/0005)。 * 提交预检:按单据声明校验必传附件分类,再逐节点逐组解析审批人(ADR 0003/0005)。
* *
@@ -85,14 +95,21 @@ public class ApprovalDocumentService {
public void preflight(ApprovalInstance instance, List<ApprovalNode> nodes) { public void preflight(ApprovalInstance instance, List<ApprovalNode> nodes) {
ApprovalDocumentType type = documentRegistry.find(instance.getFlowCode()); ApprovalDocumentType type = documentRegistry.find(instance.getFlowCode());
String requiredCategory = type == null ? null : type.requiredAttachmentCategory(); String requiredCategory = type == null ? null : type.requiredAttachmentCategory();
if (requiredCategory != null) { // 链首提交环节可关闭整单必传(ADR 0016);没有提交环节的存量链沿用代码声明
if (attachmentService.countCategory(instance.getId(), requiredCategory) == 0) { if (submitAttachmentRequired(nodes)) {
throw new BusinessException("请至少上传一张" + ApprovalConstants.categoryName(requiredCategory)); 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<ApprovalNode> 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) { if (missing != null) {
throw new BusinessException(missing); throw new BusinessException(missing);
} }
@@ -49,10 +49,12 @@ public class ApprovalEngineService {
public int startFlow(ApprovalInstance instance, List<ApprovalApprover> approvers, public int startFlow(ApprovalInstance instance, List<ApprovalApprover> approvers,
User operator, String action) { User operator, String action) {
List<ApprovalNode> nodes = flowService.parseNodes(instance.getDefinitionId()); List<ApprovalNode> nodes = flowService.parseNodes(instance.getDefinitionId());
if (nodes.isEmpty()) { // 链首的提交环节不产生待办(ADR 0016)
ApprovalNode first = ApprovalNode.firstProcessable(nodes);
if (first == null) {
throw new BusinessException("审批流程未配置节点"); throw new BusinessException("审批流程未配置节点");
} }
return activateNode(instance, nodes.get(0), approvers, operator, action, null); return activateNode(instance, first, approvers, operator, action, null);
} }
/** /**
@@ -150,12 +150,13 @@ public class ApprovalInstanceService {
List<ApprovalNode> nodes = flowService.parseNodes(instance.getDefinitionId()); List<ApprovalNode> nodes = flowService.parseNodes(instance.getDefinitionId());
// 附件分类与逐节点逐组审批人解析(ADR 0003 / 0005),必须在事务外完成 // 附件分类与逐节点逐组审批人解析(ADR 0003 / 0005),必须在事务外完成
documentService.preflight(instance, nodes); documentService.preflight(instance, nodes);
if (nodes.isEmpty()) { ApprovalNode firstProcessable = ApprovalNode.firstProcessable(nodes);
if (firstProcessable == null) {
throw new BusinessException("审批流程未配置节点"); throw new BusinessException("审批流程未配置节点");
} }
// 首节点审批人同样在事务外解析:TransactionTemplate 内 @DS("server") 不生效 // 首节点审批人同样在事务外解析:TransactionTemplate 内 @DS("server") 不生效
ApprovalApproverResolver.Resolution firstNode = ApprovalApproverResolver.Resolution firstNode =
approverResolver.resolve(instance, nodes.get(0)); approverResolver.resolve(instance, firstProcessable);
return inTransaction(() -> return inTransaction(() ->
doSubmit(instanceId, user, organization, resolvedAcceptOrg, type, firstNode)); doSubmit(instanceId, user, organization, resolvedAcceptOrg, type, firstNode));
@@ -27,7 +27,8 @@ public class ApprovalNodeValidator {
/** 节点对象允许的字段名。新增节点配置字段时两处同步:ApprovalNode 与此处。 */ /** 节点对象允许的字段名。新增节点配置字段时两处同步:ApprovalNode 与此处。 */
private static final List<String> NODE_FIELDS = List.of( private static final List<String> NODE_FIELDS = List.of(
"key", "type", "resolver", "orgCode", "name", "groups", "writableFields"); "key", "type", "resolver", "orgCode", "name", "groups", "writableFields",
"attachmentRequired");
private static final List<String> GROUP_FIELDS = List.of( private static final List<String> GROUP_FIELDS = List.of(
"key", "name", "resolver", "orgCode"); "key", "name", "resolver", "orgCode");
@@ -39,7 +40,8 @@ public class ApprovalNodeValidator {
ApprovalConstants.NODE_COUNTERSIGN, ApprovalConstants.NODE_COUNTERSIGN,
ApprovalConstants.NODE_DISPATCH, ApprovalConstants.NODE_DISPATCH,
ApprovalConstants.NODE_CONFIRM, ApprovalConstants.NODE_CONFIRM,
ApprovalConstants.NODE_ARCHIVE); ApprovalConstants.NODE_ARCHIVE,
ApprovalConstants.NODE_SUBMIT);
/** 按固定组织编码取负责人的解析器,必须配 orgCode。 */ /** 按固定组织编码取负责人的解析器,必须配 orgCode。 */
private static final List<String> ORG_RESOLVERS = List.of( private static final List<String> ORG_RESOLVERS = List.of(
@@ -191,7 +193,9 @@ public class ApprovalNodeValidator {
if (ApprovalConstants.NODE_DISPATCH.equals(nodeType)) { if (ApprovalConstants.NODE_DISPATCH.equals(nodeType)) {
dispatchCount++; 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); checkGroups(where, node);
} else { } else {
if (node.getGroups() != null && !node.getGroups().isEmpty()) { if (node.getGroups() != null && !node.getGroups().isEmpty()) {
@@ -199,6 +203,9 @@ public class ApprovalNodeValidator {
} }
checkResolver(where, node.getResolver(), node.getOrgCode()); checkResolver(where, node.getResolver(), node.getOrgCode());
} }
if (ApprovalConstants.NODE_DISPATCH.equals(nodeType) && node.isAttachmentRequired()) {
throw new BusinessException(where + "派单节点不能要求上传附件");
}
checkWritableFields(where, nodeType, node, fieldKeys); checkWritableFields(where, nodeType, node, fieldKeys);
} }
if (approveCount == 0) { 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) { private void checkGroups(String where, ApprovalNode node) {
List<ApprovalNode.ApprovalGroup> groups = node.getGroups(); List<ApprovalNode.ApprovalGroup> groups = node.getGroups();
if (groups == null || groups.isEmpty()) { if (groups == null || groups.isEmpty()) {
@@ -62,8 +62,29 @@ public class ApprovalTaskService {
@Resource @Resource
private ApprovalDocumentRegistry documentRegistry; private ApprovalDocumentRegistry documentRegistry;
@Resource @Resource
private ApprovalFlowService flowService;
@Resource
private ApprovalAttachmentService attachmentService;
@Resource
private PlatformTransactionManager transactionManager; private PlatformTransactionManager transactionManager;
/**
* 节点级附件要求(ADR 0016):节点声明必须附件时,处理人本次必须新传过一张。
*
* <p>「本次」以当前待办生成时刻为基线,驳回重提或派单换人后待办重建,基线随之刷新。</p>
*/
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 节点组内或签。 * 通过:APPROVE 节点或签、COUNTERSIGN 节点组内或签。
* *
@@ -81,6 +102,7 @@ public class ApprovalTaskService {
if (instance == null) { if (instance == null) {
throw new BusinessException("单据不存在"); throw new BusinessException("单据不存在");
} }
requireNodeAttachment(instance, task, user);
ApprovalDocumentType documentType = documentRegistry.find(instance.getFlowCode()); ApprovalDocumentType documentType = documentRegistry.find(instance.getFlowCode());
// 回填字段在事务外校验(可能涉及唯一性查询与业务表读取) // 回填字段在事务外校验(可能涉及唯一性查询与业务表读取)
List<ApprovalFieldUpdate> changes = writeFields(instance, documentType, task, fields, true, user); List<ApprovalFieldUpdate> changes = writeFields(instance, documentType, task, fields, true, user);
@@ -142,6 +164,7 @@ public class ApprovalTaskService {
throw new BusinessException("单据不存在"); throw new BusinessException("单据不存在");
} }
ensureNotArchived(instance); ensureNotArchived(instance);
requireNodeAttachment(instance, task, user);
// ADR 0006:CONFIRM 节点也可声明可写字段(如设施价格表的运营管理部存档人) // ADR 0006:CONFIRM 节点也可声明可写字段(如设施价格表的运营管理部存档人)
ApprovalDocumentType documentType = documentRegistry.find(instance.getFlowCode()); ApprovalDocumentType documentType = documentRegistry.find(instance.getFlowCode());
List<ApprovalFieldUpdate> changes = writeFields(instance, documentType, task, fields, true, user); List<ApprovalFieldUpdate> changes = writeFields(instance, documentType, task, fields, true, user);
@@ -167,6 +190,7 @@ public class ApprovalTaskService {
throw new BusinessException("单据不存在"); throw new BusinessException("单据不存在");
} }
ensureNotArchived(instance); ensureNotArchived(instance);
requireNodeAttachment(instance, task, user);
inTransaction(() -> doArchive(taskId, task, user)); inTransaction(() -> doArchive(taskId, task, user));
return new Result(instance.getId(), null); return new Result(instance.getId(), null);
} }
@@ -41,6 +41,26 @@ public class ApprovalNode {
/** 可写字段(APPROVE/COUNTERSIGN/CONFIRM 可声明);为空表示不可回填。 */ /** 可写字段(APPROVE/COUNTERSIGN/CONFIRM 可声明);为空表示不可回填。 */
private List<WritableField> writableFields; private List<WritableField> writableFields;
/**
* 处理人通过前是否必须本次新传附件(ADR 0016)。
*
* <p>SUBMIT 节点上表示整单提交时是否必须上传附件;缺省 false,存量链不受影响。</p>
*/
private boolean attachmentRequired;
/** 链首的提交环节不产生待办,取首个可处理节点时跳过它。 */
public static ApprovalNode firstProcessable(List<ApprovalNode> 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 @Data
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
@@ -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)));
}
}