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

对全仓 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
@@ -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<Integer, ReadCellData<?>> row, AnalysisContext context) {
Map<String, BigDecimal> 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());
}
}
@@ -73,7 +73,7 @@ public class CommonCargoExcel implements Serializable {
@ExcelProperty("规格")
private String specification;
@ExcelProperty("型号")
@ExcelProperty("*型号")
private String model;
@ExcelProperty("单价")
@@ -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<Integer, ReadCellData<?>> row, AnalysisContext context) {
Map<String, BigDecimal> 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());
}
}
@@ -71,10 +71,6 @@ public class CommonCargoServiceImpl extends BaseServiceImpl<CommonCargoMapper, C
@Override
@Transactional(rollbackFor = Exception.class)
public boolean submit(CommonCargo commonCargo) {
return submit(commonCargo, true);
}
private boolean submit(CommonCargo commonCargo, boolean requireModel) {
boolean created = Func.isEmpty(commonCargo.getId());
if (!created) {
CommonCargo oldRecord = loadEditable(commonCargo.getId(), true);
@@ -82,7 +78,7 @@ public class CommonCargoServiceImpl extends BaseServiceImpl<CommonCargoMapper, C
commonCargo.setDeptName(oldRecord.getDeptName());
}
prepare(commonCargo);
validate(commonCargo, requireModel);
validate(commonCargo);
return saveOrUpdate(commonCargo);
}
@@ -173,7 +169,7 @@ public class CommonCargoServiceImpl extends BaseServiceImpl<CommonCargoMapper, C
commonCargo.setDescriptionOne(TransportBusinessSupport.trimToNull(excel.getDescriptionOne()));
commonCargo.setRemark(TransportBusinessSupport.trimToNull(excel.getRemark()));
commonCargo.setDataSource("批量导入");
submit(commonCargo, false);
submit(commonCargo);
} catch (Exception exception) {
// 除收集到 failureList 外,还必须把原因写回原行对象:
// 抛出异常时携带的是原表 data,导出工具从行对象的 errorMessage 字段取原因,
@@ -315,14 +311,15 @@ public class CommonCargoServiceImpl extends BaseServiceImpl<CommonCargoMapper, C
return cargoValue != null && cargoValue.compareTo(BigDecimal.ONE.negate()) == 0 ? null : cargoValue;
}
private void validate(CommonCargo commonCargo, boolean requireModel) {
private void validate(CommonCargo commonCargo) {
TransportBusinessSupport.validateRequired(commonCargo.getFirstCargoTypeName(), "请选择一级货物类型");
TransportBusinessSupport.validateRequired(commonCargo.getSecondCargoTypeCode(), "请选择二级货物类型");
TransportBusinessSupport.validateRequired(commonCargo.getCargoName(), "请输入货物名称");
TransportBusinessSupport.validateRequired(commonCargo.getCargoCode(), "请输入货物编号");
if (requireModel) {
TransportBusinessSupport.validateRequired(commonCargo.getModel(), "请输入型号");
}
// 型号必填:新增/编辑入口(/submit)一直要求,导入侧此前用 requireModel=false
// 放过了它,导致「页面上存不进去、导入却能写进库」,与根文档
// docs/import-spec.md 的「导入校验必须与新增/编辑一致」冲突,故统一为必填。
TransportBusinessSupport.validateRequired(commonCargo.getModel(), "请输入型号");
TransportBusinessSupport.validateLength(commonCargo.getFirstCargoTypeName(), 100, "一级货物类型不能超过100字");
TransportBusinessSupport.validateLength(commonCargo.getFirstCargoTypeCode(), 100, "一级货物类型编码不能超过100字");
TransportBusinessSupport.validateLength(commonCargo.getSecondCargoTypeName(), 100, "二级货物类型不能超过100字");
@@ -454,12 +454,14 @@ public class TransportPlanServiceImpl extends BaseServiceImpl<TransportPlanMappe
}
}
// 5. 校验数量和里程为正数
// 5. 校验数量和里程非负。
// 文案与规则必须一致:条件只拦 < 0(即 0 是合法值),就不能写「必须为正数」。
// 新增/编辑走 TransportBusinessSupport.validateNonNegative,文案是「不能小于0」。
if (excel.getQuantity() != null && excel.getQuantity().compareTo(BigDecimal.ZERO) < 0) {
errors.add("数量必须为正数");
errors.add("数量不能小于0");
}
if (excel.getMileage() != null && excel.getMileage().compareTo(BigDecimal.ZERO) < 0) {
errors.add("里程必须为正数");
errors.add("里程不能小于0");
}
// 6. 校验计量单位(如果有值)