diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalNodeValidator.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalNodeValidator.java index 27db0b7..3f4b266 100644 --- a/src/main/java/com/gxwebsoft/approval/service/ApprovalNodeValidator.java +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalNodeValidator.java @@ -202,9 +202,11 @@ public class ApprovalNodeValidator { checkSubmitNode(where, i, node); } else if (ApprovalConstants.NODE_COUNTERSIGN.equals(nodeType)) { checkGroups(where, node); + } else if (hasGroups(node) && ApprovalConstants.NODE_CONFIRM.equals(nodeType)) { + checkConfirmGroups(where, node); } else { - if (node.getGroups() != null && !node.getGroups().isEmpty()) { - throw new BusinessException(where + "只有会签节点可以配置会签组"); + if (hasGroups(node)) { + throw new BusinessException(where + "只有会签节点和存档节点可以配置会签组"); } checkResolver(where, node.getResolver(), node.getOrgCode()); } @@ -246,6 +248,21 @@ public class ApprovalNodeValidator { } } + /** + * 存档节点也可以按组确认:多组全部确认后节点才前进({@link ApprovalApproverResolver} + * 里 groups 优先于单解析器,两处同口径)。组声明本身与会签组同构,复用 {@link #checkGroups}。 + */ + private void checkConfirmGroups(String where, ApprovalNode node) { + if (!isBlank(node.getResolver()) || !isBlank(node.getOrgCode())) { + throw new BusinessException(where + "按组确认时不要再配置审批人解析器"); + } + checkGroups(where, node); + } + + private boolean hasGroups(ApprovalNode node) { + return node.getGroups() != null && !node.getGroups().isEmpty(); + } + private void checkGroups(String where, ApprovalNode node) { List groups = node.getGroups(); if (groups == null || groups.isEmpty()) { diff --git a/src/test/java/com/gxwebsoft/approval/ApprovalFlowConfigIT.java b/src/test/java/com/gxwebsoft/approval/ApprovalFlowConfigIT.java index 6df8f13..6c2ef76 100644 --- a/src/test/java/com/gxwebsoft/approval/ApprovalFlowConfigIT.java +++ b/src/test/java/com/gxwebsoft/approval/ApprovalFlowConfigIT.java @@ -144,6 +144,36 @@ class ApprovalFlowConfigIT { .andExpect(jsonPath("$.message").value("流程最后一个节点必须是归档节点")); } + @Test + void confirmNodeWithGroupsIsAccepted() throws Exception { + // 收据单、价格表的「存档」节点就是按组确认(报送人 + 部门负责人),保存不能被拦下 + String nodes = "[{\"key\":\"deptLeader\",\"type\":\"APPROVE\",\"name\":\"部门负责人\"," + + "\"resolver\":\"reporterOrgLeader\"}," + + "{\"key\":\"confirm\",\"type\":\"CONFIRM\",\"name\":\"存档\",\"groups\":[" + + "{\"key\":\"reporter\",\"name\":\"报送人\",\"resolver\":\"reporter\"}," + + "{\"key\":\"finance\",\"name\":\"财务部负责人\",\"resolver\":\"orgLeaderByCode\"," + + "\"orgCode\":\"APPROVAL_FINANCE\"}]}," + + "{\"key\":\"archive\",\"type\":\"ARCHIVE\",\"name\":\"归档\"," + + "\"resolver\":\"archivist\"}]"; + publish(nodes); + } + + @Test + void groupsAreRejectedOnOtherNodeTypes() throws Exception { + String nodes = "[{\"key\":\"deptLeader\",\"type\":\"APPROVE\",\"name\":\"部门负责人\"," + + "\"groups\":[{\"key\":\"g1\",\"name\":\"会签组1\",\"resolver\":\"reporter\"}]}," + + "{\"key\":\"archive\",\"type\":\"ARCHIVE\",\"name\":\"归档\"," + + "\"resolver\":\"archivist\"}]"; + mockMvc.perform(post("/api/approval/flow/publish") + .contentType(MediaType.APPLICATION_JSON) + .content(body(nodes)) + .header("tenantId", TENANT) + .with(authentication(TestAuthentication.admin(LIST, PUBLISH)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value("第 1 个节点只有会签节点和存档节点可以配置会签组")); + } + @Test void listPermissionAloneCannotPublish() throws Exception { mockMvc.perform(post("/api/approval/flow/publish") diff --git a/src/test/java/com/gxwebsoft/approval/ApprovalFlowTemplateIT.java b/src/test/java/com/gxwebsoft/approval/ApprovalFlowTemplateIT.java new file mode 100644 index 0000000..dc5be00 --- /dev/null +++ b/src/test/java/com/gxwebsoft/approval/ApprovalFlowTemplateIT.java @@ -0,0 +1,53 @@ +package com.gxwebsoft.approval; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gxwebsoft.approval.document.ApprovalDocumentRegistry; +import com.gxwebsoft.approval.document.ApprovalDocumentType; +import com.gxwebsoft.approval.service.ApprovalNodeValidator; +import com.gxwebsoft.approval.support.CountersignProbeDocumentType; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; + +/** + * 出厂模板自检(ADR 0015 / 0016):每个已注册流程代码里声明的节点链都必须过节点校验。 + * + *

模板与校验器漂移的代价很直接:后台打开流程配置、什么都不改点保存就报错 + * (存档节点按组确认曾经被「只有会签节点可以配置会签组」拦下)。这里按流程逐个校验, + * 新增单据时模板写错也会在集成测试里暴露。

+ */ +@SpringBootTest +@ActiveProfiles({"gxtyzx-test", "test"}) +class ApprovalFlowTemplateIT { + + @Autowired + private ApprovalDocumentRegistry documentRegistry; + + @Autowired + private ApprovalNodeValidator nodeValidator; + + @Autowired + private ObjectMapper objectMapper; + + @Test + void factoryTemplatesPassValidation() throws Exception { + List types = documentRegistry.all(); + assertFalse(types.isEmpty()); + for (ApprovalDocumentType type : types) { + // 引擎探针是测试专用单据,节点 JSON 随用例动态拼装,不适用静态模板校验 + if (CountersignProbeDocumentType.FLOW_CODE.equals(type.flowCode())) { + continue; + } + JsonNode nodes = objectMapper.readTree(type.nodesJson()); + assertDoesNotThrow(() -> nodeValidator.validate(type.flowCode(), nodes), + type.flowName() + " 的出厂模板未通过节点校验"); + } + } +}