🐛 修复三处导入校验缺陷与失败明细行号错位

对全仓 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 项全绿,证明行号修正未破坏既有断言。
This commit is contained in:
2026-09-26 12:45:17 +08:00
parent 9bf7fdfe99
commit 4ccc28e991
7 changed files with 149 additions and 24 deletions
@@ -0,0 +1,93 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.common.excel;
import org.springblade.core.log.exception.ServiceException;
import java.io.Serial;
/**
* 单元格读取失败异常 —— 用于「异常不是 fastexcel 抛出、但同样发生在读取阶段」的场景。
* <p>
* fastexcel 自身解析失败时抛 {@code ExcelDataConvertException},位置信息齐全,
* {@link ImportFailureExcelUtil#exportReadFailure} 可以直接定位到行与列。但有些模块
* 在读取阶段有自己的解析逻辑(例如运输对账的动态费用列:模板列不固定,由
* {@code VehicleReconciliationFeeReader} 自行按表头解析),这些代码如果只是
* {@code catch (NumberFormatException)} 后返回 {@code BigDecimal.ZERO},
* 脏数据就会静默入库(费用 0.00 还会参与结算金额与自动匹配)。
* <p>
* 正确做法是改抛本异常,把位置信息补齐,让导出工具能把原因挂到正确的行、标红正确的列。
* <p>
* 本类位于 blade-common,供 blade-system 与 blade-transport 等模块共用。
*
* @author Chill
*/
public class ImportCellReadException extends ServiceException {
@Serial
private static final long serialVersionUID = 1L;
/**
* 出错的单元格所在的 Excel 行号,<b>绝对 0 基</b>(表头占 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;
}
}
@@ -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 基,只对数据行计数),供挂失败原因使用。
* <p>
* 无论哪种异常,源信息都是<b>绝对 0 基行号</b>(表头占 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)。
* <p>
* 两条来源统一到这里: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 = "导入失败原因";