Files
gxtyzx-java/src/main/java/com/gxwebsoft/approval/service/ApprovalExportService.java
T
weicw d5001c981c feat: 二期审批引擎扩展与会签/明细/回填/附件分类共享层(#46)
- ApprovalDocumentType SPI + 注册表:新增单据不再改引擎
- ApprovalApproverResolver:会签组、orgLeaderByCode/orgDeputyByCode、租户岗位解析器
- 会签:任务带 group_key/group_name/group_seq,一人多组一次通过,只取消同组待办
- 回填:节点 writableFields、BACKFILL 动作、approval_field_change 留痕
- 附件:新增 VOUCHER 分类并按单据类型校验必传
- 详情:document.fields/lines/lineColumns/groupProgress + permissions.writableFields
- 列表/导出:title/search_text 落库、flowCode 筛选、明细行数/合计金额/会签进度列、明细导出
- sql/05-approval_phase2.sql(可重复执行)
2026-10-31 06:13:45 +08:00

375 lines
15 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.gxwebsoft.approval.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.gxwebsoft.approval.constant.ApprovalConstants;
import com.gxwebsoft.approval.document.ApprovalDocumentRegistry;
import com.gxwebsoft.approval.document.ApprovalDocumentType;
import com.gxwebsoft.approval.entity.ApprovalInstance;
import com.gxwebsoft.approval.mapper.ApprovalExportMapper;
import com.gxwebsoft.approval.mapper.ApprovalInstanceMapper;
import com.gxwebsoft.approval.param.ApprovalQueryParam;
import com.gxwebsoft.approval.support.ApprovalExportResult;
import com.gxwebsoft.approval.support.ApprovalExportRow;
import com.gxwebsoft.approval.support.ApprovalLineSpec;
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.Menu;
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.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 单据导出:跨单据总表与按单据类型的明细导出。
*
* <p>总表保持「一行单据一行」,只多「明细行数」「合计金额」与「会签进度」三列(ADR 0004);
* 逐行数据另做按 flow_code 的明细导出,供对账与成本归集。导出内容不含附件。</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");
/** 一期 16 列顺序保持不变,二期三列追加在末尾(ADR 0004 / 0003)。 */
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, 10, 14, 18
};
@Resource
private ApprovalExportMapper exportMapper;
@Resource
private ApprovalInstanceMapper instanceMapper;
@Resource
private ApprovalDocumentRegistry documentRegistry;
@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;
int assignVisible = hasAuthority(user, ApprovalConstants.PERMISSION_ASSIGN) ? 1 : 0;
List<ApprovalExportRow> rows = exportMapper.selectExportRows(param, user.getUserId(), allVisible,
assignVisible);
if (rows.size() > MAX_EXPORT_ROWS) {
throw new BusinessException("请缩小筛选范围");
}
String filename = "excel/审批单据-" + System.currentTimeMillis() + ".xlsx";
return write(user, rows.size(), filename, sheet -> fillSummarySheet(sheet, rows));
}
/**
* 按单据类型的明细导出:明细列 + 单号/报送部门/状态,含逐行数据。
*/
public ApprovalExportResult exportLines(String flowCode, ApprovalQueryParam param, User user)
throws IOException {
ApprovalDocumentType type = documentRegistry.require(flowCode);
List<ApprovalLineSpec> columns = type.lineColumns();
if (columns == null || columns.isEmpty()) {
throw new BusinessException("该单据类型没有明细行");
}
int allVisible = directoryService.hasAnyRole(user.getUserId(),
ApprovalConstants.ROLE_SUPER_ADMIN, ApprovalConstants.ROLE_ARCHIVIST) ? 1 : 0;
int assignVisible = hasAuthority(user, ApprovalConstants.PERMISSION_ASSIGN) ? 1 : 0;
List<ApprovalExportRow> instances = exportMapper.selectExportRows(param, user.getUserId(), allVisible,
assignVisible);
if (instances.size() > MAX_EXPORT_ROWS) {
throw new BusinessException("请缩小筛选范围");
}
List<ExportLine> lines = new ArrayList<>();
for (ApprovalExportRow row : instances) {
if (!flowCode.equals(row.getFlowCode())) {
continue;
}
ApprovalInstance instance = instanceMapper.selectById(row.getId());
if (instance == null) {
continue;
}
for (Map<String, Object> line : type.lines(instance)) {
lines.add(new ExportLine(row, line));
}
}
String filename = "excel/审批单据明细-" + flowCode + "-" + System.currentTimeMillis() + ".xlsx";
return write(user, lines.size(), filename, sheet -> fillLineSheet(sheet, type, columns, lines));
}
/** 明细导出的一行:所属单据 + 明细行。 */
private static final class ExportLine {
private final ApprovalExportRow instance;
private final Map<String, Object> line;
private ExportLine(ApprovalExportRow instance, Map<String, Object> line) {
this.instance = instance;
this.line = line;
}
}
private ApprovalExportResult write(User user, int rowCount, String filename,
WorkbookFiller filler) throws IOException {
String path = config.getUploadPath() + filename;
File target = new File(path);
try {
File parent = target.getParentFile();
if (parent != null) {
Files.createDirectories(parent.toPath());
}
SXSSFWorkbook workbook = new SXSSFWorkbook(100);
try {
filler.fill(workbook);
try (OutputStream output = Files.newOutputStream(target.toPath())) {
workbook.write(output);
}
} finally {
workbook.dispose();
workbook.close();
}
} 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/download/" + filename);
record.setDownloadUrl(record.getUrl());
fileService.save(record);
return new ApprovalExportResult(record, rowCount);
}
private void fillSummarySheet(SXSSFWorkbook workbook, List<ApprovalExportRow> rows) {
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<>();
// 批量取实例,避免逐行往返(5000 行导出时 N+1 会拖到分钟级)
Map<Long, ApprovalInstance> instances = new HashMap<>();
List<Long> instanceIds = new ArrayList<>();
for (ApprovalExportRow row : rows) {
instanceIds.add(row.getId());
}
if (!instanceIds.isEmpty()) {
for (ApprovalInstance instance : instanceMapper.selectBatchIds(instanceIds)) {
instances.put(instance.getId(), instance);
}
}
Map<String, int[]> lineCountCache = new HashMap<>();
Map<String, BigDecimal> amountCache = new HashMap<>();
Map<String, String> progressCache = new HashMap<>();
int rowNum = 1;
for (ApprovalExportRow row : rows) {
ApprovalInstance instance = instances.get(row.getId());
ApprovalDocumentType type = instance == null ? null
: documentRegistry.find(instance.getFlowCode());
String cacheKey = type == null ? "" : type.flowCode() + ":" + row.getId();
int lineCount = type == null ? 0
: lineCountCache.computeIfAbsent(cacheKey, k -> new int[]{documentRegistry.lineCount(type, instance)})[0];
BigDecimal amountTotal = type == null ? null : amountCache.computeIfAbsent(cacheKey,
k -> documentRegistry.lineAmountTotal(type, instance));
String progress = instance == null ? "" : progressCache.computeIfAbsent(cacheKey,
k -> countersignProgress(instance));
SXSSFRow dataRow = sheet.createRow(rowNum++);
String[] values = toValues(row, nodeCache, lineCount, amountTotal, progress);
for (int i = 0; i < values.length; i++) {
dataRow.createCell(i).setCellValue(values[i]);
}
}
}
private void fillLineSheet(SXSSFWorkbook workbook, ApprovalDocumentType type,
List<ApprovalLineSpec> columns, List<ExportLine> lines) {
SXSSFSheet sheet = workbook.createSheet(type.flowName());
List<String> headers = new ArrayList<>();
headers.add("单号");
headers.add("状态");
headers.add("报送部门");
headers.add("报送人");
for (ApprovalLineSpec column : columns) {
headers.add(column.getLabel());
}
for (int i = 0; i < headers.size(); i++) {
sheet.setColumnWidth(i, 18 * 256);
}
SXSSFRow header = sheet.createRow(0);
for (int i = 0; i < headers.size(); i++) {
header.createCell(i).setCellValue(headers.get(i));
}
int rowNum = 1;
for (ExportLine exportLine : lines) {
SXSSFRow dataRow = sheet.createRow(rowNum++);
int col = 0;
dataRow.createCell(col++).setCellValue(text(exportLine.instance.getBizNo()));
dataRow.createCell(col++).setCellValue(text(ApprovalConstants.statusName(exportLine.instance.getStatus())));
dataRow.createCell(col++).setCellValue(text(exportLine.instance.getReportOrgName()));
dataRow.createCell(col++).setCellValue(text(exportLine.instance.getReporterName()));
for (ApprovalLineSpec column : columns) {
dataRow.createCell(col++).setCellValue(lineValue(exportLine.line.get(column.getKey())));
}
}
}
/**
* 会签进度列:仅会签节点有组时填「已签 N/M」,其余为空。
*/
private String countersignProgress(ApprovalInstance instance) {
List<ApprovalNode> nodes = flowService.parseNodes(instance.getDefinitionId());
List<String> parts = new ArrayList<>();
for (ApprovalNode node : nodes) {
if (node.getGroups() == null || node.getGroups().isEmpty()) {
continue;
}
// 用详情装配同一套进度,避免导出与详情口径分叉
com.gxwebsoft.approval.vo.ApprovalDetail detail = new com.gxwebsoft.approval.vo.ApprovalDetail();
documentService.fillGroupProgress(instance, detail);
List<com.gxwebsoft.approval.vo.ApprovalGroupProgressVO> progress = detail.getGroupProgress();
int signedGroups = 0;
int totalGroups = 0;
for (com.gxwebsoft.approval.vo.ApprovalGroupProgressVO group : progress) {
if (!node.getKey().equals(group.getNodeKey())) {
continue;
}
totalGroups++;
if (group.getSigned() >= group.getTotal() && group.getTotal() > 0) {
signedGroups++;
}
}
if (totalGroups > 0) {
parts.add(node.getName() + " 已签 " + signedGroups + "/" + totalGroups);
}
}
return String.join(";", parts);
}
private String[] toValues(ApprovalExportRow row, Map<Long, List<ApprovalNode>> nodeCache,
int lineCount, BigDecimal amountTotal, String progress) {
boolean completion = ApprovalConstants.DOC_COMPLETION.equals(row.getDocType());
String flowName = flowService.flowName(row.getDefinitionId(), row.getFlowCode());
return new String[]{
text(row.getBizNo()),
text(flowName == null ? ApprovalConstants.docTypeName(row.getDocType()) : flowName),
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()),
lineCount > 0 ? String.valueOf(lineCount) : "",
amountTotal == null ? "" : fee(amountTotal),
text(progress)
};
}
private String lineValue(Object value) {
if (value == null) {
return "";
}
if (value instanceof BigDecimal) {
return ((BigDecimal) value).stripTrailingZeros().toPlainString();
}
if (value instanceof LocalDateTime) {
return TIME_FORMAT.format((LocalDateTime) value);
}
if (value instanceof java.time.LocalDate) {
return value.toString();
}
if (value instanceof Boolean) {
return (Boolean) value ? "是" : "否";
}
return String.valueOf(value);
}
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;
}
private boolean hasAuthority(User user, String authority) {
if (user.getAuthorities() == null) {
return false;
}
return user.getAuthorities().stream()
.map(Menu::getAuthority)
.anyMatch(authority::equals);
}
/** 工作簿填充回调。 */
private interface WorkbookFiller {
void fill(SXSSFWorkbook workbook);
}
@Resource
private ApprovalDocumentService documentService;
}