🐛 修复三处导入校验缺陷与失败明细行号错位
对全仓 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:
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
+27
-4
@@ -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 = "导入失败原因";
|
||||
|
||||
+8
-3
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ public class CommonCargoExcel implements Serializable {
|
||||
@ExcelProperty("规格")
|
||||
private String specification;
|
||||
|
||||
@ExcelProperty("型号")
|
||||
@ExcelProperty("*型号")
|
||||
private String model;
|
||||
|
||||
@ExcelProperty("单价")
|
||||
|
||||
+8
-3
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
-10
@@ -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字");
|
||||
|
||||
+5
-3
@@ -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. 校验计量单位(如果有值)
|
||||
|
||||
Reference in New Issue
Block a user