feat: 外部协调单流程与端到端用例(#51);查询列表返回 flowCode

This commit is contained in:
weicw
2026-10-31 06:54:03 +08:00
parent 80e1837f75
commit 7cf3fd55a0
2 changed files with 263 additions and 1 deletions
@@ -62,7 +62,8 @@ public class ApprovalQueryService {
item.setId(row.getId()); item.setId(row.getId());
item.setBizNo(row.getBizNo()); item.setBizNo(row.getBizNo());
item.setDocType(row.getDocType()); 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.setStatus(row.getStatus());
item.setStatusName(ApprovalConstants.statusName(row.getStatus())); item.setStatusName(ApprovalConstants.statusName(row.getStatus()));
item.setTitle(row.getTitle()); item.setTitle(row.getTitle());
@@ -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):与内部协调单共用一张表单与业务表,节点链多一个受理部门分管领导。
*
* <p>关键断言:doc_type 仍是 coordination、flow_code 是 coordination_external、
* 分管领导按**受理部门**定位、单据类型显示名取流程定义名称。</p>
*/
@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("\"", "\\\"") + "\"";
}
}