From 80e1837f75519ab7578698536efb4b312e3daf7a Mon Sep 17 00:00:00 2001 From: weicw Date: Sat, 31 Oct 2026 06:48:52 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E4=BC=9A=E7=AD=BE=E4=B8=8E=E5=9B=9E?= =?UTF-8?q?=E5=A1=AB=E5=BC=95=E6=93=8E=E7=BA=A7=E7=94=A8=E4=BE=8B=EF=BC=88?= =?UTF-8?q?#47/#49=EF=BC=89=EF=BC=9B=E7=BC=BA=E9=85=8D=E6=8C=89=E7=BB=84?= =?UTF-8?q?=E5=90=8D=E6=8A=A5=E9=94=99=E3=80=81=E9=80=9A=E8=BF=87=E6=97=B6?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E5=BF=85=E5=A1=AB=E5=8F=AF=E5=86=99=E5=AD=97?= =?UTF-8?q?=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ApprovalApproverResolver.java | 22 +- .../approval/service/ApprovalFlowService.java | 5 + .../approval/service/ApprovalTaskService.java | 23 +- .../approval/ApprovalCountersignEngineIT.java | 387 ++++++++++++++++++ .../support/CountersignProbeDocumentType.java | 185 +++++++++ 5 files changed, 609 insertions(+), 13 deletions(-) create mode 100644 src/test/java/com/gxwebsoft/approval/ApprovalCountersignEngineIT.java create mode 100644 src/test/java/com/gxwebsoft/approval/support/CountersignProbeDocumentType.java diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalApproverResolver.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalApproverResolver.java index 88ed4a4..129377b 100644 --- a/src/main/java/com/gxwebsoft/approval/service/ApprovalApproverResolver.java +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalApproverResolver.java @@ -77,8 +77,9 @@ public class ApprovalApproverResolver { } List members = resolveSingle(instance, group.getResolver(), group.getOrgCode()); if (members.isEmpty()) { - return Resolution.missing(missingMessage(instance, group.getResolver(), group.getOrgCode(), - groupName(group, seq))); + // 会签组的缺配按组声明名报,而不是按组织编码拼出来的名称(ADR 0003 示例: + // 「安全运保部负责人角色缺配」) + return Resolution.missing(groupMissingMessage(group.getResolver(), groupName(group, seq))); } String groupKey = group.getKey() == null || group.getKey().trim().isEmpty() ? "group" + seq : group.getKey().trim(); @@ -195,6 +196,23 @@ public class ApprovalApproverResolver { } } + /** + * 会签组缺配提示:按解析器种类给出「<组名>负责人角色缺配 / 分管领导角色缺配」。 + */ + private String groupMissingMessage(String resolver, String groupName) { + if (ApprovalConstants.RESOLVER_ORG_DEPUTY_BY_CODE.equals(resolver) + || ApprovalConstants.RESOLVER_REPORTER_ORG_DEPUTY.equals(resolver) + || ApprovalConstants.RESOLVER_ACCEPT_ORG_DEPUTY.equals(resolver)) { + return groupName + "分管领导角色缺配"; + } + if (ApprovalConstants.RESOLVER_ORG_LEADER_BY_CODE.equals(resolver) + || ApprovalConstants.RESOLVER_REPORTER_ORG_LEADER.equals(resolver) + || ApprovalConstants.RESOLVER_ACCEPT_ORG_LEADER.equals(resolver)) { + return groupName + "负责人角色缺配"; + } + return groupName + "审批人缺配"; + } + /** 组织编码 → 展示名;组织不存在时退回编码本身。 */ private String organizationLabel(String orgCode) { ApprovalOrganizationVO organization = directoryService.getOrganizationByCode(orgCode); diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalFlowService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalFlowService.java index 5def125..7db2063 100644 --- a/src/main/java/com/gxwebsoft/approval/service/ApprovalFlowService.java +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalFlowService.java @@ -100,6 +100,11 @@ public class ApprovalFlowService { return node == null ? null : node.getName(); } + /** 该流程是否已有发布版本(测试夹具用来清理探针定义)。 */ + public boolean hasDefinition(String flowCode) { + return findLatest(flowCode) != null; + } + /** 流程定义名称(单据类型显示名,ADR 0007);定义缺失时回退实现名称。 */ public String flowName(Long definitionId, String flowCode) { ApprovalFlowDefinition definition = diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java index 96f309e..a730fe3 100644 --- a/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalTaskService.java @@ -30,6 +30,7 @@ import javax.annotation.Resource; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.function.Supplier; @@ -110,7 +111,6 @@ public class ApprovalTaskService { throw new BusinessException("单据状态已变化,请刷新后重试"); } ensureTaskCurrent(task, instance); - requireWritableNode(instance, task); ApprovalDocumentType documentType = documentRegistry.find(instance.getFlowCode()); List changes = writeFields(instance, documentType, task, fields, false, user); if (changes.isEmpty()) { @@ -503,21 +503,22 @@ public class ApprovalTaskService { private List writeFields(ApprovalInstance instance, ApprovalDocumentType type, ApprovalTask task, Map fields, boolean requireComplete, User user) { - if (type == null || fields == null || fields.isEmpty()) { + if (type == null) { return new ArrayList<>(); } - requireWritableNode(instance, task); - return type.applyWritableFields(instance, fields, user, requireComplete); - } - - /** - * 节点必须声明了可写字段,否则拒绝回填。 - */ - private void requireWritableNode(ApprovalInstance instance, ApprovalTask task) { ApprovalNode node = engineService.findNode(instance, task.getNodeKey()); - if (node == null || node.getWritableFields() == null || node.getWritableFields().isEmpty()) { + boolean declaresWritable = node != null && node.getWritableFields() != null + && !node.getWritableFields().isEmpty(); + boolean hasValues = fields != null && !fields.isEmpty(); + // 节点没声明可写字段、也没传值:普通通过/回填,无需交给单据实现 + if (!declaresWritable && !hasValues) { + return new ArrayList<>(); + } + if (!declaresWritable) { throw new BusinessException("当前节点不支持回填"); } + Map values = fields == null ? new HashMap<>() : fields; + return type.applyWritableFields(instance, values, user, requireComplete); } /** diff --git a/src/test/java/com/gxwebsoft/approval/ApprovalCountersignEngineIT.java b/src/test/java/com/gxwebsoft/approval/ApprovalCountersignEngineIT.java new file mode 100644 index 0000000..87e0256 --- /dev/null +++ b/src/test/java/com/gxwebsoft/approval/ApprovalCountersignEngineIT.java @@ -0,0 +1,387 @@ +package com.gxwebsoft.approval; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gxwebsoft.approval.constant.ApprovalConstants; +import com.gxwebsoft.approval.support.CountersignProbeDocumentType; +import com.gxwebsoft.support.ApprovalTestData; +import com.gxwebsoft.support.TestAuthentication; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +import javax.sql.DataSource; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 引擎级用例:会签分组(#47)与节点回填(#49)。 + * + *

用测试专用探针单据 {@link CountersignProbeDocumentType},会签组按本次用例新建的 + * 组织定位负责人角色,避免共享测试库里常驻账号干扰「某组缺配」的构造。

+ */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles({"gxtyzx-test", "test"}) +class ApprovalCountersignEngineIT { + + private static final int TENANT = TestAuthentication.TENANT_ID; + private static final String LIST = "approval:instance:list"; + private static final String SUBMIT = "approval:instance:submit"; + private static final String APPROVE = "approval:instance:approve"; + + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @Autowired + private DataSource dataSource; + + private ApprovalTestData data; + private ApprovalTestData.TestUser reporter; + private Integer organizationId; + private ApprovalTestData.TestUser leader; + + /** 四个会签组:组织 + 负责人,按顺序。 */ + private final List groupOrgs = new ArrayList<>(); + private final List groupLeaders = new ArrayList<>(); + private final List groupNames = List.of("场馆部", "运营管理部", "安全运保部", "财务部"); + + @BeforeEach + void setUp() { + data = new ApprovalTestData(dataSource); + String suffix = data.uniqueSuffix(); + organizationId = data.createOrganization("会签报送部门" + suffix, 136); + reporter = data.createUser("会签报送人" + suffix, organizationId); + + ApprovalTestData.TestRole leaderRole = + data.createRole(data.leaderRoleCode(organizationId), "部门负责人"); + leader = data.createUser("会签负责人" + suffix, organizationId); + data.bind(leader.userId, leaderRole.roleId); + + ApprovalTestData.TestRole archivistRole = + data.createRole(ApprovalConstants.ROLE_ARCHIVIST, "收文员"); + ApprovalTestData.TestUser archivist = data.createUser("会签收文员" + suffix, organizationId); + data.bind(archivist.userId, archivistRole.roleId); + + List codes = new ArrayList<>(); + for (int i = 0; i < groupNames.size(); i++) { + Integer org = data.createOrganization("会签组" + (i + 1) + suffix, 136); + ApprovalTestData.TestRole role = data.createRole(data.leaderRoleCode(org), "负责人" + (i + 1)); + ApprovalTestData.TestUser member = data.createUser("会签人" + (i + 1) + suffix, org); + data.bind(member.userId, role.roleId); + groupOrgs.add(org); + groupLeaders.add(member); + codes.add(data.organizationCode(org)); + } + // 探针流程定义要按用例重建:旧定义里的组织编码指向上一个用例已删除的组织 + data.master().update("DELETE FROM approval_flow_definition WHERE tenant_id = ? AND flow_code = ?", + TENANT, CountersignProbeDocumentType.FLOW_CODE); + CountersignProbeDocumentType.configureGroups(codes, groupNames); + } + + @AfterEach + void tearDown() { + data.cleanup(); + } + + @Test + void activatesAllFourGroupsAndAdvancesOnlyAfterEveryGroupSigns() throws Exception { + long instanceId = createAndSubmit(); + + assertThat(countersignTaskCount(instanceId)).isEqualTo(4); + assertThat(data.queryMaster("SELECT COUNT(DISTINCT group_key) FROM approval_task " + + "WHERE instance_id = ? AND node_key = 'countersign'", Integer.class, instanceId)).isEqualTo(4); + + mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), reporter, LIST)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.currentNodeName").value("四部门会签")) + .andExpect(jsonPath("$.data.document.groupProgress.length()").value(4)) + .andExpect(jsonPath("$.data.document.groupProgress[0].groupSeq").value(1)) + .andExpect(jsonPath("$.data.document.groupProgress[0].signed").value(0)) + .andExpect(jsonPath("$.data.document.groupProgress[0].total").value(1)); + + approve(taskFor(instanceId, groupLeaders.get(0))).andExpect(jsonPath("$.code").value(0)); + assertThat(instanceStatus(instanceId)).isEqualTo("PENDING"); + assertThat(currentNodeKey(instanceId)).isEqualTo("countersign"); + assertThat(countersignPendingCount(instanceId)).isEqualTo(3); + + approve(taskFor(instanceId, groupLeaders.get(1))).andExpect(jsonPath("$.code").value(0)); + approve(taskFor(instanceId, groupLeaders.get(2))).andExpect(jsonPath("$.code").value(0)); + assertThat(countersignPendingCount(instanceId)).isEqualTo(1); + + approve(taskFor(instanceId, groupLeaders.get(3))) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.currentNodeName").value("存档")); + assertThat(currentNodeKey(instanceId)).isEqualTo("confirm"); + } + + @Test + void onePersonInTwoGroupsCompletesBothWithSingleClick() throws Exception { + // 让第一组负责人同时挂上第二组的部门负责人角色:他会拿到两条会签任务 + Integer secondRoleId = data.server().queryForObject( + "SELECT role_id FROM sys_role WHERE tenant_id = ? AND deleted = 0 AND role_code = ?", + Integer.class, TENANT, data.leaderRoleCode(groupOrgs.get(1))); + data.bind(groupLeaders.get(0).userId, secondRoleId); + // 第二组只留这一个成员,使总任务数仍是 4,其中该用户占 2 条 + data.server().update("DELETE FROM sys_user_role WHERE user_id = ? AND role_id = ?", + groupLeaders.get(1).userId, secondRoleId); + + long instanceId = createAndSubmit(); + assertThat(countersignTaskCount(instanceId)).isEqualTo(4); + + approve(taskFor(instanceId, groupLeaders.get(0))).andExpect(jsonPath("$.code").value(0)); + + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? " + + "AND assignee_id = ? AND status = 'DONE'", instanceId, groupLeaders.get(0).userId)).isEqualTo(2); + assertThat(currentNodeKey(instanceId)).isEqualTo("countersign"); + assertThat(countersignPendingCount(instanceId)).isEqualTo(2); + } + + @Test + void anyGroupMissingBlocksWholeNodeAndSubmitPreflightNamesTheGroup() throws Exception { + // 第 3 组(安全运保部)清空负责人:整节点不激活,提交预检指名缺的是哪一组 + data.server().update("DELETE FROM sys_user_role WHERE user_id = ?", groupLeaders.get(2).userId); + long draft = createDraft(); + uploadAttachment(draft); + + mockMvc.perform(authed(post("/api/approval/instance/" + draft + "/submit"), reporter, SUBMIT)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("安全运保部"))); + + assertThat(instanceStatus(draft)).isEqualTo("DRAFT"); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ?", draft)).isZero(); + } + + @Test + void anyGroupRejectEndsWholeNodeAndRaisesRound() throws Exception { + long instanceId = createAndSubmit(); + String bizNo = data.queryMaster("SELECT biz_no FROM approval_instance WHERE id = ?", + String.class, instanceId); + int roundBefore = round(instanceId); + + approve(taskFor(instanceId, groupLeaders.get(0))).andExpect(jsonPath("$.code").value(0)); + mockMvc.perform(authed(post("/api/approval/task/" + taskFor(instanceId, groupLeaders.get(1)) + "/reject"), + groupLeaders.get(1), APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"opinion\":\"金额对不上\"}")) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("REJECTED")); + + assertThat(round(instanceId)).isEqualTo(roundBefore + 1); + assertThat(data.queryMaster("SELECT biz_no FROM approval_instance WHERE id = ?", String.class, instanceId)) + .isEqualTo(bizNo); + // 本轮已通过的组任务保持 DONE 留痕 + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? " + + "AND round = ? AND node_key = 'countersign' AND status = 'DONE'", instanceId, roundBefore)) + .isEqualTo(2); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? " + + "AND round = ? AND node_key = 'countersign' AND status = 'PENDING'", instanceId, roundBefore)) + .isZero(); + } + + @Test + void backfillWritesFieldWithoutAdvancingNode() throws Exception { + long instanceId = createDraft(); + uploadAttachment(instanceId); + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, SUBMIT)) + .andExpect(jsonPath("$.code").value(0)); + + mockMvc.perform(authed(post("/api/approval/task/" + taskFor(instanceId, leader) + "/backfill"), + leader, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"fields\":{\"probeNo\":\"P-001\"}}")) + .andExpect(jsonPath("$.code").value(0)); + + assertThat(currentNodeKey(instanceId)).isEqualTo("deptLeader"); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_field_change WHERE instance_id = ? " + + "AND field_key = 'probeNo' AND new_value = 'P-001'", instanceId)).isEqualTo(1); + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_record WHERE instance_id = ? " + + "AND action = 'BACKFILL'", instanceId)).isEqualTo(1); + } + + @Test + void approveRequiresWritableFieldBeforeAdvancing() throws Exception { + long instanceId = createDraft(); + uploadAttachment(instanceId); + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, SUBMIT)) + .andExpect(jsonPath("$.code").value(0)); + + mockMvc.perform(authed(post("/api/approval/task/" + taskFor(instanceId, leader) + "/approve"), + leader, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"opinion\":\"同意\"}")) + .andExpect(jsonPath("$.code").value(1)) + .andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("测试编号"))); + + assertThat(currentNodeKey(instanceId)).isEqualTo("deptLeader"); + assertThat(taskStatus(instanceId, leader.userId)).isEqualTo("PENDING"); + } + + @Test + void approveWithFieldAdvancesAndLocksValue() throws Exception { + long instanceId = createDraft(); + uploadAttachment(instanceId); + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, SUBMIT)) + .andExpect(jsonPath("$.code").value(0)); + + mockMvc.perform(authed(post("/api/approval/task/" + taskFor(instanceId, leader) + "/approve"), + leader, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"opinion\":\"同意\",\"fields\":{\"probeNo\":\"P-002\"}}")) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.currentNodeKey").value("countersign")); + + assertThat(data.countMaster("SELECT COUNT(*) FROM approval_field_change WHERE instance_id = ? " + + "AND new_value = 'P-002'", instanceId)).isEqualTo(1); + // 节点已推进,会签组处理人没有该字段的可写权 + mockMvc.perform(authed(get("/api/approval/instance/" + instanceId), groupLeaders.get(0), LIST)) + .andExpect(jsonPath("$.data.permissions.writableFields.length()").value(0)); + } + + @Test + void withdrawClearsRoundScopedFields() throws Exception { + long instanceId = createDraft(); + uploadAttachment(instanceId); + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, SUBMIT)) + .andExpect(jsonPath("$.code").value(0)); + mockMvc.perform(authed(post("/api/approval/task/" + taskFor(instanceId, leader) + "/backfill"), + leader, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"fields\":{\"probeNo\":\"P-003\"}}")) + .andExpect(jsonPath("$.code").value(0)); + + // 本轮尚无人处理,报送人可撤回 + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/withdraw"), reporter, SUBMIT)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("WITHDRAWN")); + + assertThat(instanceStatus(instanceId)).isEqualTo("WITHDRAWN"); + assertThat(round(instanceId)).isEqualTo(2); + } + + // ---------- helpers ---------- + + private long createDraft() throws Exception { + String response = mockMvc.perform(authed(post("/api/approval/instance/probe"), reporter, SUBMIT) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + long instanceId = objectMapper.readTree(response).path("data").asLong(); + data.trackInstance(instanceId); + return instanceId; + } + + /** + * 建草稿 → 提交 → 报送负责人带着必填字段通过 → 停在会签节点。 + */ + private long createAndSubmit() throws Exception { + long instanceId = createDraft(); + uploadAttachment(instanceId); + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, SUBMIT)) + .andExpect(jsonPath("$.code").value(0)); + mockMvc.perform(authed(post("/api/approval/task/" + taskFor(instanceId, leader) + "/approve"), + leader, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"opinion\":\"同意\",\"fields\":{\"probeNo\":\"P-INIT\"}}")) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.currentNodeKey").value("countersign")); + return instanceId; + } + + private ResultActions approve(long taskId) throws Exception { + return mockMvc.perform(authed(post("/api/approval/task/" + taskId + "/approve"), + operatorOf(taskId), APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"opinion\":\"同意\"}")); + } + + private ApprovalTestData.TestUser operatorOf(long taskId) { + int assigneeId = data.queryMaster("SELECT assignee_id FROM approval_task WHERE id = ?", + Integer.class, taskId); + for (ApprovalTestData.TestUser candidate : groupLeaders) { + if (candidate.userId == assigneeId) { + return candidate; + } + } + throw new IllegalStateException("未知处理人: " + assigneeId); + } + + private long taskFor(long instanceId, ApprovalTestData.TestUser user) { + Long taskId = data.queryMaster("SELECT id FROM approval_task WHERE instance_id = ? " + + "AND assignee_id = ? AND status = 'PENDING' ORDER BY id LIMIT 1", Long.class, instanceId, + user.userId); + assertThat(taskId).as("待办任务应存在: " + user.userId).isNotNull(); + return taskId; + } + + private String taskStatus(long instanceId, int userId) { + return data.queryMaster("SELECT status FROM approval_task WHERE instance_id = ? " + + "AND assignee_id = ? ORDER BY id DESC LIMIT 1", String.class, instanceId, userId); + } + + private int countersignTaskCount(long instanceId) { + return data.queryMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? " + + "AND node_key = 'countersign'", Integer.class, instanceId); + } + + private int countersignPendingCount(long instanceId) { + return data.queryMaster("SELECT COUNT(*) FROM approval_task WHERE instance_id = ? " + + "AND node_key = 'countersign' AND status = 'PENDING'", Integer.class, instanceId); + } + + private String instanceStatus(long instanceId) { + return data.queryMaster("SELECT status FROM approval_instance WHERE id = ?", String.class, instanceId); + } + + private String currentNodeKey(long instanceId) { + return data.queryMaster("SELECT current_node_key FROM approval_instance WHERE id = ?", + String.class, instanceId); + } + + private int round(long instanceId) { + return data.queryMaster("SELECT round FROM approval_instance WHERE id = ?", Integer.class, instanceId); + } + + private void uploadAttachment(long instanceId) throws Exception { + MockMultipartFile file = new MockMultipartFile("file", "sign.png", "image/png", + new byte[]{(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}); + mockMvc.perform(multipart("/api/approval/attachment/upload") + .file(file) + .param("instanceId", String.valueOf(instanceId)) + .param("category", ApprovalConstants.CATEGORY_SIGNED) + .header("tenantId", TENANT) + .with(authentication(TestAuthentication.user(reporter.userId, reporter.username, + organizationId, TENANT, SUBMIT)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + } + + private MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder, + ApprovalTestData.TestUser user, + String... authorities) { + return builder.header("tenantId", TENANT) + .with(authentication(TestAuthentication.user(user.userId, user.username, organizationId, + TENANT, authorities))); + } +} diff --git a/src/test/java/com/gxwebsoft/approval/support/CountersignProbeDocumentType.java b/src/test/java/com/gxwebsoft/approval/support/CountersignProbeDocumentType.java new file mode 100644 index 0000000..470189f --- /dev/null +++ b/src/test/java/com/gxwebsoft/approval/support/CountersignProbeDocumentType.java @@ -0,0 +1,185 @@ +package com.gxwebsoft.approval.support; + +import com.gxwebsoft.approval.document.AbstractApprovalDocumentType; +import com.gxwebsoft.approval.document.ApprovalDocumentParam; +import com.gxwebsoft.approval.document.ApprovalFieldUpdate; +import com.gxwebsoft.approval.entity.ApprovalInstance; +import com.gxwebsoft.approval.vo.ApprovalDetail; +import com.gxwebsoft.common.core.exception.BusinessException; +import com.gxwebsoft.common.system.entity.User; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 测试专用单据实现:只用于验证引擎能力(会签分组、回填),不承担业务。 + * + *

会签组按**固定组织编码**声明,由用例在 {@link #configureGroups} 里指定,每个用例 + * 用自己的组织;不用租户岗位角色是因为共享测试库里有常驻的 test02 账号持有全部审批角色, + * 会让「某组缺配」无法构造。

+ * + *

节点 JSON 随用例变化,因此每次配置都递增发布版本(流程定义版本不可变)。

+ */ +@Component +public class CountersignProbeDocumentType extends AbstractApprovalDocumentType { + + /** 测试流程编码。 */ + public static final String FLOW_CODE = "probe"; + + /** 可回填字段键。 */ + public static final String FIELD_PROBE_NO = "probeNo"; + + public static final String NODE_DEPT_LEADER = "deptLeader"; + public static final String NODE_COUNTERSIGN = "countersign"; + + /** 本次用例的会签组组织编码(有序)。 */ + private static volatile List groupCodes = new ArrayList<>(); + /** 本次用例的会签组显示名(与 groupCodes 对应)。 */ + private static volatile List groupNames = new ArrayList<>(); + /** + * 探针流程版本固定 1;用 {@link #reset()} 在用例间清理定义行, + * 否则跨 JVM 运行时旧版本行会被 {@code getOrCreate} 当成「已是最新」复用, + * 而其组织编码已被上一个用例清理,导致所有组都缺配。 + */ + private static final AtomicInteger VERSION = new AtomicInteger(1); + + private static final Map> VALUES = new ConcurrentHashMap<>(); + + /** 用例开始前配置会签组(组织编码 + 显示名,一一对应)。 */ + public static synchronized void configureGroups(List orgCodes, List names) { + groupCodes = new ArrayList<>(orgCodes); + groupNames = new ArrayList<>(names); + } + + @Override + public String flowCode() { + return FLOW_CODE; + } + + @Override + public String docType() { + return FLOW_CODE; + } + + @Override + public String flowName() { + return "引擎测试单据"; + } + + @Override + public String nodesJson() { + StringBuilder groups = new StringBuilder(); + List codes = groupCodes; + List names = groupNames; + for (int i = 0; i < codes.size(); i++) { + if (i > 0) { + groups.append(','); + } + groups.append("{\"key\":\"g").append(i + 1).append("\",\"name\":\"") + .append(names.size() > i ? names.get(i) : "会签组" + (i + 1)) + .append("\",\"resolver\":\"orgLeaderByCode\",\"orgCode\":\"") + .append(codes.get(i)).append("\"}"); + } + return "[" + + "{\"key\":\"deptLeader\",\"type\":\"APPROVE\",\"resolver\":\"reporterOrgLeader\"," + + "\"name\":\"报送部门负责人\",\"writableFields\":[{\"field\":\"probeNo\",\"required\":true}]}," + + "{\"key\":\"countersign\",\"type\":\"COUNTERSIGN\",\"name\":\"四部门会签\",\"groups\":[" + + groups + "]}," + + "{\"key\":\"confirm\",\"type\":\"CONFIRM\",\"resolver\":\"reporter\",\"name\":\"存档\"}," + + "{\"key\":\"archive\",\"type\":\"ARCHIVE\",\"resolver\":\"archivist\",\"name\":\"归档\"}" + + "]"; + } + + @Override + public int latestVersion() { + return VERSION.get(); + } + + @Override + public String noPrefix() { + return "PB"; + } + + @Override + public Class paramClass() { + return ProbeParam.class; + } + + @Override + public void validate(ApprovalDocumentParam param, User user) { + } + + @Override + public Long persist(Long instanceId, Long bizId, ApprovalDocumentParam param, User user) { + return bizId == null ? instanceId : bizId; + } + + @Override + public void load(ApprovalInstance instance, ApprovalDetail detail) { + detail.addField("probeNo", "测试编号", readField(instance, FIELD_PROBE_NO)); + } + + @Override + public String title(ApprovalInstance instance) { + return "引擎测试单据"; + } + + @Override + public List fieldSpecs() { + List specs = new ArrayList<>(); + specs.add(com.gxwebsoft.approval.support.ApprovalFieldSpec.uniqueText(FIELD_PROBE_NO, "测试编号", 40)); + return specs; + } + + @Override + public List applyWritableFields(ApprovalInstance instance, Map fields, + User user, boolean requireComplete) { + String submitted = fields.get(FIELD_PROBE_NO); + if (submitted != null) { + submitted = submitted.trim(); + } + if (requireComplete && (submitted == null || submitted.isEmpty())) { + throw new BusinessException("测试编号不能为空"); + } + if (submitted != null && submitted.length() > 40) { + throw new BusinessException("测试编号不能超过40字"); + } + if (submitted == null) { + return new ArrayList<>(); + } + Map values = VALUES.computeIfAbsent(instance.getId(), ignored -> new ConcurrentHashMap<>()); + String oldValue = values.get(FIELD_PROBE_NO); + values.put(FIELD_PROBE_NO, submitted); + List changes = new ArrayList<>(); + changes.add(new ApprovalFieldUpdate(FIELD_PROBE_NO, "测试编号", oldValue, submitted)); + return changes; + } + + @Override + public String readField(ApprovalInstance instance, String field) { + Map values = VALUES.get(instance.getId()); + return values == null ? null : values.get(field); + } + + @Override + public void onRoundReset(ApprovalInstance instance, User user) { + Map values = VALUES.get(instance.getId()); + if (values != null) { + values.remove(FIELD_PROBE_NO); + } + } + + /** 清空测试缓存(用例结束时调用)。 */ + public static void clear(Long instanceId) { + VALUES.remove(instanceId); + } + + /** 探针单据参数。 */ + public static class ProbeParam extends ApprovalDocumentParam { + private static final long serialVersionUID = 1L; + } +}