From 2c1dc9b43c038b6e6a0be17d105d28dd92bf0d3a Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Fri, 30 Oct 2026 21:21:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(approval):=20=E5=8D=95=E6=8D=AE=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E5=AF=BC=E5=87=BA=20xlsx=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ApprovalExportController.java | 40 ++ .../approval/mapper/ApprovalExportMapper.java | 22 + .../mapper/xml/ApprovalExportMapper.xml | 79 +++ .../service/ApprovalExportService.java | 186 +++++++ .../support/ApprovalExportResult.java | 20 + .../approval/support/ApprovalExportRow.java | 45 ++ .../gxwebsoft/approval/ApprovalExportIT.java | 479 ++++++++++++++++++ 7 files changed, 871 insertions(+) create mode 100644 src/main/java/com/gxwebsoft/approval/controller/ApprovalExportController.java create mode 100644 src/main/java/com/gxwebsoft/approval/mapper/ApprovalExportMapper.java create mode 100644 src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalExportMapper.xml create mode 100644 src/main/java/com/gxwebsoft/approval/service/ApprovalExportService.java create mode 100644 src/main/java/com/gxwebsoft/approval/support/ApprovalExportResult.java create mode 100644 src/main/java/com/gxwebsoft/approval/support/ApprovalExportRow.java create mode 100644 src/test/java/com/gxwebsoft/approval/ApprovalExportIT.java diff --git a/src/main/java/com/gxwebsoft/approval/controller/ApprovalExportController.java b/src/main/java/com/gxwebsoft/approval/controller/ApprovalExportController.java new file mode 100644 index 0000000..89c94a4 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/controller/ApprovalExportController.java @@ -0,0 +1,40 @@ +package com.gxwebsoft.approval.controller; + +import com.gxwebsoft.approval.param.ApprovalQueryParam; +import com.gxwebsoft.approval.service.ApprovalExportService; +import com.gxwebsoft.approval.support.ApprovalExportResult; +import com.gxwebsoft.common.core.annotation.OperationLog; +import com.gxwebsoft.common.core.web.ApiResult; +import com.gxwebsoft.common.core.web.BaseController; +import com.gxwebsoft.common.system.entity.FileRecord; +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.annotation.Resource; +import java.io.IOException; + +/** + * PC 单据导出接口:与单据查询共用筛选参数与数据权限。 + */ +@Api(tags = "审批单据导出") +@RestController +@RequestMapping("/api/approval") +public class ApprovalExportController extends BaseController { + + @Resource + private ApprovalExportService exportService; + + @PreAuthorize("hasAuthority('approval:instance:export')") + @OperationLog(module = "审批管理", comments = "导出单据查询结果") + @ApiOperation("导出单据") + @PostMapping("/export") + public ApiResult export(@RequestBody ApprovalQueryParam param) throws IOException { + ApprovalExportResult result = exportService.export(param, getLoginUser()); + return success("导出成功,共" + result.getRowCount() + "条", result.getFile()); + } +} diff --git a/src/main/java/com/gxwebsoft/approval/mapper/ApprovalExportMapper.java b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalExportMapper.java new file mode 100644 index 0000000..2663ca4 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/mapper/ApprovalExportMapper.java @@ -0,0 +1,22 @@ +package com.gxwebsoft.approval.mapper; + +import com.gxwebsoft.approval.param.ApprovalQueryParam; +import com.gxwebsoft.approval.support.ApprovalExportRow; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 导出查询Mapper:独立的非分页 SQL,绕过分页插件上限,最多取 5001 行。 + * + *

筛选条件与数据权限必须与 {@code ApprovalQueryMapper.selectInstancePage} 保持一致。

+ */ +public interface ApprovalExportMapper { + + /** + * 按筛选条件取导出数据,最多 5001 行(超出即代表需要拒绝导出)。 + */ + List selectExportRows(@Param("param") ApprovalQueryParam param, + @Param("userId") Integer userId, + @Param("allVisible") Integer allVisible); +} diff --git a/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalExportMapper.xml b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalExportMapper.xml new file mode 100644 index 0000000..837c511 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/mapper/xml/ApprovalExportMapper.xml @@ -0,0 +1,79 @@ + + + + + + + + diff --git a/src/main/java/com/gxwebsoft/approval/service/ApprovalExportService.java b/src/main/java/com/gxwebsoft/approval/service/ApprovalExportService.java new file mode 100644 index 0000000..4cbc29b --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/service/ApprovalExportService.java @@ -0,0 +1,186 @@ +package com.gxwebsoft.approval.service; + +import com.gxwebsoft.approval.constant.ApprovalConstants; +import com.gxwebsoft.approval.mapper.ApprovalExportMapper; +import com.gxwebsoft.approval.param.ApprovalQueryParam; +import com.gxwebsoft.approval.support.ApprovalExportResult; +import com.gxwebsoft.approval.support.ApprovalExportRow; +import com.gxwebsoft.approval.support.ApprovalNode; +import com.gxwebsoft.common.core.config.ConfigProperties; +import com.gxwebsoft.common.core.exception.BusinessException; +import com.gxwebsoft.common.system.entity.FileRecord; +import com.gxwebsoft.common.system.entity.User; +import org.apache.poi.xssf.streaming.SXSSFRow; +import org.apache.poi.xssf.streaming.SXSSFSheet; +import org.apache.poi.xssf.streaming.SXSSFWorkbook; +import org.springframework.stereotype.Service; + +import javax.annotation.Resource; +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.nio.file.Files; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 单据导出:按列表同一套筛选与数据权限同步生成 xlsx,并写文件记录。 + * + *

独立查询一次取 5001 行判断是否超限,不经过分页插件的 2000 条上限; + * 超过 5000 行直接拒绝,不生成文件。导出内容不含附件。

+ */ +@Service +public class ApprovalExportService { + + /** 单次导出上限,超过即要求缩小筛选范围。 */ + public static final int MAX_EXPORT_ROWS = 5000; + + private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private static final String[] HEADERS = { + "单号", "单据类型", "标题或名称", "报送部门", "报送人", "受理部门", "实施单位", "费用(元)", + "服务内容或协调内容", "备注", "状态", "当前节点", "提交时间", "审批完成时间", "归档时间", "归档人" + }; + + /** 列宽(字符数),长文本列加宽但不截断内容。 */ + private static final int[] COLUMN_WIDTHS = { + 20, 16, 28, 20, 12, 20, 22, 12, 50, 28, 12, 16, 20, 20, 20, 12 + }; + + @Resource + private ApprovalExportMapper exportMapper; + + @Resource + private ApprovalFlowService flowService; + + @Resource + private ApprovalDirectoryService directoryService; + + @Resource + private ApprovalFileService fileService; + + @Resource + private ConfigProperties config; + + /** + * 导出当前筛选结果。 + * + * @throws BusinessException 结果超过 {@link #MAX_EXPORT_ROWS} 行时拒绝,不生成文件 + */ + public ApprovalExportResult export(ApprovalQueryParam param, User user) throws IOException { + int allVisible = directoryService.hasAnyRole(user.getUserId(), + ApprovalConstants.ROLE_SUPER_ADMIN, ApprovalConstants.ROLE_ARCHIVIST) ? 1 : 0; + List rows = exportMapper.selectExportRows(param, user.getUserId(), allVisible); + if (rows.size() > MAX_EXPORT_ROWS) { + throw new BusinessException("请缩小筛选范围"); + } + String filename = "excel/审批单据-" + System.currentTimeMillis() + ".xlsx"; + String path = config.getUploadPath() + filename; + File target = new File(path); + try { + File parent = target.getParentFile(); + if (parent != null) { + Files.createDirectories(parent.toPath()); + } + writeWorkbook(rows, target); + } catch (IOException | RuntimeException e) { + // 生成失败时不留下半成品文件 + Files.deleteIfExists(target.toPath()); + throw e; + } + + FileRecord record = new FileRecord(); + record.setName(filename); + record.setPath(path); + record.setLength(target.length()); + record.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + record.setCreateUserId(user.getUserId()); + record.setUrl(config.getServerUrl() + "/file/" + filename); + fileService.save(record); + return new ApprovalExportResult(record, rows.size()); + } + + private void writeWorkbook(List rows, File target) throws IOException { + SXSSFWorkbook workbook = new SXSSFWorkbook(100); + try { + SXSSFSheet sheet = workbook.createSheet("审批单据"); + for (int i = 0; i < COLUMN_WIDTHS.length; i++) { + sheet.setColumnWidth(i, COLUMN_WIDTHS[i] * 256); + } + SXSSFRow header = sheet.createRow(0); + for (int i = 0; i < HEADERS.length; i++) { + header.createCell(i).setCellValue(HEADERS[i]); + } + Map> nodeCache = new HashMap<>(); + int rowNum = 1; + for (ApprovalExportRow row : rows) { + SXSSFRow dataRow = sheet.createRow(rowNum++); + String[] values = toValues(row, nodeCache); + for (int i = 0; i < values.length; i++) { + dataRow.createCell(i).setCellValue(values[i]); + } + } + try (OutputStream output = Files.newOutputStream(target.toPath())) { + workbook.write(output); + } + } finally { + workbook.dispose(); + workbook.close(); + } + } + + /** + * 固定列顺序:单号、单据类型、标题或名称、报送部门、报送人、受理部门、实施单位、费用(元)、 + * 服务内容或协调内容、备注、状态、当前节点、提交时间、审批完成时间、归档时间、归档人。 + */ + private String[] toValues(ApprovalExportRow row, Map> nodeCache) { + boolean completion = ApprovalConstants.DOC_COMPLETION.equals(row.getDocType()); + return new String[]{ + text(row.getBizNo()), + text(ApprovalConstants.docTypeName(row.getDocType())), + completion ? text(row.getName()) : text(row.getTitle()), + text(row.getReportOrgName()), + text(row.getReporterName()), + text(row.getAcceptOrgName()), + completion ? text(row.getImplementOrg()) : "", + completion ? fee(row.getFee()) : "", + completion ? text(row.getServiceContent()) : text(row.getContent()), + text(row.getRemark()), + text(ApprovalConstants.statusName(row.getStatus())), + text(nodeName(nodeCache, row.getDefinitionId(), row.getCurrentNodeKey())), + time(row.getSubmitTime()), + time(row.getApproveTime()), + time(row.getArchiveTime()), + text(row.getArchivistName()) + }; + } + + private String nodeName(Map> cache, Long definitionId, String nodeKey) { + if (definitionId == null || nodeKey == null) { + return null; + } + List nodes = cache.computeIfAbsent(definitionId, flowService::parseNodes); + return nodes.stream() + .filter(node -> nodeKey.equals(node.getKey())) + .map(ApprovalNode::getName) + .findFirst() + .orElse(null); + } + + private String fee(BigDecimal fee) { + return fee == null ? "" : fee.setScale(2, RoundingMode.HALF_UP).toPlainString(); + } + + private String time(LocalDateTime time) { + return time == null ? "" : TIME_FORMAT.format(time); + } + + private String text(String value) { + return value == null ? "" : value; + } +} diff --git a/src/main/java/com/gxwebsoft/approval/support/ApprovalExportResult.java b/src/main/java/com/gxwebsoft/approval/support/ApprovalExportResult.java new file mode 100644 index 0000000..a63bccf --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/support/ApprovalExportResult.java @@ -0,0 +1,20 @@ +package com.gxwebsoft.approval.support; + +import com.gxwebsoft.common.system.entity.FileRecord; +import lombok.Data; + +/** + * 导出结果:返回给前端的文件记录 + 实际导出行数(用于提示与操作日志)。 + */ +@Data +public class ApprovalExportResult { + + private final FileRecord file; + + private final int rowCount; + + public ApprovalExportResult(FileRecord file, int rowCount) { + this.file = file; + this.rowCount = rowCount; + } +} diff --git a/src/main/java/com/gxwebsoft/approval/support/ApprovalExportRow.java b/src/main/java/com/gxwebsoft/approval/support/ApprovalExportRow.java new file mode 100644 index 0000000..0ad1409 --- /dev/null +++ b/src/main/java/com/gxwebsoft/approval/support/ApprovalExportRow.java @@ -0,0 +1,45 @@ +package com.gxwebsoft.approval.support; + +import lombok.Data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 导出查询数据库行:两类单据合并列,未命中的表单字段为空。 + */ +@Data +public class ApprovalExportRow implements Serializable { + private static final long serialVersionUID = 1L; + + private Long id; + private String bizNo; + private String docType; + private String status; + private String reporterName; + private String reportOrgName; + private String acceptOrgName; + private String currentNodeKey; + private Long definitionId; + private LocalDateTime submitTime; + private LocalDateTime approveTime; + private LocalDateTime archiveTime; + private String archivistName; + + /** 业务完成确认单:名称。 */ + private String name; + /** 业务完成确认单:实施单位。 */ + private String implementOrg; + /** 业务完成确认单:费用(元)。 */ + private BigDecimal fee; + /** 业务完成确认单:服务内容。 */ + private String serviceContent; + + /** 内部协调单:标题。 */ + private String title; + /** 内部协调单:协调内容。 */ + private String content; + /** 内部协调单:备注。 */ + private String remark; +} diff --git a/src/test/java/com/gxwebsoft/approval/ApprovalExportIT.java b/src/test/java/com/gxwebsoft/approval/ApprovalExportIT.java new file mode 100644 index 0000000..d29ddcc --- /dev/null +++ b/src/test/java/com/gxwebsoft/approval/ApprovalExportIT.java @@ -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 exportedFiles = new ArrayList<>(); + /** 本次用例批量种子的关键字,结束时按单号前缀清理。 */ + private final List batchMarks = new ArrayList<>(); + /** 本次用例创建的用户id,结束时清理其操作日志。 */ + private final List 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 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 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 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 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 awaitOperationRecord(String paramsMark) throws InterruptedException { + long deadline = System.currentTimeMillis() + 3000; + while (System.currentTimeMillis() < deadline) { + List> 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 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 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 标识,便于按前缀清理。 + * + *

master 库连接未开启 rewriteBatchedStatements,逐条 batchUpdate 会产生上千次往返; + * 这里按 200 行一条多值 INSERT 落库,再一次性回填 biz_id。

+ */ + private void seedCompletionBatch(String mark, int count) { + seedCompletionBatch(mark, count, null, null, null, null); + } + + private void seedCompletionBatch(String mark, int count, Map 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 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 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 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 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 listExportFiles() throws Exception { + Path dir = Paths.get(config.getUploadPath(), "excel"); + if (!Files.isDirectory(dir)) { + return new ArrayList<>(); + } + List files = new ArrayList<>(); + try (java.util.stream.Stream 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(); + } +}