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_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";
|
||||
|
||||
@@ -77,6 +77,16 @@ public class ApprovalDocumentService {
|
||||
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)。
|
||||
*
|
||||
@@ -85,14 +95,21 @@ public class ApprovalDocumentService {
|
||||
public void preflight(ApprovalInstance instance, List<ApprovalNode> 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<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) {
|
||||
throw new BusinessException(missing);
|
||||
}
|
||||
|
||||
@@ -49,10 +49,12 @@ public class ApprovalEngineService {
|
||||
public int startFlow(ApprovalInstance instance, List<ApprovalApprover> approvers,
|
||||
User operator, String action) {
|
||||
List<ApprovalNode> 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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -150,12 +150,13 @@ public class ApprovalInstanceService {
|
||||
List<ApprovalNode> 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));
|
||||
|
||||
@@ -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<String> 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<ApprovalNode.ApprovalGroup> groups = node.getGroups();
|
||||
if (groups == null || groups.isEmpty()) {
|
||||
|
||||
@@ -44,10 +44,23 @@ public class ApprovalNode {
|
||||
/**
|
||||
* 处理人通过前是否必须本次新传附件(ADR 0016)。
|
||||
*
|
||||
* <p>缺省 false;提交时的必传仍由单据实现的 requiredAttachmentCategory 决定。</p>
|
||||
* <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
|
||||
@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;
|
||||
|
||||
/**
|
||||
* 节点级附件要求(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});
|
||||
|
||||
Reference in New Issue
Block a user