🐛 修复港口码头批量导入的一二级顺序与全量回滚问题
对应 BUG.xlsx 序号 1 与序号 2。 序号 1(同表混装一二级数据): - 改为两阶段导入,先落库港口(一级)再落库码头(二级), 消除对 Excel 行序的依赖;码头优先引用本批次新增的港口 序号 2(全失败即整批回滚): - 逐行收集错误,只要有一行失败就抛异常触发整批回滚,不再部分成功 - 失败明细按【原表全部行】返回,未出错的行不标注,用户只需改错行 附带修复: - 父港口找不到时改抛专用 ParentPortNotFoundException: 上级港口声明在本文件里、但那一行自身校验失败时,不再连带给码头行报错, 避免用户看到"两行都错"的假象;父港口确实漏填时仍照常报错 - 批内重复编码在落库前预检并定位到行,两行都标注原因 - 父港口区域信息为空(历史数据)时保留码头自身填写的值, 此前会被覆盖成空值导致码头无法导入
This commit is contained in:
+14
-3
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出港口码头主数据
|
||||
*/
|
||||
|
||||
+60
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
+236
-11
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user