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/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 3461711..cf20185 100644 --- a/src/main/java/com/gxwebsoft/approval/service/ApprovalNodeValidator.java +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalNodeValidator.java @@ -40,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( @@ -192,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()) { @@ -222,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/support/ApprovalNode.java b/src/main/java/com/gxwebsoft/approval/support/ApprovalNode.java index d6a0eb7..1307c48 100644 --- a/src/main/java/com/gxwebsoft/approval/support/ApprovalNode.java +++ b/src/main/java/com/gxwebsoft/approval/support/ApprovalNode.java @@ -44,10 +44,23 @@ public class ApprovalNode { /** * 处理人通过前是否必须本次新传附件(ADR 0016)。 * - *

缺省 false;提交时的必传仍由单据实现的 requiredAttachmentCategory 决定。

+ *

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 index 07b42af..5c757a2 100644 --- a/src/test/java/com/gxwebsoft/approval/ApprovalAttachmentConfigIT.java +++ b/src/test/java/com/gxwebsoft/approval/ApprovalAttachmentConfigIT.java @@ -30,7 +30,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; /** - * 节点级附件要求(ADR 0016):审批节点要求本次新传、补传后放行;提交必传仍是代码声明。 + * 附件要求可配置(ADR 0016):提交环节关闭必传、提交环节要求附件、审批节点要求本次新传。 */ @SpringBootTest @AutoConfigureMockMvc @@ -105,25 +105,38 @@ class ApprovalAttachmentConfigIT { } @Test - void submitStillRequiresAttachmentFromCode() throws Exception { - publish(chain(false)); + void submitWithoutAttachmentPassesWhenSubmitStageAllows() throws Exception { + publish(chain(false, false)); long instanceId = createDraft(); - mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, SUBMIT)) - .andExpect(status().isOk()) + 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 legacyChainWithoutSubmitNodeStillRequiresAttachment() throws Exception { + // 存量定义没有提交环节:沿用代码声明,提交仍需附件 + publish(legacyChain()); + long instanceId = createDraft(); + submit(instanceId) .andExpect(jsonPath("$.code").value(1)) .andExpect(jsonPath("$.message").value(containsString("请至少上传一张"))); } @Test void approverMustUploadOnNodeThatRequiresIt() throws Exception { - publish(chain(true)); + publish(chain(false, true)); long instanceId = createDraft(); - uploadAs(reporter, instanceId); - mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, SUBMIT)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(0)); + 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) @@ -132,7 +145,7 @@ class ApprovalAttachmentConfigIT { .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) @@ -142,8 +155,10 @@ class ApprovalAttachmentConfigIT { .andExpect(jsonPath("$.code").value(0)); } - private String chain(boolean leaderRequired) { - return "[{\"key\":\"deptLeader\",\"type\":\"APPROVE\",\"name\":\"部门负责人\"," + 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\"}," @@ -151,6 +166,15 @@ class ApprovalAttachmentConfigIT { + "\"resolver\":\"archivist\"}]"; } + private String legacyChain() { + return "[{\"key\":\"deptLeader\",\"type\":\"APPROVE\",\"name\":\"部门负责人\"," + + "\"resolver\":\"reporterOrgLeader\"}," + + "{\"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) @@ -175,6 +199,12 @@ class ApprovalAttachmentConfigIT { 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});