修正流程配置校验:存档节点可以按组确认(ADR 0015)

收据申请单与设施价格表的「存档」节点用 groups 表达「报送人 + 部门负责人
全部确认」,而校验器只允许会签节点带组,于是后台打开流程配置、什么都不改
点保存就报「第 x 个节点只有会签节点可以配置会签组」。

- 放行 CONFIRM 带组,组声明复用会签组那套校验;带组时不许再配单解析器
  (ApprovalApproverResolver 里 groups 优先,否则配置被静默忽略)
- 其余节点类型带组仍拒绝,提示改为「只有会签节点和存档节点可以配置会签组」
- 新增 ApprovalFlowTemplateIT:逐个流程校验代码里的出厂模板,模板与校验器
  漂移会在集成测试里暴露
- ApprovalFlowConfigIT 补两条:存档节点带组可发布、审批节点带组被拒
This commit is contained in:
2026-11-01 19:39:31 +08:00
parent 7f72162b5b
commit 4b077e5fd5
3 changed files with 102 additions and 2 deletions
@@ -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<ApprovalNode.ApprovalGroup> groups = node.getGroups();
if (groups == null || groups.isEmpty()) {
@@ -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")
@@ -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):每个已注册流程代码里声明的节点链都必须过节点校验。
*
* <p>模板与校验器漂移的代价很直接:后台打开流程配置、什么都不改点保存就报错
* (存档节点按组确认曾经被「只有会签节点可以配置会签组」拦下)。这里按流程逐个校验,
* 新增单据时模板写错也会在集成测试里暴露。</p>
*/
@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<ApprovalDocumentType> 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() + " 的出厂模板未通过节点校验");
}
}
}