From 247440288b6cea2574681dcaed19faba38518044 Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Sun, 27 Sep 2026 23:16:17 +0800 Subject: [PATCH] =?UTF-8?q?:bug:=20=E4=BF=AE=E5=A4=8D=E8=BF=90=E5=8D=95?= =?UTF-8?q?=E8=AE=A1=E5=88=92-1=E5=8D=A0=E4=BD=8D=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E5=AF=B9=E8=B4=A6=E5=AF=BC=E5=85=A5=E5=A4=B1=E8=B4=A5=E6=98=8E?= =?UTF-8?q?=E7=BB=86=E6=8C=89=E5=8E=9F=E8=A1=A8=E5=85=A8=E9=87=8F=E5=88=97?= =?UTF-8?q?=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../common/excel/ImportFailureExcelUtil.java | 183 +++++++++++++++--- .../TransportReconciliationController.java | 10 +- .../impl/WaybillImportBatchServiceImpl.java | 20 +- .../service/impl/WaybillServiceImpl.java | 7 + .../transport/wrapper/WaybillWrapper.java | 7 + 5 files changed, 196 insertions(+), 31 deletions(-) diff --git a/blade-common/src/main/java/org/springblade/common/excel/ImportFailureExcelUtil.java b/blade-common/src/main/java/org/springblade/common/excel/ImportFailureExcelUtil.java index 955cc989..0fc7d2f1 100644 --- a/blade-common/src/main/java/org/springblade/common/excel/ImportFailureExcelUtil.java +++ b/blade-common/src/main/java/org/springblade/common/excel/ImportFailureExcelUtil.java @@ -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 { } /** - * 以「原始单元格文本」方式重读导入文件,得到的行用于构造读取阶段的失败明细。 + * 以「原始单元格文本」方式重读导入文件,得到的行用于构造失败明细。 + *

+ * 优先使用 Excel 文件真实表头(含动态费用列等模型类未声明的列),保证失败明细 + * 与原导入模板列一致,用户修正后可直接用该文件重新导入。 *

* 用途:当 {@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> readRawRows(java.io.InputStream file, Class excelClass) { List> rawRows = new ArrayList<>(); + Map headerByIndex = new LinkedHashMap<>(); try { cn.idev.excel.read.builder.ExcelReaderBuilder builder = cn.idev.excel.FastExcel .read(file, new cn.idev.excel.event.AnalysisEventListener>() { + @Override + public void invokeHeadMap(Map 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 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 fields = excelClass == null ? List.of() : excelFields(excelClass); List> rows = new ArrayList<>(); for (Map rawRow : rawRows) { - Map row = new java.util.LinkedHashMap<>(); - for (Map.Entry 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 row = new LinkedHashMap<>(); + Set columnIndexes = new LinkedHashSet<>(headerByIndex.keySet()); + if (rawRow != null) { + columnIndexes.addAll(rawRow.keySet()); + } + List 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; } + /** + * 按原上传文件真实表头导出失败明细(含动态列),并追加「导入失败原因」。 + *

+ * 适用于运输对账等「模板列 = 固定列 + 动态费用列」场景:按实体字段反射导出 + * 会丢掉动态列,用户无法用失败文件修正后重新导入。本方法重读原文件全量列, + * 再按 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> 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 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); + } + /** * 导出「读取阶段失败」明细:原表全量行 + 末尾「导入失败原因」列。 *

@@ -259,24 +337,20 @@ public class ImportFailureExcelUtil { } List> rows = new ArrayList<>(); Map> redColumnsByRow = new HashMap<>(); + List> 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 rawRow = rawRows.get(rowIndex); List row = new ArrayList<>(); - Set 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 headerKeywords(String header) { + if (header == null || header.isBlank()) { + return List.of(); + } + List 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 resolveRawRedColumns(String failureMessage, List> headerKeywords, + int failureReasonColumnIndex) { + Set redColumns = new LinkedHashSet<>(); + redColumns.add(failureReasonColumnIndex); + String normalizedReason = normalize(failureMessage); + if (normalizedReason.isEmpty()) { + return redColumns; + } + List 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 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; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/TransportReconciliationController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/TransportReconciliationController.java index 97242618..70fa983e 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/TransportReconciliationController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/TransportReconciliationController.java @@ -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) { // 读取阶段异常兜底:单元格内容无法转换(如金额列填「一万二千元」)时, diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java index 4754a874..b1eb6ab9 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java @@ -406,11 +406,22 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl 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 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())); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/WaybillWrapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/WaybillWrapper.java index c7db3b41..65401f97 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/WaybillWrapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/WaybillWrapper.java @@ -72,6 +72,13 @@ public class WaybillWrapper extends BaseEntityWrapper { 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; }