diff --git a/blade-common/pom.xml b/blade-common/pom.xml index 5fb0c22..25f7365 100644 --- a/blade-common/pom.xml +++ b/blade-common/pom.xml @@ -22,6 +22,15 @@ org.springblade blade-starter-loadbalancer + + org.springblade + blade-starter-log + + provided + org.springblade blade-core-auto diff --git a/blade-common/src/main/java/org/springblade/common/excel/ImportFailureExcelUtil.java b/blade-common/src/main/java/org/springblade/common/excel/ImportFailureExcelUtil.java index 70d7183..6b0ad40 100644 --- a/blade-common/src/main/java/org/springblade/common/excel/ImportFailureExcelUtil.java +++ b/blade-common/src/main/java/org/springblade/common/excel/ImportFailureExcelUtil.java @@ -46,9 +46,11 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; /** * 导入失败明细 Excel 导出工具类 @@ -95,7 +97,7 @@ public class ImportFailureExcelUtil { * @param excelClass 原导入 Excel 类型 */ public static void export(HttpServletResponse response, String fileName, String sheetName, List data, Class excelClass) { - exportFailureReasonOnly(response, fileName, sheetName, data, excelClass); + export(response, fileName, sheetName, data, excelClass, true); } /** @@ -109,6 +111,16 @@ public class ImportFailureExcelUtil { */ public static void exportFailureReasonOnly(HttpServletResponse response, String fileName, String sheetName, List data, Class excelClass) { + export(response, fileName, sheetName, data, excelClass, false); + } + + /** + * 导出导入失败明细 + * + * @param markErrorColumns 是否按失败原因里出现的列名,标红对应字段单元格 + */ + private static void export(HttpServletResponse response, String fileName, String sheetName, + List data, Class excelClass, boolean markErrorColumns) { response.setContentType("application/vnd.ms-excel"); response.setCharacterEncoding(StandardCharsets.UTF_8.name()); String encodeFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8); @@ -118,7 +130,7 @@ public class ImportFailureExcelUtil { List> rows = buildRows(data, excelFields); try { FastExcel.write(response.getOutputStream()) - .registerWriteHandler(new ImportFailureCellStyleHandler(excelFields.size())) + .registerWriteHandler(new ImportFailureCellStyleHandler(excelFields, rows, markErrorColumns)) .head(head) .sheet(sheetName) .doWrite(rows); @@ -204,15 +216,59 @@ public class ImportFailureExcelUtil { throw new NoSuchFieldException(String.join(",", fieldNames)); } + /** + * 文本归一化:去掉空白、星号与中英文标点并统一小写 + *

+ * 目的是让「失败原因里写的列名」与「表头列名」能直接做包含匹配, + * 不受「*」「/」「:」等写法差异影响。 + */ + private static String normalize(String value) { + return value == null ? "" : value.replaceAll("[\\s*_::,,。;;()()\\[\\]【】<>《》//、-]", "").toLowerCase(); + } + + /** + * 取某一列用于定位的候选关键词 + *

+ * 包含表头列名、字段名,以及列名按分隔符拆出的片段 + * (如「车牌号/船号」拆出「车牌号」「船号」,失败原因只写其中一段时也能定位)。 + */ + private static List columnKeywords(Field field) { + ExcelProperty excelProperty = field.getAnnotation(ExcelProperty.class); + String[] value = excelProperty.value(); + String columnName = value.length == 0 ? field.getName() : value[0]; + List keywords = new ArrayList<>(); + keywords.add(columnName); + keywords.add(field.getName()); + keywords.addAll(Arrays.asList(columnName.replace("*", "").split("[//、()()\\s]+"))); + return keywords.stream() + .map(ImportFailureExcelUtil::normalize) + .filter(keyword -> keyword.length() >= 2) + .distinct() + .toList(); + } + private static class ImportFailureCellStyleHandler implements CellWriteHandler { private final int failureReasonColumnIndex; + private final boolean markErrorColumns; + private final List> rows; + private final Map> redColumnsByRow = new HashMap<>(); private final Map redStyleCache = new HashMap<>(); private final Map noWrapStyleCache = new HashMap<>(); private final Map columnWidthCache = new HashMap<>(); - private ImportFailureCellStyleHandler(int failureReasonColumnIndex) { - this.failureReasonColumnIndex = failureReasonColumnIndex; + private ImportFailureCellStyleHandler(List excelFields, List> rows, boolean markErrorColumns) { + this.failureReasonColumnIndex = excelFields.size(); + this.markErrorColumns = markErrorColumns; + this.rows = rows; + if (markErrorColumns) { + List> columnKeywords = excelFields.stream() + .map(ImportFailureExcelUtil::columnKeywords) + .toList(); + for (int rowIndex = 0; rowIndex < rows.size(); rowIndex++) { + redColumnsByRow.put(rowIndex, resolveRedColumns(rows.get(rowIndex), columnKeywords)); + } + } } @Override @@ -230,11 +286,83 @@ public class ImportFailureExcelUtil { return; } adjustColumnWidth(cell); - if (cell.getColumnIndex() == failureReasonColumnIndex) { + if (relativeRowIndex == null || relativeRowIndex < 0 || relativeRowIndex >= rows.size()) { + return; + } + if (shouldMarkRed(relativeRowIndex, cell.getColumnIndex())) { markRed(cell); } } + /** + * 计算某一行需要标红的列 + *

+ * 失败原因里出现的列名即视为出错列;但当某个命中片段被另一列更长的命中 + * 片段完全覆盖时(如原因「车船类型不能为空」同时命中「车船类型」和它的 + * 子串「类型」),只保留更长的那一列,避免把无关列一起标红。 + */ + private Set resolveRedColumns(List row, List> columnKeywords) { + Set redColumns = new LinkedHashSet<>(); + redColumns.add(failureReasonColumnIndex); + String failureReason = normalize(String.valueOf(row.get(failureReasonColumnIndex))); + if (failureReason.isEmpty()) { + return redColumns; + } + List matches = new ArrayList<>(); + for (int column = 0; column < columnKeywords.size(); column++) { + for (String keyword : columnKeywords.get(column)) { + int fromIndex = 0; + while (true) { + int index = failureReason.indexOf(keyword, fromIndex); + if (index < 0) { + break; + } + matches.add(new int[]{index, index + keyword.length(), column}); + fromIndex = index + 1; + } + } + } + for (int[] match : matches) { + if (!isSubsumed(match, matches)) { + redColumns.add(match[2]); + } + } + return redColumns; + } + + /** + * 判断该命中片段是否被另一列更长的命中片段完全覆盖 + */ + private boolean isSubsumed(int[] match, List matches) { + int matchLength = match[1] - match[0]; + for (int[] other : matches) { + if (other[2] == match[2]) { + continue; + } + if ((other[1] - other[0]) > matchLength && other[0] <= match[0] && other[1] >= match[1]) { + return true; + } + } + return false; + } + + private boolean shouldMarkRed(int rowIndex, int columnIndex) { + if (columnIndex == failureReasonColumnIndex) { + return true; + } + if (!markErrorColumns) { + return false; + } + Set redColumns = redColumnsByRow.get(rowIndex); + return redColumns != null && redColumns.contains(columnIndex); + } + + /** + * 标红单元格 + *

+ * 统一只改字体颜色,不加底纹;空单元格的红色字体在屏幕上不可见, + * 这类错误依靠失败原因列的文字定位。 + */ private void markRed(Cell cell) { CellStyle currentStyle = cell.getCellStyle(); CellStyle redStyle = redStyleCache.computeIfAbsent(currentStyle.getIndex(), styleIndex -> { diff --git a/blade-common/src/main/java/org/springblade/common/excel/LenientDateParser.java b/blade-common/src/main/java/org/springblade/common/excel/LenientDateParser.java new file mode 100644 index 0000000..750c101 --- /dev/null +++ b/blade-common/src/main/java/org/springblade/common/excel/LenientDateParser.java @@ -0,0 +1,198 @@ +/** + * 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.common.excel; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 批量导入宽容日期解析器(口径见根工作区 docs/import-spec.md)。 + *

+ * 解析规则: + *

    + *
  • 分隔符 {@code -}、{@code /}、{@code .} 均接受;补零与否均可(2026-08-02 ≡ 2026-8-2)
  • + *
  • 日期时间:日期部分同上,时间 HH:mm:ss,可省略秒或秒+分(2026-8-2 12:3 可解析)
  • + *
  • 拒绝:两位年份(26-8-2)、日在前(2/8/2026)、无分隔符(20260802)
  • + *
+ * + * @author Chill + */ +public final class LenientDateParser { + + /** + * 日期部分:4 位年 + 分隔符 + 1~2 位月 + 同一分隔符 + 1~2 位日(年在前,拒绝两位年份与日在前)。 + */ + private static final Pattern DATE_PATTERN = Pattern.compile("(\\d{4})([-/.])(\\d{1,2})\\2(\\d{1,2})"); + + /** + * 时间部分:1~2 位时[:1~2 位分[:1~2 位秒]],逐级可省略。 + */ + private static final Pattern TIME_PATTERN = Pattern.compile("(\\d{1,2})(?::(\\d{1,2})(?::(\\d{1,2}))?)?"); + + private LenientDateParser() { + } + + /** + * 解析日期文本,失败返回 {@code null}。 + * + * @param value 单元格原始文本 + * @return 日期;无法识别时返回 null + */ + public static LocalDate parseDateOrNull(String value) { + String normalized = normalize(value); + if (normalized == null) { + return null; + } + Matcher matcher = DATE_PATTERN.matcher(normalized); + if (!matcher.matches()) { + return null; + } + try { + return LocalDate.of(Integer.parseInt(matcher.group(1)), + Integer.parseInt(matcher.group(3)), Integer.parseInt(matcher.group(4))); + } catch (NumberFormatException | java.time.DateTimeException exception) { + return null; + } + } + + /** + * 解析日期文本;允许携带合法时间部分并截断(如 2026-9-1 8:0:0 按 2026-09-01 解析, + * 与 fastexcel 内置 LocalDate 转换的既有宽容度保持一致),失败返回 {@code null}。 + * + * @param value 单元格原始文本 + * @return 日期;无法识别时返回 null + */ + public static LocalDate parseDateLenientlyOrNull(String value) { + LocalDate date = parseDateOrNull(value); + if (date != null) { + return date; + } + LocalDateTime dateTime = parseDateTimeOrNull(value); + return dateTime == null ? null : dateTime.toLocalDate(); + } + + /** + * 解析日期时间文本;纯日期按当日零点处理,失败返回 {@code null}。 + * + * @param value 单元格原始文本 + * @return 日期时间;无法识别时返回 null + */ + public static LocalDateTime parseDateTimeOrNull(String value) { + String normalized = normalize(value); + if (normalized == null) { + return null; + } + // 拆出日期与时间两部分;中间允许 1 个及以上空白或小写 t(2026-8-2t12:3 亦接受)。 + String[] parts = normalized.split("[ \\t]+|(?<=\\d)t", 2); + if (parts.length == 0) { + return null; + } + LocalDate date = parseDateOrNull(parts[0]); + if (date == null) { + return null; + } + if (parts.length == 1) { + return date.atStartOfDay(); + } + Matcher matcher = TIME_PATTERN.matcher(parts[1]); + if (!matcher.matches()) { + return null; + } + try { + int hour = Integer.parseInt(matcher.group(1)); + int minute = matcher.group(2) == null ? 0 : Integer.parseInt(matcher.group(2)); + int second = matcher.group(3) == null ? 0 : Integer.parseInt(matcher.group(3)); + return LocalDateTime.of(date, LocalTime.of(hour, minute, second)); + } catch (NumberFormatException | java.time.DateTimeException exception) { + return null; + } + } + + /** + * 解析日期文本,失败抛出携带口径文案的 {@link org.springblade.core.log.exception.ServiceException}。 + * 空白与 {@code null} 返回 {@code null}(可选字段由业务校验决定是否必填)。 + * + * @param value 单元格原始文本 + * @param columnName 导入模板列名(用于失败原因文案与失败明细标红定位) + * @return 日期 + */ + public static LocalDate parseDate(String value, String columnName) { + if (value == null || value.trim().isEmpty()) { + return null; + } + LocalDate date = parseDateLenientlyOrNull(value); + if (date == null) { + throw unrecognizedDate(value, columnName); + } + return date; + } + + /** + * 解析日期时间文本,失败抛出携带口径文案的 {@link org.springblade.core.log.exception.ServiceException}。 + * 空白与 {@code null} 返回 {@code null}(可选字段由业务校验决定是否必填)。 + * + * @param value 单元格原始文本 + * @param columnName 导入模板列名(用于失败原因文案与失败明细标红定位) + * @return 日期时间 + */ + public static LocalDateTime parseDateTime(String value, String columnName) { + if (value == null || value.trim().isEmpty()) { + return null; + } + LocalDateTime dateTime = parseDateTimeOrNull(value); + if (dateTime == null) { + throw unrecognizedDate(value, columnName); + } + return dateTime; + } + + /** + * 按口径构造「日期格式无法识别」错误。 + * + * @param value 单元格原始文本 + * @param columnName 导入模板列名 + * @return 业务异常 + */ + public static org.springblade.core.log.exception.ServiceException unrecognizedDate(String value, String columnName) { + String displayName = columnName == null || columnName.isBlank() ? "日期" : columnName; + return new org.springblade.core.log.exception.ServiceException( + displayName + " 日期格式无法识别:" + (value == null ? "" : value.trim())); + } + + /** + * 归一化输入:去首尾空白,跳过空白值。 + */ + private static String normalize(String value) { + if (value == null) { + return null; + } + String normalized = value.trim(); + return normalized.isEmpty() ? null : normalized; + } + +} diff --git a/blade-common/src/main/java/org/springblade/common/excel/LenientDateStringConverter.java b/blade-common/src/main/java/org/springblade/common/excel/LenientDateStringConverter.java new file mode 100644 index 0000000..970dde7 --- /dev/null +++ b/blade-common/src/main/java/org/springblade/common/excel/LenientDateStringConverter.java @@ -0,0 +1,75 @@ +/** + * 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.common.excel; + +import cn.idev.excel.converters.Converter; +import cn.idev.excel.enums.CellDataTypeEnum; +import cn.idev.excel.metadata.GlobalConfiguration; +import cn.idev.excel.metadata.data.ReadCellData; +import cn.idev.excel.metadata.data.WriteCellData; +import cn.idev.excel.metadata.property.ExcelContentProperty; +import cn.idev.excel.util.DateUtils; + +import java.time.LocalDate; + +/** + * 批量导入宽容日期转换器(String 承载):Excel 数值日期(序列号)转 ISO 文本,文本原样透传。 + *

+ * 文本的宽容解析由服务层调用 {@link LenientDateParser} 完成,不在此处抛错—— + * 转换器阶段抛出的异常会被 fastexcel 包装成 ExcelDataConvertException 直接中断读取, + * 无法进入导入失败明细流程。口径见根工作区 docs/import-spec.md。 + * + * @author Chill + */ +public class LenientDateStringConverter implements Converter { + + @Override + public Class supportJavaTypeKey() { + return String.class; + } + + @Override + public CellDataTypeEnum supportExcelTypeKey() { + return CellDataTypeEnum.STRING; + } + + @Override + public String convertToJavaData(ReadCellData cellData, ExcelContentProperty contentProperty, + GlobalConfiguration globalConfiguration) { + if (cellData.getType() == CellDataTypeEnum.NUMBER) { + LocalDate date = DateUtils.getLocalDateTime(cellData.getNumberValue().doubleValue(), + globalConfiguration.getUse1904windowing()).toLocalDate(); + return date.toString(); + } + return cellData.getStringValue(); + } + + @Override + public WriteCellData convertToExcelData(String value, ExcelContentProperty contentProperty, + GlobalConfiguration globalConfiguration) { + return new WriteCellData<>(value); + } + +} diff --git a/blade-common/src/main/java/org/springblade/common/excel/LenientDateTimeStringConverter.java b/blade-common/src/main/java/org/springblade/common/excel/LenientDateTimeStringConverter.java new file mode 100644 index 0000000..bcb070a --- /dev/null +++ b/blade-common/src/main/java/org/springblade/common/excel/LenientDateTimeStringConverter.java @@ -0,0 +1,82 @@ +/** + * 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.common.excel; + +import cn.idev.excel.converters.Converter; +import cn.idev.excel.enums.CellDataTypeEnum; +import cn.idev.excel.metadata.GlobalConfiguration; +import cn.idev.excel.metadata.data.ReadCellData; +import cn.idev.excel.metadata.data.WriteCellData; +import cn.idev.excel.metadata.property.ExcelContentProperty; +import cn.idev.excel.util.DateUtils; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +/** + * 批量导入宽容日期时间转换器(String 承载):Excel 数值日期(序列号)转 + * {@code yyyy-MM-dd HH:mm:ss} 文本,文本原样透传。 + *

+ * 文本的宽容解析由服务层调用 {@link LenientDateParser} 完成,不在此处抛错—— + * 转换器阶段抛出的异常会被 fastexcel 包装成 ExcelDataConvertException 直接中断读取, + * 无法进入导入失败明细流程。口径见根工作区 docs/import-spec.md。 + * + * @author Chill + */ +public class LenientDateTimeStringConverter implements Converter { + + /** + * 数值日期序列号转文本的输出格式,与导入模板展示格式保持一致。 + */ + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + @Override + public Class supportJavaTypeKey() { + return String.class; + } + + @Override + public CellDataTypeEnum supportExcelTypeKey() { + return CellDataTypeEnum.STRING; + } + + @Override + public String convertToJavaData(ReadCellData cellData, ExcelContentProperty contentProperty, + GlobalConfiguration globalConfiguration) { + if (cellData.getType() == CellDataTypeEnum.NUMBER) { + LocalDateTime dateTime = DateUtils.getLocalDateTime(cellData.getNumberValue().doubleValue(), + globalConfiguration.getUse1904windowing()); + return dateTime.format(DATE_TIME_FORMATTER); + } + return cellData.getStringValue(); + } + + @Override + public WriteCellData convertToExcelData(String value, ExcelContentProperty contentProperty, + GlobalConfiguration globalConfiguration) { + return new WriteCellData<>(value); + } + +} 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 3979b3c..0639b52 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 @@ -1,263 +1,276 @@ -/** - * 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.controller; - -import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; -import com.baomidou.mybatisplus.core.metadata.IPage; -import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.servlet.http.HttpServletResponse; -import jakarta.validation.Valid; -import lombok.AllArgsConstructor; -import org.springblade.core.boot.ctrl.BladeController; -import org.springblade.core.excel.util.ExcelUtil; -import org.springblade.core.mp.support.Condition; -import org.springblade.core.mp.support.Query; -import org.springblade.core.secure.annotation.PreAuth; -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.system.excel.PortTerminalExcel; -import org.springblade.system.excel.PortTerminalExportExcel; -import org.springblade.system.excel.PortTerminalImporter; -import org.springblade.system.pojo.entity.PortTerminal; -import org.springblade.system.pojo.vo.PortTerminalVO; -import org.springblade.system.service.IPortTerminalService; -import org.springblade.system.wrapper.PortTerminalWrapper; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.multipart.MultipartFile; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -/** - * 港口码头主数据 控制器 - * - * @author Chill - */ -@NonDS -@RestController -@AllArgsConstructor -@PreAuth(menu = "port_terminal") -@RequestMapping("/port-terminal") -@Tag(name = "港口码头主数据", description = "港口码头主数据") -public class PortTerminalController extends BladeController { - - private static final int DEFAULT_CURRENT = 1; - private static final int DEFAULT_SIZE = 10; - private static final int MAX_SIZE = 100; - private static final String SOURCE_INITIAL = "初始化录入"; - private static final String SOURCE_INITIAL_IMPORT = "初始化导入"; - private static final String SOURCE_INITIAL_OLD = "初始导入"; - private static final String SOURCE_MANUAL = "手动录入"; - private static final String SOURCE_MANUAL_OLD = "手工导入"; - - private final IPortTerminalService portTerminalService; - - /** - * 详情 - */ - @GetMapping("/detail") - @ApiOperationSupport(order = 1) - @Operation(summary = "详情", description = "传入portTerminal") - public R detail(PortTerminal portTerminal) { - if (Func.isEmpty(portTerminal.getId())) { - return R.fail("主键不能为空"); - } - PortTerminal detail = portTerminalService.getById(portTerminal.getId()); - if (Func.isEmpty(detail)) { - return R.fail("港口码头不存在"); - } - detail.setDataSource(normalizeDataSource(detail.getDataSource())); - return R.data(PortTerminalWrapper.build().entityVO(detail)); - } - - /** - * 分页 - */ - @GetMapping("/list") - @ApiOperationSupport(order = 2) - @Operation(summary = "分页", description = "传入portTerminal") - public R> list(PortTerminalVO portTerminal, Query query) { - IPage pages = portTerminalService.selectPortTerminalPage(Condition.getPage(normalizeQuery(query)), portTerminal); - return R.data(pages); - } - - /** - * 新增或修改 - */ - @PostMapping("/submit") - @ApiOperationSupport(order = 3) - @Operation(summary = "新增或修改", description = "传入portTerminal") - public R submit(@Valid @RequestBody PortTerminal portTerminal) { - return R.status(portTerminalService.submit(portTerminal)); - } - - /** - * 删除 - */ - @PostMapping("/remove") - @ApiOperationSupport(order = 4) - @Operation(summary = "逻辑删除", description = "传入ids") - public R remove(@Parameter(description = "主键集合", required = true) @RequestParam String ids) { - if (Func.isEmpty(ids)) { - return R.fail("主键不能为空"); - } - return R.status(portTerminalService.deleteLogic(Func.toLongList(ids))); - } - - /** - * 启用或停用 - */ - @PostMapping("/status") - @ApiOperationSupport(order = 5) - @Operation(summary = "启用或停用", description = "传入id和status") - public R status(@Parameter(description = "主键", required = true) @RequestParam Long id, - @Parameter(description = "状态", required = true) @RequestParam Integer status) { - return R.status(portTerminalService.changeStatus(id, status)); - } - - /** - * 上级港口下拉数据源 - */ - @GetMapping("/port-select") - @ApiOperationSupport(order = 6) - @Operation(summary = "上级港口下拉数据源") - public R> portSelect() { - List ports = portTerminalService.selectEnabledPorts(); - ports.forEach(port -> port.setDataSource(normalizeDataSource(port.getDataSource()))); - return R.data(ports); - } - - /** - * 导入港口码头主数据 - */ - @PostMapping("/import-port-terminal") - @ApiOperationSupport(order = 7) - @Operation(summary = "导入港口码头主数据", description = "传入excel") - public R importPortTerminal(MultipartFile file, HttpServletResponse response) { - if (file == null || file.isEmpty()) { - return R.fail("上传文件不能为空"); - } - String fileName = Func.toStrWithEmpty(file.getOriginalFilename(), "").toLowerCase(); - 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.exportFailureReasonOnly(response, "港口码头主数据导入失败明细" + DateUtil.time(), "导入失败明细", failureList, PortTerminalExcel.class); - return null; - } - return R.success("操作成功"); - } - - /** - * 导出港口码头主数据 - */ - @GetMapping("/export-port-terminal") - @ApiOperationSupport(order = 8) - @Operation(summary = "导出港口码头主数据") - public void exportPortTerminal(@Parameter(hidden = true) @RequestParam Map portTerminal, HttpServletResponse response) { - Object ids = portTerminal.remove("ids"); - Object dataSource = portTerminal.remove("dataSource"); - portTerminal.remove("Blade-Auth"); - portTerminal.remove("Authorization"); - portTerminal.remove("access_token"); - normalizeRegionCodeCondition(portTerminal); - QueryWrapper queryWrapper = Condition.getQueryWrapper(portTerminal, PortTerminal.class); - applyDataSourceCondition(queryWrapper, dataSource); - if (Func.isNotEmpty(ids)) { - queryWrapper.lambda().in(PortTerminal::getId, Func.toLongList(ids.toString())); - } - List list = portTerminalService.exportPortTerminal(queryWrapper); - ExcelUtil.export(response, "港口码头主数据" + DateUtil.time(), "港口码头主数据表", list, PortTerminalExportExcel.class); - } - - /** - * 导出模板 - */ - @GetMapping("/export-template") - @ApiOperationSupport(order = 9) - @Operation(summary = "导出模板") - public void exportTemplate(HttpServletResponse response) { - List list = new ArrayList<>(); - ExcelUtil.export(response, "港口码头主数据模板", "港口码头主数据表", list, PortTerminalExcel.class); - } - - private Query normalizeQuery(Query query) { - if (query == null) { - query = new Query(); - } - if (query.getCurrent() == null || query.getCurrent() < DEFAULT_CURRENT) { - query.setCurrent(DEFAULT_CURRENT); - } - if (query.getSize() == null || query.getSize() <= 0) { - query.setSize(DEFAULT_SIZE); - } - if (query.getSize() > MAX_SIZE) { - query.setSize(MAX_SIZE); - } - return query; - } - - private void normalizeRegionCodeCondition(Map params) { - Object regionCode = params.remove("regionCode"); - if (Func.isNotEmpty(regionCode) && Func.isEmpty(params.get("districtCode"))) { - params.put("districtCode", regionCode); - } - } - - private void applyDataSourceCondition(QueryWrapper queryWrapper, Object dataSource) { - String value = Func.toStrWithEmpty(dataSource, ""); - if (Func.isEmpty(value)) { - return; - } - if (SOURCE_INITIAL.equals(value)) { - queryWrapper.in("data_source", SOURCE_INITIAL, SOURCE_INITIAL_IMPORT, SOURCE_INITIAL_OLD); - } else if (SOURCE_MANUAL.equals(value)) { - queryWrapper.in("data_source", SOURCE_MANUAL, SOURCE_MANUAL_OLD); - } else { - queryWrapper.eq("data_source", value); - } - } - - private String normalizeDataSource(String dataSource) { - if (SOURCE_INITIAL_IMPORT.equals(dataSource) || SOURCE_INITIAL_OLD.equals(dataSource)) { - return SOURCE_INITIAL; - } - return SOURCE_MANUAL_OLD.equals(dataSource) ? SOURCE_MANUAL : dataSource; - } - -} +/** + * 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.controller; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.AllArgsConstructor; +import org.springblade.core.boot.ctrl.BladeController; +import org.springblade.core.excel.util.ExcelUtil; +import org.springblade.core.mp.support.Condition; +import org.springblade.core.mp.support.Query; +import org.springblade.core.secure.annotation.PreAuth; +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.PortTerminalExportExcel; +import org.springblade.system.excel.PortTerminalImporter; +import org.springblade.system.pojo.entity.PortTerminal; +import org.springblade.system.pojo.vo.PortTerminalVO; +import org.springblade.system.service.IPortTerminalService; +import org.springblade.system.wrapper.PortTerminalWrapper; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * 港口码头主数据 控制器 + * + * @author Chill + */ +@NonDS +@RestController +@AllArgsConstructor +@PreAuth(menu = "port_terminal") +@RequestMapping("/port-terminal") +@Tag(name = "港口码头主数据", description = "港口码头主数据") +public class PortTerminalController extends BladeController { + + private static final int DEFAULT_CURRENT = 1; + private static final int DEFAULT_SIZE = 10; + private static final int MAX_SIZE = 100; + private static final String SOURCE_INITIAL = "初始化录入"; + private static final String SOURCE_INITIAL_IMPORT = "初始化导入"; + private static final String SOURCE_INITIAL_OLD = "初始导入"; + private static final String SOURCE_MANUAL = "手动录入"; + private static final String SOURCE_MANUAL_OLD = "手工导入"; + + private final IPortTerminalService portTerminalService; + + /** + * 详情 + */ + @GetMapping("/detail") + @ApiOperationSupport(order = 1) + @Operation(summary = "详情", description = "传入portTerminal") + public R detail(PortTerminal portTerminal) { + if (Func.isEmpty(portTerminal.getId())) { + return R.fail("主键不能为空"); + } + PortTerminal detail = portTerminalService.getById(portTerminal.getId()); + if (Func.isEmpty(detail)) { + return R.fail("港口码头不存在"); + } + detail.setDataSource(normalizeDataSource(detail.getDataSource())); + return R.data(PortTerminalWrapper.build().entityVO(detail)); + } + + /** + * 分页 + */ + @GetMapping("/list") + @ApiOperationSupport(order = 2) + @Operation(summary = "分页", description = "传入portTerminal") + public R> list(PortTerminalVO portTerminal, Query query) { + IPage pages = portTerminalService.selectPortTerminalPage(Condition.getPage(normalizeQuery(query)), portTerminal); + return R.data(pages); + } + + /** + * 新增或修改 + */ + @PostMapping("/submit") + @ApiOperationSupport(order = 3) + @Operation(summary = "新增或修改", description = "传入portTerminal") + public R submit(@Valid @RequestBody PortTerminal portTerminal) { + return R.status(portTerminalService.submit(portTerminal)); + } + + /** + * 删除 + */ + @PostMapping("/remove") + @ApiOperationSupport(order = 4) + @Operation(summary = "逻辑删除", description = "传入ids") + public R remove(@Parameter(description = "主键集合", required = true) @RequestParam String ids) { + if (Func.isEmpty(ids)) { + return R.fail("主键不能为空"); + } + return R.status(portTerminalService.deleteLogic(Func.toLongList(ids))); + } + + /** + * 启用或停用 + */ + @PostMapping("/status") + @ApiOperationSupport(order = 5) + @Operation(summary = "启用或停用", description = "传入id和status") + public R status(@Parameter(description = "主键", required = true) @RequestParam Long id, + @Parameter(description = "状态", required = true) @RequestParam Integer status) { + return R.status(portTerminalService.changeStatus(id, status)); + } + + /** + * 上级港口下拉数据源 + */ + @GetMapping("/port-select") + @ApiOperationSupport(order = 6) + @Operation(summary = "上级港口下拉数据源") + public R> portSelect() { + List ports = portTerminalService.selectEnabledPorts(); + ports.forEach(port -> port.setDataSource(normalizeDataSource(port.getDataSource()))); + return R.data(ports); + } + + /** + * 导入港口码头主数据 + */ + @PostMapping("/import-port-terminal") + @ApiOperationSupport(order = 7) + @Operation(summary = "导入港口码头主数据", description = "传入excel") + public R importPortTerminal(MultipartFile file, HttpServletResponse response) { + if (file == null || file.isEmpty()) { + return R.fail("上传文件不能为空"); + } + String fileName = Func.toStrWithEmpty(file.getOriginalFilename(), "").toLowerCase(); + if (!fileName.endsWith(".xls") && !fileName.endsWith(".xlsx")) { + return R.fail("请上传 .xls,.xlsx 标准格式文件"); + } + 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.exportFailureReasonOnly(response, "港口码头主数据导入失败明细" + DateUtil.time(), "导入失败明细", failureList, PortTerminalExcel.class); + } + + /** + * 导出港口码头主数据 + */ + @GetMapping("/export-port-terminal") + @ApiOperationSupport(order = 8) + @Operation(summary = "导出港口码头主数据") + public void exportPortTerminal(@Parameter(hidden = true) @RequestParam Map portTerminal, HttpServletResponse response) { + Object ids = portTerminal.remove("ids"); + Object dataSource = portTerminal.remove("dataSource"); + portTerminal.remove("Blade-Auth"); + portTerminal.remove("Authorization"); + portTerminal.remove("access_token"); + normalizeRegionCodeCondition(portTerminal); + QueryWrapper queryWrapper = Condition.getQueryWrapper(portTerminal, PortTerminal.class); + applyDataSourceCondition(queryWrapper, dataSource); + if (Func.isNotEmpty(ids)) { + queryWrapper.lambda().in(PortTerminal::getId, Func.toLongList(ids.toString())); + } + List list = portTerminalService.exportPortTerminal(queryWrapper); + ExcelUtil.export(response, "港口码头主数据" + DateUtil.time(), "港口码头主数据表", list, PortTerminalExportExcel.class); + } + + /** + * 导出模板 + */ + @GetMapping("/export-template") + @ApiOperationSupport(order = 9) + @Operation(summary = "导出模板") + public void exportTemplate(HttpServletResponse response) { + List list = new ArrayList<>(); + ExcelUtil.export(response, "港口码头主数据模板", "港口码头主数据表", list, PortTerminalExcel.class); + } + + private Query normalizeQuery(Query query) { + if (query == null) { + query = new Query(); + } + if (query.getCurrent() == null || query.getCurrent() < DEFAULT_CURRENT) { + query.setCurrent(DEFAULT_CURRENT); + } + if (query.getSize() == null || query.getSize() <= 0) { + query.setSize(DEFAULT_SIZE); + } + if (query.getSize() > MAX_SIZE) { + query.setSize(MAX_SIZE); + } + return query; + } + + private void normalizeRegionCodeCondition(Map params) { + Object regionCode = params.remove("regionCode"); + if (Func.isNotEmpty(regionCode) && Func.isEmpty(params.get("districtCode"))) { + params.put("districtCode", regionCode); + } + } + + private void applyDataSourceCondition(QueryWrapper queryWrapper, Object dataSource) { + String value = Func.toStrWithEmpty(dataSource, ""); + if (Func.isEmpty(value)) { + return; + } + if (SOURCE_INITIAL.equals(value)) { + queryWrapper.in("data_source", SOURCE_INITIAL, SOURCE_INITIAL_IMPORT, SOURCE_INITIAL_OLD); + } else if (SOURCE_MANUAL.equals(value)) { + queryWrapper.in("data_source", SOURCE_MANUAL, SOURCE_MANUAL_OLD); + } else { + queryWrapper.eq("data_source", value); + } + } + + private String normalizeDataSource(String dataSource) { + if (SOURCE_INITIAL_IMPORT.equals(dataSource) || SOURCE_INITIAL_OLD.equals(dataSource)) { + return SOURCE_INITIAL; + } + return SOURCE_MANUAL_OLD.equals(dataSource) ? SOURCE_MANUAL : dataSource; + } + +} diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/excel/CurrencyExcel.java b/blade-service/blade-system/src/main/java/org/springblade/system/excel/CurrencyExcel.java index 941b7eb..b59573d 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/excel/CurrencyExcel.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/excel/CurrencyExcel.java @@ -35,7 +35,6 @@ import lombok.Data; import java.io.Serial; import java.io.Serializable; import java.math.BigDecimal; -import java.time.LocalDate; /** * 币种汇率 Excel @@ -62,8 +61,8 @@ public class CurrencyExcel implements Serializable { @ExcelProperty("汇率") private BigDecimal exchangeRate; - @ExcelProperty("生效日期") - private LocalDate effectiveDate; + @ExcelProperty(value = "生效日期", converter = org.springblade.common.excel.LenientDateStringConverter.class) + private String effectiveDate; @ExcelProperty("状态") private String statusName; @@ -71,8 +70,8 @@ public class CurrencyExcel implements Serializable { @ExcelProperty("来源") private String dataSource; - @ExcelProperty("失效日期") - private LocalDate expiryDate; + @ExcelProperty(value = "失效日期", converter = org.springblade.common.excel.LenientDateStringConverter.class) + private String expiryDate; @ExcelProperty("备注") private String remark; 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/excel/UserExcel.java b/blade-service/blade-system/src/main/java/org/springblade/system/excel/UserExcel.java index 1dd71ac..306e144 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/excel/UserExcel.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/excel/UserExcel.java @@ -34,7 +34,6 @@ import lombok.Data; import java.io.Serial; import java.io.Serializable; -import java.util.Date; /** * UserExcel @@ -102,7 +101,7 @@ public class UserExcel implements Serializable { private String postName; @ColumnWidth(20) - @ExcelProperty("生日") - private Date birthday; + @ExcelProperty(value = "生日", converter = org.springblade.common.excel.LenientDateStringConverter.class) + private String birthday; } diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserMapper.xml b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserMapper.xml index 6a4dd47..70dd2e9 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserMapper.xml +++ b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserMapper.xml @@ -95,7 +95,7 @@