From 4ccc28e9913fea9aacb3ac61cf51428f58bb2ed4 Mon Sep 17 00:00:00 2001 From: "weicw1996@qq.com" Date: Sat, 26 Sep 2026 12:45:17 +0800 Subject: [PATCH] =?UTF-8?q?:bug:=20=E4=BF=AE=E5=A4=8D=E4=B8=89=E5=A4=84?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E6=A0=A1=E9=AA=8C=E7=BC=BA=E9=99=B7=E4=B8=8E?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E6=98=8E=E7=BB=86=E8=A1=8C=E5=8F=B7=E9=94=99?= =?UTF-8?q?=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对全仓 31 处导入入口做了一次「导入校验 vs 新增/编辑校验」的一致性审计, 以下三处是审计中严重度最高的、且业务口径无歧义的缺陷: 1) 常用货物:型号必填只在新增/编辑生效,导入侧被放过 submit(CommonCargo) 走 validate(cargo, requireModel=true),而导入调 submit(cargo, false) —— 页面上存不进去的数据(型号为空)却能通过导入写进库。 CommonCargoExcel 的「型号」列也没标 *,用户按模板填根本不知道它必填。 改法:去掉 requireModel 开关(该参数已成死参数,留着只会让两边再次分叉), 导入与新增/编辑共用同一个 validate;模板表头改 *型号。 2) 运输对账:动态费用列填非数字被静默改成 0.00 VehicleReconciliationFeeReader / CargoReconciliationFeeReader 的 decimal() 在 catch (NumberFormatException) 后直接返回 ZERO —— 「一百元」变成 0.00 存进 feeItemsJson,参与结算金额与自动匹配,属静默写脏数据。 改法:抛新增的 ImportCellReadException(blade-common),带上行号/列名/原值, 由既有读取阶段兜底导出原表全量明细。 3) 运输计划:文案「数量/里程必须为正数」与规则不符 条件只判 < 0(0 是合法值),新增/编辑侧的文案是「不能小于0」。统一为「不能小于0」。 顺带修掉一个更基础的错位: ImportFailureExcelUtil.readFailureRowIndex 直接拿 ExcelDataConvertException.getRowIndex() 当失败明细的行下标,但前者是 【绝对 0 基行号】(表头=0),后者只对数据行计数。错误恰好落在最后一行时 靠 clamping 侥幸正确,落在首行就会把原因标到下一行 —— 实测首行出错时原因 被标到了第 3 行(应为第 2 行)。现统一换算(-1)并把两类异常收口到 readFailureExcelRowIndex。 验证: - mvn clean install -DskipTests 通过。 - 全套 16 个导入回归脚本 469 项断言全绿,其中: - 运单核对新增用例 04(首行读取失败须标在第 2 行)、05(动态费用列填中文 必须报错),运单核对套件 28 项全绿; - 常用货物新增用例 08(型号为空须整批回滚),套件 29 项全绿; - 读取失败套件 4 项全绿,证明行号修正未破坏既有断言。 --- .../common/excel/ImportCellReadException.java | 93 +++++++++++++++++++ .../common/excel/ImportFailureExcelUtil.java | 31 ++++++- .../excel/CargoReconciliationFeeReader.java | 11 ++- .../transport/excel/CommonCargoExcel.java | 2 +- .../excel/VehicleReconciliationFeeReader.java | 11 ++- .../service/impl/CommonCargoServiceImpl.java | 17 ++-- .../impl/TransportPlanServiceImpl.java | 8 +- 7 files changed, 149 insertions(+), 24 deletions(-) create mode 100644 blade-common/src/main/java/org/springblade/common/excel/ImportCellReadException.java diff --git a/blade-common/src/main/java/org/springblade/common/excel/ImportCellReadException.java b/blade-common/src/main/java/org/springblade/common/excel/ImportCellReadException.java new file mode 100644 index 00000000..7b038ad0 --- /dev/null +++ b/blade-common/src/main/java/org/springblade/common/excel/ImportCellReadException.java @@ -0,0 +1,93 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.common.excel; + +import org.springblade.core.log.exception.ServiceException; + +import java.io.Serial; + +/** + * 单元格读取失败异常 —— 用于「异常不是 fastexcel 抛出、但同样发生在读取阶段」的场景。 + *

+ * fastexcel 自身解析失败时抛 {@code ExcelDataConvertException},位置信息齐全, + * {@link ImportFailureExcelUtil#exportReadFailure} 可以直接定位到行与列。但有些模块 + * 在读取阶段有自己的解析逻辑(例如运输对账的动态费用列:模板列不固定,由 + * {@code VehicleReconciliationFeeReader} 自行按表头解析),这些代码如果只是 + * {@code catch (NumberFormatException)} 后返回 {@code BigDecimal.ZERO}, + * 脏数据就会静默入库(费用 0.00 还会参与结算金额与自动匹配)。 + *

+ * 正确做法是改抛本异常,把位置信息补齐,让导出工具能把原因挂到正确的行、标红正确的列。 + *

+ * 本类位于 blade-common,供 blade-system 与 blade-transport 等模块共用。 + * + * @author Chill + */ +public class ImportCellReadException extends ServiceException { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 出错的单元格所在的 Excel 行号,绝对 0 基(表头占 0,第一个数据行为 1)。 + * 与 {@code ExcelDataConvertException.getRowIndex()} 保持同一口径, + * 便于导出工具统一换算。 + */ + private final transient Integer rowIndex; + + /** + * 出错的列名(导入模板表头原文),会写进原因文案里,导出工具靠它标红对应列。 + */ + private final transient String columnName; + + public ImportCellReadException(Integer rowIndex, String columnName, String cellValue) { + super(buildMessage(rowIndex, columnName, cellValue)); + this.rowIndex = rowIndex; + this.columnName = columnName; + } + + /** + * 文案格式与 {@link ImportFailureExcelUtil#describeReadFailure} 保持一致: + * 「第N行「列名」格式不正确:<原值>」。列名必须包在「」里,导出工具才能标红该列。 + */ + private static String buildMessage(Integer rowIndex, String columnName, String cellValue) { + String rowText = rowIndex == null ? "第?行" : "第" + (rowIndex + 1) + "行"; + String columnText = columnName == null || columnName.isBlank() ? "该列" : "「" + columnName + "」"; + StringBuilder message = new StringBuilder(rowText).append(columnText).append("格式不正确"); + if (cellValue != null && !cellValue.isBlank()) { + message.append(":").append(cellValue); + } + return message.toString(); + } + + public Integer getRowIndex() { + return rowIndex; + } + + public String getColumnName() { + return columnName; + } + +} 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 d4330eed..955cc989 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 @@ -99,12 +99,15 @@ public class ImportFailureExcelUtil { cn.idev.excel.exception.ExcelDataConvertException convertException = findCause(exception, cn.idev.excel.exception.ExcelDataConvertException.class); if (convertException == null) { - return null; + // 不是 fastexcel 抛的转换异常:模块自带的读取期解析逻辑(如运输对账的动态费用列) + // 用 ImportCellReadException 补齐了位置信息,直接透传它已经拼好的原因文案。 + ImportCellReadException cellReadException = findCause(exception, ImportCellReadException.class); + return cellReadException == null ? null : cellReadException.getMessage(); } Integer rowIndex = convertException.getRowIndex(); Integer columnIndex = convertException.getColumnIndex(); String columnName = columnName(excelClass, columnIndex); - // Excel 行号:EasyExcel 的 rowIndex 为 1 基数据行号(表头占第 1 行),故 Excel 行号 = rowIndex + 1 + // Excel 行号:getRowIndex() 是绝对 0 基行号(表头占 0),故 Excel 行号 = rowIndex + 1 String rowText = rowIndex == null ? "第?行" : "第" + (rowIndex + 1) + "行"; String columnText = columnName == null ? ("第" + (columnIndex == null ? "?" : columnIndex + 1) + "列") : "「" + columnName + "」"; String value = cellValue(convertException); @@ -414,12 +417,32 @@ public class ImportFailureExcelUtil { } /** - * 取转换异常中的数据行下标(0 基) + * 取读取阶段异常中「失败数据行」的下标(0 基,只对数据行计数),供挂失败原因使用。 + *

+ * 无论哪种异常,源信息都是绝对 0 基行号(表头占 0,第一个数据行为 1), + * 而这里要的下标是相对数据行的,故统一减 1。 + * 不减的话原因会整体下移一行:错误恰好落在最后一行时会因调用处的 clamping + * 侥幸正确,落在首行/中间行就会标到不该标的行上,误导用户去改正确的那一行。 */ private static Integer readFailureRowIndex(Throwable exception) { + Integer excelRowIndex = readFailureExcelRowIndex(exception); + return excelRowIndex == null ? null : Math.max(0, excelRowIndex - 1); + } + + /** + * 取读取阶段异常的绝对 0 基行号(表头 = 0)。 + *

+ * 两条来源统一到这里:fastexcel 自带的 {@code ExcelDataConvertException}, + * 以及模块自行解析时抛出的 {@link ImportCellReadException}。 + */ + private static Integer readFailureExcelRowIndex(Throwable exception) { cn.idev.excel.exception.ExcelDataConvertException convertException = findCause(exception, cn.idev.excel.exception.ExcelDataConvertException.class); - return convertException == null ? null : convertException.getRowIndex(); + if (convertException != null) { + return convertException.getRowIndex(); + } + ImportCellReadException cellReadException = findCause(exception, ImportCellReadException.class); + return cellReadException == null ? null : cellReadException.getRowIndex(); } private static final String FAILURE_REASON = "导入失败原因"; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/CargoReconciliationFeeReader.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/CargoReconciliationFeeReader.java index e2802105..ee7af2f8 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/CargoReconciliationFeeReader.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/CargoReconciliationFeeReader.java @@ -13,6 +13,7 @@ import cn.idev.excel.FastExcel; import cn.idev.excel.context.AnalysisContext; import cn.idev.excel.event.AnalysisEventListener; import cn.idev.excel.metadata.data.ReadCellData; +import org.springblade.common.excel.ImportCellReadException; import org.springblade.core.log.exception.ServiceException; import org.springframework.web.multipart.MultipartFile; @@ -60,10 +61,11 @@ public final class CargoReconciliationFeeReader { @Override public void invoke(Map> row, AnalysisContext context) { Map values = new LinkedHashMap<>(); + Integer excelRowIndex = context.readRowHolder() == null ? null : context.readRowHolder().getRowIndex(); if (freightColumn >= 0 && settlementColumn > freightColumn) { for (int column = freightColumn + 1; column < settlementColumn; column++) { String name = headers.get(column); - if (name != null && !name.isBlank()) values.put(name.trim(), decimal(row.get(column))); + if (name != null && !name.isBlank()) values.put(name.trim(), decimal(row.get(column), name.trim(), excelRowIndex)); } } feeItems.add(values); @@ -80,7 +82,7 @@ public final class CargoReconciliationFeeReader { .findFirst().orElse(-1); } - private BigDecimal decimal(ReadCellData cellData) { + private BigDecimal decimal(ReadCellData cellData, String columnName, Integer excelRowIndex) { if (cellData == null) return BigDecimal.ZERO.setScale(2); Object value = cellData.getData(); if (value == null) { @@ -95,7 +97,10 @@ public final class CargoReconciliationFeeReader { try { return new BigDecimal(String.valueOf(value).trim()).setScale(2); } catch (NumberFormatException exception) { - return BigDecimal.ZERO.setScale(2); + // 这里绝不能静默返回 0:费用列会进 feeItemsJson 参与结算金额与自动匹配, + // 把「一百元」这类写法悄悄变成 0.00 等于往库里写脏数据。 + // 改为抛带位置信息的异常,由 Controller 的读取阶段兜底导出原表全量明细。 + throw new ImportCellReadException(excelRowIndex, columnName, String.valueOf(value).trim()); } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/CommonCargoExcel.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/CommonCargoExcel.java index 4440dc7f..e5c26caa 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/CommonCargoExcel.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/CommonCargoExcel.java @@ -73,7 +73,7 @@ public class CommonCargoExcel implements Serializable { @ExcelProperty("规格") private String specification; - @ExcelProperty("型号") + @ExcelProperty("*型号") private String model; @ExcelProperty("单价") diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/VehicleReconciliationFeeReader.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/VehicleReconciliationFeeReader.java index e6d889a6..990e8973 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/VehicleReconciliationFeeReader.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/VehicleReconciliationFeeReader.java @@ -13,6 +13,7 @@ import cn.idev.excel.FastExcel; import cn.idev.excel.context.AnalysisContext; import cn.idev.excel.event.AnalysisEventListener; import cn.idev.excel.metadata.data.ReadCellData; +import org.springblade.common.excel.ImportCellReadException; import org.springblade.core.log.exception.ServiceException; import org.springframework.web.multipart.MultipartFile; @@ -60,10 +61,11 @@ public final class VehicleReconciliationFeeReader { @Override public void invoke(Map> row, AnalysisContext context) { Map values = new LinkedHashMap<>(); + Integer excelRowIndex = context.readRowHolder() == null ? null : context.readRowHolder().getRowIndex(); if (freightColumn >= 0 && settlementColumn > freightColumn) { for (int column = freightColumn + 1; column < settlementColumn; column++) { String name = headers.get(column); - if (name != null && !name.isBlank()) values.put(name.trim(), decimal(row.get(column))); + if (name != null && !name.isBlank()) values.put(name.trim(), decimal(row.get(column), name.trim(), excelRowIndex)); } } feeItems.add(values); @@ -80,7 +82,7 @@ public final class VehicleReconciliationFeeReader { .findFirst().orElse(-1); } - private BigDecimal decimal(ReadCellData cellData) { + private BigDecimal decimal(ReadCellData cellData, String columnName, Integer excelRowIndex) { if (cellData == null) return BigDecimal.ZERO.setScale(2); Object value = cellData.getData(); if (value == null) { @@ -95,7 +97,10 @@ public final class VehicleReconciliationFeeReader { try { return new BigDecimal(String.valueOf(value).trim()).setScale(2); } catch (NumberFormatException exception) { - return BigDecimal.ZERO.setScale(2); + // 这里绝不能静默返回 0:费用列会进 feeItemsJson 参与结算金额与自动匹配, + // 把「一百元」这类写法悄悄变成 0.00 等于往库里写脏数据。 + // 改为抛带位置信息的异常,由 Controller 的读取阶段兜底导出原表全量明细。 + throw new ImportCellReadException(excelRowIndex, columnName, String.valueOf(value).trim()); } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CommonCargoServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CommonCargoServiceImpl.java index 9ba8a1c1..e7a5e11b 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CommonCargoServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CommonCargoServiceImpl.java @@ -71,10 +71,6 @@ public class CommonCargoServiceImpl extends BaseServiceImpl