feat(approval): 单据查询导出 xlsx (#26)

This commit is contained in:
2026-10-30 21:21:10 +08:00
parent 49c789f2b6
commit 2c1dc9b43c
7 changed files with 871 additions and 0 deletions
@@ -0,0 +1,479 @@
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.common.core.config.ConfigProperties;
import com.gxwebsoft.support.ApprovalTestData;
import com.gxwebsoft.support.TestAuthentication;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellType;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
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.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import javax.sql.DataSource;
import java.io.File;
import java.io.FileInputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
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.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* 单据导出:权限、5000 行上限、列头与内容、数据权限与操作日志。
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles({"gxtyzx-test", "test"})
class ApprovalExportIT {
private static final int TENANT = TestAuthentication.TENANT_ID;
private static final String LIST = "approval:instance:list";
private static final String EXPORT = "approval:instance:export";
private static final String URL = "/api/approval/export";
private static final int EXPORT_LIMIT = 5000;
/** 多值 INSERT 每批行数,避免超出占位符上限。 */
private static final int SEED_CHUNK = 200;
private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final String[] HEADERS = {
"单号", "单据类型", "标题或名称", "报送部门", "报送人", "受理部门", "实施单位", "费用(元)",
"服务内容或协调内容", "备注", "状态", "当前节点", "提交时间", "审批完成时间", "归档时间", "归档人"
};
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private DataSource dataSource;
@Autowired
private ConfigProperties config;
private ApprovalTestData data;
private Integer reportOrgId;
private Integer acceptOrgId;
private ApprovalTestData.TestUser reporter;
/** 本次用例创建的导出文件,结束时删除。 */
private final List<Path> exportedFiles = new ArrayList<>();
/** 本次用例批量种子的关键字,结束时按单号前缀清理。 */
private final List<String> batchMarks = new ArrayList<>();
/** 本次用例创建的用户id,结束时清理其操作日志。 */
private final List<Integer> testUserIds = new ArrayList<>();
@BeforeEach
void setUp() {
data = new ApprovalTestData(dataSource);
String suffix = data.uniqueSuffix();
reportOrgId = data.createOrganization("报送部门" + suffix, 136);
acceptOrgId = data.createOrganization("受理部门" + suffix, 136);
reporter = createUser("报送人" + suffix, reportOrgId);
}
@AfterEach
void tearDown() {
for (String mark : batchMarks) {
cleanBatch(mark);
}
batchMarks.clear();
for (Path path : exportedFiles) {
try {
Files.deleteIfExists(path);
} catch (Exception ignored) {
// 文件清理失败不影响断言结果
}
}
exportedFiles.clear();
try {
// 操作日志异步写入,稍等后再按测试用户清理
Thread.sleep(300);
for (Integer userId : testUserIds) {
data.server().update("DELETE FROM sys_operation_record WHERE url = ? AND user_id = ?", URL, userId);
}
} catch (Exception ignored) {
// 共享库清理尽力而为
}
testUserIds.clear();
data.cleanup();
}
@Test
void rejectsWithoutExportAuthority() throws Exception {
mockMvc.perform(authed(post(URL), reporter, LIST)
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(403));
}
@Test
void rejectsWhenResultExceedsLimitAndWritesNoFile() throws Exception {
String mark = "超限" + data.uniqueSuffix();
seedCompletionBatch(mark, EXPORT_LIMIT + 1);
List<Path> before = listExportFiles();
mockMvc.perform(authed(post(URL), reporter, LIST, EXPORT)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"keyword\":" + quote(mark) + "}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(1))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("请缩小筛选范围")));
assertThat(listExportFiles()).isEqualTo(before);
}
@Test
void exportsExactlyLimitRowsWithFixedColumnsAndFullText() throws Exception {
String mark = "上限" + data.uniqueSuffix();
String specialName = mark + "特殊名称";
String longContent = repeat("场馆设施维修与验收", 200);
Map<String, Object> special = new LinkedHashMap<>();
special.put("name", specialName);
special.put("implementOrg", mark + "实施单位");
special.put("fee", "1234.56");
special.put("serviceContent", longContent);
LocalDateTime submitTime = LocalDateTime.now().withNano(0).minusDays(1);
LocalDateTime approveTime = submitTime.plusHours(2);
LocalDateTime archiveTime = submitTime.plusDays(1);
seedCompletionBatch(mark, EXPORT_LIMIT, special, submitTime, approveTime, archiveTime);
String response = mockMvc.perform(authed(post(URL), reporter, LIST, EXPORT)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"keyword\":" + quote(mark) + "}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
JsonNode node = objectMapper.readTree(response).path("data");
assertThat(node.path("id").asInt()).isPositive();
String name = node.path("name").asText();
assertThat(name).startsWith("excel/审批单据-").endsWith(".xlsx");
assertThat(node.path("url").asText()).contains("/file/excel/审批单据-");
File file = new File(node.path("path").asText());
assertThat(file).exists();
exportedFiles.add(file.toPath());
assertThat(data.countServer("SELECT COUNT(*) FROM sys_file_record WHERE id = ? AND name = ?",
node.path("id").asInt(), name)).isEqualTo(1);
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(file))) {
Sheet sheet = workbook.getSheetAt(0);
assertThat(sheet.getLastRowNum() + 1).isEqualTo(EXPORT_LIMIT + 1);
Row header = sheet.getRow(0);
List<String> headerValues = new ArrayList<>();
for (int i = 0; i < HEADERS.length; i++) {
headerValues.add(cellText(header.getCell(i)));
}
assertThat(headerValues).containsExactly(HEADERS);
Row matched = null;
for (int i = 1; i <= sheet.getLastRowNum(); i++) {
Row row = sheet.getRow(i);
if (specialName.equals(cellText(row.getCell(2)))) {
matched = row;
break;
}
}
assertThat(matched).as("应导出特殊名称行").isNotNull();
assertThat(cellText(matched.getCell(0))).isEqualTo(mark + "-0");
assertThat(cellText(matched.getCell(1))).isEqualTo("业务完成确认单");
assertThat(cellText(matched.getCell(3))).isEqualTo(orgName(reportOrgId));
assertThat(cellText(matched.getCell(4))).isEqualTo(reporter.realName);
assertThat(cellText(matched.getCell(5))).isEqualTo(orgName(acceptOrgId));
assertThat(cellText(matched.getCell(6))).isEqualTo(mark + "实施单位");
assertThat(cellText(matched.getCell(7))).isEqualTo("1234.56");
assertThat(cellText(matched.getCell(8))).isEqualTo(longContent);
assertThat(cellText(matched.getCell(9))).isEmpty();
assertThat(cellText(matched.getCell(10))).isEqualTo("已归档");
assertThat(cellText(matched.getCell(11))).isEqualTo("报送部门负责人");
assertThat(cellText(matched.getCell(12))).isEqualTo(TIME_FORMAT.format(submitTime));
assertThat(cellText(matched.getCell(13))).isEqualTo(TIME_FORMAT.format(approveTime));
assertThat(cellText(matched.getCell(14))).isEqualTo(TIME_FORMAT.format(archiveTime));
assertThat(cellText(matched.getCell(15))).isEqualTo("收文人" + mark);
}
}
@Test
void appliesSameDataPermissionAsList() throws Exception {
String mark = "权限" + data.uniqueSuffix();
long draft = seedCompletion(mark + "草稿", "DRAFT", null);
long pending = seedCompletion(mark + "在途", "PENDING", LocalDateTime.now().minusHours(1));
ApprovalTestData.TestUser outsider = createUser("无关人员" + data.uniqueSuffix(), acceptOrgId);
ApprovalTestData.TestUser assignee = createUser("任务处理人" + data.uniqueSuffix(), acceptOrgId);
ApprovalTestData.TestUser archivist = 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 (?, ?, 'deptLeader', 'APPROVE', 1, 'APPROVE', ?, 'PENDING')",
TENANT, pending, assignee.userId);
ApprovalTestData.TestRole archivistRole = data.createRole(ApprovalConstants.ROLE_ARCHIVIST, "收文员");
data.bind(archivist.userId, archivistRole.roleId);
assertThat(exportBizNos(reporter, mark)).containsExactlyInAnyOrder(bizNoOf(draft), bizNoOf(pending));
assertThat(exportBizNos(assignee, mark)).containsExactly(bizNoOf(pending));
assertThat(exportBizNos(outsider, mark)).isEmpty();
assertThat(exportBizNos(archivist, mark)).containsExactly(bizNoOf(pending));
}
@Test
void writesOperationLogWithFilterAndRowCount() throws Exception {
String mark = "日志" + data.uniqueSuffix();
seedCompletion(mark + "一", "PENDING", LocalDateTime.now().minusHours(2));
seedCompletion(mark + "二", "PENDING", LocalDateTime.now().minusHours(1));
mockMvc.perform(authed(post(URL), reporter, LIST, EXPORT)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"keyword\":" + quote(mark) + "}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.message").value(org.hamcrest.Matchers.containsString("共2条")));
Map<String, Object> record = awaitOperationRecord(mark);
assertThat(record).as("应记录导出操作日志").isNotNull();
assertThat(record.get("user_id")).isEqualTo(reporter.userId);
assertThat((String) record.get("params")).contains(mark);
assertThat((String) record.get("result")).contains("共2条");
assertThat(record.get("create_time")).isNotNull();
assertThat(((Number) record.get("spend_time")).longValue()).isNotNegative();
}
// ---------- helpers ----------
private ApprovalTestData.TestUser createUser(String realName, Integer organizationId) {
ApprovalTestData.TestUser user = data.createUser(realName, organizationId);
testUserIds.add(user.userId);
return user;
}
private Map<String, Object> awaitOperationRecord(String paramsMark) throws InterruptedException {
long deadline = System.currentTimeMillis() + 3000;
while (System.currentTimeMillis() < deadline) {
List<Map<String, Object>> rows = data.server().queryForList(
"SELECT user_id, params, result, spend_time, create_time FROM sys_operation_record "
+ "WHERE url = ? AND params LIKE ? ORDER BY id DESC LIMIT 1",
URL, "%" + paramsMark + "%");
if (!rows.isEmpty()) {
return rows.get(0);
}
Thread.sleep(100);
}
return null;
}
private List<String> exportBizNos(ApprovalTestData.TestUser user, String keyword) throws Exception {
String response = mockMvc.perform(authed(post(URL), user, LIST, EXPORT)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"keyword\":" + quote(keyword) + "}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
JsonNode node = objectMapper.readTree(response).path("data");
File file = new File(node.path("path").asText());
exportedFiles.add(file.toPath());
List<String> bizNos = new ArrayList<>();
try (XSSFWorkbook workbook = new XSSFWorkbook(new FileInputStream(file))) {
Sheet sheet = workbook.getSheetAt(0);
for (int i = 1; i <= sheet.getLastRowNum(); i++) {
bizNos.add(cellText(sheet.getRow(i).getCell(0)));
}
}
return bizNos;
}
private long seedCompletion(String name, String status, LocalDateTime submitTime) {
long definitionId = completionDefinitionId();
String bizNo = "WG" + data.uniqueSuffix();
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) "
+ "VALUES (?, ?, 'completion', ?, 1, 'completion', 0, ?, 1, ?, ?, ?, ?, ?, ?, 'deptLeader', 'APPROVE', ?)",
TENANT, bizNo, definitionId, status, reporter.userId, reporter.realName, reportOrgId,
orgName(reportOrgId), acceptOrgId, orgName(acceptOrgId),
submitTime == null ? null : Timestamp.valueOf(submitTime));
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_completion_form (tenant_id, instance_id, name, implement_org, "
+ "fee, service_content) VALUES (?, ?, ?, '广西体育中心实施部', 100.00, '完成场馆设施维修与验收')",
TENANT, instanceId, name);
Long formId = data.queryMaster("SELECT id FROM approval_completion_form WHERE instance_id = ?",
Long.class, instanceId);
data.master().update("UPDATE approval_instance SET biz_id = ? WHERE id = ?", formId, instanceId);
return instanceId;
}
private String bizNoOf(long instanceId) {
return data.queryMaster("SELECT biz_no FROM approval_instance WHERE id = ?", String.class, instanceId);
}
/**
* 批量种子:count 条已归档确认单,单号前缀与名称都用 mark 标识,便于按前缀清理。
*
* <p>master 库连接未开启 rewriteBatchedStatements,逐条 batchUpdate 会产生上千次往返;
* 这里按 200 行一条多值 INSERT 落库,再一次性回填 biz_id。</p>
*/
private void seedCompletionBatch(String mark, int count) {
seedCompletionBatch(mark, count, null, null, null, null);
}
private void seedCompletionBatch(String mark, int count, Map<String, Object> special,
LocalDateTime submitTime, LocalDateTime approveTime,
LocalDateTime archiveTime) {
batchMarks.add(mark);
long definitionId = completionDefinitionId();
String reportOrgName = orgName(reportOrgId);
String acceptOrgName = orgName(acceptOrgId);
Timestamp submit = Timestamp.valueOf(submitTime == null
? LocalDateTime.now().withNano(0).minusHours(1) : submitTime);
Timestamp approve = approveTime == null ? null : Timestamp.valueOf(approveTime);
Timestamp archive = archiveTime == null ? null : Timestamp.valueOf(archiveTime);
Integer archivistId = archive == null ? null : reporter.userId;
String archivistName = archive == null ? null : "收文人" + mark;
String instanceRow = "(?, ?, 'completion', ?, 1, 'completion', 0, 'ARCHIVED', 1, ?, ?, ?, ?, ?, ?, "
+ "'deptLeader', 'APPROVE', ?, ?, ?, ?, ?)";
for (int start = 0; start < count; start += SEED_CHUNK) {
int end = Math.min(start + SEED_CHUNK, count);
StringBuilder sql = new StringBuilder("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, approve_time, archive_time, archivist_id, archivist_name) VALUES ");
List<Object> args = new ArrayList<>();
for (int i = start; i < end; i++) {
if (i > start) {
sql.append(',');
}
sql.append(instanceRow);
java.util.Collections.addAll(args, TENANT, mark + "-" + i, definitionId, reporter.userId,
reporter.realName, reportOrgId, reportOrgName, acceptOrgId, acceptOrgName, submit,
approve, archive, archivistId, archivistName);
}
data.master().update(sql.toString(), args.toArray());
}
List<Long> ids = data.master().queryForList(
"SELECT id FROM approval_instance WHERE tenant_id = ? AND biz_no LIKE ? ORDER BY id",
Long.class, TENANT, mark + "-%");
assertThat(ids).hasSize(count);
for (int start = 0; start < ids.size(); start += SEED_CHUNK) {
int end = Math.min(start + SEED_CHUNK, ids.size());
StringBuilder sql = new StringBuilder("INSERT INTO approval_completion_form (tenant_id, instance_id, "
+ "name, implement_org, fee, service_content) VALUES ");
List<Object> args = new ArrayList<>();
for (int i = start; i < end; i++) {
if (i > start) {
sql.append(',');
}
sql.append("(?, ?, ?, ?, ?, ?)");
boolean isSpecial = special != null && i == 0;
java.util.Collections.addAll(args, TENANT, ids.get(i),
isSpecial ? special.get("name") : mark + "-" + i,
isSpecial ? special.get("implementOrg") : "广西体育中心实施部",
new java.math.BigDecimal(isSpecial ? (String) special.get("fee") : "100.00"),
isSpecial ? special.get("serviceContent") : "完成场馆设施维修与验收");
}
data.master().update(sql.toString(), args.toArray());
}
data.master().update("UPDATE approval_instance i JOIN approval_completion_form f "
+ "ON f.instance_id = i.id SET i.biz_id = f.id WHERE i.biz_no LIKE ?", mark + "-%");
}
private void cleanBatch(String mark) {
try {
String[] tables = {"approval_attachment", "approval_record", "approval_task",
"approval_completion_form", "approval_coordination_form"};
for (String table : tables) {
data.master().update("DELETE t FROM " + table + " t JOIN approval_instance i "
+ "ON t.instance_id = i.id WHERE i.biz_no LIKE ?", mark + "-%");
}
data.master().update("DELETE FROM approval_instance WHERE biz_no LIKE ?", mark + "-%");
} catch (Exception ignored) {
// 清理失败不阻断测试报告
}
}
private long completionDefinitionId() {
List<Long> ids = data.master().queryForList("SELECT id FROM approval_flow_definition WHERE tenant_id = ? "
+ "AND flow_code = 'completion' 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 (?, 'completion', 1, '业务完成确认单', ?)",
TENANT, ApprovalConstants.COMPLETION_NODES_JSON);
ids = data.master().queryForList("SELECT id FROM approval_flow_definition WHERE tenant_id = ? "
+ "AND flow_code = 'completion' AND version = 1 LIMIT 1", Long.class, TENANT);
}
return ids.isEmpty() ? 0L : ids.get(0);
}
private List<Path> listExportFiles() throws Exception {
Path dir = Paths.get(config.getUploadPath(), "excel");
if (!Files.isDirectory(dir)) {
return new ArrayList<>();
}
List<Path> files = new ArrayList<>();
try (java.util.stream.Stream<Path> stream = Files.list(dir)) {
stream.filter(path -> path.getFileName().toString().startsWith("审批单据-"))
.forEach(files::add);
}
files.sort(Comparator.comparing(Path::toString));
return files;
}
private String cellText(Cell cell) {
if (cell == null) {
return "";
}
if (cell.getCellType() == CellType.NUMERIC) {
return new java.math.BigDecimal(String.valueOf(cell.getNumericCellValue())).stripTrailingZeros()
.toPlainString();
}
return cell.getStringCellValue();
}
private String orgName(Integer organizationId) {
return data.server().queryForObject("SELECT organization_name FROM sys_organization WHERE organization_id = ?",
String.class, organizationId);
}
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("\"", "\\\"") + "\"";
}
private String repeat(String unit, int times) {
StringBuilder builder = new StringBuilder(unit.length() * times);
for (int i = 0; i < times; i++) {
builder.append(unit);
}
return builder.toString();
}
}