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 { // 让第一组负责人同时挂上第二组负责人角色:他会拿到两条会签任务, // 而第二组另有一名成员(groupLeaders.get(1))——该成员的待办必须随这次点击一并取消, // 否则第二组永远有 PENDING,节点无法推进。 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); long instanceId = createAndSubmit(); assertThat(countersignTaskCount(instanceId)).isEqualTo(5); 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); // 他所在两个组的其余成员待办被取消,只剩另外两组共 2 条待办 assertThat(countersignPendingCount(instanceId)).isEqualTo(2); assertThat(currentNodeKey(instanceId)).isEqualTo("countersign"); // 另外两组签完后节点正常推进:证明一人双组没有把节点卡死 approve(taskFor(instanceId, groupLeaders.get(2))).andExpect(jsonPath("$.code").value(0)); approve(taskFor(instanceId, groupLeaders.get(3))) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.currentNodeName").value("存档")); } @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))); } }