From 7cf3fd55a01c205e6fec3f44058b6fa7a1e18f2e Mon Sep 17 00:00:00 2001 From: weicw Date: Sat, 31 Oct 2026 06:54:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A4=96=E9=83=A8=E5=8D=8F=E8=B0=83?= =?UTF-8?q?=E5=8D=95=E6=B5=81=E7=A8=8B=E4=B8=8E=E7=AB=AF=E5=88=B0=E7=AB=AF?= =?UTF-8?q?=E7=94=A8=E4=BE=8B=EF=BC=88#51=EF=BC=89=EF=BC=9B=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E5=88=97=E8=A1=A8=E8=BF=94=E5=9B=9E=20flowCode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ApprovalQueryService.java | 3 +- .../ApprovalCoordinationExternalIT.java | 261 ++++++++++++++++++ 2 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/gxwebsoft/approval/ApprovalCoordinationExternalIT.java diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalQueryService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalQueryService.java index 53a310e..c07915a 100644 --- a/src/main/java/com/gxwebsoft/approval/service/ApprovalQueryService.java +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalQueryService.java @@ -62,7 +62,8 @@ public class ApprovalQueryService { item.setId(row.getId()); item.setBizNo(row.getBizNo()); item.setDocType(row.getDocType()); - item.setDocTypeName(ApprovalConstants.docTypeName(row.getDocType())); + item.setDocTypeName(flowService.flowName(row.getDefinitionId(), row.getFlowCode())); + item.setFlowCode(row.getFlowCode()); item.setStatus(row.getStatus()); item.setStatusName(ApprovalConstants.statusName(row.getStatus())); item.setTitle(row.getTitle()); diff --git a/src/test/java/com/gxwebsoft/approval/ApprovalCoordinationExternalIT.java b/src/test/java/com/gxwebsoft/approval/ApprovalCoordinationExternalIT.java new file mode 100644 index 0000000..13d784f --- /dev/null +++ b/src/test/java/com/gxwebsoft/approval/ApprovalCoordinationExternalIT.java @@ -0,0 +1,261 @@ +package com.gxwebsoft.approval; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gxwebsoft.approval.constant.ApprovalConstants; +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.request.MockHttpServletRequestBuilder; + +import javax.sql.DataSource; +import java.nio.charset.StandardCharsets; + +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; + +/** + * 外部协调单(#51):与内部协调单共用一张表单与业务表,节点链多一个受理部门分管领导。 + * + *

关键断言:doc_type 仍是 coordination、flow_code 是 coordination_external、 + * 分管领导按**受理部门**定位、单据类型显示名取流程定义名称。

+ */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles({"gxtyzx-test", "test"}) +class ApprovalCoordinationExternalIT { + + 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"; + private static final String ARCHIVE = "approval:instance:archive"; + + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @Autowired + private DataSource dataSource; + + private ApprovalTestData data; + private ApprovalTestData.TestUser reporter; + private Integer reportOrgId; + private Integer acceptOrgId; + + private ApprovalTestData.TestUser reportLeader; + private ApprovalTestData.TestUser acceptLeader; + private ApprovalTestData.TestUser acceptDeputy; + private ApprovalTestData.TestUser dispatchTarget; + private ApprovalTestData.TestUser archivist; + + @BeforeEach + void setUp() { + data = new ApprovalTestData(dataSource); + String suffix = data.uniqueSuffix(); + reportOrgId = data.createOrganization("外部报送部门" + suffix, 136); + acceptOrgId = data.createOrganization("外部受理部门" + suffix, 136); + reporter = data.createUser("外部报送人" + suffix, reportOrgId); + + ApprovalTestData.TestRole reportLeaderRole = + data.createRole(data.leaderRoleCode(reportOrgId), "报送部门负责人"); + reportLeader = data.createUser("外部报送负责人" + suffix, reportOrgId); + data.bind(reportLeader.userId, reportLeaderRole.roleId); + + ApprovalTestData.TestRole acceptLeaderRole = + data.createRole(data.leaderRoleCode(acceptOrgId), "受理部门负责人"); + acceptLeader = data.createUser("外部受理负责人" + suffix, acceptOrgId); + data.bind(acceptLeader.userId, acceptLeaderRole.roleId); + + // 分管领导按受理部门定位:只有受理部门的分管角色有人 + ApprovalTestData.TestRole acceptDeputyRole = data.createRole( + ApprovalConstants.departmentDeputyRoleCode(data.organizationCode(acceptOrgId)), "受理部门分管领导"); + acceptDeputy = data.createUser("外部受理分管" + suffix, acceptOrgId); + data.bind(acceptDeputy.userId, acceptDeputyRole.roleId); + + ApprovalTestData.TestRole staffRole = data.createRole(ApprovalConstants.ROLE_STAFF, "员工"); + data.bind(reporter.userId, staffRole.roleId); + dispatchTarget = data.createUser("外部派单对象" + suffix, acceptOrgId); + data.bind(dispatchTarget.userId, staffRole.roleId); + + ApprovalTestData.TestRole archivistRole = + data.createRole(ApprovalConstants.ROLE_ARCHIVIST, "收文员"); + archivist = data.createUser("外部收文员" + suffix, reportOrgId); + data.bind(archivist.userId, archivistRole.roleId); + } + + @AfterEach + void tearDown() { + data.cleanup(); + } + + @Test + void sharesCoordinationFormButRunsExternalNodeChain() throws Exception { + long instanceId = saveExternalDraft("外部协调:设备进场"); + uploadAttachment(instanceId); + + assertThat(data.queryMaster("SELECT doc_type FROM approval_instance WHERE id = ?", + String.class, instanceId)).isEqualTo(ApprovalConstants.DOC_COORDINATION); + assertThat(data.queryMaster("SELECT flow_code FROM approval_instance WHERE id = ?", + String.class, instanceId)).isEqualTo(ApprovalConstants.FLOW_COORDINATION_EXTERNAL); + // 业务表共用,未新建表 + assertThat(data.queryMaster("SELECT COUNT(*) FROM approval_coordination_form WHERE instance_id = ?", + Integer.class, instanceId)).isEqualTo(1); + + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, SUBMIT)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.docTypeName").value("外部协调单")) + .andExpect(jsonPath("$.data.currentNodeName").value("报送部门负责人")); + + approveFor(instanceId, reportLeader) + .andExpect(jsonPath("$.data.currentNodeName").value("受理部门负责人")); + approveFor(instanceId, acceptLeader) + .andExpect(jsonPath("$.data.currentNodeName").value("分管领导")); + approveFor(instanceId, acceptDeputy) + .andExpect(jsonPath("$.data.currentNodeName").value("派单")); + + // 派单:受理部门审批参与人 + mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, acceptLeader.userId) + + "/dispatch"), acceptLeader, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targetUserId\":" + dispatchTarget.userId + "}")) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.currentNodeName").value("存档")); + + // 双人存档:报送人 + 被派单人 + mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, reporter.userId) + + "/confirm"), reporter, APPROVE)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("PENDING")); + mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, dispatchTarget.userId) + + "/confirm"), dispatchTarget, APPROVE)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("APPROVED")); + + mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, archivist.userId) + + "/archive"), archivist, ARCHIVE)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.status").value("ARCHIVED")); + } + + @Test + void deputyIsResolvedFromAcceptOrganizationNotReportOrganization() throws Exception { + // 报送部门没有分管领导,只有受理部门有:外部流程应能正常提交 + long instanceId = saveExternalDraft("外部协调:分管归属"); + uploadAttachment(instanceId); + mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, SUBMIT)) + .andExpect(jsonPath("$.code").value(0)); + approveFor(instanceId, reportLeader); + approveFor(instanceId, acceptLeader); + // 若按报送部门解析分管领导,这里会缺配 + approveFor(instanceId, acceptDeputy) + .andExpect(jsonPath("$.data.currentNodeName").value("派单")); + } + + @Test + void internalFlowIsUnaffectedAndBothAppearInTypeFilter() throws Exception { + long external = saveExternalDraft("外部协调:并存"); + uploadAttachment(external); + mockMvc.perform(authed(post("/api/approval/instance/" + external + "/submit"), reporter, SUBMIT)) + .andExpect(jsonPath("$.code").value(0)); + + String internalResponse = mockMvc.perform(authed(post("/api/approval/instance/coordination"), + reporter, SUBMIT) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"acceptOrgId\":" + acceptOrgId + ",\"title\":\"内部协调:并存\"," + + "\"content\":\"内部内容\"}")) + .andExpect(jsonPath("$.code").value(0)) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + long internal = objectMapper.readTree(internalResponse).path("data").asLong(); + data.trackInstance(internal); + uploadAttachment(internal); + mockMvc.perform(authed(post("/api/approval/instance/" + internal + "/submit"), reporter, SUBMIT)) + .andExpect(jsonPath("$.code").value(0)); + + // 按流程类型筛选:两类单据显示名取流程定义名称 + mockMvc.perform(authed(get("/api/approval/instance/page?keywords=并存&flowCode=coordination_external"), + archivist, LIST)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.list.length()").value(1)) + .andExpect(jsonPath("$.data.list[0].flowCode").value("coordination_external")) + .andExpect(jsonPath("$.data.list[0].docTypeName").value("外部协调单")); + + mockMvc.perform(authed(get("/api/approval/instance/page?keywords=并存&flowCode=coordination_internal"), + archivist, LIST)) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.list.length()").value(1)) + .andExpect(jsonPath("$.data.list[0].docTypeName").value("内部协调单")); + } + + // ---------- helpers ---------- + + private long saveExternalDraft(String title) throws Exception { + String response = mockMvc.perform(authed(post("/api/approval/instance/coordination_external"), + reporter, SUBMIT) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"acceptOrgId\":" + acceptOrgId + ",\"title\":" + quote(title) + + ",\"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 org.springframework.test.web.servlet.ResultActions approveFor(long instanceId, + ApprovalTestData.TestUser approver) + throws Exception { + return mockMvc.perform(authed(post("/api/approval/task/" + pendingTaskId(instanceId, approver.userId) + + "/approve"), approver, APPROVE) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"opinion\":\"同意\"}")) + .andExpect(jsonPath("$.code").value(0)); + } + + private long pendingTaskId(long instanceId, int userId) { + 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, userId); + assertThat(taskId).as("待办任务应存在: " + userId).isNotNull(); + return taskId; + } + + 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, + reportOrgId, 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, + user.userId == reporter.userId ? reportOrgId : acceptOrgId, TENANT, authorities))); + } + + private String quote(String value) { + return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\""; + } +}