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,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<FileRecord> export(@RequestBody ApprovalQueryParam param) throws IOException {
ApprovalExportResult result = exportService.export(param, getLoginUser());
return success("导出成功,共" + result.getRowCount() + "条", result.getFile());
}
}
@@ -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 行。
*
* <p>筛选条件与数据权限必须与 {@code ApprovalQueryMapper.selectInstancePage} 保持一致。</p>
*/
public interface ApprovalExportMapper {
/**
* 按筛选条件取导出数据,最多 5001 行(超出即代表需要拒绝导出)。
*/
List<ApprovalExportRow> selectExportRows(@Param("param") ApprovalQueryParam param,
@Param("userId") Integer userId,
@Param("allVisible") Integer allVisible);
}
@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.gxwebsoft.approval.mapper.ApprovalExportMapper">
<!-- 导出:数据权限与筛选条件同 ApprovalQueryMapper.selectInstancePage;LIMIT 5001 由 SQL 固定,不经过分页插件 -->
<select id="selectExportRows" resultType="com.gxwebsoft.approval.support.ApprovalExportRow">
SELECT i.id,
i.biz_no,
i.doc_type,
i.status,
i.reporter_name,
i.report_org_name,
i.accept_org_name,
i.current_node_key,
i.definition_id,
i.submit_time,
i.approve_time,
i.archive_time,
i.archivist_name,
f.name,
f.implement_org,
f.fee,
f.service_content,
co.title,
co.content,
co.remark
FROM approval_instance i
LEFT JOIN approval_completion_form f ON f.instance_id = i.id
LEFT JOIN approval_coordination_form co ON co.instance_id = i.id
WHERE (
i.reporter_id = #{userId}
OR (i.status != 'DRAFT' AND (
#{allVisible} = 1
OR EXISTS (SELECT 1 FROM approval_task t WHERE t.instance_id = i.id AND t.assignee_id = #{userId})
OR EXISTS (SELECT 1 FROM approval_record r WHERE r.instance_id = i.id AND r.operator_id = #{userId})
OR i.dispatch_target_id = #{userId}
))
)
<if test="param.docType != null and param.docType != ''">
AND i.doc_type = #{param.docType}
</if>
<if test="param.bizNo != null and param.bizNo != ''">
AND i.biz_no LIKE CONCAT('%', #{param.bizNo}, '%')
</if>
<if test="param.reportOrgId != null">
AND i.report_org_id = #{param.reportOrgId}
</if>
<if test="param.acceptOrgId != null">
AND i.accept_org_id = #{param.acceptOrgId}
</if>
<if test="param.reporterId != null">
AND i.reporter_id = #{param.reporterId}
</if>
<if test="param.status != null and param.status != ''">
AND i.status = #{param.status}
</if>
<if test="param.keyword != null and param.keyword != ''">
AND (i.biz_no LIKE CONCAT('%', #{param.keyword}, '%')
OR f.name LIKE CONCAT('%', #{param.keyword}, '%')
OR co.title LIKE CONCAT('%', #{param.keyword}, '%')
OR f.implement_org LIKE CONCAT('%', #{param.keyword}, '%'))
</if>
<if test="param.submitTimeStart != null and param.submitTimeStart != ''">
AND i.submit_time &gt;= #{param.submitTimeStart}
</if>
<if test="param.submitTimeEnd != null and param.submitTimeEnd != ''">
AND i.submit_time &lt;= #{param.submitTimeEnd}
</if>
<if test="param.archiveTimeStart != null and param.archiveTimeStart != ''">
AND i.archive_time &gt;= #{param.archiveTimeStart}
</if>
<if test="param.archiveTimeEnd != null and param.archiveTimeEnd != ''">
AND i.archive_time &lt;= #{param.archiveTimeEnd}
</if>
ORDER BY (i.submit_time IS NULL) ASC, i.submit_time DESC, i.id DESC
LIMIT 5001
</select>
</mapper>
@@ -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,并写文件记录。
*
* <p>独立查询一次取 5001 行判断是否超限,不经过分页插件的 2000 条上限;
* 超过 5000 行直接拒绝,不生成文件。导出内容不含附件。</p>
*/
@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<ApprovalExportRow> 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<ApprovalExportRow> 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<Long, List<ApprovalNode>> 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<Long, List<ApprovalNode>> 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<Long, List<ApprovalNode>> cache, Long definitionId, String nodeKey) {
if (definitionId == null || nodeKey == null) {
return null;
}
List<ApprovalNode> 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;
}
}
@@ -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;
}
}
@@ -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;
}