feat: 提交环节回到节点链——关掉全部节点开关即整单不必传(ADR 0016)

This commit is contained in:
2026-11-01 16:31:07 +08:00
parent b25c7eaa1e
commit c7adae386d
7 changed files with 112 additions and 27 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";
@@ -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));
@@ -40,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(
@@ -192,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()) {
@@ -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) { 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()) {
@@ -44,10 +44,23 @@ public class ApprovalNode {
/** /**
* 处理人通过前是否必须本次新传附件(ADR 0016)。 * 处理人通过前是否必须本次新传附件(ADR 0016)。
* *
* <p>缺省 false;提交时的必传仍由单据实现的 requiredAttachmentCategory 决定。</p> * <p>SUBMIT 节点上表示整单提交时是否必须上传附件;缺省 false,存量链不受影响。</p>
*/ */
private boolean attachmentRequired; 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)
@@ -30,7 +30,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/** /**
* 节点级附件要求(ADR 0016):审批节点要求本次新传、补传后放行;提交必传仍是代码声明。 * 附件要求可配置(ADR 0016):提交环节关闭必传、提交环节要求附件、审批节点要求本次新传。
*/ */
@SpringBootTest @SpringBootTest
@AutoConfigureMockMvc @AutoConfigureMockMvc
@@ -105,25 +105,38 @@ class ApprovalAttachmentConfigIT {
} }
@Test @Test
void submitStillRequiresAttachmentFromCode() throws Exception { void submitWithoutAttachmentPassesWhenSubmitStageAllows() throws Exception {
publish(chain(false)); publish(chain(false, false));
long instanceId = createDraft(); long instanceId = createDraft();
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, SUBMIT)) submit(instanceId).andExpect(jsonPath("$.code").value(0));
.andExpect(status().isOk()) }
@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("$.code").value(1))
.andExpect(jsonPath("$.message").value(containsString("请至少上传一张"))); .andExpect(jsonPath("$.message").value(containsString("请至少上传一张")));
} }
@Test @Test
void approverMustUploadOnNodeThatRequiresIt() throws Exception { void approverMustUploadOnNodeThatRequiresIt() throws Exception {
publish(chain(true)); publish(chain(false, true));
long instanceId = createDraft(); long instanceId = createDraft();
uploadAs(reporter, instanceId); submit(instanceId).andExpect(jsonPath("$.code").value(0));
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, SUBMIT))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
// 未新传:节点拦住 // 未上传:节点拦住
mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, leader.userId) mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, leader.userId)
+ "/approve"), leader, APPROVE) + "/approve"), leader, APPROVE)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
@@ -132,7 +145,7 @@ class ApprovalAttachmentConfigIT {
.andExpect(jsonPath("$.code").value(1)) .andExpect(jsonPath("$.code").value(1))
.andExpect(jsonPath("$.message").value("本节点处理前请先上传附件")); .andExpect(jsonPath("$.message").value("本节点处理前请先上传附件"));
// 处理人补传后放行 // 处理人补传后可继续
uploadAs(leader, instanceId); uploadAs(leader, instanceId);
mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, leader.userId) mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, leader.userId)
+ "/approve"), leader, APPROVE) + "/approve"), leader, APPROVE)
@@ -142,8 +155,10 @@ class ApprovalAttachmentConfigIT {
.andExpect(jsonPath("$.code").value(0)); .andExpect(jsonPath("$.code").value(0));
} }
private String chain(boolean leaderRequired) { private String chain(boolean submitRequired, boolean leaderRequired) {
return "[{\"key\":\"deptLeader\",\"type\":\"APPROVE\",\"name\":\"部门负责人\"," return "[{\"key\":\"submit\",\"type\":\"SUBMIT\",\"name\":\"提交\","
+ "\"attachmentRequired\":" + submitRequired + "},"
+ "{\"key\":\"deptLeader\",\"type\":\"APPROVE\",\"name\":\"部门负责人\","
+ "\"resolver\":\"reporterOrgLeader\",\"attachmentRequired\":" + leaderRequired + "}," + "\"resolver\":\"reporterOrgLeader\",\"attachmentRequired\":" + leaderRequired + "},"
+ "{\"key\":\"confirm\",\"type\":\"CONFIRM\",\"name\":\"存档\"," + "{\"key\":\"confirm\",\"type\":\"CONFIRM\",\"name\":\"存档\","
+ "\"resolver\":\"reporter\"}," + "\"resolver\":\"reporter\"},"
@@ -151,6 +166,15 @@ class ApprovalAttachmentConfigIT {
+ "\"resolver\":\"archivist\"}]"; + "\"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 { private void publish(String nodes) throws Exception {
mockMvc.perform(authed(post("/api/approval/flow/publish"), superAdmin, FLOW_LIST, FLOW_PUBLISH) mockMvc.perform(authed(post("/api/approval/flow/publish"), superAdmin, FLOW_LIST, FLOW_PUBLISH)
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
@@ -175,6 +199,12 @@ class ApprovalAttachmentConfigIT {
return 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 { private void uploadAs(ApprovalTestData.TestUser user, long instanceId) throws Exception {
MockMultipartFile file = new MockMultipartFile("file", "voucher.png", "image/png", MockMultipartFile file = new MockMultipartFile("file", "voucher.png", "image/png",
new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}); new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A});