feat: 附件要求可配置——提交环节节点 + 节点级本次新传校验(ADR 0016)
This commit is contained in:
@@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user