🐛 修复运单计划-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;
@@ -140,8 +140,9 @@ public class TransportReconciliationController extends BladeController {
for (int index = 0; index < rows.size() && index < feeItems.size(); index++) rows.get(index).setFeeItems(feeItems.get(index));
reconciliationService.importVehicles(id, rows);
} catch (org.springblade.common.excel.ImportFailureException exception) {
// 任一行失败即整批回滚,导出原表全部数据并标注错误(含按列标红)。
ImportFailureExcelUtil.export(response, "运输对账导入失败明细" + DateUtil.time(), "导入失败明细", exception.getFailureList(), VehicleReconciliationExcel.class);
// 按原表真实表头导出全量列(含动态费用),修正后可直接重新导入
ImportFailureExcelUtil.exportOriginalWithFailures(response, "运输对账导入失败明细" + DateUtil.time(),
"导入失败明细", file, exception.getFailureList(), VehicleReconciliationExcel.class);
return null;
} catch (Exception readException) {
// 读取阶段异常兜底:单元格内容无法转换(如金额列填「一万二千元」)时,
@@ -173,8 +174,9 @@ public class TransportReconciliationController extends BladeController {
for (int index = 0; index < rows.size() && index < feeItems.size(); index++) rows.get(index).setFeeItems(feeItems.get(index));
reconciliationService.importCargoes(id, rows);
} catch (org.springblade.common.excel.ImportFailureException exception) {
// 任一行失败即整批回滚,导出原表全部数据并标注错误(含按列标红)。
ImportFailureExcelUtil.export(response, "运输对账导入失败明细" + DateUtil.time(), "导入失败明细", exception.getFailureList(), CargoReconciliationExcel.class);
// 按原表真实表头导出全量列(含动态费用),修正后可直接重新导入
ImportFailureExcelUtil.exportOriginalWithFailures(response, "运输对账导入失败明细" + DateUtil.time(),
"导入失败明细", file, exception.getFailureList(), CargoReconciliationExcel.class);
return null;
} catch (Exception readException) {
// 读取阶段异常兜底:单元格内容无法转换(如金额列填「一万二千元」)时,
@@ -406,11 +406,22 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
}
batch.setProjectName(project.getProjectName());
if (Func.isEmpty(batch.getCustomerName())) batch.setCustomerName(project.getCustomerNames());
TransportPlan plan = Func.isEmpty(request.getPlanId()) ? null : transportPlanService.getById(request.getPlanId());
if (plan != null) batch.setPlanName(plan.getPlanName());
// BladeX 数字 null 序列化为 -1;未选计划时不得落库占位值
Long planId = normalizeOptionalId(request.getPlanId());
batch.setPlanId(planId);
TransportPlan plan = planId == null ? null : transportPlanService.getById(planId);
if (plan != null) {
batch.setPlanName(plan.getPlanName());
} else {
batch.setPlanName(null);
}
return batch;
}
private Long normalizeOptionalId(Long id) {
return id == null || id <= 0L ? null : id;
}
private Waybill buildWaybill(Map<String, Object> row, WaybillImportBatch batch, Long carrierContractId, boolean draft) {
if (row == null) throw new ServiceException("运单数据不正确");
Waybill waybill = new Waybill();
@@ -461,8 +472,9 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
waybill.setCarrierId(firstCarrierId(batch.getCarrierIds()));
waybill.setCarrierName(batch.getCarrierName());
waybill.setCarrierContractId(carrierContractId);
waybill.setPlanId(batch.getPlanId());
waybill.setPlanName(batch.getPlanName());
Long planId = normalizeOptionalId(batch.getPlanId());
waybill.setPlanId(planId);
waybill.setPlanName(planId == null ? null : batch.getPlanName());
waybill.setImportBatchId(batch.getId());
waybill.setBatchNo(batch.getBatchNo());
waybill.setDataSource("批量导入");
@@ -1418,6 +1418,13 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
waybill.setBusinessStatus(TransportBusinessSupport.trimToNull(waybill.getBusinessStatus()));
waybill.setDataSource(TransportBusinessSupport.normalizeWaybillDataSource(waybill.getDataSource()));
waybill.setPlanName(TransportBusinessSupport.trimToNull(waybill.getPlanName()));
// BladeX 数字 null 序列化为 -1;未选计划时归一为空,避免占位值落库/回显
if (waybill.getPlanId() != null && waybill.getPlanId() <= 0L) {
waybill.setPlanId(null);
}
if (waybill.getPlanId() == null || "-1".equals(waybill.getPlanName())) {
waybill.setPlanName(null);
}
waybill.setMasterNo(TransportBusinessSupport.trimToNull(waybill.getMasterNo()));
waybill.setLoadingNo(TransportBusinessSupport.trimToNull(waybill.getLoadingNo()));
waybill.setLoadingIdentifier(TransportBusinessSupport.trimToNull(waybill.getLoadingIdentifier()));
@@ -72,6 +72,13 @@ public class WaybillWrapper extends BaseEntityWrapper<Waybill, WaybillVO> {
if (isSentinelMinusOne(waybillVO.getUnitPrice())) {
waybillVO.setUnitPrice(null);
}
// 计划未选时 planId 可能为库内 -1 或序列化占位,统一清空避免前端回显 -1
if (waybillVO.getPlanId() != null && waybillVO.getPlanId() <= 0L) {
waybillVO.setPlanId(null);
}
if (waybillVO.getPlanId() == null || "-1".equals(waybillVO.getPlanName())) {
waybillVO.setPlanName(null);
}
return waybillVO;
}