feat(approval): PC 单据查询分页与数据权限 (#25)
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
package com.gxwebsoft.approval;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
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 org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
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;
|
||||
|
||||
/**
|
||||
* PC 单据查询:筛选、关键词、排序、归档检索与数据权限的集成测试。
|
||||
*/
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles({"gxtyzx-test", "test"})
|
||||
@Transactional
|
||||
class ApprovalQueryIT {
|
||||
|
||||
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 DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
@Autowired
|
||||
private DataSource dataSource;
|
||||
|
||||
private ApprovalTestData data;
|
||||
private Integer reportOrgId;
|
||||
private Integer acceptOrgId;
|
||||
private ApprovalTestData.TestUser reporter;
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
data.cleanup();
|
||||
}
|
||||
|
||||
@Test
|
||||
void filtersByTypeKeywordOrganizationReporterAndStatus() throws Exception {
|
||||
long submitted = submitCompletion("场馆维修实施");
|
||||
long coordination = seedCoordination("场馆维修协调", "PENDING", LocalDateTime.now().minusHours(1), null);
|
||||
long draft = createCompletionDraft("场馆维护草稿", "广西体育中心保障部");
|
||||
|
||||
// 单据类型 + 关键词命中实施单位
|
||||
assertThat(queryIds("?docType=completion&keyword=实施部")).containsExactly(submitted);
|
||||
// 关键词命中协调单标题
|
||||
assertThat(queryIds("?docType=coordination&keyword=维修协调")).containsExactly(coordination);
|
||||
// 关键词命中单号
|
||||
String bizNo = data.queryMaster("SELECT biz_no FROM approval_instance WHERE id = ?", String.class, submitted);
|
||||
assertThat(queryIds("?keyword=" + bizNo)).containsExactly(submitted);
|
||||
// 报送部门:两类单据都按报送部门过滤,其它部门不命中
|
||||
assertThat(queryIds("?reportOrgId=" + reportOrgId))
|
||||
.containsExactlyInAnyOrder(submitted, coordination, draft);
|
||||
assertThat(queryIds("?reportOrgId=" + acceptOrgId)).isEmpty();
|
||||
// 受理部门
|
||||
assertThat(queryIds("?acceptOrgId=" + acceptOrgId)).containsExactly(coordination);
|
||||
// 发起人
|
||||
assertThat(queryIds("?reporterId=" + reporter.userId)).containsExactlyInAnyOrder(submitted, coordination, draft);
|
||||
// 状态
|
||||
assertThat(queryIds("?status=PENDING")).containsExactlyInAnyOrder(submitted, coordination);
|
||||
assertThat(queryIds("?status=DRAFT")).containsExactly(draft);
|
||||
// 总数与分页总数一致
|
||||
assertThat(queryCount("?keyword=实施部")).isEqualTo(1);
|
||||
// 单号精确筛选
|
||||
assertThat(queryIds("?bizNo=" + bizNo)).containsExactly(submitted);
|
||||
// 提交时间范围
|
||||
assertThat(queryIds("?submitTimeStart=" + format(LocalDateTime.now().minusHours(2))
|
||||
+ "&submitTimeEnd=" + format(LocalDateTime.now().plusMinutes(1))))
|
||||
.containsExactlyInAnyOrder(submitted, coordination);
|
||||
assertThat(queryIds("?submitTimeEnd=" + format(LocalDateTime.now().minusDays(1)))).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void ordersBySubmitTimeDescThenIdDescAndKeepsPaginationStable() throws Exception {
|
||||
LocalDateTime sameSecond = LocalDateTime.now().withNano(0).minusMinutes(10);
|
||||
long first = seedCoordination("同秒一", "PENDING", sameSecond, null);
|
||||
long second = seedCoordination("同秒二", "PENDING", sameSecond, null);
|
||||
long third = seedCoordination("同秒三", "PENDING", sameSecond, null);
|
||||
long older = seedCoordination("更早", "PENDING", sameSecond.minusDays(1), null);
|
||||
long draft = createCompletionDraft("无提交时间草稿");
|
||||
|
||||
List<Long> page1 = queryIds("?limit=2&page=1");
|
||||
List<Long> page2 = queryIds("?limit=2&page=2");
|
||||
List<Long> page3 = queryIds("?limit=2&page=3");
|
||||
|
||||
assertThat(page1).containsExactly(third, second);
|
||||
assertThat(page2).containsExactly(first, older);
|
||||
assertThat(page3).containsExactly(draft);
|
||||
}
|
||||
|
||||
@Test
|
||||
void includesArchivedInstancesAndFiltersByArchiveTime() throws Exception {
|
||||
LocalDateTime archivedAt = LocalDateTime.now().withNano(0).minusDays(3);
|
||||
long archived = seedCoordination("已归档协调单", "ARCHIVED", archivedAt.minusDays(1), archivedAt);
|
||||
long pending = seedCoordination("在途协调单", "PENDING", archivedAt.minusDays(1), null);
|
||||
|
||||
assertThat(queryIds("?status=ARCHIVED")).containsExactly(archived);
|
||||
assertThat(queryIds("?archiveTimeStart=" + format(archivedAt.minusHours(1))
|
||||
+ "&archiveTimeEnd=" + format(archivedAt.plusHours(1)))).containsExactly(archived);
|
||||
assertThat(queryIds("?archiveTimeStart=" + format(archivedAt.plusDays(1)))).isEmpty();
|
||||
assertThat(queryIds("")).containsExactlyInAnyOrder(archived, pending);
|
||||
}
|
||||
|
||||
@Test
|
||||
void limitsListByDataPermission() throws Exception {
|
||||
long draft = createCompletionDraft("仅发起人可见草稿");
|
||||
long pending = seedCoordination("待处理协调单", "PENDING", LocalDateTime.now().minusHours(1), null);
|
||||
|
||||
ApprovalTestData.TestUser outsider = data.createUser("无关人员" + data.uniqueSuffix(), acceptOrgId);
|
||||
ApprovalTestData.TestUser assignee = data.createUser("任务处理人" + data.uniqueSuffix(), acceptOrgId);
|
||||
ApprovalTestData.TestUser operator = data.createUser("记录操作人" + data.uniqueSuffix(), acceptOrgId);
|
||||
ApprovalTestData.TestUser dispatched = data.createUser("被派单人" + data.uniqueSuffix(), acceptOrgId);
|
||||
ApprovalTestData.TestUser archivist = data.createUser("收文员" + data.uniqueSuffix(), acceptOrgId);
|
||||
|
||||
data.master().update("INSERT INTO approval_task (tenant_id, instance_id, node_key, node_type, round, "
|
||||
+ "task_type, assignee_id, status) VALUES (?, ?, 'reportLeader', 'APPROVE', 1, 'APPROVE', ?, 'PENDING')",
|
||||
TENANT, pending, assignee.userId);
|
||||
data.master().update("INSERT INTO approval_record (tenant_id, instance_id, operator_id, action, round) "
|
||||
+ "VALUES (?, ?, ?, 'SUBMIT', 1)", TENANT, pending, operator.userId);
|
||||
data.master().update("UPDATE approval_instance SET dispatch_target_id = ?, dispatch_target_name = ? WHERE id = ?",
|
||||
dispatched.userId, dispatched.realName, pending);
|
||||
ApprovalTestData.TestRole archivistRole = data.createRole(ApprovalConstants.ROLE_ARCHIVIST, "收文员");
|
||||
data.bind(archivist.userId, archivistRole.roleId);
|
||||
|
||||
// 发起人可见自己的草稿与在途单据
|
||||
assertThat(queryIdsAs(reporter, "")).containsExactlyInAnyOrder(draft, pending);
|
||||
// 参与人:有任务、有处理记录、被派单
|
||||
assertThat(queryIdsAs(assignee, "")).containsExactly(pending);
|
||||
assertThat(queryIdsAs(operator, "")).containsExactly(pending);
|
||||
assertThat(queryIdsAs(dispatched, "")).containsExactly(pending);
|
||||
// 无关人员看不到他人单据
|
||||
assertThat(queryIdsAs(outsider, "")).isEmpty();
|
||||
assertThat(queryIdsAs(outsider, "?status=PENDING")).isEmpty();
|
||||
// 收文员看全租户,但草稿仍只属于发起人
|
||||
assertThat(queryIdsAs(archivist, "")).contains(pending).doesNotContain(draft);
|
||||
assertThat(queryIdsAs(archivist, "?status=DRAFT")).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDetailOfInstanceOutsideDataPermission() throws Exception {
|
||||
long draft = createCompletionDraft("他人草稿");
|
||||
ApprovalTestData.TestUser outsider = data.createUser("越权人员" + data.uniqueSuffix(), acceptOrgId);
|
||||
|
||||
mockMvc.perform(authed(get("/api/approval/instance/" + draft), outsider, LIST))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(1))
|
||||
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("无权查看该单据")));
|
||||
|
||||
mockMvc.perform(authed(get("/api/approval/instance/" + draft), reporter, LIST))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.id").value((int) draft))
|
||||
.andExpect(jsonPath("$.data.docTypeName").value("业务完成确认单"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void allowsInstanceQueryUsersToSearchDirectoryUsers() throws Exception {
|
||||
mockMvc.perform(authed(get("/api/approval/directory/users?keywords=" + reporter.username), reporter, LIST))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data[0].userId").value(reporter.userId));
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
private List<Long> queryIds(String query) throws Exception {
|
||||
return queryIdsAs(reporter, query);
|
||||
}
|
||||
|
||||
private List<Long> queryIdsAs(ApprovalTestData.TestUser user, String query) throws Exception {
|
||||
String response = mockMvc.perform(authed(get("/api/approval/instance/page" + query), user, LIST))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||
JsonNode node = objectMapper.readTree(response).path("data");
|
||||
List<Long> ids = new ArrayList<>();
|
||||
for (JsonNode item : node.path("list")) {
|
||||
ids.add(item.path("id").asLong());
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private long queryCount(String query) throws Exception {
|
||||
String response = mockMvc.perform(authed(get("/api/approval/instance/page" + query), reporter, LIST))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||
return objectMapper.readTree(response).path("data").path("count").asLong();
|
||||
}
|
||||
|
||||
private long createCompletionDraft(String name) throws Exception {
|
||||
return createCompletionDraft(name, "广西体育中心实施部");
|
||||
}
|
||||
|
||||
private long createCompletionDraft(String name, String implementOrg) throws Exception {
|
||||
String body = "{\"name\":" + quote(name) + ",\"implementOrg\":" + quote(implementOrg) + ","
|
||||
+ "\"fee\":100.00,\"serviceContent\":\"完成场馆设施维修与验收\"}";
|
||||
String response = mockMvc.perform(authed(post("/api/approval/instance/completion"), reporter, SUBMIT)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(body))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||
long id = objectMapper.readTree(response).path("data").asLong();
|
||||
data.trackInstance(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
private long submitCompletion(String name) throws Exception {
|
||||
grantDepartmentLeader();
|
||||
grantArchivist();
|
||||
long instanceId = createCompletionDraft(name);
|
||||
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", "SIGNED")
|
||||
.header("tenantId", TENANT)
|
||||
.with(authentication(TestAuthentication.user(reporter.userId, reporter.username, reportOrgId, TENANT, SUBMIT))))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
mockMvc.perform(authed(post("/api/approval/instance/" + instanceId + "/submit"), reporter, SUBMIT))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0));
|
||||
assertThat(data.queryMaster("SELECT status FROM approval_instance WHERE id = ?", String.class, instanceId))
|
||||
.isEqualTo("PENDING");
|
||||
return instanceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接落库一条协调单实例:T23 的表单/提交流程尚未合入,查询侧只需完整数据行。
|
||||
*/
|
||||
private long seedCoordination(String title, String status, LocalDateTime submitTime, LocalDateTime archiveTime) {
|
||||
String bizNo = "XT" + System.nanoTime();
|
||||
data.master().update("INSERT INTO approval_instance (tenant_id, biz_no, flow_code, definition_id, "
|
||||
+ "definition_version, doc_type, biz_id, status, round, reporter_id, reporter_name, report_org_id, "
|
||||
+ "report_org_name, accept_org_id, accept_org_name, current_node_key, current_node_type, submit_time, "
|
||||
+ "archive_time, archivist_id, archivist_name) "
|
||||
+ "VALUES (?, ?, 'coordination_internal', ?, 1, 'coordination', 0, ?, 1, ?, ?, ?, ?, ?, ?, "
|
||||
+ "'reportLeader', 'APPROVE', ?, ?, ?, ?)",
|
||||
TENANT, bizNo, coordinationDefinitionId(), status, reporter.userId, reporter.realName,
|
||||
reportOrgId, orgName(reportOrgId), acceptOrgId, orgName(acceptOrgId),
|
||||
Timestamp.valueOf(submitTime),
|
||||
archiveTime == null ? null : Timestamp.valueOf(archiveTime),
|
||||
archiveTime == null ? null : reporter.userId,
|
||||
archiveTime == null ? null : reporter.realName);
|
||||
Long instanceId = data.queryMaster("SELECT id FROM approval_instance WHERE tenant_id = ? AND biz_no = ?",
|
||||
Long.class, TENANT, bizNo);
|
||||
data.trackInstance(instanceId);
|
||||
data.master().update("INSERT INTO approval_coordination_form (tenant_id, instance_id, accept_org_id, "
|
||||
+ "accept_org_name, title, content) VALUES (?, ?, ?, ?, ?, '协调内容')",
|
||||
TENANT, instanceId, acceptOrgId, orgName(acceptOrgId), title);
|
||||
Long formId = data.queryMaster("SELECT id FROM approval_coordination_form WHERE instance_id = ?",
|
||||
Long.class, instanceId);
|
||||
data.master().update("UPDATE approval_instance SET biz_id = ? WHERE id = ?", formId, instanceId);
|
||||
return instanceId;
|
||||
}
|
||||
|
||||
private long coordinationDefinitionId() {
|
||||
List<Long> ids = data.master().queryForList("SELECT id FROM approval_flow_definition WHERE tenant_id = ? "
|
||||
+ "AND flow_code = 'coordination_internal' AND version = 1 LIMIT 1", Long.class, TENANT);
|
||||
if (ids.isEmpty()) {
|
||||
data.master().update("INSERT INTO approval_flow_definition (tenant_id, flow_code, version, name, nodes_json) "
|
||||
+ "VALUES (?, 'coordination_internal', 1, '内部协调单', ?)",
|
||||
TENANT, ApprovalConstants.COORDINATION_NODES_JSON);
|
||||
ids = data.master().queryForList("SELECT id FROM approval_flow_definition WHERE tenant_id = ? "
|
||||
+ "AND flow_code = 'coordination_internal' AND version = 1 LIMIT 1", Long.class, TENANT);
|
||||
}
|
||||
return ids.isEmpty() ? 0L : ids.get(0);
|
||||
}
|
||||
|
||||
private String format(LocalDateTime time) {
|
||||
return TIME_FORMAT.format(time.withNano(0));
|
||||
}
|
||||
|
||||
private String orgName(Integer organizationId) {
|
||||
return data.server().queryForObject("SELECT organization_name FROM sys_organization WHERE organization_id = ?",
|
||||
String.class, organizationId);
|
||||
}
|
||||
|
||||
private void grantDepartmentLeader() {
|
||||
ApprovalTestData.TestRole role = data.createRole("dept_leader_" + reportOrgId, "部门负责人");
|
||||
ApprovalTestData.TestUser leader = data.createUser("负责人" + data.uniqueSuffix(), reportOrgId);
|
||||
data.bind(leader.userId, role.roleId);
|
||||
}
|
||||
|
||||
private void grantArchivist() {
|
||||
ApprovalTestData.TestRole role = data.createRole(ApprovalConstants.ROLE_ARCHIVIST, "收文员");
|
||||
ApprovalTestData.TestUser archivist = data.createUser("收文员" + data.uniqueSuffix(), reportOrgId);
|
||||
data.bind(archivist.userId, role.roleId);
|
||||
}
|
||||
|
||||
private MockHttpServletRequestBuilder authed(MockHttpServletRequestBuilder builder,
|
||||
ApprovalTestData.TestUser user, String... authorities) {
|
||||
return builder.header("tenantId", TENANT)
|
||||
.with(authentication(TestAuthentication.user(user.userId, user.username,
|
||||
reportOrgId, TENANT, authorities)));
|
||||
}
|
||||
|
||||
private String quote(String value) {
|
||||
return "\"" + value.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user