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_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});
|
||||||
|
|||||||
Reference in New Issue
Block a user