🐛 修复运单计划-1占位,并对账导入失败明细按原表全量列导出

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-27 23:16:17 +08:00
parent eef9d7ad9d
commit 247440288b
5 changed files with 196 additions and 31 deletions
@@ -45,7 +45,9 @@ import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
@@ -174,7 +176,10 @@ public class ImportFailureExcelUtil {
}
/**
* 以「原始单元格文本」方式重读导入文件,得到的行用于构造读取阶段的失败明细。
* 以「原始单元格文本」方式重读导入文件,得到的行用于构造失败明细。
* <p>
* 优先使用 Excel 文件真实表头(含动态费用列等模型类未声明的列),保证失败明细
* 与原导入模板列一致,用户修正后可直接用该文件重新导入。
* <p>
* 用途:当 {@code ExcelUtil.read(file, XxxExcel.class)} 因单元格无法转换而失败时,
* 已解析的实体列表是不完整的(异常中断了整批读取)。此时用本方法重读一次:
@@ -182,18 +187,36 @@ public class ImportFailureExcelUtil {
* 供导出「原表全量 + 仅出错行带原因」的失败明细。
*
* @param file 上传的 Excel 文件
* @param excelClass 导入模型类(按列序号反查表头名,用于把列序号映射成列名)
* @param excelClass 导入模型类(表头缺失时按列序号反查表头名)
* @return 按行组织的明细数据:每行是「表头名 → 单元格文本」的有序 Map;
* 读不出内容时返回空列表(调用方按空明细处理)
*/
public static List<Map<String, String>> readRawRows(java.io.InputStream file, Class<?> excelClass) {
List<Map<Integer, String>> rawRows = new ArrayList<>();
Map<Integer, String> headerByIndex = new LinkedHashMap<>();
try {
cn.idev.excel.read.builder.ExcelReaderBuilder builder = cn.idev.excel.FastExcel
.read(file, new cn.idev.excel.event.AnalysisEventListener<Map<Integer, String>>() {
@Override
public void invokeHeadMap(Map<Integer, String> headMap, cn.idev.excel.context.AnalysisContext context) {
if (headMap == null) {
return;
}
headMap.forEach((index, header) -> {
if (index == null) {
return;
}
String name = header == null ? "" : header.trim();
// 历史失败明细再导入时忽略旧的失败原因列,导出时重新追加
if (!name.isEmpty() && !FAILURE_REASON.equals(name)) {
headerByIndex.put(index, name);
}
});
}
@Override
public void invoke(Map<Integer, String> data, cn.idev.excel.context.AnalysisContext context) {
rawRows.add(new java.util.LinkedHashMap<>(data));
rawRows.add(data == null ? new LinkedHashMap<>() : new LinkedHashMap<>(data));
}
@Override
@@ -206,25 +229,80 @@ public class ImportFailureExcelUtil {
// 重读本身失败时退回空明细,由调用方按「无法读取」提示
return new ArrayList<>();
}
List<Field> fields = excelClass == null ? List.of() : excelFields(excelClass);
List<Map<String, String>> rows = new ArrayList<>();
for (Map<Integer, String> rawRow : rawRows) {
Map<String, String> row = new java.util.LinkedHashMap<>();
for (Map.Entry<Integer, String> entry : rawRow.entrySet()) {
Integer columnIndex = entry.getKey();
String columnName = columnIndex != null && columnIndex < fields.size()
? columnName(excelClass, columnIndex)
: null;
if (columnName == null) {
columnName = "第" + (columnIndex == null ? "?" : columnIndex + 1) + "列";
Map<String, String> row = new LinkedHashMap<>();
Set<Integer> columnIndexes = new LinkedHashSet<>(headerByIndex.keySet());
if (rawRow != null) {
columnIndexes.addAll(rawRow.keySet());
}
List<Integer> sortedIndexes = new ArrayList<>(columnIndexes);
Collections.sort(sortedIndexes);
for (Integer columnIndex : sortedIndexes) {
if (columnIndex == null) {
continue;
}
row.put(columnName, entry.getValue());
String columnName = headerByIndex.get(columnIndex);
if (columnName == null || columnName.isBlank()) {
columnName = columnName(excelClass, columnIndex);
}
if (columnName == null || columnName.isBlank()) {
columnName = "第" + (columnIndex + 1) + "列";
}
if (FAILURE_REASON.equals(columnName)) {
continue;
}
row.put(columnName, rawRow == null ? null : rawRow.get(columnIndex));
}
rows.add(row);
}
return rows;
}
/**
* 按原上传文件真实表头导出失败明细(含动态列),并追加「导入失败原因」。
* <p>
* 适用于运输对账等「模板列 = 固定列 + 动态费用列」场景:按实体字段反射导出
* 会丢掉动态列,用户无法用失败文件修正后重新导入。本方法重读原文件全量列,
* 再按 failureList 行序挂上失败原因。
*
* @param response 响应
* @param fileName 文件名
* @param sheetName 工作表名
* @param file 原导入文件
* @param failureList 校验失败列表(与原表行序一致,成功行 errorMessage 可为空)
* @param excelClass 导入模型类(原文件重读失败时回退到按实体导出)
*/
public static void exportOriginalWithFailures(HttpServletResponse response, String fileName, String sheetName,
org.springframework.web.multipart.MultipartFile file,
List<?> failureList, Class<?> excelClass) {
List<Map<String, String>> rawRows = new ArrayList<>();
if (file != null && !file.isEmpty()) {
try (java.io.InputStream inputStream = file.getInputStream()) {
rawRows = readRawRows(inputStream, excelClass);
} catch (IOException ignored) {
rawRows = new ArrayList<>();
}
}
if (rawRows.isEmpty()) {
// 原文件读不出时回退实体导出,至少保证失败原因可下载
if (failureList != null && !failureList.isEmpty()) {
export(response, fileName, sheetName, failureList, excelClass);
}
return;
}
Map<Integer, String> failureMessageByRow = new HashMap<>();
if (failureList != null) {
for (int index = 0; index < failureList.size(); index++) {
Object message = errorMessage(failureList.get(index));
if (message != null && !String.valueOf(message).isBlank()) {
failureMessageByRow.put(index, String.valueOf(message));
}
}
}
exportRawFailure(response, fileName, sheetName, rawRows, failureMessageByRow);
}
/**
* 导出「读取阶段失败」明细:原表全量行 + 末尾「导入失败原因」列。
* <p>
@@ -259,24 +337,20 @@ public class ImportFailureExcelUtil {
}
List<List<Object>> rows = new ArrayList<>();
Map<Integer, Set<Integer>> redColumnsByRow = new HashMap<>();
List<List<String>> headerKeywords = new ArrayList<>();
for (int columnIndex = 0; columnIndex < headers.size() - 1; columnIndex++) {
headerKeywords.add(headerKeywords(headers.get(columnIndex)));
}
for (int rowIndex = 0; rowIndex < rawRows.size(); rowIndex++) {
Map<String, String> rawRow = rawRows.get(rowIndex);
List<Object> row = new ArrayList<>();
Set<Integer> redColumns = new LinkedHashSet<>();
// 失败原因列恒标红
redColumns.add(headers.size() - 1);
String failureMessage = failureMessageByRow == null ? null : failureMessageByRow.get(rowIndex);
for (int columnIndex = 0; columnIndex < headers.size() - 1; columnIndex++) {
String header = headers.get(columnIndex);
row.add(rawRow.get(header));
// 失败原因里出现该列名时,标红该列单元格
if (failureMessage != null && !failureMessage.isBlank() && failureMessage.contains("「" + header + "」")) {
redColumns.add(columnIndex);
}
row.add(rawRow.get(headers.get(columnIndex)));
}
row.add(failureMessage == null ? "" : failureMessage);
rows.add(row);
redColumnsByRow.put(rowIndex, redColumns);
redColumnsByRow.put(rowIndex, resolveRawRedColumns(failureMessage, headerKeywords, headers.size() - 1));
}
try {
FastExcel.write(response.getOutputStream())
@@ -612,6 +686,69 @@ public class ImportFailureExcelUtil {
.toList();
}
/**
* 按真实表头生成定位关键词(用于原表失败明细标红)
*/
private static List<String> headerKeywords(String header) {
if (header == null || header.isBlank()) {
return List.of();
}
List<String> keywords = new ArrayList<>();
keywords.add(header);
keywords.addAll(Arrays.asList(header.replace("*", "").split("[//、()()\\s]+")));
return keywords.stream()
.map(ImportFailureExcelUtil::normalize)
.filter(keyword -> keyword.length() >= 2)
.distinct()
.toList();
}
/**
* 按失败原因命中表头关键词,计算需要标红的列(含失败原因列)
*/
private static Set<Integer> resolveRawRedColumns(String failureMessage, List<List<String>> headerKeywords,
int failureReasonColumnIndex) {
Set<Integer> redColumns = new LinkedHashSet<>();
redColumns.add(failureReasonColumnIndex);
String normalizedReason = normalize(failureMessage);
if (normalizedReason.isEmpty()) {
return redColumns;
}
List<int[]> matches = new ArrayList<>();
for (int column = 0; column < headerKeywords.size(); column++) {
for (String keyword : headerKeywords.get(column)) {
int fromIndex = 0;
while (true) {
int index = normalizedReason.indexOf(keyword, fromIndex);
if (index < 0) {
break;
}
matches.add(new int[]{index, index + keyword.length(), column});
fromIndex = index + 1;
}
}
}
for (int[] match : matches) {
if (!isRawMatchSubsumed(match, matches)) {
redColumns.add(match[2]);
}
}
return redColumns;
}
private static boolean isRawMatchSubsumed(int[] match, List<int[]> matches) {
int matchLength = match[1] - match[0];
for (int[] other : matches) {
if (other[2] == match[2]) {
continue;
}
if ((other[1] - other[0]) > matchLength && other[0] <= match[0] && other[1] >= match[1]) {
return true;
}
}
return false;
}
private static class ImportFailureCellStyleHandler implements CellWriteHandler {
private final int failureReasonColumnIndex;