diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/controller/PortTerminalController.java b/blade-service/blade-system/src/main/java/org/springblade/system/controller/PortTerminalController.java index a34396b..8d7a0dd 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/controller/PortTerminalController.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/controller/PortTerminalController.java @@ -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 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); + } + /** * 导出港口码头主数据 */ diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/excel/ImportFailureException.java b/blade-service/blade-system/src/main/java/org/springblade/system/excel/ImportFailureException.java new file mode 100644 index 0000000..0277ec0 --- /dev/null +++ b/blade-service/blade-system/src/main/java/org/springblade/system/excel/ImportFailureException.java @@ -0,0 +1,60 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * 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; + +/** + * 导入失败异常,携带失败明细用于导出原表并标注错误。 + *

+ * 批量导入采用「全失败即整批回滚」语义:任一行校验失败都会抛出本异常触发事务回滚, + * 失败明细在抛异常前已收集完毕,因此回滚不影响明细的内容。 + * + * @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; + } + +} diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/PortTerminalServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/PortTerminalServiceImpl.java index 07aaf8a..0551c2d 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/PortTerminalServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/PortTerminalServiceImpl.java @@ -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 errorList = new ArrayList<>(); + // 全量校验:任何一行失败都整批回滚,因此先收集所有错误再统一抛出。 + List failureList = new ArrayList<>(); + // 批内已占用的编码,用于识别文件内重复数据。 + Map occupiedCodeMap = new HashMap<>(); + // 本文件声明的一级数据(港口)编码集合。 + // 用于区分两种情况:父港口"漏填"与"父行自身校验失败"。 + // 后者说明父行已被标红、用户只需改那一行,因此不再连带给码头行报错。 + Set declaredPortCodeSet = new HashSet<>(); + // 两阶段导入:先落库一级数据(港口),再落库二级数据(码头),消除对 Excel 行序的依赖。 + Map batchPortMap = new LinkedHashMap<>(); + List portIndexList = new ArrayList<>(); + List 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 data, List portIndexList, List terminalIndexList, + Map occupiedCodeMap, Set declaredPortCodeSet, + List 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 data, List portIndexList, Map batchPortMap, + List 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 data, List terminalIndexList, Map batchPortMap, + Set declaredPortCodeSet, List 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 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 data, int index, String message) { + PortTerminalExcel excel = data.get(index); + markFailure(data, index, message); + return excel; + } + + /** + * 仅标注错误原因,不重复加入失败明细集合。 + */ + private void markFailure(List 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 + * 与普通业务异常区分开,是因为调用方需要判断:这个父港口到底是"用户漏填了", + * 还是"父港口那一行就在本文件里、只是它自己校验失败"。后者不该连带给码头行报错。 + * + * @author Chill + */ + private static class ParentPortNotFoundException extends ServiceException { + + @Serial + private static final long serialVersionUID = 1L; + + ParentPortNotFoundException(String message) { + super(message); + } + + } + }