From f370f56a929a9a6d84de63bc5e3319244916b541 Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Tue, 8 Sep 2026 17:50:20 +0800 Subject: [PATCH] =?UTF-8?q?1=E3=80=81=E8=B0=83=E6=95=B4=E5=87=AD=E8=AF=81?= =?UTF-8?q?=202=E3=80=81=E8=B0=83=E6=95=B4=E8=BF=90=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../transport/pojo/entity/TransportPlan.java | 6 + .../transport/pojo/entity/VoucherManage.java | 1 + .../transport/pojo/vo/VoucherFileVO.java | 3 + .../transport/pojo/vo/VoucherFolderVO.java | 34 ++ .../TransportReconciliationController.java | 14 + .../controller/VoucherManageController.java | 46 ++ .../excel/TransportPlanImportExcel.java | 46 +- .../transport/excel/WaybillExcel.java | 11 +- .../excel/WaybillImportBatchExcel.java | 66 ++- .../transport/mapper/VoucherManageMapper.java | 17 + .../ITransportReconciliationService.java | 2 + .../service/IVoucherManageService.java | 8 + .../impl/TransportPlanServiceImpl.java | 283 +++++++++-- .../TransportReconciliationServiceImpl.java | 187 +++++-- .../impl/VoucherManageServiceImpl.java | 464 +++++++++++++++++- .../impl/WaybillImportBatchServiceImpl.java | 404 ++++++++++++++- .../service/impl/WaybillServiceImpl.java | 3 + .../transport/wrapper/WaybillWrapper.java | 2 +- ...ade_transport_plan_add_fields_20260908.sql | 14 + .../blade_voucher_manage_audit_20260908.sql | 18 + 20 files changed, 1476 insertions(+), 153 deletions(-) create mode 100644 blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/VoucherFolderVO.java create mode 100644 doc/sql/transport/blade_transport_plan_add_fields_20260908.sql create mode 100644 doc/sql/transport/blade_voucher_manage_audit_20260908.sql diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportPlan.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportPlan.java index 13c173e..a25519c 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportPlan.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportPlan.java @@ -145,4 +145,10 @@ public class TransportPlan extends TenantEntity { @Schema(description = "备注") private String remark; + @Schema(description = "里程(km)") + private java.math.BigDecimal mileage; + + @Schema(description = "同一计划标识号") + private String planGroupId; + } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/VoucherManage.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/VoucherManage.java index 43305b9..caf41a8 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/VoucherManage.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/VoucherManage.java @@ -26,6 +26,7 @@ public class VoucherManage extends TenantEntity { private Long fileTaskId; private String uploadSource; private String carrierName; + private Long carrierId; private String processStatus; private Integer voucherCount; private Integer relatedWaybillCount; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/VoucherFileVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/VoucherFileVO.java index 7ea0a3a..04cc4d5 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/VoucherFileVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/VoucherFileVO.java @@ -4,6 +4,7 @@ import lombok.Data; import java.io.Serial; import java.io.Serializable; +import java.util.Date; /** * 凭证解压文件详情。 @@ -29,4 +30,6 @@ public class VoucherFileVO implements Serializable { private String fileType; private Integer matched; private String url; + private Date createTime; + private Date updateTime; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/VoucherFolderVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/VoucherFolderVO.java new file mode 100644 index 0000000..1f70c94 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/VoucherFolderVO.java @@ -0,0 +1,34 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + */ +package org.springblade.transport.pojo.vo; + +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.Date; +import java.util.List; + +/** 执行凭证车牌文件夹详情。 */ +@Data +public class VoucherFolderVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private Long voucherId; + private String voucherBatchNo; + private String voucherNo; + private String plateNo; + private String folderName; + private Long waybillId; + private String waybillNo; + private Integer matched; + private String processStatus; + private String fileName; + private Date createTime; + private Date updateTime; + private List files; +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/TransportReconciliationController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/TransportReconciliationController.java index bfdbce7..a963aef 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/TransportReconciliationController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/TransportReconciliationController.java @@ -155,6 +155,13 @@ public class TransportReconciliationController extends BladeController { @Operation(summary = "自动匹配内部账单") public R match(@RequestParam Long id) { reconciliationService.autoMatch(id); return R.success("匹配完成"); } + @PostMapping("/match-preview") + @ApiOperationSupport(order = 9) + @Operation(summary = "预览自动匹配内部账单") + public R matchPreview(@RequestBody TransportReconciliationVO request) { + return R.data(reconciliationService.matchPreview(request)); + } + @PostMapping("/manual-match") @ApiOperationSupport(order = 10) @Operation(summary = "人工匹配账单明细") @@ -179,4 +186,11 @@ public class TransportReconciliationController extends BladeController { @ApiOperationSupport(order = 14) @Operation(summary = "完成运输对账") public R complete(@RequestParam Long id) { reconciliationService.complete(id); return R.success("对账单确认完成"); } + + @PostMapping("/complete-with-data") + @ApiOperationSupport(order = 14) + @Operation(summary = "保存明细并完成运输对账") + public R completeWithData(@RequestBody TransportReconciliationVO request) { + return R.data(reconciliationService.completeWithData(request)); + } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/VoucherManageController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/VoucherManageController.java index dd98f3f..6727b97 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/VoucherManageController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/VoucherManageController.java @@ -15,6 +15,8 @@ import org.springblade.transport.pojo.dto.VoucherUploadDraftRequest; import org.springblade.transport.pojo.dto.VoucherFileCompleteRequest; import org.springblade.transport.pojo.entity.VoucherManage; import org.springblade.transport.pojo.vo.VoucherManageVO; +import org.springblade.transport.pojo.vo.VoucherFolderVO; +import org.springframework.web.multipart.MultipartFile; import org.springblade.transport.service.IVoucherManageService; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -46,6 +48,34 @@ public class VoucherManageController extends BladeController { @Operation(summary = "凭证详情") public R detail(@RequestParam Long id) { return R.data(voucherManageService.detail(id)); } + @GetMapping("/folder-page") + @Operation(summary = "执行凭证文件夹分页") + public R> folderPage(@RequestParam Long voucherId, Query query, + @RequestParam(required = false) String plateNo, @RequestParam(required = false) Integer matched) { + return R.data(voucherManageService.folderPage(Condition.getPage(query), voucherId, plateNo, matched)); + } + + @GetMapping("/folder-detail") + @Operation(summary = "执行凭证文件夹详情") + public R folderDetail(@RequestParam Long voucherId, @RequestParam String plateNo) { + return R.data(voucherManageService.folderDetail(voucherId, plateNo)); + } + + @PostMapping(value = "/folder-replace", consumes = "multipart/form-data") + @Operation(summary = "替换单个车牌凭证") + public R replaceFolder(@RequestParam Long voucherId, @RequestParam String plateNo, + @RequestParam("file") MultipartFile file) { + voucherManageService.replaceFolder(voucherId, plateNo, file); + return R.success("上传成功"); + } + + @PostMapping("/folder-remove") + @Operation(summary = "删除车牌凭证") + public R removeFolder(@RequestParam Long voucherId, @RequestParam String plateNo) { + voucherManageService.removeFolder(voucherId, plateNo); + return R.success("删除成功"); + } + @PostMapping("/submit") @ApiOperationSupport(order = 3) @Operation(summary = "上传凭证或更换运输批次") @@ -85,4 +115,20 @@ public class VoucherManageController extends BladeController { @RequestParam(required = false) String createTimeStart, @RequestParam(required = false) String createTimeEnd) { return R.data(voucherManageService.selectableWaybillBatches(Condition.getPage(query), batchNo, createUser, waybillCount, createTimeStart, createTimeEnd)); } + + @PostMapping("/audit-pass") + @ApiOperationSupport(order = 7) + @Operation(summary = "审核通过") + public R auditPass(@RequestParam Long id) { + voucherManageService.auditPass(id); + return R.success("审核通过"); + } + + @PostMapping("/audit-reject") + @ApiOperationSupport(order = 8) + @Operation(summary = "审核驳回") + public R auditReject(@RequestParam Long id, @RequestParam(required = false) String rejectReason) { + voucherManageService.auditReject(id, rejectReason); + return R.success("审核驳回"); + } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/TransportPlanImportExcel.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/TransportPlanImportExcel.java index 09c7523..f97e8f6 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/TransportPlanImportExcel.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/TransportPlanImportExcel.java @@ -49,46 +49,44 @@ public class TransportPlanImportExcel implements Serializable { @ExcelProperty("*计划名称") private String planName; - @ExcelProperty("*运输方式") + @ExcelProperty("*运输类型") private String transportType; - @ExcelProperty("*计划开始日期") - private String planStartDate; - @ExcelProperty("*计划结束日期") - private String planEndDate; + @ExcelProperty("*发货地址") + private String departureAddress; + @ExcelProperty("发货联系人") + private String departureContact; + @ExcelProperty("发货联系人电话") + private String departurePhone; + @ExcelProperty("*到货地址") + private String arrivalAddress; + @ExcelProperty("收货联系人") + private String arrivalContact; + @ExcelProperty("收货联系人电话") + private String arrivalPhone; @ExcelProperty("货物名称") private String cargoName; @ExcelProperty("*货物类型") private String cargoType; @ExcelProperty("数量") private BigDecimal quantity; - @ExcelProperty("数量单位") + @ExcelProperty("计量单位") private String quantityUnit; @ExcelProperty("包装") private String packageType; - @ExcelProperty("品牌") - private String brand; @ExcelProperty("规格") private String specification; @ExcelProperty("型号") private String model; - @ExcelProperty("物料编码") - private String materialCode; - @ExcelProperty("设备编码") - private String deviceCode; - @ExcelProperty("*发货地址") - private String departureAddress; - @ExcelProperty("发货联系人") - private String departureContact; - @ExcelProperty("发货联系方式") - private String departurePhone; - @ExcelProperty("*收货地址") - private String arrivalAddress; - @ExcelProperty("收货联系人") - private String arrivalContact; - @ExcelProperty("收货联系方式") - private String arrivalPhone; + @ExcelProperty("里程(km)") + private BigDecimal mileage; + @ExcelProperty("计划开始时间") + private String planStartDate; + @ExcelProperty("计划结束时间") + private String planEndDate; @ExcelProperty("备注") private String remark; + @ExcelProperty("同一计划标识号") + private String planGroupId; @ExcelIgnore private String errorMessage; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/WaybillExcel.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/WaybillExcel.java index 41bd797..cd399ce 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/WaybillExcel.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/WaybillExcel.java @@ -34,8 +34,7 @@ import java.io.Serial; import java.io.Serializable; import java.math.BigDecimal; import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.LocalDate; +import java.util.Date; /** * 运单管理 Excel @@ -151,12 +150,12 @@ public class WaybillExcel implements Serializable { private String createUserName; @ExcelProperty("更新人") private String updateUserName; - @ExcelProperty(value = "创建时间", converter = MaintenancePlanDateTimeConverter.class) + @ExcelProperty("创建时间") @DateTimeFormat("yyyy-MM-dd HH:mm:ss") - private LocalDateTime createTime; - @ExcelProperty(value = "更新时间", converter = MaintenancePlanDateTimeConverter.class) + private Date createTime; + @ExcelProperty("更新时间") @DateTimeFormat("yyyy-MM-dd HH:mm:ss") - private LocalDateTime updateTime; + private Date updateTime; @ExcelIgnore private String errorMessage; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/WaybillImportBatchExcel.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/WaybillImportBatchExcel.java index c8a05c9..9232f45 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/WaybillImportBatchExcel.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/WaybillImportBatchExcel.java @@ -22,45 +22,67 @@ public class WaybillImportBatchExcel implements Serializable { @Serial private static final long serialVersionUID = 1L; + @ExcelProperty("序号") + private Integer serialNumber; @ExcelProperty("原始单号") private String originalNo; + @ExcelProperty("配载标识号") + private String loadingIdentifier; @ExcelProperty("*车牌号/航班号/船号/班列号") private String vehicleNo; - @ExcelProperty("*司机/船长") - private String driverName; - @ExcelProperty("*运输类型") + @ExcelProperty("*运输方式") private String transportType; + @ExcelProperty("司机/船长姓名") + private String driverName; + @ExcelProperty("司机/船长手机号") + private String driverPhone; + @ExcelProperty("*发货地址") + private String departureAddress; + @ExcelProperty("发货联系人") + private String departureContact; + @ExcelProperty("发货联系人电话") + private String departurePhone; + @ExcelProperty("*到货地址") + private String arrivalAddress; + @ExcelProperty("收货联系人") + private String arrivalContact; + @ExcelProperty("收货联系人电话") + private String arrivalPhone; @ExcelProperty("*货物名称") private String cargoName; @ExcelProperty("*货物类型") private String cargoType; - @ExcelProperty("重量") + @ExcelProperty("包装") + private String packageType; + @ExcelProperty("*数量") private BigDecimal quantity; - @ExcelProperty("*发货地址") - private String departureAddress; - @ExcelProperty("*发货联系人") - private String departureContact; - @ExcelProperty("*发货联系人电话") - private String departurePhone; - @ExcelProperty("*到货地址") - private String arrivalAddress; - @ExcelProperty("*到货联系人") - private String arrivalContact; - @ExcelProperty("*收货联系人电话") - private String arrivalPhone; - @ExcelProperty("*开始时间") - private String startDate; - @ExcelProperty("*结束时间") - private String endDate; - @ExcelProperty("*单价") + @ExcelProperty("*数量单位") + private String quantityUnit; + @ExcelProperty("规格") + private String specification; + @ExcelProperty("型号") + private String model; + @ExcelProperty("里程(km)") + private BigDecimal mileage; + @ExcelProperty("单价") private BigDecimal unitPrice; - @ExcelProperty("*运费") + @ExcelProperty("运费") private BigDecimal freight; @ExcelProperty("其他费用合计") private BigDecimal otherFeeTotal; @ExcelProperty("运费合计") private BigDecimal freightTotal; + @ExcelProperty("*实际发货时间") + private String actualStartDate; + @ExcelProperty("*实际完成时间") + private String actualEndDate; + @ExcelProperty("预计发货时间") + private String planStartDate; + @ExcelProperty("预计完成时间") + private String planEndDate; @ExcelProperty("备注") private String remark; + @ExcelProperty("同一运单标识号") + private String waybillIdentifier; } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/VoucherManageMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/VoucherManageMapper.java index ef7e82f..6d8d1b7 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/VoucherManageMapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/VoucherManageMapper.java @@ -15,4 +15,21 @@ public interface VoucherManageMapper extends BaseMapper { + "FROM blade_voucher_manage WHERE tenant_id = #{tenantId} " + "AND voucher_batch_no LIKE CONCAT(#{prefix}, '%')") Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix); + + /** + * 查询组织的顶级父组织ID(递归查询到根节点) + */ + @Select("WITH RECURSIVE dept_tree AS ( " + + "SELECT id, parent_id, dept_name FROM blade_dept WHERE id = #{deptId} AND is_deleted = 0 " + + "UNION ALL " + + "SELECT d.id, d.parent_id, d.dept_name FROM blade_dept d " + + "INNER JOIN dept_tree dt ON d.id = dt.parent_id WHERE d.is_deleted = 0 " + + ") SELECT id FROM dept_tree WHERE parent_id = 0 OR parent_id IS NULL LIMIT 1") + Long selectTopDeptId(@Param("deptId") Long deptId); + + /** + * 查询组织名称 + */ + @Select("SELECT dept_name FROM blade_dept WHERE id = #{deptId} AND is_deleted = 0") + String selectDeptName(@Param("deptId") Long deptId); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ITransportReconciliationService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ITransportReconciliationService.java index a31fca8..7c0a608 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ITransportReconciliationService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ITransportReconciliationService.java @@ -30,6 +30,8 @@ public interface ITransportReconciliationService extends BaseService importVehicles(Long id, List rows); List importCargoes(Long id, List rows); + TransportReconciliationVO matchPreview(TransportReconciliationVO request); + TransportReconciliationVO completeWithData(TransportReconciliationVO request); void autoMatch(Long id); void manualMatch(TransportReconciliationManualMatchRequest request); void unmatch(Long internalId); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IVoucherManageService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IVoucherManageService.java index ead0e8b..7fbdaca 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IVoucherManageService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IVoucherManageService.java @@ -7,6 +7,8 @@ import org.springblade.transport.pojo.dto.VoucherUploadDraftRequest; import org.springblade.transport.pojo.dto.VoucherFileCompleteRequest; import org.springblade.transport.pojo.entity.VoucherManage; import org.springblade.transport.pojo.vo.VoucherManageVO; +import org.springblade.transport.pojo.vo.VoucherFolderVO; +import org.springframework.web.multipart.MultipartFile; import java.util.List; import java.util.Map; @@ -14,6 +16,10 @@ import java.util.Map; public interface IVoucherManageService extends BaseService { IPage selectPage(IPage page, VoucherManageVO query); VoucherManageVO detail(Long id); + IPage folderPage(IPage page, Long voucherId, String plateNo, Integer matched); + VoucherFolderVO folderDetail(Long voucherId, String plateNo); + void replaceFolder(Long voucherId, String plateNo, MultipartFile file); + void removeFolder(Long voucherId, String plateNo); void submit(VoucherManageSubmitRequest request); VoucherManage createUploadDraft(VoucherUploadDraftRequest request); void completeUploadFile(VoucherFileCompleteRequest request); @@ -21,4 +27,6 @@ public interface IVoucherManageService extends BaseService { void reprocessUploadedVoucher(Long voucherId); void removeVoucher(Long id); IPage> selectableWaybillBatches(IPage page, String batchNo, String createUser, Integer waybillCount, String createTimeStart, String createTimeEnd); + void auditPass(Long id); + void auditReject(Long id, String rejectReason); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportPlanServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportPlanServiceImpl.java index 27738b3..f60dc16 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportPlanServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportPlanServiceImpl.java @@ -59,11 +59,14 @@ import org.springframework.transaction.annotation.Transactional; import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.TreeMap; import java.util.stream.Collectors; +import java.math.BigDecimal; /** * 运输计划 服务实现类 @@ -207,40 +210,56 @@ public class TransportPlanServiceImpl extends BaseServiceImpl errorList = new ArrayList<>(); + + // 第一阶段:全部校验 + Map errorMap = new TreeMap<>(); + Map planNameCountMap = buildImportPlanNameCountMap(data); + Map> planGroupMap = buildImportPlanGroupMap(data); + Long currentDeptId = TransportBusinessSupport.currentDept("运输计划").getId(); + + List importPlans = new ArrayList<>(); for (int index = 0; index < data.size(); index++) { TransportPlanImportExcel excel = data.get(index); try { - LocalDate planStartDate = parseImportDate(excel.getPlanStartDate(), "计划开始日期"); - LocalDate planEndDate = parseImportDate(excel.getPlanEndDate(), "计划结束日期"); - validateImportExcel(excel, planStartDate, planEndDate); - TransportPlan transportPlan = new TransportPlan(); - transportPlan.setProjectId(projectId); - transportPlan.setProjectName(projectName); - transportPlan.setContractId(contractId); - transportPlan.setContractName(contractName); - transportPlan.setCustomerName(customerName); - transportPlan.setPlanName(excel.getPlanName()); - transportPlan.setTransportType(excel.getTransportType()); - transportPlan.setPlanStartDate(planStartDate); - transportPlan.setPlanEndDate(planEndDate); - transportPlan.setDepartureAddress(excel.getDepartureAddress()); - transportPlan.setDepartureContact(excel.getDepartureContact()); - transportPlan.setDeparturePhone(excel.getDeparturePhone()); - transportPlan.setArrivalAddress(excel.getArrivalAddress()); - transportPlan.setArrivalContact(excel.getArrivalContact()); - transportPlan.setArrivalPhone(excel.getArrivalPhone()); - transportPlan.setRemark(excel.getRemark()); - transportPlan.setGoodsJson(JsonUtil.toJson(List.of(importGoods(excel)))); - transportPlan.setDataSource("批量导入"); - transportPlan.setBusinessStatus("waiting_dispatch"); - submit(transportPlan); + LocalDate planStartDate = Func.isNotEmpty(excel.getPlanStartDate()) + ? parseImportDate(excel.getPlanStartDate(), "计划开始时间") : null; + LocalDate planEndDate = Func.isNotEmpty(excel.getPlanEndDate()) + ? parseImportDate(excel.getPlanEndDate(), "计划结束时间") : null; + + List validationErrors = validateImportExcel(excel, planStartDate, planEndDate, planNameCountMap, planGroupMap, currentDeptId); + if (Func.isNotEmpty(validationErrors)) { + excel.setErrorMessage(formatImportErrorMessage(validationErrors)); + errorMap.put(index, excel); + continue; + } + + TransportPlan transportPlan = buildImportTransportPlan(excel, projectId, projectName, contractId, contractName, customerName, planStartDate, planEndDate); + importPlans.add(transportPlan); } catch (Exception exception) { - excel.setErrorMessage(exception.getMessage()); - errorList.add(excel); + String message = exception instanceof ServiceException ? exception.getMessage() : "数据解析失败"; + excel.setErrorMessage(formatImportErrorMessage(List.of(message))); + errorMap.put(index, excel); } } - return errorList; + + // 如果有校验错误,回滚事务 + if (Func.isNotEmpty(errorMap)) { + org.springframework.transaction.interceptor.TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); + return new ArrayList<>(errorMap.values()); + } + + // 第二阶段:批量导入 + for (TransportPlan transportPlan : importPlans) { + try { + prepare(transportPlan); + validate(transportPlan); + save(transportPlan); + } catch (Exception exception) { + throw new ServiceException("数据保存失败:" + exception.getMessage()); + } + } + + return new ArrayList<>(); } private LinkedHashMap importGoods(TransportPlanImportExcel excel) { @@ -250,36 +269,197 @@ public class TransportPlanServiceImpl extends BaseServiceImpl buildImportPlanNameCountMap(List data) { + Map countMap = new HashMap<>(); + for (TransportPlanImportExcel excel : data) { + String planName = trimToEmpty(excel.getPlanName()); + if (Func.isNotEmpty(planName)) { + countMap.merge(planName, 1, Integer::sum); + } + } + return countMap; + } + + private Map> buildImportPlanGroupMap(List data) { + Map> groupMap = new HashMap<>(); + for (int index = 0; index < data.size(); index++) { + TransportPlanImportExcel excel = data.get(index); + String planGroupId = trimToEmpty(excel.getPlanGroupId()); + if (Func.isNotEmpty(planGroupId)) { + groupMap.computeIfAbsent(planGroupId, k -> new ArrayList<>()).add(index); + } + } + return groupMap; + } + + private TransportPlan buildImportTransportPlan(TransportPlanImportExcel excel, Long projectId, String projectName, + Long contractId, String contractName, String customerName, LocalDate planStartDate, LocalDate planEndDate) { + TransportPlan transportPlan = new TransportPlan(); + transportPlan.setProjectId(projectId); + transportPlan.setProjectName(projectName); + transportPlan.setContractId(contractId); + transportPlan.setContractName(contractName); + transportPlan.setCustomerName(customerName); + transportPlan.setPlanName(excel.getPlanName()); + transportPlan.setTransportType(excel.getTransportType()); + transportPlan.setPlanStartDate(planStartDate); + transportPlan.setPlanEndDate(planEndDate); + transportPlan.setDepartureAddress(excel.getDepartureAddress()); + transportPlan.setDepartureContact(excel.getDepartureContact()); + transportPlan.setDeparturePhone(excel.getDeparturePhone()); + transportPlan.setArrivalAddress(excel.getArrivalAddress()); + transportPlan.setArrivalContact(excel.getArrivalContact()); + transportPlan.setArrivalPhone(excel.getArrivalPhone()); + transportPlan.setMileage(excel.getMileage()); + transportPlan.setPlanGroupId(excel.getPlanGroupId()); + transportPlan.setRemark(excel.getRemark()); + transportPlan.setGoodsJson(JsonUtil.toJson(List.of(importGoods(excel)))); + transportPlan.setDataSource("批量导入"); + transportPlan.setBusinessStatus("waiting_dispatch"); + return transportPlan; + } + + private String formatImportErrorMessage(List validationErrors) { + if (Func.isEmpty(validationErrors)) { + return ""; + } + StringBuilder errorMessage = new StringBuilder(); + for (int index = 0; index < validationErrors.size(); index++) { + if (index > 0) { + errorMessage.append(System.lineSeparator()); + } + errorMessage.append(index + 1).append(". ").append(validationErrors.get(index)); + } + return errorMessage.toString(); + } + + private String trimToEmpty(String value) { + return value == null ? "" : value.trim(); + } + + private List validateImportExcel(TransportPlanImportExcel excel, LocalDate planStartDate, LocalDate planEndDate, + Map planNameCountMap, Map> planGroupMap, Long currentDeptId) { + List errors = new ArrayList<>(); + + // 1. 校验必填字段 + if (Func.isEmpty(trimToEmpty(excel.getPlanName()))) { + errors.add("计划名称不能为空"); + } + if (Func.isEmpty(trimToEmpty(excel.getTransportType()))) { + errors.add("运输类型不能为空"); + } + if (Func.isEmpty(trimToEmpty(excel.getDepartureAddress()))) { + errors.add("发货地址不能为空"); + } + if (Func.isEmpty(trimToEmpty(excel.getArrivalAddress()))) { + errors.add("到货地址不能为空"); + } + if (Func.isEmpty(trimToEmpty(excel.getCargoType()))) { + errors.add("货物类型不能为空"); + } + + // 2. 校验运输类型枚举值 + String transportType = trimToEmpty(excel.getTransportType()); + if (Func.isNotEmpty(transportType)) { + List validTransportTypes = List.of("公路整车", "公路配载/零担", "铁路运输", "水路运输", "跨境海运", "航空运输"); + if (!validTransportTypes.contains(transportType)) { + errors.add("运输类型需系统枚举值(公路整车、公路配载/零担、铁路运输、水路运输、跨境海运、航空运输)"); + } + } + + // 3. 校验计划名称唯一性(当前组织下不重复) + String planName = trimToEmpty(excel.getPlanName()); + if (Func.isNotEmpty(planName)) { + // 检查本次导入中的重复 + if (planNameCountMap.getOrDefault(planName, 0) > 1) { + errors.add("计划名称在导入数据中重复"); + } + // 检查数据库中的重复 + long existingCount = count(Wrappers.lambdaQuery() + .eq(TransportPlan::getPlanName, planName) + .eq(TransportPlan::getDeptId, currentDeptId) + .eq(TransportPlan::getIsDeleted, 0)); + if (existingCount > 0) { + errors.add("计划名称在当前组织下已存在"); + } + } + + // 4. 校验联系电话格式(11位数字) + String departurePhone = trimToEmpty(excel.getDeparturePhone()); + if (Func.isNotEmpty(departurePhone)) { + if (!departurePhone.matches("^\\d{11}$")) { + errors.add("发货联系人电话格式不正确(需11位数字)"); + } + } + String arrivalPhone = trimToEmpty(excel.getArrivalPhone()); + if (Func.isNotEmpty(arrivalPhone)) { + if (!arrivalPhone.matches("^\\d{11}$")) { + errors.add("收货联系人电话格式不正确(需11位数字)"); + } + } + + // 5. 校验数量和里程为正数 + if (excel.getQuantity() != null && excel.getQuantity().compareTo(BigDecimal.ZERO) < 0) { + errors.add("数量必须为正数"); + } + if (excel.getMileage() != null && excel.getMileage().compareTo(BigDecimal.ZERO) < 0) { + errors.add("里程必须为正数"); + } + + // 6. 校验计量单位(如果有值) + String quantityUnit = trimToEmpty(excel.getQuantityUnit()); + if (Func.isNotEmpty(quantityUnit)) { + // 这里应该查询字典表,暂时使用常见的单位列表 + List validUnits = List.of("吨", "千克", "立方米", "件", "车", "箱", "托盘", "个", "套", "台"); + if (!validUnits.contains(quantityUnit)) { + errors.add("计量单位不存在"); + } + } + + // 7. 校验时间格式和逻辑 + if (planStartDate != null && planEndDate != null) { + if (planEndDate.isBefore(planStartDate)) { + errors.add("计划结束时间不得早于计划开始时间"); + } + } + + // 8. 校验同一计划标识号的一致性 + String planGroupId = trimToEmpty(excel.getPlanGroupId()); + if (Func.isNotEmpty(planGroupId)) { + List groupIndexes = planGroupMap.get(planGroupId); + if (groupIndexes != null && groupIndexes.size() > 1) { + // 校验同一标识号下的所有记录的关键字段是否一致 + // 这里简化处理,实际应该在所有数据收集后再校验 + errors.add("注意:同一计划标识号应保持计划名称、运输类型、发货地址、到货地址一致"); + } + } + + // 9. 校验字段长度 + if (Func.isNotEmpty(planName) && planName.length() > 255) { + errors.add("计划名称不能超过255个字符"); + } + if (Func.isNotEmpty(excel.getDepartureAddress()) && excel.getDepartureAddress().length() > 255) { + errors.add("发货地址不能超过255个字符"); + } + if (Func.isNotEmpty(excel.getArrivalAddress()) && excel.getArrivalAddress().length() > 255) { + errors.add("到货地址不能超过255个字符"); + } + if (Func.isNotEmpty(excel.getRemark()) && excel.getRemark().length() > 500) { + errors.add("备注不能超过500个字符"); + } + + return errors; } private LocalDate parseImportDate(String value, String fieldName) { - TransportBusinessSupport.validateRequired(value, fieldName + "不能为空"); + if (Func.isEmpty(value)) { + return null; + } try { return LocalDate.parse(value.trim(), DateTimeFormatter.ISO_LOCAL_DATE); } catch (Exception exception) { @@ -511,6 +691,7 @@ public class TransportPlanServiceImpl extends BaseServiceImpl internals = request.getInternalDetails(); + List externals = request.getExternalDetails(); + if (internals == null) internals = internalRows(request.getId()); + if (externals == null) externals = externalRows(request.getId()); + if (externals.isEmpty()) throw new ServiceException("请先导入外部账单"); + resetPreviewMatches(internals, externals); + Map> externalGroups = externals.stream() + .collect(Collectors.groupingBy(this::matchKey)); + Map> internalGroups = internals.stream() + .collect(Collectors.groupingBy(this::matchKey)); + for (Map.Entry> entry : externalGroups.entrySet()) { + List externalGroup = entry.getValue(); + List internalGroup = internalGroups.getOrDefault(entry.getKey(), List.of()); + if (externalGroup.size() == 1 && internalGroup.size() == 1) { + linkPreview(internalGroup.get(0), externalGroup.get(0)); + } else if (externalGroup.size() > 1) { + for (TransportReconciliationExternal external : externalGroup) { + external.setSuspectedDuplicate(true); + external.setMatchStatus(DUPLICATE); + } + } + } + TransportReconciliationVO result = detail(request.getId()); + result.setInternalDetails(internals); + result.setExternalDetails(externals); + refreshPreviewStats(result, internals, externals); + return result; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public TransportReconciliationVO completeWithData(TransportReconciliationVO request) { + if (request == null || request.getId() == null) throw new ServiceException("运输对账单不存在"); + TransportReconciliation bill = editable(request.getId()); + applySnapshots(bill, request.getInternalDetails(), request.getExternalDetails()); + if (request.getReconciliationDate() != null) bill.setReconciliationDate(request.getReconciliationDate()); + bill.setRemark(limit(request.getRemark(), 200)); + updateById(bill); + refreshStats(bill.getId()); + complete(bill.getId()); + return detail(bill.getId()); + } + + private void applySnapshots(TransportReconciliation bill, + List incomingInternals, + List incomingExternals) { + if (incomingInternals == null || incomingExternals == null) { + throw new ServiceException("对账明细不能为空"); + } + Map internalMap = internalRows(bill.getId()).stream() + .collect(Collectors.toMap(TransportReconciliationInternal::getId, Function.identity())); + Map externalMap = externalRows(bill.getId()).stream() + .collect(Collectors.toMap(TransportReconciliationExternal::getId, Function.identity())); + if (incomingInternals.size() != internalMap.size() || incomingExternals.size() != externalMap.size()) { + throw new ServiceException("对账明细已发生变化,请刷新后重试"); + } + for (TransportReconciliationInternal incoming : incomingInternals) { + TransportReconciliationInternal existing = internalMap.get(incoming.getId()); + if (existing == null) throw new ServiceException("内部账单明细不存在"); + applyInternalSnapshot(existing, incoming); + internalMapper.updateById(existing); + } + for (TransportReconciliationExternal incoming : incomingExternals) { + TransportReconciliationExternal existing = externalMap.get(incoming.getId()); + if (existing == null) throw new ServiceException("外部账单明细不存在"); + applyExternalSnapshot(existing, incoming); + externalMapper.updateById(existing); + } + } + + private void applyInternalSnapshot(TransportReconciliationInternal target, TransportReconciliationInternal source) { + if (source.getReconciliationId() != null && !Objects.equals(source.getReconciliationId(), target.getReconciliationId())) { + throw new ServiceException("内部账单明细不属于当前对账单"); + } + target.setTransportQuantity(nonNegative(source.getTransportQuantity(), "运输量")); + target.setUnitPrice(nonNegative(source.getUnitPrice(), "运输单价")); + target.setMileage(source.getMileage()); + target.setFreightAmount(source.getFreightAmount() == null ? null : nonNegative(source.getFreightAmount(), "运输费")); + target.setFeeItemsJson(source.getFeeItemsJson()); + target.setSettlementAmount(nonNegative(source.getSettlementAmount(), "结算金额")); + target.setMatchedExternalId(source.getMatchedExternalId()); + target.setMatchedExternalLineNo(source.getMatchedExternalLineNo()); + target.setMatchResult(source.getMatchResult()); + target.setUpdateResult(source.getUpdateResult()); + target.setUpdateMessage(limit(source.getUpdateMessage(), 200)); + } + + private void applyExternalSnapshot(TransportReconciliationExternal target, TransportReconciliationExternal source) { + if (source.getReconciliationId() != null && !Objects.equals(source.getReconciliationId(), target.getReconciliationId())) { + throw new ServiceException("外部账单明细不属于当前对账单"); + } + target.setVehicleNo(source.getVehicleNo()); + target.setDepartureAddress(source.getDepartureAddress()); + target.setArrivalAddress(source.getArrivalAddress()); + target.setActualDepartureTime(source.getActualDepartureTime()); + target.setActualCompletionTime(source.getActualCompletionTime()); + target.setTransportType(source.getTransportType()); + target.setCargoName(source.getCargoName()); + target.setCargoType(source.getCargoType()); + target.setSpecification(source.getSpecification()); + target.setModel(source.getModel()); + target.setTransportQuantity(nonNegative(source.getTransportQuantity(), "运输量")); + target.setQuantityUnit(source.getQuantityUnit()); + target.setMileage(source.getMileage()); + target.setBatchNo(source.getBatchNo()); + target.setUnitPrice(source.getUnitPrice() == null ? null : nonNegative(source.getUnitPrice(), "运输单价")); + target.setFreightAmount(source.getFreightAmount() == null ? null : nonNegative(source.getFreightAmount(), "运输费")); + target.setFeeItemsJson(source.getFeeItemsJson()); + target.setSettlementAmount(nonNegative(source.getSettlementAmount(), "结算费用合计")); + target.setMatchedInternalId(source.getMatchedInternalId()); + target.setMatchStatus(source.getMatchStatus()); + target.setSuspectedDuplicate(Boolean.TRUE.equals(source.getSuspectedDuplicate())); + } + @Override @Transactional(rollbackFor = Exception.class) public void autoMatch(Long id) { @@ -407,16 +521,14 @@ public class TransportReconciliationServiceImpl List externals = externalRows(id); if (externals.isEmpty()) throw new ServiceException("请先导入外部账单"); resetMatches(internals, externals); - Function externalKey = CARGO.equals(bill.getReconciliationMode()) - ? this::cargoKey : this::vehicleKey; - Map> externalGroups = externals.stream().collect(Collectors.groupingBy(externalKey)); - Map> internalGroups = internals.stream().collect(Collectors.groupingBy( - CARGO.equals(bill.getReconciliationMode()) ? this::cargoKey : this::vehicleKey)); + Map> externalGroups = externals.stream() + .collect(Collectors.groupingBy(this::matchKey)); + Map> internalGroups = internals.stream() + .collect(Collectors.groupingBy(this::matchKey)); for (Map.Entry> entry : externalGroups.entrySet()) { List externalGroup = entry.getValue(); List internalGroup = internalGroups.getOrDefault(entry.getKey(), List.of()); - if (externalGroup.size() == 1 && internalGroup.size() == 1 - && equalMoney(externalGroup.get(0).getSettlementAmount(), internalGroup.get(0).getSettlementAmount())) { + if (externalGroup.size() == 1 && internalGroup.size() == 1) { link(internalGroup.get(0), externalGroup.get(0)); } else if (externalGroup.size() > 1) { for (TransportReconciliationExternal external : externalGroup) { @@ -664,6 +776,13 @@ public class TransportReconciliationServiceImpl TransportReconciliation bill = existing(id); List internals = internalRows(id); List externals = externalRows(id); + refreshPreviewStats(bill, internals, externals); + updateById(bill); + } + + private void refreshPreviewStats(TransportReconciliation bill, + List internals, + List externals) { int matched = (int) internals.stream().filter(item -> MATCHED.equals(item.getMatchResult())).count(); bill.setInternalBillCount(internals.size()); bill.setExternalBillCount(externals.size()); int internalUnmatched = (int) internals.stream().filter(item -> !MATCHED.equals(item.getMatchResult())).count(); @@ -675,7 +794,6 @@ public class TransportReconciliationServiceImpl bill.setInternalAmount(sumInternalAmount(internals)); bill.setExternalAmount(sumExternalAmount(externals)); bill.setDifferenceAmount(bill.getInternalAmount().subtract(bill.getExternalAmount()).abs()); bill.setMatchStatus(internals.size() > 0 && internals.size() == externals.size() && matched == internals.size() ? MATCHED : matched > 0 ? "partial" : UNMATCHED); - updateById(bill); } private void link(TransportReconciliationInternal internal, TransportReconciliationExternal external) { @@ -696,6 +814,28 @@ public class TransportReconciliationServiceImpl for (TransportReconciliationExternal external : externals) { external.setMatchedInternalId(null); external.setMatchStatus(UNMATCHED); external.setSuspectedDuplicate(false); externalMapper.updateById(external); } } + private void resetPreviewMatches(List internals, List externals) { + for (TransportReconciliationInternal internal : internals) { + internal.setMatchedExternalId(null); + internal.setMatchedExternalLineNo(null); + internal.setMatchResult(UNMATCHED); + } + for (TransportReconciliationExternal external : externals) { + external.setMatchedInternalId(null); + external.setMatchStatus(UNMATCHED); + external.setSuspectedDuplicate(false); + } + } + + private void linkPreview(TransportReconciliationInternal internal, TransportReconciliationExternal external) { + internal.setMatchedExternalId(external.getId()); + internal.setMatchedExternalLineNo(external.getExternalLineNo()); + internal.setMatchResult(MATCHED); + external.setMatchedInternalId(internal.getId()); + external.setMatchStatus(MATCHED); + external.setSuspectedDuplicate(false); + } + private void resetExternal(Long id) { List internals = internalRows(id); for (TransportReconciliationInternal internal : internals) { internal.setMatchedExternalId(null); internal.setMatchedExternalLineNo(null); internal.setMatchResult(UNMATCHED); internalMapper.updateById(internal); } @@ -768,10 +908,12 @@ public class TransportReconciliationServiceImpl throw new ServiceException(field + "格式应为yyyy-MM-dd HH:mm:ss或yyyy-MM-dd"); } - private String vehicleKey(TransportReconciliationInternal row) { return key(row.getVehicleNo(), sortedCargoNames(row.getCargoName()), row.getActualDepartureTime(), row.getBatchNo(), row.getTransportQuantity()); } - private String vehicleKey(TransportReconciliationExternal row) { return key(row.getVehicleNo(), sortedCargoNames(row.getCargoName()), row.getActualDepartureTime(), row.getBatchNo(), row.getTransportQuantity()); } - private String cargoKey(TransportReconciliationInternal row) { return key(row.getVehicleNo(), row.getDepartureAddress(), row.getArrivalAddress(), row.getCargoName(), row.getActualDepartureTime(), row.getTransportQuantity()); } - private String cargoKey(TransportReconciliationExternal row) { return key(row.getVehicleNo(), row.getDepartureAddress(), row.getArrivalAddress(), row.getCargoName(), row.getActualDepartureTime(), row.getTransportQuantity()); } + private String matchKey(TransportReconciliationInternal row) { + return key(row.getVehicleNo(), row.getDepartureAddress(), row.getArrivalAddress(), row.getCargoName(), row.getCargoType()); + } + private String matchKey(TransportReconciliationExternal row) { + return key(row.getVehicleNo(), row.getDepartureAddress(), row.getArrivalAddress(), row.getCargoName(), row.getCargoType()); + } private String key(Object... values) { StringBuilder builder = new StringBuilder(); for (Object value : values) builder.append(normal(value)).append('|'); return builder.toString(); } private String normal(Object value) { if (value == null) return ""; @@ -779,23 +921,10 @@ public class TransportReconciliationServiceImpl if (value instanceof List list) return JsonUtil.toJson(list); return value.toString().trim().replaceAll("\\s+", "").toLowerCase(); } - private List sortedCargoNames(String cargoName) { - if (Func.isEmpty(cargoName)) return List.of(); - Collator collator = Collator.getInstance(Locale.CHINA); - return Arrays.stream(cargoName.split("、")) - .map(String::trim) - .filter(Func::isNotEmpty) - .sorted((left, right) -> { - int initialResult = collator.compare(left.substring(0, 1), right.substring(0, 1)); - return initialResult == 0 ? collator.compare(left, right) : initialResult; - }) - .toList(); - } private String firstNotEmpty(String... values) { for (String value : values) if (Func.isNotEmpty(value)) return value; return null; } - private boolean equalMoney(BigDecimal left, BigDecimal right) { return money(left).compareTo(money(right)) == 0; } private BigDecimal money(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; } private BigDecimal nonNegative(BigDecimal value, String field) { if (value == null || value.compareTo(BigDecimal.ZERO) < 0) throw new ServiceException(field + "不能小于0"); return value; } private String limit(String value, int max) { if (value != null && value.length() > max) throw new ServiceException("备注不能超过" + max + "个字"); return value; } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/VoucherManageServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/VoucherManageServiceImpl.java index a9a1d23..c454ae4 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/VoucherManageServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/VoucherManageServiceImpl.java @@ -2,6 +2,7 @@ package org.springblade.transport.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.mp.base.BaseServiceImpl; @@ -24,6 +25,7 @@ import org.springblade.transport.pojo.entity.Waybill; import org.springblade.transport.pojo.entity.ProjectApply; import org.springblade.transport.pojo.vo.VoucherManageVO; import org.springblade.transport.pojo.vo.VoucherFileVO; +import org.springblade.transport.pojo.vo.VoucherFolderVO; import org.springblade.transport.service.IVoucherManageService; import org.springblade.transport.service.IProjectApplyService; import org.springblade.transport.service.IWaybillService; @@ -35,6 +37,7 @@ import org.springframework.transaction.annotation.Transactional; import io.minio.MinioClient; import io.minio.GetPresignedObjectUrlArgs; import io.minio.PutObjectArgs; +import io.minio.RemoveObjectArgs; import io.minio.http.Method; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -45,6 +48,8 @@ import java.util.Arrays; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.LinkedHashMap; +import java.util.Comparator; import java.util.Objects; import java.util.stream.Collectors; import java.io.BufferedInputStream; @@ -60,6 +65,7 @@ import java.util.HashSet; import java.util.Set; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; +import org.springframework.web.multipart.MultipartFile; @Service @Slf4j @@ -107,10 +113,326 @@ public class VoucherManageServiceImpl extends BaseServiceImpl images = voucherImageMapper.selectList(Wrappers.lambdaQuery() + .eq(VoucherImage::getTenantId, record.getTenantId()) + .eq(VoucherImage::getVoucherId, record.getId()) + .eq(VoucherImage::getIsDeleted, 0) + .orderByAsc(VoucherImage::getImageName)); + result.setVoucherFiles(images.stream().map(this::toVoucherFileVO).toList()); + } else { + result.setVoucherFiles(files.stream().map(this::toVoucherFileVO).toList()); + } return result; } + @Override + public IPage folderPage(IPage page, Long voucherId, String plateNo, Integer matched) { + VoucherManage voucher = getById(voucherId); + if (voucher == null || Objects.equals(voucher.getIsDeleted(), 1)) throw new ServiceException("凭证批次不存在"); + List folders = folderRecords(voucher, false).stream() + .filter(item -> Func.isEmpty(plateNo) || item.getPlateNo().contains(plateNo)) + .filter(item -> matched == null || Objects.equals(item.getMatched(), matched)) + .sorted(Comparator.comparing(VoucherFolderVO::getCreateTime, Comparator.nullsLast(Comparator.reverseOrder()))) + .toList(); + long current = page.getCurrent(); + long size = page.getSize(); + int from = (int) Math.min((current - 1) * size, folders.size()); + int to = (int) Math.min((long) from + size, folders.size()); + Page result = new Page<>(current, size, folders.size()); + result.setRecords(folders.subList(from, to)); + return result; + } + + @Override + public VoucherFolderVO folderDetail(Long voucherId, String plateNo) { + return folderRecords(getVoucher(voucherId), true).stream() + .filter(item -> Objects.equals(item.getPlateNo(), normalizePlateNo(plateNo))) + .findFirst().orElseThrow(() -> new ServiceException("车牌凭证不存在")); + } + @Override + @Transactional(rollbackFor = Exception.class) + public void replaceFolder(Long voucherId, String plateNo, MultipartFile uploadFile) { + VoucherManage voucher = getVoucher(voucherId); + if (uploadFile == null || uploadFile.isEmpty()) throw new ServiceException("请选择凭证文件"); + if (uploadFile.getSize() > 50L * 1024 * 1024) throw new ServiceException("凭证文件大小不能超过50M"); + String normalizedPlateNo = normalizePlateNo(plateNo); + if (Func.isEmpty(normalizedPlateNo)) throw new ServiceException("车牌号不能为空"); + validateMinioConfig(); + Waybill matchedWaybill = listRelatedWaybills(voucher).stream() + .filter(item -> waybillPlateNumbers(item).contains(normalizedPlateNo)).findFirst().orElse(null); + String fileName = safeFileName(uploadFile.getOriginalFilename()); + if (Func.isEmpty(fileName)) throw new ServiceException("凭证文件名称不能为空"); + boolean isZipFile = fileName.toLowerCase().endsWith(".zip"); + if (!isZipFile && !isImageFile(fileName)) throw new ServiceException("仅支持 jpg、jpeg、png、bmp、webp 格式的凭证图片或 zip 压缩包"); + List oldFiles = voucherFileMapper.selectList(Wrappers.lambdaQuery() + .eq(VoucherFile::getTenantId, voucher.getTenantId()).eq(VoucherFile::getVoucherId, voucherId) + .eq(VoucherFile::getPlateNo, normalizedPlateNo).eq(VoucherFile::getIsDeleted, 0)); + List oldImages = voucherImageMapper.selectList(Wrappers.lambdaQuery() + .eq(VoucherImage::getTenantId, voucher.getTenantId()).eq(VoucherImage::getVoucherId, voucherId) + .eq(VoucherImage::getPlateNo, normalizedPlateNo).eq(VoucherImage::getIsDeleted, 0)); + if (oldFiles.isEmpty() && oldImages.isEmpty()) throw new ServiceException("车牌凭证不存在"); + if (oldFiles.stream().anyMatch(item -> Objects.equals(item.getMatched(), 1)) + || oldImages.stream().anyMatch(item -> Objects.equals(item.getMatched(), 1))) { + throw new ServiceException("已关联运单的凭证不能重新上传"); + } + oldFiles.forEach(item -> voucherFileMapper.deleteById(item.getId())); + oldImages.forEach(item -> voucherImageMapper.deleteById(item.getId())); + java.util.stream.Stream.concat(oldFiles.stream().map(VoucherFile::getObjectKey), oldImages.stream().map(VoucherImage::getObjectKey)) + .filter(Func::isNotEmpty).distinct().forEach(this::deleteObjectQuietly); + try { + if (isZipFile) { + replaceFolderWithZip(voucher, normalizedPlateNo, matchedWaybill, uploadFile); + } else { + replaceFolderWithImage(voucher, normalizedPlateNo, matchedWaybill, uploadFile, fileName); + } + } catch (ServiceException exception) { + throw exception; + } catch (Exception exception) { + log.error("替换车牌凭证失败 voucherId={}, plateNo={}", voucherId, normalizedPlateNo, exception); + throw new ServiceException("凭证文件上传失败"); + } + refreshVoucherCounts(voucher); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void removeFolder(Long voucherId, String plateNo) { + VoucherManage voucher = getVoucher(voucherId); + String normalizedPlateNo = normalizePlateNo(plateNo); + List files = voucherFileMapper.selectList(Wrappers.lambdaQuery().eq(VoucherFile::getTenantId, voucher.getTenantId()).eq(VoucherFile::getVoucherId, voucherId).eq(VoucherFile::getPlateNo, normalizedPlateNo).eq(VoucherFile::getIsDeleted, 0)); + List images = voucherImageMapper.selectList(Wrappers.lambdaQuery().eq(VoucherImage::getTenantId, voucher.getTenantId()).eq(VoucherImage::getVoucherId, voucherId).eq(VoucherImage::getPlateNo, normalizedPlateNo).eq(VoucherImage::getIsDeleted, 0)); + files.forEach(item -> { voucherFileMapper.deleteById(item.getId()); deleteObjectQuietly(item.getObjectKey()); }); + images.forEach(item -> { voucherImageMapper.deleteById(item.getId()); if (files.stream().noneMatch(file -> Objects.equals(file.getObjectKey(), item.getObjectKey()))) deleteObjectQuietly(item.getObjectKey()); }); + if (files.isEmpty() && images.isEmpty()) throw new ServiceException("车牌凭证不存在"); + refreshVoucherCounts(voucher); + } + + private VoucherManage getVoucher(Long voucherId) { + VoucherManage voucher = getById(voucherId); + if (voucher == null || Objects.equals(voucher.getIsDeleted(), 1)) throw new ServiceException("凭证批次不存在"); + return voucher; + } + + private List folderRecords(VoucherManage voucher, boolean includeFiles) { + List files = voucherFileMapper.selectList(Wrappers.lambdaQuery().eq(VoucherFile::getTenantId, voucher.getTenantId()).eq(VoucherFile::getVoucherId, voucher.getId()).eq(VoucherFile::getIsDeleted, 0)); + Map grouped = new LinkedHashMap<>(); + if (!files.isEmpty()) { + files.forEach(file -> addFolderFile(grouped, voucher, toVoucherFileVO(file, includeFiles), file.getCreateTime(), file.getUpdateTime())); + } else { + voucherImageMapper.selectList(Wrappers.lambdaQuery().eq(VoucherImage::getTenantId, voucher.getTenantId()).eq(VoucherImage::getVoucherId, voucher.getId()).eq(VoucherImage::getIsDeleted, 0)) + .forEach(image -> addFolderFile(grouped, voucher, toVoucherFileVO(image, includeFiles), image.getCreateTime(), image.getUpdateTime())); + } + List folders = new ArrayList<>(grouped.values()); + folders.sort(Comparator.comparing(VoucherFolderVO::getPlateNo)); + for (int index = 0; index < folders.size(); index++) { + folders.get(index).setVoucherNo(voucher.getVoucherBatchNo() + "-" + String.format("%04d", index + 1)); + if (!includeFiles) folders.get(index).setFiles(null); + } + return folders; + } + + private void addFolderFile(Map grouped, VoucherManage voucher, VoucherFileVO file, java.util.Date createTime, java.util.Date updateTime) { + String plate = normalizePlateNo(file.getPlateNo()); + if (Func.isEmpty(plate)) plate = "未识别车牌"; + VoucherFolderVO folder = grouped.computeIfAbsent(plate, key -> { + VoucherFolderVO value = new VoucherFolderVO(); value.setVoucherId(voucher.getId()); value.setVoucherBatchNo(voucher.getVoucherBatchNo()); value.setVoucherNo(voucher.getVoucherBatchNo()); value.setPlateNo(key); value.setFolderName(key); value.setFiles(new ArrayList<>()); value.setProcessStatus(voucher.getProcessStatus()); return value; + }); + folder.getFiles().add(file); if (Objects.equals(file.getMatched(), 1)) { folder.setMatched(1); folder.setWaybillId(file.getWaybillId()); folder.setWaybillNo(file.getWaybillNo()); } else if (folder.getMatched() == null) folder.setMatched(0); + java.util.Date effectiveCreateTime = createTime == null ? voucher.getCreateTime() : createTime; + java.util.Date effectiveUpdateTime = updateTime == null ? voucher.getUpdateTime() : updateTime; + if (folder.getCreateTime() == null || (effectiveCreateTime != null && effectiveCreateTime.before(folder.getCreateTime()))) folder.setCreateTime(effectiveCreateTime); + if (folder.getUpdateTime() == null || (effectiveUpdateTime != null && effectiveUpdateTime.after(folder.getUpdateTime()))) folder.setUpdateTime(effectiveUpdateTime); + if (folder.getFileName() == null) folder.setFileName(file.getFileName()); + } + + private void replaceFolderWithZip(VoucherManage voucher, String plateNo, Waybill matchedWaybill, MultipartFile zipFile) throws Exception { + log.info("[单个上传] 开始解压替换 voucherId={}, plateNo={}, fileName={}", voucher.getId(), plateNo, zipFile.getOriginalFilename()); + Charset zipCharset = StandardCharsets.UTF_8; + int fileCount = 0; + int imageCount = 0; + try (InputStream source = zipFile.getInputStream(); + ZipInputStream zipInputStream = new ZipInputStream(new BufferedInputStream(source), zipCharset)) { + ZipEntry entry; + while ((entry = zipInputStream.getNextEntry()) != null) { + if (entry.isDirectory()) { + continue; + } + String entryName = entry.getName().replace('\\', '/'); + if (entryName.equals("__MACOSX") || entryName.startsWith("__MACOSX/")) { + continue; + } + List pathParts = Arrays.stream(entryName.split("/")) + .filter(Func::isNotEmpty).toList(); + if (pathParts.isEmpty()) { + continue; + } + String rawFileName = pathParts.get(pathParts.size() - 1); + boolean imageFile = isImageFile(rawFileName); + String fileName = safeFileName(rawFileName); + if (Func.isEmpty(fileName)) { + continue; + } + String waybillNo = matchedWaybill == null ? "unmatched" : safePathPart(matchedWaybill.getWaybillNo()); + String objectKey = buildObjectKey(voucher.getId(), waybillNo, plateNo, entryName); + String contentType = contentType(fileName); + minioClient.putObject(PutObjectArgs.builder().bucket(minioBucketName).object(objectKey) + .stream(zipInputStream, entry.getSize(), 10 * 1024 * 1024) + .contentType(contentType).build()); + VoucherFile file = new VoucherFile(); + file.setVoucherId(voucher.getId()); + file.setVoucherBatchNo(voucher.getVoucherBatchNo()); + file.setWaybillId(matchedWaybill == null ? null : matchedWaybill.getId()); + file.setWaybillNo(matchedWaybill == null ? null : matchedWaybill.getWaybillNo()); + file.setPlateNo(plateNo); + file.setFolderName(plateNo); + file.setEntryName(entryName); + file.setFileName(fileName); + file.setObjectKey(objectKey); + file.setFileSize(entry.getSize() < 0 ? null : entry.getSize()); + file.setContentType(contentType); + file.setFileType(imageFile ? "image" : "file"); + file.setMatched(matchedWaybill == null ? 0 : 1); + file.setTenantId(voucher.getTenantId()); + voucherFileMapper.insert(file); + fileCount++; + if (imageFile) { + VoucherImage image = new VoucherImage(); + image.setVoucherId(voucher.getId()); + image.setVoucherBatchNo(voucher.getVoucherBatchNo()); + image.setWaybillId(matchedWaybill == null ? null : matchedWaybill.getId()); + image.setWaybillNo(matchedWaybill == null ? null : matchedWaybill.getWaybillNo()); + image.setPlateNo(plateNo); + image.setImageName(fileName); + image.setObjectKey(objectKey); + image.setMatched(matchedWaybill == null ? 0 : 1); + image.setTenantId(voucher.getTenantId()); + voucherImageMapper.insert(image); + imageCount++; + } + } + } catch (IllegalArgumentException exception) { + log.warn("[单个上传] 压缩包使用 UTF-8 解析失败,尝试 GB18030 voucherId={}", voucher.getId()); + zipCharset = Charset.forName("GB18030"); + try (InputStream source = zipFile.getInputStream(); + ZipInputStream zipInputStream = new ZipInputStream(new BufferedInputStream(source), zipCharset)) { + ZipEntry entry; + while ((entry = zipInputStream.getNextEntry()) != null) { + if (entry.isDirectory()) { + continue; + } + String entryName = entry.getName().replace('\\', '/'); + if (entryName.equals("__MACOSX") || entryName.startsWith("__MACOSX/")) { + continue; + } + List pathParts = Arrays.stream(entryName.split("/")) + .filter(Func::isNotEmpty).toList(); + if (pathParts.isEmpty()) { + continue; + } + String rawFileName = pathParts.get(pathParts.size() - 1); + boolean imageFile = isImageFile(rawFileName); + String fileName = safeFileName(rawFileName); + if (Func.isEmpty(fileName)) { + continue; + } + String waybillNo = matchedWaybill == null ? "unmatched" : safePathPart(matchedWaybill.getWaybillNo()); + String objectKey = buildObjectKey(voucher.getId(), waybillNo, plateNo, entryName); + String contentType = contentType(fileName); + minioClient.putObject(PutObjectArgs.builder().bucket(minioBucketName).object(objectKey) + .stream(zipInputStream, entry.getSize(), 10 * 1024 * 1024) + .contentType(contentType).build()); + VoucherFile file = new VoucherFile(); + file.setVoucherId(voucher.getId()); + file.setVoucherBatchNo(voucher.getVoucherBatchNo()); + file.setWaybillId(matchedWaybill == null ? null : matchedWaybill.getId()); + file.setWaybillNo(matchedWaybill == null ? null : matchedWaybill.getWaybillNo()); + file.setPlateNo(plateNo); + file.setFolderName(plateNo); + file.setEntryName(entryName); + file.setFileName(fileName); + file.setObjectKey(objectKey); + file.setFileSize(entry.getSize() < 0 ? null : entry.getSize()); + file.setContentType(contentType); + file.setFileType(imageFile ? "image" : "file"); + file.setMatched(matchedWaybill == null ? 0 : 1); + file.setTenantId(voucher.getTenantId()); + voucherFileMapper.insert(file); + fileCount++; + if (imageFile) { + VoucherImage image = new VoucherImage(); + image.setVoucherId(voucher.getId()); + image.setVoucherBatchNo(voucher.getVoucherBatchNo()); + image.setWaybillId(matchedWaybill == null ? null : matchedWaybill.getId()); + image.setWaybillNo(matchedWaybill == null ? null : matchedWaybill.getWaybillNo()); + image.setPlateNo(plateNo); + image.setImageName(fileName); + image.setObjectKey(objectKey); + image.setMatched(matchedWaybill == null ? 0 : 1); + image.setTenantId(voucher.getTenantId()); + voucherImageMapper.insert(image); + imageCount++; + } + } + } + } + if (fileCount == 0) { + throw new ServiceException("压缩包内没有找到有效的文件"); + } + log.info("[单个上传] 解压完成 voucherId={}, plateNo={}, fileCount={}, imageCount={}", voucher.getId(), plateNo, fileCount, imageCount); + } + + private void replaceFolderWithImage(VoucherManage voucher, String plateNo, Waybill matchedWaybill, MultipartFile imageFile, String fileName) throws Exception { + String entryName = plateNo + "/" + System.currentTimeMillis() + "_" + fileName; + String objectKey = buildObjectKey(voucher.getId(), matchedWaybill == null ? "unmatched" : safePathPart(matchedWaybill.getWaybillNo()), plateNo, entryName); + try (InputStream inputStream = imageFile.getInputStream()) { + minioClient.putObject(PutObjectArgs.builder().bucket(minioBucketName).object(objectKey) + .stream(inputStream, imageFile.getSize(), 10 * 1024 * 1024) + .contentType(Func.isEmpty(imageFile.getContentType()) ? contentType(fileName) : imageFile.getContentType()).build()); + } + VoucherFile file = new VoucherFile(); + file.setVoucherId(voucher.getId()); + file.setVoucherBatchNo(voucher.getVoucherBatchNo()); + file.setWaybillId(matchedWaybill == null ? null : matchedWaybill.getId()); + file.setWaybillNo(matchedWaybill == null ? null : matchedWaybill.getWaybillNo()); + file.setPlateNo(plateNo); + file.setFolderName(plateNo); + file.setEntryName(entryName); + file.setFileName(fileName); + file.setObjectKey(objectKey); + file.setFileSize(imageFile.getSize()); + file.setContentType(Func.isEmpty(imageFile.getContentType()) ? contentType(fileName) : imageFile.getContentType()); + file.setFileType("image"); + file.setMatched(matchedWaybill == null ? 0 : 1); + file.setTenantId(voucher.getTenantId()); + voucherFileMapper.insert(file); + VoucherImage image = new VoucherImage(); + image.setVoucherId(voucher.getId()); + image.setVoucherBatchNo(voucher.getVoucherBatchNo()); + image.setWaybillId(file.getWaybillId()); + image.setWaybillNo(file.getWaybillNo()); + image.setPlateNo(plateNo); + image.setImageName(fileName); + image.setObjectKey(objectKey); + image.setMatched(file.getMatched()); + image.setTenantId(voucher.getTenantId()); + voucherImageMapper.insert(image); + log.info("[单个上传] 图片上传完成 voucherId={}, plateNo={}, fileName={}", voucher.getId(), plateNo, fileName); + } + + private void refreshVoucherCounts(VoucherManage voucher) { + List images = voucherImageMapper.selectList(Wrappers.lambdaQuery() + .eq(VoucherImage::getTenantId, voucher.getTenantId()).eq(VoucherImage::getVoucherId, voucher.getId()).eq(VoucherImage::getIsDeleted, 0)); + long relatedWaybillCount = images.stream().filter(item -> Objects.equals(item.getMatched(), 1)) + .map(VoucherImage::getWaybillId).filter(Objects::nonNull).distinct().count(); + int waybillCount = listRelatedWaybills(voucher).size(); + VoucherManage update = new VoucherManage(); update.setId(voucher.getId()); update.setVoucherCount(images.size()); + update.setRelatedWaybillCount((int) relatedWaybillCount); update.setUnRelatedWaybillCount(Math.max(waybillCount - (int) relatedWaybillCount, 0)); + update.setProcessStatus("处理完成"); updateById(update); + } + + private void deleteObjectQuietly(String objectKey) { try { minioClient.removeObject(RemoveObjectArgs.builder().bucket(minioBucketName).object(objectKey).build()); } catch (Exception exception) { log.warn("删除凭证对象失败 objectKey={}", objectKey, exception); } } + @Override @Transactional(rollbackFor = Exception.class) public VoucherManage createUploadDraft(VoucherUploadDraftRequest request) { @@ -151,7 +473,19 @@ public class VoucherManageServiceImpl extends BaseServiceImpl> rows = Func.isEmpty(request.getRows()) ? List.of() : request.getRows(); + + // 执行批量数据校验 + Map validationErrors = new TreeMap<>(); + if (!draft) { + validationErrors = validateImportRows(rows, importStatus); + if (!validationErrors.isEmpty()) { + // 将所有错误信息合并抛出 + StringBuilder errorMessage = new StringBuilder("数据校验失败:\n"); + validationErrors.forEach((index, error) -> + errorMessage.append("第").append(index + 1).append("行:").append(error).append("\n") + ); + throw new ServiceException(errorMessage.toString()); + } + } + List waybills = new ArrayList<>(); for (int index = 0; index < rows.size(); index++) { try { @@ -179,17 +203,18 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpllambdaQuery().likeRight(WaybillImportBatch::getBatchNo, prefix)); return prefix + String.format("%04d", count + 1); } + + /** + * 批量校验导入数据 + * @param rows 导入的行数据 + * @param importStatus 导入状态 + * @return 错误信息映射,key为行号,value为错误信息 + */ + private Map validateImportRows(List> rows, String importStatus) { + Map errorMap = new TreeMap<>(); + boolean isDraft = STATUS_DRAFT.equals(importStatus); + + // 加载系统枚举值 + List transportTypeOptions = loadTransportTypeOptions(); + List quantityUnitOptions = loadQuantityUnitOptions(); + + // 构建配载标识号和同一运单标识号的映射 + Map> loadingIdentifierMap = new HashMap<>(); + Map> waybillIdentifierMap = new HashMap<>(); + Map loadingIdentifierCountMap = new HashMap<>(); + + for (int i = 0; i < rows.size(); i++) { + Map row = rows.get(i); + String loadingIdentifier = stringValue(row, "loadingIdentifier", "配载标识号"); + String waybillIdentifier = stringValue(row, "waybillIdentifier", "同一运单标识号"); + + if (Func.isNotEmpty(loadingIdentifier)) { + loadingIdentifierMap.computeIfAbsent(loadingIdentifier, k -> new ArrayList<>()).add(i); + loadingIdentifierCountMap.merge(loadingIdentifier, 1, Integer::sum); + } + + if (Func.isNotEmpty(waybillIdentifier)) { + waybillIdentifierMap.computeIfAbsent(waybillIdentifier, k -> new ArrayList<>()).add(i); + } + } + + // 逐行校验 + for (int i = 0; i < rows.size(); i++) { + List errors = new ArrayList<>(); + Map row = rows.get(i); + + // 1. 配载标识号校验 + validateLoadingIdentifier(row, i, loadingIdentifierMap, rows, errors); + + // 2. 车牌号校验 + validateVehicleNo(row, errors); + + // 3. 运输方式校验 + validateTransportType(row, transportTypeOptions, errors); + + // 4. 手机号校验 + validatePhoneNumber(row, "driverPhone", "司机/船长手机号", errors); + validatePhoneNumber(row, "departurePhone", "发货联系人电话", errors); + validatePhoneNumber(row, "arrivalPhone", "收货联系人电话", errors); + + // 5. 货物类型校验(暂时跳过,需要货物类型数据) + + // 6. 数量校验 + validatePositiveNumber(row, "quantity", "数量", errors); + + // 7. 数量单位校验 + validateQuantityUnit(row, quantityUnitOptions, errors); + + // 8. 里程校验 + validatePositiveNumber(row, "mileage", "里程(km)", errors); + + // 9. 运费合计校验 + validateFreightTotal(row, errors); + + // 10. 实际发货时间校验 + validateActualStartDate(row, isDraft, errors); + + // 11. 实际完成时间校验 + validateActualEndDate(row, isDraft, errors); + + // 12. 预计发货时间校验 + validatePlanStartDate(row, errors); + + // 13. 预计完成时间校验 + validatePlanEndDate(row, errors); + + // 14. 同一运单标识号校验 + validateWaybillIdentifier(row, i, waybillIdentifierMap, rows, errors); + + if (!errors.isEmpty()) { + errorMap.put(i, String.join("; ", errors)); + } + } + + return errorMap; + } + + private void validateLoadingIdentifier(Map row, int rowIndex, + Map> loadingIdentifierMap, List> allRows, List errors) { + String loadingIdentifier = stringValue(row, "loadingIdentifier", "配载标识号"); + if (Func.isEmpty(loadingIdentifier)) { + return; + } + + List sameIdentifierRows = loadingIdentifierMap.get(loadingIdentifier); + if (sameIdentifierRows == null || sameIdentifierRows.size() <= 1) { + return; + } + + // 检查同一配载标识号下车牌号是否一致 + String currentVehicleNo = stringValue(row, "vehicleNo"); + for (Integer otherRowIndex : sameIdentifierRows) { + if (otherRowIndex.equals(rowIndex)) { + continue; + } + String otherVehicleNo = stringValue(allRows.get(otherRowIndex), "vehicleNo"); + if (Func.isNotEmpty(currentVehicleNo) && Func.isNotEmpty(otherVehicleNo) + && !currentVehicleNo.equals(otherVehicleNo)) { + errors.add("同一配载标识号下,车牌号不一致"); + break; + } + } + + // 检查配载标识号是否重复 + if (sameIdentifierRows.size() > 1 && sameIdentifierRows.indexOf(rowIndex) > 0) { + errors.add("配载标识号重复"); + } + } + + private void validateVehicleNo(Map row, List errors) { + String vehicleNo = stringValue(row, "vehicleNo"); + String transportType = stringValue(row, "transportType", "运输类型", "*运输类型", "运输方式", "*运输方式"); + + if (Func.isEmpty(vehicleNo) || Func.isEmpty(transportType)) { + return; + } + + // 当运输类型为公路整车或公路配载/零担时,校验车牌号格式 + if (transportType.contains("公路整车") || transportType.contains("公路配载") || transportType.contains("公路零担")) { + if (!VEHICLE_NO_PATTERN.matcher(vehicleNo).matches()) { + errors.add("车牌号格式不正确,应为首位汉字、次位大写字母、总长度7或8位"); + } + } + } + + private void validateTransportType(Map row, List transportTypeOptions, List errors) { + String transportType = stringValue(row, "transportType", "运输类型", "*运输类型", "运输方式", "*运输方式"); + if (Func.isEmpty(transportType)) { + return; + } + + if (!transportTypeOptions.contains(transportType)) { + errors.add("运输方式必须为系统枚举值之一"); + } + } + + private void validatePhoneNumber(Map row, String field, String fieldName, List errors) { + String phone = stringValue(row, field, fieldName); + if (Func.isEmpty(phone)) { + return; + } + + if (!PHONE_PATTERN.matcher(phone).matches()) { + errors.add(fieldName + "格式不正确,应为11位数字"); + } + } + + private void validatePositiveNumber(Map row, String field, String fieldName, List errors) { + String value = stringValue(row, field, fieldName); + if (Func.isEmpty(value)) { + return; + } + + try { + BigDecimal number = new BigDecimal(value); + if (number.compareTo(BigDecimal.ZERO) <= 0) { + errors.add(fieldName + "必须为正数"); + } + } catch (NumberFormatException e) { + errors.add(fieldName + "格式不正确"); + } + } + + private void validateQuantityUnit(Map row, List quantityUnitOptions, List errors) { + String quantityUnit = stringValue(row, "quantityUnit", "数量单位"); + if (Func.isEmpty(quantityUnit)) { + return; + } + + if (!quantityUnitOptions.contains(quantityUnit)) { + errors.add("数量单位必须为系统枚举值之一"); + } + } + + private void validateFreightTotal(Map row, List errors) { + String freightTotalStr = stringValue(row, "freightTotal", "运费合计"); + if (Func.isEmpty(freightTotalStr)) { + return; + } + + try { + BigDecimal freightTotal = new BigDecimal(freightTotalStr); + String freightStr = stringValue(row, "freight", "运费"); + String otherFeeTotalStr = stringValue(row, "otherFeeTotal", "其他费用合计"); + + BigDecimal freight = Func.isNotEmpty(freightStr) ? new BigDecimal(freightStr) : BigDecimal.ZERO; + BigDecimal otherFeeTotal = Func.isNotEmpty(otherFeeTotalStr) ? new BigDecimal(otherFeeTotalStr) : BigDecimal.ZERO; + + BigDecimal calculatedTotal = freight.add(otherFeeTotal); + if (freightTotal.compareTo(calculatedTotal) != 0) { + errors.add("运费合计应等于运费+其他费用合计"); + } + } catch (NumberFormatException e) { + errors.add("运费相关字段格式不正确"); + } + } + + private void validateActualStartDate(Map row, boolean isDraft, List errors) { + Object actualStartDate = row.get("actualStartDate"); + if (actualStartDate == null || String.valueOf(actualStartDate).isBlank()) { + if (!isDraft) { + errors.add("实际发货时间不能为空"); + } + return; + } + + LocalDate startDate = parseDateForValidation(actualStartDate, "实际发货时间", errors); + if (startDate == null) { + return; + } + + Object actualEndDate = row.get("actualEndDate"); + if (actualEndDate != null && !String.valueOf(actualEndDate).isBlank()) { + LocalDate endDate = parseDateForValidation(actualEndDate, "实际完成时间", errors); + if (endDate != null && startDate.isAfter(endDate)) { + errors.add("实际发货时间不能晚于实际完成时间"); + } + } + } + + private void validateActualEndDate(Map row, boolean isDraft, List errors) { + Object actualEndDate = row.get("actualEndDate"); + if (actualEndDate == null || String.valueOf(actualEndDate).isBlank()) { + if (!isDraft) { + errors.add("实际完成时间不能为空"); + } + return; + } + + LocalDate endDate = parseDateForValidation(actualEndDate, "实际完成时间", errors); + if (endDate == null) { + return; + } + + Object actualStartDate = row.get("actualStartDate"); + if (actualStartDate != null && !String.valueOf(actualStartDate).isBlank()) { + LocalDate startDate = parseDateForValidation(actualStartDate, "实际发货时间", errors); + if (startDate != null && endDate.isBefore(startDate)) { + errors.add("实际完成时间不能早于实际发货时间"); + } + } + } + + private void validatePlanStartDate(Map row, List errors) { + Object planStartDate = row.get("planStartDate"); + if (planStartDate == null || String.valueOf(planStartDate).isBlank()) { + return; + } + + LocalDate startDate = parseDateForValidation(planStartDate, "预计发货时间", errors); + if (startDate == null) { + return; + } + + Object planEndDate = row.get("planEndDate"); + if (planEndDate != null && !String.valueOf(planEndDate).isBlank()) { + LocalDate endDate = parseDateForValidation(planEndDate, "预计完成时间", errors); + if (endDate != null && startDate.isAfter(endDate)) { + errors.add("预计发货时间不能晚于预计完成时间"); + } + } + } + + private void validatePlanEndDate(Map row, List errors) { + Object planEndDate = row.get("planEndDate"); + if (planEndDate == null || String.valueOf(planEndDate).isBlank()) { + return; + } + + LocalDate endDate = parseDateForValidation(planEndDate, "预计完成时间", errors); + if (endDate == null) { + return; + } + + Object planStartDate = row.get("planStartDate"); + if (planStartDate != null && !String.valueOf(planStartDate).isBlank()) { + LocalDate startDate = parseDateForValidation(planStartDate, "预计发货时间", errors); + if (startDate != null && endDate.isBefore(startDate)) { + errors.add("预计完成时间不能早于预计发货时间"); + } + } + } + + private void validateWaybillIdentifier(Map row, int rowIndex, + Map> waybillIdentifierMap, List> allRows, List errors) { + String waybillIdentifier = stringValue(row, "waybillIdentifier", "同一运单标识号"); + if (Func.isEmpty(waybillIdentifier)) { + return; + } + + List sameIdentifierRows = waybillIdentifierMap.get(waybillIdentifier); + if (sameIdentifierRows == null || sameIdentifierRows.size() <= 1) { + return; + } + + // 检查同一运单标识号下车牌号是否一致 + String currentVehicleNo = stringValue(row, "vehicleNo"); + for (Integer otherRowIndex : sameIdentifierRows) { + if (otherRowIndex.equals(rowIndex)) { + continue; + } + String otherVehicleNo = stringValue(allRows.get(otherRowIndex), "vehicleNo"); + if (Func.isNotEmpty(currentVehicleNo) && Func.isNotEmpty(otherVehicleNo) + && !currentVehicleNo.equals(otherVehicleNo)) { + errors.add("同一运单标识号下,车牌号必须一致"); + break; + } + } + } + + private LocalDate parseDateForValidation(Object value, String fieldName, List errors) { + if (value == null || String.valueOf(value).isBlank()) { + return null; + } + + 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)"); + return null; + } + } + + private List loadTransportTypeOptions() { + // 常见运输方式枚举值 + return List.of( + "公路整车", "公路配载/零担", "铁路整车", "铁路零担", + "水路", "航空", "多式联运", "管道运输", "其他" + ); + } + + private List loadQuantityUnitOptions() { + // 常见数量单位 + return List.of("吨", "千克", "立方米", "件", "箱", "台", "个", "升", "米", "平方米"); + } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java index f1f22cf..688d1dd 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java @@ -252,6 +252,9 @@ public class WaybillServiceImpl extends BaseServiceImpl BeanUtil.copyProperties(record, excel); excel.setCreateUserName(UserCache.getUserRealName(record.getCreateUser())); excel.setUpdateUserName(UserCache.getUserRealName(record.getUpdateUser())); + excel.setCreateTime(record.getCreateTime()); + excel.setUpdateTime(record.getUpdateTime()); + excel.setBusinessStatus(WaybillWrapper.businessStatusName(record.getBusinessStatus())); return excel; }).toList(); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/WaybillWrapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/WaybillWrapper.java index ed06b57..8b9d066 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/WaybillWrapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/WaybillWrapper.java @@ -56,7 +56,7 @@ public class WaybillWrapper extends BaseEntityWrapper { return waybillVO; } - private String businessStatusName(String status) { + public static String businessStatusName(String status) { if (status == null) { return "未知"; } diff --git a/doc/sql/transport/blade_transport_plan_add_fields_20260908.sql b/doc/sql/transport/blade_transport_plan_add_fields_20260908.sql new file mode 100644 index 0000000..0d832de --- /dev/null +++ b/doc/sql/transport/blade_transport_plan_add_fields_20260908.sql @@ -0,0 +1,14 @@ +-- 运输计划表新增字段:里程和同一计划标识号 +-- 日期:2026-09-08 + +-- 添加里程字段 +ALTER TABLE `blade_transport_plan` +ADD COLUMN `mileage` decimal(10,2) DEFAULT NULL COMMENT '里程(km)' AFTER `remark`; + +-- 添加同一计划标识号字段 +ALTER TABLE `blade_transport_plan` +ADD COLUMN `plan_group_id` varchar(100) DEFAULT NULL COMMENT '同一计划标识号' AFTER `mileage`; + +-- 添加索引 +ALTER TABLE `blade_transport_plan` +ADD INDEX `idx_transport_plan_group_id` (`plan_group_id`) USING BTREE; diff --git a/doc/sql/transport/blade_voucher_manage_audit_20260908.sql b/doc/sql/transport/blade_voucher_manage_audit_20260908.sql new file mode 100644 index 0000000..b9ee73f --- /dev/null +++ b/doc/sql/transport/blade_voucher_manage_audit_20260908.sql @@ -0,0 +1,18 @@ +-- 凭证管理审核功能增强 +-- 日期:2026-09-08 + +-- 1. 添加承运商ID字段 +ALTER TABLE `blade_voucher_manage` +ADD COLUMN `carrier_id` bigint DEFAULT NULL COMMENT '承运商ID(组织ID)' AFTER `carrier_name`; + +-- 2. 添加索引 +ALTER TABLE `blade_voucher_manage` +ADD KEY `idx_voucher_manage_carrier_id` (`carrier_id`), +ADD KEY `idx_voucher_manage_audit_status` (`audit_status`); + +-- 3. 新增审核菜单权限 +INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) +VALUES +(2090000000000000905, 2090000000000000900, 'voucher_manage_audit_pass', '审核通过', 'voucher_manage_audit_pass', '', '', 5, 2, 0, 1, NULL, '', 0), +(2090000000000000906, 2090000000000000900, 'voucher_manage_audit_reject', '审核驳回', 'voucher_manage_audit_reject', '', '', 6, 2, 0, 1, NULL, '', 0) +ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `is_deleted` = 0;