批量导入日期宽容解析:兼容 2026-08-02 与 2026-8-2 等书写变体

- blade-common 新增 LenientDateParser(宽容解析:- / . 分隔符、补零与否、
  时间可省略秒/分;拒绝两位年份、日在前、无分隔符)
- 新增 LenientDateStringConverter / LenientDateTimeStringConverter:
  Excel 数值日期转文本透传,文本解析留待服务层以进入失败明细流程
- 8 个裸走 fastexcel 内置转换的导入 DTO(保险/年检/事故/其他费用/运单/
  币种/用户生日/设备台账)日期字段改为 String 承载,服务层统一经
  LenientDateParser 解析,失败报「<列名> 日期格式无法识别:<原值>」
- MaintenancePlanDateTimeConverter / TireReplacementDateStringConverter
  内部委托公共解析器,6 个 DTO 注解零改动
- 运输计划、运单批量、运输对账、换胎 4 处手动解析点收敛至同一工具类
- UserMapper 导出 SQL 的生日改 DATE_FORMAT 输出,保持导出格式不变

口径见根工作区 docs/import-spec.md(weicw/tms-erp#1)。

回归:port-terminal 45 断言、violation-record 40 断言(含新增不补零
用例 09)、waybill-import 48 断言、insurance-record 新增用例 6 断言,
全部通过。
This commit is contained in:
2026-09-21 09:47:50 +08:00
parent ddfa52a2ac
commit fd6563f53b
27 changed files with 444 additions and 81 deletions
+4
View File
@@ -22,6 +22,10 @@
<groupId>org.springblade</groupId> <groupId>org.springblade</groupId>
<artifactId>blade-starter-loadbalancer</artifactId> <artifactId>blade-starter-loadbalancer</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springblade</groupId>
<artifactId>blade-starter-log</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.springblade</groupId> <groupId>org.springblade</groupId>
<artifactId>blade-core-auto</artifactId> <artifactId>blade-core-auto</artifactId>
@@ -0,0 +1,198 @@
/**
* 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 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)。
* <p>
* 解析规则:
* <ul>
* <li>分隔符 {@code -}、{@code /}、{@code .} 均接受;补零与否均可(2026-08-02 ≡ 2026-8-2</li>
* <li>日期时间:日期部分同上,时间 HH:mm:ss,可省略秒或秒+分(2026-8-2 12:3 可解析)</li>
* <li>拒绝:两位年份(26-8-2)、日在前(2/8/2026)、无分隔符(20260802</li>
* </ul>
*
* @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;
}
}
@@ -0,0 +1,75 @@
/**
* 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 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 文本,文本原样透传。
* <p>
* 文本的宽容解析由服务层调用 {@link LenientDateParser} 完成,不在此处抛错——
* 转换器阶段抛出的异常会被 fastexcel 包装成 ExcelDataConvertException 直接中断读取,
* 无法进入导入失败明细流程。口径见根工作区 docs/import-spec.md。
*
* @author Chill
*/
public class LenientDateStringConverter implements Converter<String> {
@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);
}
}
@@ -0,0 +1,82 @@
/**
* 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 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} 文本,文本原样透传。
* <p>
* 文本的宽容解析由服务层调用 {@link LenientDateParser} 完成,不在此处抛错——
* 转换器阶段抛出的异常会被 fastexcel 包装成 ExcelDataConvertException 直接中断读取,
* 无法进入导入失败明细流程。口径见根工作区 docs/import-spec.md。
*
* @author Chill
*/
public class LenientDateTimeStringConverter implements Converter<String> {
/**
* 数值日期序列号转文本的输出格式,与导入模板展示格式保持一致。
*/
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);
}
}
@@ -35,7 +35,6 @@ import lombok.Data;
import java.io.Serial; import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate;
/** /**
* 币种汇率 Excel * 币种汇率 Excel
@@ -62,8 +61,8 @@ public class CurrencyExcel implements Serializable {
@ExcelProperty("汇率") @ExcelProperty("汇率")
private BigDecimal exchangeRate; private BigDecimal exchangeRate;
@ExcelProperty("生效日期") @ExcelProperty(value = "生效日期", converter = org.springblade.common.excel.LenientDateStringConverter.class)
private LocalDate effectiveDate; private String effectiveDate;
@ExcelProperty("状态") @ExcelProperty("状态")
private String statusName; private String statusName;
@@ -71,8 +70,8 @@ public class CurrencyExcel implements Serializable {
@ExcelProperty("来源") @ExcelProperty("来源")
private String dataSource; private String dataSource;
@ExcelProperty("失效日期") @ExcelProperty(value = "失效日期", converter = org.springblade.common.excel.LenientDateStringConverter.class)
private LocalDate expiryDate; private String expiryDate;
@ExcelProperty("备注") @ExcelProperty("备注")
private String remark; private String remark;
@@ -34,7 +34,6 @@ import lombok.Data;
import java.io.Serial; import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import java.util.Date;
/** /**
* UserExcel * UserExcel
@@ -102,7 +101,7 @@ public class UserExcel implements Serializable {
private String postName; private String postName;
@ColumnWidth(20) @ColumnWidth(20)
@ExcelProperty("生日") @ExcelProperty(value = "生日", converter = org.springblade.common.excel.LenientDateStringConverter.class)
private Date birthday; private String birthday;
} }
@@ -95,7 +95,7 @@
</select> </select>
<select id="exportUser" resultType="org.springblade.system.excel.UserExcel"> <select id="exportUser" resultType="org.springblade.system.excel.UserExcel">
SELECT id, tenant_id, user_type, account, name, real_name, email, phone, birthday, role_id, dept_id, post_id FROM blade_user ${ew.customSqlSegment} SELECT id, tenant_id, user_type, account, name, real_name, email, phone, DATE_FORMAT(birthday, '%Y-%m-%d') AS birthday, role_id, dept_id, post_id FROM blade_user ${ew.customSqlSegment}
</select> </select>
<select id="selectCustomerOptions" resultType="java.util.HashMap"> <select id="selectCustomerOptions" resultType="java.util.HashMap">
@@ -125,6 +125,8 @@ public class CurrencyServiceImpl extends BaseServiceImpl<CurrencyMapper, Currenc
CurrencyExcel excel = data.get(index); CurrencyExcel excel = data.get(index);
try { try {
Currency currency = Objects.requireNonNull(BeanUtil.copyProperties(excel, Currency.class)); Currency currency = Objects.requireNonNull(BeanUtil.copyProperties(excel, Currency.class));
currency.setEffectiveDate(org.springblade.common.excel.LenientDateParser.parseDate(excel.getEffectiveDate(), "生效日期"));
currency.setExpiryDate(org.springblade.common.excel.LenientDateParser.parseDate(excel.getExpiryDate(), "失效日期"));
currency.setDataSource(SOURCE_BATCH); currency.setDataSource(SOURCE_BATCH);
currency.setStatus(STATUS_ENABLED); currency.setStatus(STATUS_ENABLED);
prepare(currency, SOURCE_BATCH); prepare(currency, SOURCE_BATCH);
@@ -689,6 +689,9 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, User> implement
*/ */
private User buildImportUser(UserExcel userExcel, String tenantId) { private User buildImportUser(UserExcel userExcel, String tenantId) {
User user = Objects.requireNonNull(BeanUtil.copyProperties(userExcel, User.class)); User user = Objects.requireNonNull(BeanUtil.copyProperties(userExcel, User.class));
// 宽容解析生日文本(2026-8-2 等写法),User.birthday 为 java.util.Date 需转换
java.time.LocalDate birthday = org.springblade.common.excel.LenientDateParser.parseDate(userExcel.getBirthday(), "生日");
user.setBirthday(birthday == null ? null : java.sql.Date.valueOf(birthday));
user.setTenantId(tenantId); user.setTenantId(tenantId);
user.setUserType(Func.toInt(DictCache.getKey(DictEnum.USER_TYPE, userExcel.getUserTypeName()), 1)); user.setUserType(Func.toInt(DictCache.getKey(DictEnum.USER_TYPE, userExcel.getUserTypeName()), 1));
user.setDeptId(Func.toStrWithEmpty(SysCache.getDeptIds(tenantId, userExcel.getDeptName()), StringPool.EMPTY)); user.setDeptId(Func.toStrWithEmpty(SysCache.getDeptIds(tenantId, userExcel.getDeptName()), StringPool.EMPTY));
@@ -36,7 +36,6 @@ import lombok.Data;
import java.io.Serial; import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate;
/** /**
* 事故记录 Excel * 事故记录 Excel
@@ -60,8 +59,8 @@ public class AccidentRecordExcel implements Serializable {
@ExcelProperty("*车牌号/船号") @ExcelProperty("*车牌号/船号")
private String vehicleNo; private String vehicleNo;
@ExcelProperty("*事故发生日期") @ExcelProperty(value = "*事故发生日期", converter = org.springblade.common.excel.LenientDateStringConverter.class)
private LocalDate accidentDate; private String accidentDate;
@ExcelProperty("事故发生地点") @ExcelProperty("事故发生地点")
private String accidentLocation; private String accidentLocation;
@@ -35,7 +35,6 @@ import lombok.Data;
import java.io.Serial; import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate;
/** /**
* 年检记录 Excel * 年检记录 Excel
@@ -59,11 +58,11 @@ public class AnnualInspectionRecordExcel implements Serializable {
@ExcelProperty("*车牌号/船号") @ExcelProperty("*车牌号/船号")
private String vehicleNo; private String vehicleNo;
@ExcelProperty("*检测评定日期") @ExcelProperty(value = "*检测评定日期", converter = org.springblade.common.excel.LenientDateStringConverter.class)
private LocalDate inspectionAssessmentDate; private String inspectionAssessmentDate;
@ExcelProperty("*有效期截止日") @ExcelProperty(value = "*有效期截止日", converter = org.springblade.common.excel.LenientDateStringConverter.class)
private LocalDate validUntilDate; private String validUntilDate;
@ExcelProperty("*车辆技术等级") @ExcelProperty("*车辆技术等级")
private String vehicleTechnicalLevel; private String vehicleTechnicalLevel;
@@ -7,12 +7,10 @@ package org.springblade.transport.excel;
import cn.idev.excel.annotation.ExcelIgnore; import cn.idev.excel.annotation.ExcelIgnore;
import cn.idev.excel.annotation.ExcelProperty; import cn.idev.excel.annotation.ExcelProperty;
import cn.idev.excel.annotation.format.DateTimeFormat;
import lombok.Data; import lombok.Data;
import java.io.Serial; import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import java.time.LocalDate;
/** /**
* 设备台账 Excel * 设备台账 Excel
@@ -29,7 +27,7 @@ public class EquipmentLedgerExcel implements Serializable {
@ExcelProperty("设备品牌") private String equipmentBrand; @ExcelProperty("设备品牌") private String equipmentBrand;
@ExcelProperty("设备类型") private String equipmentType; @ExcelProperty("设备类型") private String equipmentType;
@ExcelProperty("规格型号") private String specificationModel; @ExcelProperty("规格型号") private String specificationModel;
@ExcelProperty("出厂日期") @DateTimeFormat("yyyy-MM-dd") private LocalDate factoryDate; @ExcelProperty(value = "出厂日期", converter = org.springblade.common.excel.LenientDateStringConverter.class) private String factoryDate;
@ExcelProperty("备注") private String remark; @ExcelProperty("备注") private String remark;
@ExcelIgnore private String errorMessage; @ExcelIgnore private String errorMessage;
} }
@@ -35,7 +35,6 @@ import lombok.Data;
import java.io.Serial; import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate;
/** /**
* 保险记录 Excel * 保险记录 Excel
@@ -68,11 +67,11 @@ public class InsuranceRecordExcel implements Serializable {
@ExcelProperty("*保单号") @ExcelProperty("*保单号")
private String policyNo; private String policyNo;
@ExcelProperty("*开始日期") @ExcelProperty(value = "*开始日期", converter = org.springblade.common.excel.LenientDateStringConverter.class)
private LocalDate startDate; private String startDate;
@ExcelProperty("*结束日期") @ExcelProperty(value = "*结束日期", converter = org.springblade.common.excel.LenientDateStringConverter.class)
private LocalDate endDate; private String endDate;
@ExcelProperty("保额") @ExcelProperty("保额")
private BigDecimal insuredAmount; private BigDecimal insuredAmount;
@@ -83,8 +82,8 @@ public class InsuranceRecordExcel implements Serializable {
@ExcelProperty("发票号") @ExcelProperty("发票号")
private String invoiceNo; private String invoiceNo;
@ExcelProperty("开票日期") @ExcelProperty(value = "开票日期", converter = org.springblade.common.excel.LenientDateStringConverter.class)
private LocalDate invoiceDate; private String invoiceDate;
@ExcelProperty("备注") @ExcelProperty("备注")
private String remark; private String remark;
@@ -33,22 +33,20 @@ import cn.idev.excel.metadata.data.WriteCellData;
import cn.idev.excel.metadata.property.ExcelContentProperty; import cn.idev.excel.metadata.property.ExcelContentProperty;
import cn.idev.excel.util.DateUtils; import cn.idev.excel.util.DateUtils;
import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
/** /**
* 保养记录日期转换器,兼容日期文本、日期时间文本和 Excel 数值日期。 * 保养记录日期转换器,兼容日期文本、日期时间文本和 Excel 数值日期。
* <p>
* 文本解析委托公共宽容解析器(支持 2026-8-2、2026/8/2 等写法,口径见根工作区
* docs/import-spec.md);无法识别的文本按既有行为抛出转换异常,
* 文案带原值,与「日期格式无法识别」口径一致。
* *
* @author Chill * @author Chill
*/ */
public class MaintenancePlanDateTimeConverter implements Converter<LocalDateTime> { public class MaintenancePlanDateTimeConverter implements Converter<LocalDateTime> {
private static final String DEFAULT_DATE_FORMAT = "yyyy-MM-dd"; private static final String DEFAULT_DATE_FORMAT = "yyyy-MM-dd";
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final DateTimeFormatter DATE_TIME_MINUTE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern(DEFAULT_DATE_FORMAT);
@Override @Override
public Class<?> supportJavaTypeKey() { public Class<?> supportJavaTypeKey() {
@@ -71,16 +69,14 @@ public class MaintenancePlanDateTimeConverter implements Converter<LocalDateTime
if (value == null || value.trim().isEmpty()) { if (value == null || value.trim().isEmpty()) {
return null; return null;
} }
String normalizedValue = value.trim(); LocalDateTime parsed = org.springblade.common.excel.LenientDateParser.parseDateTimeOrNull(value);
try { if (parsed != null) {
return LocalDateTime.parse(normalizedValue, DATE_TIME_FORMATTER); return parsed;
} catch (DateTimeParseException ignored) {
try {
return LocalDateTime.parse(normalizedValue, DATE_TIME_MINUTE_FORMATTER);
} catch (DateTimeParseException ignoredMinute) {
return LocalDate.parse(normalizedValue, DATE_FORMATTER).atStartOfDay();
}
} }
// 无法识别的文本:与既有行为一致抛出转换异常(ExcelDataConvertException 是 RuntimeException
// 会带上本文案一路抛到 Controller,由全局异常处理返回给前端提示)。
throw new cn.idev.excel.exception.ExcelDataConvertException(-1, -1, cellData, contentProperty,
value.trim() + " 日期格式无法识别");
} }
@Override @Override
@@ -16,7 +16,6 @@ import lombok.Data;
import java.io.Serial; import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate;
/** /**
* 其他费用记录 Excel * 其他费用记录 Excel
@@ -40,8 +39,8 @@ public class OtherExpenseRecordExcel implements Serializable {
@ExcelProperty("*车牌号/船号") @ExcelProperty("*车牌号/船号")
private String vehicleNo; private String vehicleNo;
@ExcelProperty("*费用日期") @ExcelProperty(value = "*费用日期", converter = org.springblade.common.excel.LenientDateStringConverter.class)
private LocalDate expenseDate; private String expenseDate;
@ExcelProperty("*费用类型") @ExcelProperty("*费用类型")
private String expenseType; private String expenseType;
@@ -38,6 +38,10 @@ import java.time.format.DateTimeFormatter;
/** /**
* 换胎日期文本转换器,保留非法日期原值并兼容 Excel 数值日期。 * 换胎日期文本转换器,保留非法日期原值并兼容 Excel 数值日期。
* <p>
* 数值日期序列号转 ISO 文本,文本原样透传;文本的宽容解析由服务层委托
* {@link org.springblade.common.excel.LenientDateParser} 完成(支持 2026-8-2 等写法,
* 口径见根工作区 docs/import-spec.md)。
* *
* @author Chill * @author Chill
*/ */
@@ -108,10 +108,10 @@ public class WaybillExcel implements Serializable {
private String escortPhone; private String escortPhone;
@ExcelProperty("里程(km)") @ExcelProperty("里程(km)")
private BigDecimal mileage; private BigDecimal mileage;
@ExcelProperty("预计发货日期") @ExcelProperty(value = "预计发货日期", converter = org.springblade.common.excel.LenientDateStringConverter.class)
private LocalDate estimatedStartTime; private String estimatedStartTime;
@ExcelProperty("预计完成日期") @ExcelProperty(value = "预计完成日期", converter = org.springblade.common.excel.LenientDateStringConverter.class)
private LocalDate estimatedEndTime; private String estimatedEndTime;
@ExcelProperty("单价") @ExcelProperty("单价")
private BigDecimal unitPrice; private BigDecimal unitPrice;
@ExcelProperty("计价单位") @ExcelProperty("计价单位")
@@ -126,10 +126,10 @@ public class WaybillExcel implements Serializable {
private String businessStatus; private String businessStatus;
@ExcelProperty("数据来源") @ExcelProperty("数据来源")
private String dataSource; private String dataSource;
@ExcelProperty("开始日期") @ExcelProperty(value = "开始日期", converter = org.springblade.common.excel.LenientDateStringConverter.class)
private LocalDate startDate; private String startDate;
@ExcelProperty("结束日期") @ExcelProperty(value = "结束日期", converter = org.springblade.common.excel.LenientDateStringConverter.class)
private LocalDate endDate; private String endDate;
@ExcelProperty("计划名称") @ExcelProperty("计划名称")
private String planName; private String planName;
@ExcelProperty("多联总单") @ExcelProperty("多联总单")
@@ -94,6 +94,7 @@ public class AccidentRecordServiceImpl extends BaseServiceImpl<AccidentRecordMap
AccidentRecordExcel excel = data.get(index); AccidentRecordExcel excel = data.get(index);
try { try {
AccidentRecord accidentRecord = Objects.requireNonNull(BeanUtil.copyProperties(excel, AccidentRecord.class)); AccidentRecord accidentRecord = Objects.requireNonNull(BeanUtil.copyProperties(excel, AccidentRecord.class));
accidentRecord.setAccidentDate(org.springblade.common.excel.LenientDateParser.parseDate(excel.getAccidentDate(), "事故发生日期"));
prepare(accidentRecord); prepare(accidentRecord);
List<String> validationErrors = validateImportAccidentRecord(accidentRecord); List<String> validationErrors = validateImportAccidentRecord(accidentRecord);
if (Func.isNotEmpty(validationErrors)) { if (Func.isNotEmpty(validationErrors)) {
@@ -113,6 +113,8 @@ public class AnnualInspectionRecordServiceImpl extends BaseServiceImpl<AnnualIns
AnnualInspectionRecordExcel excel = data.get(index); AnnualInspectionRecordExcel excel = data.get(index);
try { try {
AnnualInspectionRecord annualInspectionRecord = Objects.requireNonNull(BeanUtil.copyProperties(excel, AnnualInspectionRecord.class)); AnnualInspectionRecord annualInspectionRecord = Objects.requireNonNull(BeanUtil.copyProperties(excel, AnnualInspectionRecord.class));
annualInspectionRecord.setInspectionAssessmentDate(org.springblade.common.excel.LenientDateParser.parseDate(excel.getInspectionAssessmentDate(), "检测评定日期"));
annualInspectionRecord.setValidUntilDate(org.springblade.common.excel.LenientDateParser.parseDate(excel.getValidUntilDate(), "有效期截止日"));
prepare(annualInspectionRecord); prepare(annualInspectionRecord);
List<String> validationErrors = validateImportAnnualInspectionRecord(annualInspectionRecord); List<String> validationErrors = validateImportAnnualInspectionRecord(annualInspectionRecord);
if (Func.isNotEmpty(validationErrors)) { if (Func.isNotEmpty(validationErrors)) {
@@ -91,6 +91,7 @@ public class EquipmentLedgerServiceImpl extends BaseServiceImpl<EquipmentLedgerM
EquipmentLedgerExcel excel = data.get(index); EquipmentLedgerExcel excel = data.get(index);
try { try {
EquipmentLedger equipmentLedger = Objects.requireNonNull(BeanUtil.copyProperties(excel, EquipmentLedger.class)); EquipmentLedger equipmentLedger = Objects.requireNonNull(BeanUtil.copyProperties(excel, EquipmentLedger.class));
equipmentLedger.setFactoryDate(org.springblade.common.excel.LenientDateParser.parseDate(excel.getFactoryDate(), "出厂日期"));
prepare(equipmentLedger); prepare(equipmentLedger);
if (equipmentLedger.getId() == null && Func.isEmpty(equipmentLedger.getEquipmentCode())) { if (equipmentLedger.getId() == null && Func.isEmpty(equipmentLedger.getEquipmentCode())) {
equipmentLedger.setEquipmentCode(nextEquipmentCode(importEquipmentCodes)); equipmentLedger.setEquipmentCode(nextEquipmentCode(importEquipmentCodes));
@@ -119,6 +119,9 @@ public class InsuranceRecordServiceImpl extends BaseServiceImpl<InsuranceRecordM
InsuranceRecordExcel excel = data.get(index); InsuranceRecordExcel excel = data.get(index);
try { try {
InsuranceRecord insuranceRecord = Objects.requireNonNull(BeanUtil.copyProperties(excel, InsuranceRecord.class)); InsuranceRecord insuranceRecord = Objects.requireNonNull(BeanUtil.copyProperties(excel, InsuranceRecord.class));
insuranceRecord.setStartDate(org.springblade.common.excel.LenientDateParser.parseDate(excel.getStartDate(), "开始日期"));
insuranceRecord.setEndDate(org.springblade.common.excel.LenientDateParser.parseDate(excel.getEndDate(), "结束日期"));
insuranceRecord.setInvoiceDate(org.springblade.common.excel.LenientDateParser.parseDate(excel.getInvoiceDate(), "开票日期"));
prepare(insuranceRecord); prepare(insuranceRecord);
List<String> validationErrors = validateImportInsuranceRecord(insuranceRecord); List<String> validationErrors = validateImportInsuranceRecord(insuranceRecord);
if (Func.isNotEmpty(insuranceRecord.getVehicleType()) && Func.isNotEmpty(insuranceRecord.getInsuranceType()) && Func.isNotEmpty(insuranceRecord.getPolicyNo())) { if (Func.isNotEmpty(insuranceRecord.getVehicleType()) && Func.isNotEmpty(insuranceRecord.getInsuranceType()) && Func.isNotEmpty(insuranceRecord.getPolicyNo())) {
@@ -75,6 +75,7 @@ public class OtherExpenseRecordServiceImpl extends BaseServiceImpl<OtherExpenseR
OtherExpenseRecordExcel excel = data.get(index); OtherExpenseRecordExcel excel = data.get(index);
try { try {
OtherExpenseRecord otherExpenseRecord = Objects.requireNonNull(BeanUtil.copyProperties(excel, OtherExpenseRecord.class)); OtherExpenseRecord otherExpenseRecord = Objects.requireNonNull(BeanUtil.copyProperties(excel, OtherExpenseRecord.class));
otherExpenseRecord.setExpenseDate(org.springblade.common.excel.LenientDateParser.parseDate(excel.getExpenseDate(), "费用日期"));
otherExpenseRecord.setDataSource("批量导入"); otherExpenseRecord.setDataSource("批量导入");
prepare(otherExpenseRecord); prepare(otherExpenseRecord);
List<String> validationErrors = validateImportOtherExpenseRecord(otherExpenseRecord); List<String> validationErrors = validateImportOtherExpenseRecord(otherExpenseRecord);
@@ -43,8 +43,6 @@ import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
@@ -64,7 +62,6 @@ public class TireReplacementRecordServiceImpl extends BaseServiceImpl<TireReplac
private static final int REMARK_MAX_LENGTH = 500; private static final int REMARK_MAX_LENGTH = 500;
private static final int ATTACHMENTS_MAX_LENGTH = 1000; private static final int ATTACHMENTS_MAX_LENGTH = 1000;
private static final int MONEY_SCALE = 2; private static final int MONEY_SCALE = 2;
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE;
@Override @Override
public IPage<TireReplacementRecordVO> selectTireReplacementRecordPage(IPage<TireReplacementRecordVO> page, TireReplacementRecordVO tireReplacementRecord) { public IPage<TireReplacementRecordVO> selectTireReplacementRecordPage(IPage<TireReplacementRecordVO> page, TireReplacementRecordVO tireReplacementRecord) {
@@ -144,9 +141,9 @@ public class TireReplacementRecordServiceImpl extends BaseServiceImpl<TireReplac
return null; return null;
} }
try { try {
return LocalDate.parse(normalizedValue, DATE_FORMATTER); return org.springblade.common.excel.LenientDateParser.parseDate(normalizedValue, "换胎时间");
} catch (DateTimeParseException exception) { } catch (Exception exception) {
validationErrors.add("换胎时间格式不正确,请使用yyyy-MM-dd格式并填写有效日期"); validationErrors.add(exception.getMessage());
return null; return null;
} }
} }
@@ -510,11 +510,12 @@ public class TransportPlanServiceImpl extends BaseServiceImpl<TransportPlanMappe
if (Func.isEmpty(value)) { if (Func.isEmpty(value)) {
return null; return null;
} }
try { // 宽容解析接受 2026-8-22026/8/2 等写法口径见根工作区 docs/import-spec.md
return LocalDate.parse(value.trim(), DateTimeFormatter.ISO_LOCAL_DATE); LocalDate date = org.springblade.common.excel.LenientDateParser.parseDateLenientlyOrNull(value);
} catch (Exception exception) { if (date == null) {
throw new ServiceException(fieldName + "格式必须为 YYYY-MM-DD"); throw new ServiceException(fieldName + " 日期格式无法识别:" + value.trim());
} }
return date;
} }
private void validateImportLength(String value, int maxLength, String fieldName) { private void validateImportLength(String value, int maxLength, String fieldName) {
@@ -60,7 +60,6 @@ import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
@@ -1346,14 +1345,12 @@ public class TransportReconciliationServiceImpl
private LocalDateTime parseTimeNullable(String value, String field) { private LocalDateTime parseTimeNullable(String value, String field) {
if (Func.isEmpty(value)) return null; if (Func.isEmpty(value)) return null;
for (String pattern : List.of("yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm", "yyyy-M-d HH:mm:ss", "yyyy-M-d HH:mm", // 宽容解析统一委托公共解析器接受 2026-8-2 12:3 等写法口径见根工作区 docs/import-spec.md
"yyyy/MM/dd HH:mm:ss", "yyyy/MM/dd HH:mm", "yyyy/M/d HH:mm:ss", "yyyy/M/d HH:mm")) { LocalDateTime result = org.springblade.common.excel.LenientDateParser.parseDateTimeOrNull(value);
try { return LocalDateTime.parse(value.trim(), DateTimeFormatter.ofPattern(pattern)); } catch (DateTimeParseException ignored) { } if (result == null) {
throw new ServiceException(field + " 日期格式无法识别:" + value.trim());
} }
for (String pattern : List.of("yyyy-MM-dd", "yyyy-M-d", "yyyy/MM/dd", "yyyy/M/d")) { return result;
try { return LocalDate.parse(value.trim(), DateTimeFormatter.ofPattern(pattern)).atStartOfDay(); } catch (DateTimeParseException ignored) { }
}
throw new ServiceException(field + "格式应为yyyy-MM-dd HH:mm:ss或yyyy-MM-dd");
} }
private String matchKey(TransportReconciliationInternal row) { private String matchKey(TransportReconciliationInternal row) {
@@ -52,7 +52,6 @@ import java.io.IOException;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
@@ -70,7 +69,6 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor @RequiredArgsConstructor
public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImportBatchMapper, WaybillImportBatch> implements IWaybillImportBatchService { public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImportBatchMapper, WaybillImportBatch> implements IWaybillImportBatchService {
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
/** 批量导入状态仅保留草稿与导入完成两种。 */ /** 批量导入状态仅保留草稿与导入完成两种。 */
private static final String STATUS_DRAFT = "draft"; private static final String STATUS_DRAFT = "draft";
private static final String STATUS_COMPLETED = "completed"; private static final String STATUS_COMPLETED = "completed";
@@ -467,11 +465,12 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
throw new ServiceException(fieldName + "不能为空"); throw new ServiceException(fieldName + "不能为空");
} }
String text = String.valueOf(value).trim(); String text = String.valueOf(value).trim();
try { // 宽容解析接受 2026-8-22026-9-1 8:0:0 等写法口径见根工作区 docs/import-spec.md
return text.length() == 10 ? LocalDate.parse(text) : LocalDate.parse(text, DATE_TIME_FORMATTER); LocalDate date = org.springblade.common.excel.LenientDateParser.parseDateLenientlyOrNull(text);
} catch (DateTimeParseException exception) { if (date == null) {
throw new ServiceException(fieldName + "格式必须为 YYYY-MM-DD HH:mm:ss"); throw new ServiceException(fieldName + " 日期格式无法识别:" + text);
} }
return date;
} }
private BigDecimal defaultQuantity(BigDecimal quantity) { private BigDecimal defaultQuantity(BigDecimal quantity) {
@@ -951,12 +950,13 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
} }
String text = String.valueOf(value).trim(); String text = String.valueOf(value).trim();
try { // 宽容解析接受 2026-8-22026-9-1 8:0:0 等写法口径见根工作区 docs/import-spec.md
return text.length() == 10 ? LocalDate.parse(text) : LocalDate.parse(text, DATE_TIME_FORMATTER); LocalDate date = org.springblade.common.excel.LenientDateParser.parseDateLenientlyOrNull(text);
} catch (DateTimeParseException exception) { if (date == null) {
errors.add(fieldName + "格式必须为日期格式(YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss"); errors.add(fieldName + " 日期格式无法识别:" + text);
return null; return null;
} }
return date;
} }
private Map<String, String> loadTransportTypeOptions() { private Map<String, String> loadTransportTypeOptions() {
@@ -885,6 +885,10 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
WaybillExcel excel = data.get(index); WaybillExcel excel = data.get(index);
try { try {
Waybill waybill = Objects.requireNonNull(BeanUtil.copyProperties(excel, Waybill.class)); Waybill waybill = Objects.requireNonNull(BeanUtil.copyProperties(excel, Waybill.class));
waybill.setEstimatedStartTime(org.springblade.common.excel.LenientDateParser.parseDate(excel.getEstimatedStartTime(), "预计发货日期"));
waybill.setEstimatedEndTime(org.springblade.common.excel.LenientDateParser.parseDate(excel.getEstimatedEndTime(), "预计完成日期"));
waybill.setStartDate(org.springblade.common.excel.LenientDateParser.parseDate(excel.getStartDate(), "开始日期"));
waybill.setEndDate(org.springblade.common.excel.LenientDateParser.parseDate(excel.getEndDate(), "结束日期"));
waybill.setDataSource("批量导入"); waybill.setDataSource("批量导入");
waybill.setCreateTime(null); waybill.setCreateTime(null);
waybill.setUpdateTime(null); waybill.setUpdateTime(null);