🐛 修复港口码头批量导入的一二级顺序与全量回滚问题

对应 BUG.xlsx 序号 1 与序号 2。

序号 1(同表混装一二级数据):
- 改为两阶段导入,先落库港口(一级)再落库码头(二级),
  消除对 Excel 行序的依赖;码头优先引用本批次新增的港口

序号 2(全失败即整批回滚):
- 逐行收集错误,只要有一行失败就抛异常触发整批回滚,不再部分成功
- 失败明细按【原表全部行】返回,未出错的行不标注,用户只需改错行

附带修复:
- 父港口找不到时改抛专用 ParentPortNotFoundException:
  上级港口声明在本文件里、但那一行自身校验失败时,不再连带给码头行报错,
  避免用户看到"两行都错"的假象;父港口确实漏填时仍照常报错
- 批内重复编码在落库前预检并定位到行,两行都标注原因
- 父港口区域信息为空(历史数据)时保留码头自身填写的值,
  此前会被覆盖成空值导致码头无法导入
This commit is contained in:
2026-09-20 06:27:33 +08:00
parent 31dbd731dd
commit cc33bc5ef2
3 changed files with 310 additions and 14 deletions
@@ -43,6 +43,8 @@ import org.springblade.core.tenant.annotation.NonDS;
import org.springblade.core.tool.api.R;
import org.springblade.core.tool.utils.DateUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.common.excel.ImportFailureExcelUtil;
import org.springblade.system.excel.ImportFailureException;
import org.springblade.system.excel.PortTerminalExcel;
import org.springblade.system.excel.PortTerminalImporter;
import org.springblade.system.pojo.entity.PortTerminal;
@@ -167,14 +169,23 @@ public class PortTerminalController extends BladeController {
if (!fileName.endsWith(".xls") && !fileName.endsWith(".xlsx")) {
return R.fail("请上传 .xls,.xlsx 标准格式文件");
}
List<PortTerminalExcel> failureList = portTerminalService.importPortTerminal(ExcelUtil.read(file, PortTerminalExcel.class));
if (Func.isNotEmpty(failureList)) {
org.springblade.common.excel.ImportFailureExcelUtil.export(response, "港口码头主数据导入失败明细" + DateUtil.time(), "导入失败明细", failureList, PortTerminalExcel.class);
try {
portTerminalService.importPortTerminal(ExcelUtil.read(file, PortTerminalExcel.class));
} catch (ImportFailureException exception) {
// 全失败即整批回滚,导出原表全部数据并标注错误,用户修正后重新导入。
exportFailure(response, exception.getFailureList());
return null;
}
return R.success("操作成功");
}
/**
* 导出导入失败明细,内容为原表全部数据并在末尾追加失败原因列。
*/
private void exportFailure(HttpServletResponse response, List<?> failureList) {
ImportFailureExcelUtil.export(response, "港口码头主数据导入失败明细" + DateUtil.time(), "导入失败明细", failureList, PortTerminalExcel.class);
}
/**
* 导出港口码头主数据
*/
@@ -0,0 +1,60 @@
/**
* 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.system.excel;
import org.springblade.core.log.exception.ServiceException;
import java.io.Serial;
import java.util.List;
/**
* 导入失败异常,携带失败明细用于导出原表并标注错误。
* <p>
* 批量导入采用「全失败即整批回滚」语义:任一行校验失败都会抛出本异常触发事务回滚,
* 失败明细在抛异常前已收集完毕,因此回滚不影响明细的内容。
*
* @author Chill
*/
public class ImportFailureException extends ServiceException {
@Serial
private static final long serialVersionUID = 1L;
/**
* 失败明细,包含原表全部数据,错误行已标注错误原因。
*/
private final transient List<?> failureList;
public ImportFailureException(List<?> failureList) {
super("导入失败,已回滚全部数据");
this.failureList = failureList;
}
public List<?> getFailureList() {
return failureList;
}
}
@@ -34,6 +34,7 @@ import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.mp.base.BaseServiceImpl;
import org.springblade.core.tool.utils.BeanUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.system.excel.ImportFailureException;
import org.springblade.system.excel.PortTerminalExcel;
import org.springblade.system.mapper.PortTerminalMapper;
import org.springblade.system.pojo.entity.PortTerminal;
@@ -44,12 +45,18 @@ import org.springblade.system.service.IRegionService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.Serial;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.regex.Pattern;
/**
@@ -133,8 +140,88 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
if (Func.isEmpty(data)) {
throw new ServiceException("导入数据不能为空");
}
List<PortTerminalExcel> errorList = new ArrayList<>();
// 全量校验:任何一行失败都整批回滚,因此先收集所有错误再统一抛出。
List<PortTerminalExcel> failureList = new ArrayList<>();
// 批内已占用的编码,用于识别文件内重复数据。
Map<String, Integer> occupiedCodeMap = new HashMap<>();
// 本文件声明的一级数据(港口)编码集合。
// 用于区分两种情况:父港口"漏填"与"父行自身校验失败"。
// 后者说明父行已被标红、用户只需改那一行,因此不再连带给码头行报错。
Set<String> declaredPortCodeSet = new HashSet<>();
// 两阶段导入:先落库一级数据(港口),再落库二级数据(码头),消除对 Excel 行序的依赖。
Map<String, PortTerminal> batchPortMap = new LinkedHashMap<>();
List<Integer> portIndexList = new ArrayList<>();
List<Integer> terminalIndexList = new ArrayList<>();
splitByCategory(data, portIndexList, terminalIndexList, occupiedCodeMap, declaredPortCodeSet, failureList);
importPorts(data, portIndexList, batchPortMap, failureList);
importTerminals(data, terminalIndexList, batchPortMap, declaredPortCodeSet, failureList);
if (Func.isNotEmpty(failureList)) {
// 抛出携带失败明细的异常,触发事务回滚。
// 明细为原表全部行(未出错行仅无错误原因),用户可对照原表修正后重新导入。
throw new ImportFailureException(data);
}
return failureList;
}
/**
* 按类型拆分数据行,并完成与阶段无关的基础校验(类型、编码格式、批内重复)。
*
* @param data 导入数据
* @param portIndexList 港口行下标
* @param terminalIndexList 码头行下标
* @param occupiedCodeMap 编码占用情况,值为首次出现的下标
* @param declaredPortCodeSet 本文件声明的港口编码(含编码格式不合法的行)
* @param failureList 失败明细
*/
private void splitByCategory(List<PortTerminalExcel> data, List<Integer> portIndexList, List<Integer> terminalIndexList,
Map<String, Integer> occupiedCodeMap, Set<String> declaredPortCodeSet,
List<PortTerminalExcel> failureList) {
for (int index = 0; index < data.size(); index++) {
PortTerminalExcel excel = data.get(index);
String category = trimToEmpty(excel.getCategory());
boolean categoryValid = CATEGORY_PORT.equals(category) || CATEGORY_TERMINAL.equals(category);
if (!categoryValid) {
failureList.add(buildFailure(data, index, "类型只能为港口或码头"));
continue;
}
// 只要类型是港口就登记为"本文件已声明",即使它的编码格式不合法:
// 这样引用它的码头行不会被连带报错,用户只需修正这一个港口行。
if (CATEGORY_PORT.equals(category)) {
String declaredCode = trimToEmpty(excel.getCode()).toUpperCase(Locale.ROOT);
if (Func.isNotEmpty(declaredCode)) {
declaredPortCodeSet.add(declaredCode);
}
}
String code = trimToEmpty(excel.getCode()).toUpperCase(Locale.ROOT);
boolean codeValid = CATEGORY_PORT.equals(category)
? PORT_CODE_PATTERN.matcher(code).matches()
: TERMINAL_CODE_PATTERN.matcher(code).matches();
if (!codeValid) {
String message = CATEGORY_PORT.equals(category) ? "港口编码为5位大写字母" : "码头编码格式为港口编码-码头标识";
failureList.add(buildFailure(data, index, message));
continue;
}
// 批内重复:两行都需要标记,由用户决定保留哪一行。
if (occupiedCodeMap.containsKey(code)) {
markFailure(data, occupiedCodeMap.get(code), "编码 " + code + " 在文件中重复出现");
failureList.add(buildFailure(data, index, "编码 " + code + " 在文件中重复出现"));
continue;
}
occupiedCodeMap.put(code, index);
if (CATEGORY_PORT.equals(category)) {
portIndexList.add(index);
} else {
terminalIndexList.add(index);
}
}
}
/**
* 阶段一:导入港口(一级数据),同时登记到批次内存映射,供码头引用。
*/
private void importPorts(List<PortTerminalExcel> data, List<Integer> portIndexList, Map<String, PortTerminal> batchPortMap,
List<PortTerminalExcel> failureList) {
for (Integer index : portIndexList) {
PortTerminalExcel excel = data.get(index);
try {
PortTerminal portTerminal = Objects.requireNonNull(BeanUtil.copyProperties(excel, PortTerminal.class));
@@ -143,13 +230,105 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
prepare(portTerminal, SOURCE_BATCH);
validate(portTerminal);
save(portTerminal);
batchPortMap.put(portTerminal.getCode(), portTerminal);
} catch (Exception exception) {
String message = exception instanceof ServiceException ? exception.getMessage() : "导入失败";
excel.setErrorMessage("" + (index + 2) + "行:" + message);
errorList.add(excel);
failureList.add(buildFailure(data, index, resolveMessage(exception)));
}
}
return errorList;
}
/**
* 阶段二:导入码头(二级数据),上级港口优先取本批次新增的港口,其次回查数据库。
*
* @param declaredPortCodeSet 本文件声明的港口编码,用于避免连带误报
*/
private void importTerminals(List<PortTerminalExcel> data, List<Integer> terminalIndexList, Map<String, PortTerminal> batchPortMap,
Set<String> declaredPortCodeSet, List<PortTerminalExcel> failureList) {
for (Integer index : terminalIndexList) {
PortTerminalExcel excel = data.get(index);
try {
PortTerminal portTerminal = Objects.requireNonNull(BeanUtil.copyProperties(excel, PortTerminal.class));
portTerminal.setDataSource(SOURCE_BATCH);
portTerminal.setStatus(STATUS_ENABLED);
prepareTerminal(portTerminal, batchPortMap);
validate(portTerminal);
save(portTerminal);
} catch (ParentPortNotFoundException exception) {
// 上级港口就声明在本文件里,只是那一行自己校验失败(已被标红)。
// 此时码头行本身没有问题,不再连带报错,避免用户看到"两行都错"的假象。
if (!declaredPortCodeSet.contains(trimToEmpty(excel.getParentCode()).toUpperCase(Locale.ROOT))) {
failureList.add(buildFailure(data, index, resolveMessage(exception)));
}
} catch (Exception exception) {
failureList.add(buildFailure(data, index, resolveMessage(exception)));
}
}
}
/**
* 码头导入:上级港口优先匹配本批次新增的港口,其次由 prepare 回查数据库。
*/
private void prepareTerminal(PortTerminal portTerminal, Map<String, PortTerminal> batchPortMap) {
String parentCode = trimToEmpty(portTerminal.getParentCode()).toUpperCase(Locale.ROOT);
PortTerminal parent = Func.isEmpty(parentCode) ? null : batchPortMap.get(parentCode);
if (Func.isNotEmpty(parent)) {
applyParent(portTerminal, parent);
// 父信息已由本批次港口回填,无需再回查数据库。
prepare(portTerminal, SOURCE_BATCH, true);
return;
}
portTerminal.setParentCode(parentCode);
prepare(portTerminal, SOURCE_BATCH);
}
/**
* 将上级港口信息回填到码头,保证码头不会出现没有港口的数据。
*/
private void applyParent(PortTerminal portTerminal, PortTerminal parent) {
portTerminal.setParentId(parent.getId());
portTerminal.setParentCode(parent.getCode());
portTerminal.setParentName(parent.getName());
// 与回查数据库保持一致:父港口区域信息为空时保留码头自身填写的值。
if (Func.isNotEmpty(parent.getCountry())) {
portTerminal.setCountry(parent.getCountry());
}
if (Func.isNotEmpty(parent.getCity())) {
portTerminal.setCity(parent.getCity());
}
if (Func.isNotEmpty(parent.getDistrictCode())) {
portTerminal.setDistrictCode(parent.getDistrictCode());
portTerminal.setRegionCode(parent.getDistrictCode());
} else if (Func.isNotEmpty(portTerminal.getDistrictCode())) {
portTerminal.setRegionCode(portTerminal.getDistrictCode());
}
if (Func.isNotEmpty(parent.getDistrictName())) {
portTerminal.setDistrictName(parent.getDistrictName());
}
}
/**
* 构造失败明细,行号按 Excel 中的实际行号(表头占第 1 行)推算。
*/
private PortTerminalExcel buildFailure(List<PortTerminalExcel> data, int index, String message) {
PortTerminalExcel excel = data.get(index);
markFailure(data, index, message);
return excel;
}
/**
* 仅标注错误原因,不重复加入失败明细集合。
*/
private void markFailure(List<PortTerminalExcel> data, int index, String message) {
if (index >= 0 && index < data.size()) {
data.get(index).setErrorMessage("" + (index + 2) + "行:" + message);
}
}
/**
* 解析异常信息,非业务异常统一提示导入失败。
*/
private String resolveMessage(Exception exception) {
return exception instanceof ServiceException ? exception.getMessage() : "导入失败";
}
@Override
@@ -174,6 +353,17 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
}
private void prepare(PortTerminal portTerminal, String defaultDataSource) {
prepare(portTerminal, defaultDataSource, false);
}
/**
* 整理并补全港口码头数据。
*
* @param portTerminal 港口码头
* @param defaultDataSource 默认数据来源
* @param parentResolved 上级港口是否已确定(批量导入时由批次内存匹配得到,无需回查数据库)
*/
private void prepare(PortTerminal portTerminal, String defaultDataSource, boolean parentResolved) {
portTerminal.setCode(trimToEmpty(portTerminal.getCode()).toUpperCase(Locale.ROOT));
portTerminal.setCategory(trimToEmpty(portTerminal.getCategory()));
portTerminal.setName(trimToEmpty(portTerminal.getName()));
@@ -198,6 +388,9 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
portTerminal.setParentName(null);
return;
}
if (parentResolved) {
return;
}
fillParentPort(portTerminal);
}
@@ -213,7 +406,9 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
.eq(PortTerminal::getIsDeleted, 0));
}
if (Func.isEmpty(parent)) {
throw new ServiceException("码头必须选择上级港口");
// 用专用异常类型:调用方需要区分"父港口真的漏填"与"父行自身失败"
// 后者不应连带给码头行报错(见 importTerminals)。
throw new ParentPortNotFoundException("码头必须选择上级港口");
}
if (!CATEGORY_PORT.equals(parent.getCategory())) {
throw new ServiceException("上级港口类型不正确");
@@ -221,11 +416,22 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
portTerminal.setParentId(parent.getId());
portTerminal.setParentCode(parent.getCode());
portTerminal.setParentName(parent.getName());
portTerminal.setCountry(parent.getCountry());
portTerminal.setCity(parent.getCity());
portTerminal.setDistrictCode(parent.getDistrictCode());
portTerminal.setDistrictName(parent.getDistrictName());
portTerminal.setRegionCode(parent.getDistrictCode());
// 父港口区域信息为空时保留码头自身填写的值,避免历史数据(区县为空)导致码头无法导入。
if (Func.isNotEmpty(parent.getCountry())) {
portTerminal.setCountry(parent.getCountry());
}
if (Func.isNotEmpty(parent.getCity())) {
portTerminal.setCity(parent.getCity());
}
if (Func.isNotEmpty(parent.getDistrictCode())) {
portTerminal.setDistrictCode(parent.getDistrictCode());
portTerminal.setRegionCode(parent.getDistrictCode());
} else if (Func.isNotEmpty(portTerminal.getRegionCode())) {
portTerminal.setDistrictCode(portTerminal.getRegionCode());
}
if (Func.isNotEmpty(parent.getDistrictName())) {
portTerminal.setDistrictName(parent.getDistrictName());
}
}
private void fillRegion(PortTerminal portTerminal) {
@@ -383,4 +589,23 @@ public class PortTerminalServiceImpl extends BaseServiceImpl<PortTerminalMapper,
return trimValue.isEmpty() ? null : trimValue;
}
/**
* 上级港口找不到时抛出。
* <p>
* 与普通业务异常区分开,是因为调用方需要判断:这个父港口到底是"用户漏填了",
* 还是"父港口那一行就在本文件里、只是它自己校验失败"。后者不该连带给码头行报错。
*
* @author Chill
*/
private static class ParentPortNotFoundException extends ServiceException {
@Serial
private static final long serialVersionUID = 1L;
ParentPortNotFoundException(String message) {
super(message);
}
}
}