批量导入日期宽容解析:兼容 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
@@ -36,7 +36,6 @@ import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
* 事故记录 Excel
@@ -60,8 +59,8 @@ public class AccidentRecordExcel implements Serializable {
@ExcelProperty("*车牌号/船号")
private String vehicleNo;
@ExcelProperty("*事故发生日期")
private LocalDate accidentDate;
@ExcelProperty(value = "*事故发生日期", converter = org.springblade.common.excel.LenientDateStringConverter.class)
private String accidentDate;
@ExcelProperty("事故发生地点")
private String accidentLocation;
@@ -35,7 +35,6 @@ import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
* 年检记录 Excel
@@ -59,11 +58,11 @@ public class AnnualInspectionRecordExcel implements Serializable {
@ExcelProperty("*车牌号/船号")
private String vehicleNo;
@ExcelProperty("*检测评定日期")
private LocalDate inspectionAssessmentDate;
@ExcelProperty(value = "*检测评定日期", converter = org.springblade.common.excel.LenientDateStringConverter.class)
private String inspectionAssessmentDate;
@ExcelProperty("*有效期截止日")
private LocalDate validUntilDate;
@ExcelProperty(value = "*有效期截止日", converter = org.springblade.common.excel.LenientDateStringConverter.class)
private String validUntilDate;
@ExcelProperty("*车辆技术等级")
private String vehicleTechnicalLevel;
@@ -7,12 +7,10 @@ package org.springblade.transport.excel;
import cn.idev.excel.annotation.ExcelIgnore;
import cn.idev.excel.annotation.ExcelProperty;
import cn.idev.excel.annotation.format.DateTimeFormat;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.time.LocalDate;
/**
* 设备台账 Excel
@@ -29,7 +27,7 @@ public class EquipmentLedgerExcel implements Serializable {
@ExcelProperty("设备品牌") private String equipmentBrand;
@ExcelProperty("设备类型") private String equipmentType;
@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;
@ExcelIgnore private String errorMessage;
}
@@ -35,7 +35,6 @@ import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
* 保险记录 Excel
@@ -68,11 +67,11 @@ public class InsuranceRecordExcel implements Serializable {
@ExcelProperty("*保单号")
private String policyNo;
@ExcelProperty("*开始日期")
private LocalDate startDate;
@ExcelProperty(value = "*开始日期", converter = org.springblade.common.excel.LenientDateStringConverter.class)
private String startDate;
@ExcelProperty("*结束日期")
private LocalDate endDate;
@ExcelProperty(value = "*结束日期", converter = org.springblade.common.excel.LenientDateStringConverter.class)
private String endDate;
@ExcelProperty("保额")
private BigDecimal insuredAmount;
@@ -83,8 +82,8 @@ public class InsuranceRecordExcel implements Serializable {
@ExcelProperty("发票号")
private String invoiceNo;
@ExcelProperty("开票日期")
private LocalDate invoiceDate;
@ExcelProperty(value = "开票日期", converter = org.springblade.common.excel.LenientDateStringConverter.class)
private String invoiceDate;
@ExcelProperty("备注")
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.util.DateUtils;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
/**
* 保养记录日期转换器,兼容日期文本、日期时间文本和 Excel 数值日期。
* <p>
* 文本解析委托公共宽容解析器(支持 2026-8-2、2026/8/2 等写法,口径见根工作区
* docs/import-spec.md);无法识别的文本按既有行为抛出转换异常,
* 文案带原值,与「日期格式无法识别」口径一致。
*
* @author Chill
*/
public class MaintenancePlanDateTimeConverter implements Converter<LocalDateTime> {
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
public Class<?> supportJavaTypeKey() {
@@ -71,16 +69,14 @@ public class MaintenancePlanDateTimeConverter implements Converter<LocalDateTime
if (value == null || value.trim().isEmpty()) {
return null;
}
String normalizedValue = value.trim();
try {
return LocalDateTime.parse(normalizedValue, DATE_TIME_FORMATTER);
} catch (DateTimeParseException ignored) {
try {
return LocalDateTime.parse(normalizedValue, DATE_TIME_MINUTE_FORMATTER);
} catch (DateTimeParseException ignoredMinute) {
return LocalDate.parse(normalizedValue, DATE_FORMATTER).atStartOfDay();
}
LocalDateTime parsed = org.springblade.common.excel.LenientDateParser.parseDateTimeOrNull(value);
if (parsed != null) {
return parsed;
}
// 无法识别的文本:与既有行为一致抛出转换异常(ExcelDataConvertException 是 RuntimeException
// 会带上本文案一路抛到 Controller,由全局异常处理返回给前端提示)。
throw new cn.idev.excel.exception.ExcelDataConvertException(-1, -1, cellData, contentProperty,
value.trim() + " 日期格式无法识别");
}
@Override
@@ -16,7 +16,6 @@ import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
* 其他费用记录 Excel
@@ -40,8 +39,8 @@ public class OtherExpenseRecordExcel implements Serializable {
@ExcelProperty("*车牌号/船号")
private String vehicleNo;
@ExcelProperty("*费用日期")
private LocalDate expenseDate;
@ExcelProperty(value = "*费用日期", converter = org.springblade.common.excel.LenientDateStringConverter.class)
private String expenseDate;
@ExcelProperty("*费用类型")
private String expenseType;
@@ -38,6 +38,10 @@ import java.time.format.DateTimeFormatter;
/**
* 换胎日期文本转换器,保留非法日期原值并兼容 Excel 数值日期。
* <p>
* 数值日期序列号转 ISO 文本,文本原样透传;文本的宽容解析由服务层委托
* {@link org.springblade.common.excel.LenientDateParser} 完成(支持 2026-8-2 等写法,
* 口径见根工作区 docs/import-spec.md)。
*
* @author Chill
*/
@@ -108,10 +108,10 @@ public class WaybillExcel implements Serializable {
private String escortPhone;
@ExcelProperty("里程(km)")
private BigDecimal mileage;
@ExcelProperty("预计发货日期")
private LocalDate estimatedStartTime;
@ExcelProperty("预计完成日期")
private LocalDate estimatedEndTime;
@ExcelProperty(value = "预计发货日期", converter = org.springblade.common.excel.LenientDateStringConverter.class)
private String estimatedStartTime;
@ExcelProperty(value = "预计完成日期", converter = org.springblade.common.excel.LenientDateStringConverter.class)
private String estimatedEndTime;
@ExcelProperty("单价")
private BigDecimal unitPrice;
@ExcelProperty("计价单位")
@@ -126,10 +126,10 @@ public class WaybillExcel implements Serializable {
private String businessStatus;
@ExcelProperty("数据来源")
private String dataSource;
@ExcelProperty("开始日期")
private LocalDate startDate;
@ExcelProperty("结束日期")
private LocalDate endDate;
@ExcelProperty(value = "开始日期", converter = org.springblade.common.excel.LenientDateStringConverter.class)
private String startDate;
@ExcelProperty(value = "结束日期", converter = org.springblade.common.excel.LenientDateStringConverter.class)
private String endDate;
@ExcelProperty("计划名称")
private String planName;
@ExcelProperty("多联总单")
@@ -94,6 +94,7 @@ public class AccidentRecordServiceImpl extends BaseServiceImpl<AccidentRecordMap
AccidentRecordExcel excel = data.get(index);
try {
AccidentRecord accidentRecord = Objects.requireNonNull(BeanUtil.copyProperties(excel, AccidentRecord.class));
accidentRecord.setAccidentDate(org.springblade.common.excel.LenientDateParser.parseDate(excel.getAccidentDate(), "事故发生日期"));
prepare(accidentRecord);
List<String> validationErrors = validateImportAccidentRecord(accidentRecord);
if (Func.isNotEmpty(validationErrors)) {
@@ -113,6 +113,8 @@ public class AnnualInspectionRecordServiceImpl extends BaseServiceImpl<AnnualIns
AnnualInspectionRecordExcel excel = data.get(index);
try {
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);
List<String> validationErrors = validateImportAnnualInspectionRecord(annualInspectionRecord);
if (Func.isNotEmpty(validationErrors)) {
@@ -91,6 +91,7 @@ public class EquipmentLedgerServiceImpl extends BaseServiceImpl<EquipmentLedgerM
EquipmentLedgerExcel excel = data.get(index);
try {
EquipmentLedger equipmentLedger = Objects.requireNonNull(BeanUtil.copyProperties(excel, EquipmentLedger.class));
equipmentLedger.setFactoryDate(org.springblade.common.excel.LenientDateParser.parseDate(excel.getFactoryDate(), "出厂日期"));
prepare(equipmentLedger);
if (equipmentLedger.getId() == null && Func.isEmpty(equipmentLedger.getEquipmentCode())) {
equipmentLedger.setEquipmentCode(nextEquipmentCode(importEquipmentCodes));
@@ -119,6 +119,9 @@ public class InsuranceRecordServiceImpl extends BaseServiceImpl<InsuranceRecordM
InsuranceRecordExcel excel = data.get(index);
try {
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);
List<String> validationErrors = validateImportInsuranceRecord(insuranceRecord);
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);
try {
OtherExpenseRecord otherExpenseRecord = Objects.requireNonNull(BeanUtil.copyProperties(excel, OtherExpenseRecord.class));
otherExpenseRecord.setExpenseDate(org.springblade.common.excel.LenientDateParser.parseDate(excel.getExpenseDate(), "费用日期"));
otherExpenseRecord.setDataSource("批量导入");
prepare(otherExpenseRecord);
List<String> validationErrors = validateImportOtherExpenseRecord(otherExpenseRecord);
@@ -43,8 +43,6 @@ import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
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 ATTACHMENTS_MAX_LENGTH = 1000;
private static final int MONEY_SCALE = 2;
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE;
@Override
public IPage<TireReplacementRecordVO> selectTireReplacementRecordPage(IPage<TireReplacementRecordVO> page, TireReplacementRecordVO tireReplacementRecord) {
@@ -144,9 +141,9 @@ public class TireReplacementRecordServiceImpl extends BaseServiceImpl<TireReplac
return null;
}
try {
return LocalDate.parse(normalizedValue, DATE_FORMATTER);
} catch (DateTimeParseException exception) {
validationErrors.add("换胎时间格式不正确,请使用yyyy-MM-dd格式并填写有效日期");
return org.springblade.common.excel.LenientDateParser.parseDate(normalizedValue, "换胎时间");
} catch (Exception exception) {
validationErrors.add(exception.getMessage());
return null;
}
}
@@ -510,11 +510,12 @@ public class TransportPlanServiceImpl extends BaseServiceImpl<TransportPlanMappe
if (Func.isEmpty(value)) {
return null;
}
try {
return LocalDate.parse(value.trim(), DateTimeFormatter.ISO_LOCAL_DATE);
} catch (Exception exception) {
throw new ServiceException(fieldName + "格式必须为 YYYY-MM-DD");
// 宽容解析:接受 2026-8-2、2026/8/2 等写法(口径见根工作区 docs/import-spec.md)。
LocalDate date = org.springblade.common.excel.LenientDateParser.parseDateLenientlyOrNull(value);
if (date == null) {
throw new ServiceException(fieldName + " 日期格式无法识别:" + value.trim());
}
return date;
}
private void validateImportLength(String value, int maxLength, String fieldName) {
@@ -60,7 +60,6 @@ import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
@@ -1346,14 +1345,12 @@ public class TransportReconciliationServiceImpl
private LocalDateTime parseTimeNullable(String value, String field) {
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",
"yyyy/MM/dd HH:mm:ss", "yyyy/MM/dd HH:mm", "yyyy/M/d HH:mm:ss", "yyyy/M/d HH:mm")) {
try { return LocalDateTime.parse(value.trim(), DateTimeFormatter.ofPattern(pattern)); } catch (DateTimeParseException ignored) { }
// 宽容解析:统一委托公共解析器,接受 2026-8-2 12:3 等写法(口径见根工作区 docs/import-spec.md)。
LocalDateTime result = org.springblade.common.excel.LenientDateParser.parseDateTimeOrNull(value);
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")) {
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");
return result;
}
private String matchKey(TransportReconciliationInternal row) {
@@ -52,7 +52,6 @@ import java.io.IOException;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
@@ -70,7 +69,6 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor
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_COMPLETED = "completed";
@@ -467,11 +465,12 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
throw new ServiceException(fieldName + "不能为空");
}
String text = String.valueOf(value).trim();
try {
return text.length() == 10 ? LocalDate.parse(text) : LocalDate.parse(text, DATE_TIME_FORMATTER);
} catch (DateTimeParseException exception) {
throw new ServiceException(fieldName + "格式必须为 YYYY-MM-DD HH:mm:ss");
// 宽容解析:接受 2026-8-2、2026-9-1 8:0:0 等写法(口径见根工作区 docs/import-spec.md)。
LocalDate date = org.springblade.common.excel.LenientDateParser.parseDateLenientlyOrNull(text);
if (date == null) {
throw new ServiceException(fieldName + " 日期格式无法识别:" + text);
}
return date;
}
private BigDecimal defaultQuantity(BigDecimal quantity) {
@@ -951,12 +950,13 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
}
String text = String.valueOf(value).trim();
try {
return text.length() == 10 ? LocalDate.parse(text) : LocalDate.parse(text, DATE_TIME_FORMATTER);
} catch (DateTimeParseException exception) {
errors.add(fieldName + "格式必须为日期格式(YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss");
// 宽容解析:接受 2026-8-2、2026-9-1 8:0:0 等写法(口径见根工作区 docs/import-spec.md)。
LocalDate date = org.springblade.common.excel.LenientDateParser.parseDateLenientlyOrNull(text);
if (date == null) {
errors.add(fieldName + " 日期格式无法识别:" + text);
return null;
}
return date;
}
private Map<String, String> loadTransportTypeOptions() {
@@ -885,6 +885,10 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
WaybillExcel excel = data.get(index);
try {
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.setCreateTime(null);
waybill.setUpdateTime(null);