1、调整凭证

2、调整运单
This commit is contained in:
2026-09-08 17:50:20 +08:00
parent b4a9e30287
commit f370f56a92
20 changed files with 1476 additions and 153 deletions
@@ -145,4 +145,10 @@ public class TransportPlan extends TenantEntity {
@Schema(description = "备注") @Schema(description = "备注")
private String remark; private String remark;
@Schema(description = "里程(km)")
private java.math.BigDecimal mileage;
@Schema(description = "同一计划标识号")
private String planGroupId;
} }
@@ -26,6 +26,7 @@ public class VoucherManage extends TenantEntity {
private Long fileTaskId; private Long fileTaskId;
private String uploadSource; private String uploadSource;
private String carrierName; private String carrierName;
private Long carrierId;
private String processStatus; private String processStatus;
private Integer voucherCount; private Integer voucherCount;
private Integer relatedWaybillCount; private Integer relatedWaybillCount;
@@ -4,6 +4,7 @@ import lombok.Data;
import java.io.Serial; import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import java.util.Date;
/** /**
* 凭证解压文件详情。 * 凭证解压文件详情。
@@ -29,4 +30,6 @@ public class VoucherFileVO implements Serializable {
private String fileType; private String fileType;
private Integer matched; private Integer matched;
private String url; private String url;
private Date createTime;
private Date updateTime;
} }
@@ -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<VoucherFileVO> files;
}
@@ -155,6 +155,13 @@ public class TransportReconciliationController extends BladeController {
@Operation(summary = "自动匹配内部账单") @Operation(summary = "自动匹配内部账单")
public R match(@RequestParam Long id) { reconciliationService.autoMatch(id); return R.success("匹配完成"); } public R match(@RequestParam Long id) { reconciliationService.autoMatch(id); return R.success("匹配完成"); }
@PostMapping("/match-preview")
@ApiOperationSupport(order = 9)
@Operation(summary = "预览自动匹配内部账单")
public R<TransportReconciliationVO> matchPreview(@RequestBody TransportReconciliationVO request) {
return R.data(reconciliationService.matchPreview(request));
}
@PostMapping("/manual-match") @PostMapping("/manual-match")
@ApiOperationSupport(order = 10) @ApiOperationSupport(order = 10)
@Operation(summary = "人工匹配账单明细") @Operation(summary = "人工匹配账单明细")
@@ -179,4 +186,11 @@ public class TransportReconciliationController extends BladeController {
@ApiOperationSupport(order = 14) @ApiOperationSupport(order = 14)
@Operation(summary = "完成运输对账") @Operation(summary = "完成运输对账")
public R complete(@RequestParam Long id) { reconciliationService.complete(id); return R.success("对账单确认完成"); } public R complete(@RequestParam Long id) { reconciliationService.complete(id); return R.success("对账单确认完成"); }
@PostMapping("/complete-with-data")
@ApiOperationSupport(order = 14)
@Operation(summary = "保存明细并完成运输对账")
public R<TransportReconciliationVO> completeWithData(@RequestBody TransportReconciliationVO request) {
return R.data(reconciliationService.completeWithData(request));
}
} }
@@ -15,6 +15,8 @@ import org.springblade.transport.pojo.dto.VoucherUploadDraftRequest;
import org.springblade.transport.pojo.dto.VoucherFileCompleteRequest; import org.springblade.transport.pojo.dto.VoucherFileCompleteRequest;
import org.springblade.transport.pojo.entity.VoucherManage; import org.springblade.transport.pojo.entity.VoucherManage;
import org.springblade.transport.pojo.vo.VoucherManageVO; 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.springblade.transport.service.IVoucherManageService;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -46,6 +48,34 @@ public class VoucherManageController extends BladeController {
@Operation(summary = "凭证详情") @Operation(summary = "凭证详情")
public R<VoucherManageVO> detail(@RequestParam Long id) { return R.data(voucherManageService.detail(id)); } public R<VoucherManageVO> detail(@RequestParam Long id) { return R.data(voucherManageService.detail(id)); }
@GetMapping("/folder-page")
@Operation(summary = "执行凭证文件夹分页")
public R<IPage<VoucherFolderVO>> 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<VoucherFolderVO> 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") @PostMapping("/submit")
@ApiOperationSupport(order = 3) @ApiOperationSupport(order = 3)
@Operation(summary = "上传凭证或更换运输批次") @Operation(summary = "上传凭证或更换运输批次")
@@ -85,4 +115,20 @@ public class VoucherManageController extends BladeController {
@RequestParam(required = false) String createTimeStart, @RequestParam(required = false) String createTimeEnd) { @RequestParam(required = false) String createTimeStart, @RequestParam(required = false) String createTimeEnd) {
return R.data(voucherManageService.selectableWaybillBatches(Condition.getPage(query), batchNo, createUser, waybillCount, createTimeStart, 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("审核驳回");
}
} }
@@ -49,46 +49,44 @@ public class TransportPlanImportExcel implements Serializable {
@ExcelProperty("*计划名称") @ExcelProperty("*计划名称")
private String planName; private String planName;
@ExcelProperty("*运输方式") @ExcelProperty("*运输类型")
private String transportType; private String transportType;
@ExcelProperty("*计划开始日期") @ExcelProperty("*发货地址")
private String planStartDate; private String departureAddress;
@ExcelProperty("*计划结束日期") @ExcelProperty("发货联系人")
private String planEndDate; private String departureContact;
@ExcelProperty("发货联系人电话")
private String departurePhone;
@ExcelProperty("*到货地址")
private String arrivalAddress;
@ExcelProperty("收货联系人")
private String arrivalContact;
@ExcelProperty("收货联系人电话")
private String arrivalPhone;
@ExcelProperty("货物名称") @ExcelProperty("货物名称")
private String cargoName; private String cargoName;
@ExcelProperty("*货物类型") @ExcelProperty("*货物类型")
private String cargoType; private String cargoType;
@ExcelProperty("数量") @ExcelProperty("数量")
private BigDecimal quantity; private BigDecimal quantity;
@ExcelProperty("量单位") @ExcelProperty("量单位")
private String quantityUnit; private String quantityUnit;
@ExcelProperty("包装") @ExcelProperty("包装")
private String packageType; private String packageType;
@ExcelProperty("品牌")
private String brand;
@ExcelProperty("规格") @ExcelProperty("规格")
private String specification; private String specification;
@ExcelProperty("型号") @ExcelProperty("型号")
private String model; private String model;
@ExcelProperty("物料编码") @ExcelProperty("里程(km)")
private String materialCode; private BigDecimal mileage;
@ExcelProperty("设备编码") @ExcelProperty("计划开始时间")
private String deviceCode; private String planStartDate;
@ExcelProperty("*发货地址") @ExcelProperty("计划结束时间")
private String departureAddress; private String planEndDate;
@ExcelProperty("发货联系人")
private String departureContact;
@ExcelProperty("发货联系方式")
private String departurePhone;
@ExcelProperty("*收货地址")
private String arrivalAddress;
@ExcelProperty("收货联系人")
private String arrivalContact;
@ExcelProperty("收货联系方式")
private String arrivalPhone;
@ExcelProperty("备注") @ExcelProperty("备注")
private String remark; private String remark;
@ExcelProperty("同一计划标识号")
private String planGroupId;
@ExcelIgnore @ExcelIgnore
private String errorMessage; private String errorMessage;
@@ -34,8 +34,7 @@ import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.util.Date;
import java.time.LocalDate;
/** /**
* 运单管理 Excel * 运单管理 Excel
@@ -151,12 +150,12 @@ public class WaybillExcel implements Serializable {
private String createUserName; private String createUserName;
@ExcelProperty("更新人") @ExcelProperty("更新人")
private String updateUserName; private String updateUserName;
@ExcelProperty(value = "创建时间", converter = MaintenancePlanDateTimeConverter.class) @ExcelProperty("创建时间")
@DateTimeFormat("yyyy-MM-dd HH:mm:ss") @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime; private Date createTime;
@ExcelProperty(value = "更新时间", converter = MaintenancePlanDateTimeConverter.class) @ExcelProperty("更新时间")
@DateTimeFormat("yyyy-MM-dd HH:mm:ss") @DateTimeFormat("yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime; private Date updateTime;
@ExcelIgnore @ExcelIgnore
private String errorMessage; private String errorMessage;
@@ -22,45 +22,67 @@ public class WaybillImportBatchExcel implements Serializable {
@Serial @Serial
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
@ExcelProperty("序号")
private Integer serialNumber;
@ExcelProperty("原始单号") @ExcelProperty("原始单号")
private String originalNo; private String originalNo;
@ExcelProperty("配载标识号")
private String loadingIdentifier;
@ExcelProperty("*车牌号/航班号/船号/班列号") @ExcelProperty("*车牌号/航班号/船号/班列号")
private String vehicleNo; private String vehicleNo;
@ExcelProperty("*司机/船长") @ExcelProperty("*运输方式")
private String driverName;
@ExcelProperty("*运输类型")
private String transportType; 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("*货物名称") @ExcelProperty("*货物名称")
private String cargoName; private String cargoName;
@ExcelProperty("*货物类型") @ExcelProperty("*货物类型")
private String cargoType; private String cargoType;
@ExcelProperty("重量") @ExcelProperty("包装")
private String packageType;
@ExcelProperty("*数量")
private BigDecimal quantity; private BigDecimal quantity;
@ExcelProperty("*发货地址") @ExcelProperty("*数量单位")
private String departureAddress; private String quantityUnit;
@ExcelProperty("*发货联系人") @ExcelProperty("规格")
private String departureContact; private String specification;
@ExcelProperty("*发货联系人电话") @ExcelProperty("型号")
private String departurePhone; private String model;
@ExcelProperty("*到货地址") @ExcelProperty("里程(km)")
private String arrivalAddress; private BigDecimal mileage;
@ExcelProperty("*到货联系人") @ExcelProperty("单价")
private String arrivalContact;
@ExcelProperty("*收货联系人电话")
private String arrivalPhone;
@ExcelProperty("*开始时间")
private String startDate;
@ExcelProperty("*结束时间")
private String endDate;
@ExcelProperty("*单价")
private BigDecimal unitPrice; private BigDecimal unitPrice;
@ExcelProperty("*运费") @ExcelProperty("运费")
private BigDecimal freight; private BigDecimal freight;
@ExcelProperty("其他费用合计") @ExcelProperty("其他费用合计")
private BigDecimal otherFeeTotal; private BigDecimal otherFeeTotal;
@ExcelProperty("运费合计") @ExcelProperty("运费合计")
private BigDecimal freightTotal; private BigDecimal freightTotal;
@ExcelProperty("*实际发货时间")
private String actualStartDate;
@ExcelProperty("*实际完成时间")
private String actualEndDate;
@ExcelProperty("预计发货时间")
private String planStartDate;
@ExcelProperty("预计完成时间")
private String planEndDate;
@ExcelProperty("备注") @ExcelProperty("备注")
private String remark; private String remark;
@ExcelProperty("同一运单标识号")
private String waybillIdentifier;
} }
@@ -15,4 +15,21 @@ public interface VoucherManageMapper extends BaseMapper<VoucherManage> {
+ "FROM blade_voucher_manage WHERE tenant_id = #{tenantId} " + "FROM blade_voucher_manage WHERE tenant_id = #{tenantId} "
+ "AND voucher_batch_no LIKE CONCAT(#{prefix}, '%')") + "AND voucher_batch_no LIKE CONCAT(#{prefix}, '%')")
Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String 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);
} }
@@ -30,6 +30,8 @@ public interface ITransportReconciliationService extends BaseService<TransportRe
void removeDraft(Long id); void removeDraft(Long id);
List<VehicleReconciliationFailureExcel> importVehicles(Long id, List<VehicleReconciliationExcel> rows); List<VehicleReconciliationFailureExcel> importVehicles(Long id, List<VehicleReconciliationExcel> rows);
List<CargoReconciliationFailureExcel> importCargoes(Long id, List<CargoReconciliationExcel> rows); List<CargoReconciliationFailureExcel> importCargoes(Long id, List<CargoReconciliationExcel> rows);
TransportReconciliationVO matchPreview(TransportReconciliationVO request);
TransportReconciliationVO completeWithData(TransportReconciliationVO request);
void autoMatch(Long id); void autoMatch(Long id);
void manualMatch(TransportReconciliationManualMatchRequest request); void manualMatch(TransportReconciliationManualMatchRequest request);
void unmatch(Long internalId); void unmatch(Long internalId);
@@ -7,6 +7,8 @@ import org.springblade.transport.pojo.dto.VoucherUploadDraftRequest;
import org.springblade.transport.pojo.dto.VoucherFileCompleteRequest; import org.springblade.transport.pojo.dto.VoucherFileCompleteRequest;
import org.springblade.transport.pojo.entity.VoucherManage; import org.springblade.transport.pojo.entity.VoucherManage;
import org.springblade.transport.pojo.vo.VoucherManageVO; 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.List;
import java.util.Map; import java.util.Map;
@@ -14,6 +16,10 @@ import java.util.Map;
public interface IVoucherManageService extends BaseService<VoucherManage> { public interface IVoucherManageService extends BaseService<VoucherManage> {
IPage<VoucherManageVO> selectPage(IPage<VoucherManage> page, VoucherManageVO query); IPage<VoucherManageVO> selectPage(IPage<VoucherManage> page, VoucherManageVO query);
VoucherManageVO detail(Long id); VoucherManageVO detail(Long id);
IPage<VoucherFolderVO> 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); void submit(VoucherManageSubmitRequest request);
VoucherManage createUploadDraft(VoucherUploadDraftRequest request); VoucherManage createUploadDraft(VoucherUploadDraftRequest request);
void completeUploadFile(VoucherFileCompleteRequest request); void completeUploadFile(VoucherFileCompleteRequest request);
@@ -21,4 +27,6 @@ public interface IVoucherManageService extends BaseService<VoucherManage> {
void reprocessUploadedVoucher(Long voucherId); void reprocessUploadedVoucher(Long voucherId);
void removeVoucher(Long id); void removeVoucher(Long id);
IPage<Map<String, Object>> selectableWaybillBatches(IPage<?> page, String batchNo, String createUser, Integer waybillCount, String createTimeStart, String createTimeEnd); IPage<Map<String, Object>> selectableWaybillBatches(IPage<?> page, String batchNo, String createUser, Integer waybillCount, String createTimeStart, String createTimeEnd);
void auditPass(Long id);
void auditReject(Long id, String rejectReason);
} }
@@ -59,11 +59,14 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.TreeMap;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.math.BigDecimal;
/** /**
* 运输计划 服务实现类 * 运输计划 服务实现类
@@ -207,40 +210,56 @@ public class TransportPlanServiceImpl extends BaseServiceImpl<TransportPlanMappe
throw new ServiceException("客户合同不能为空"); throw new ServiceException("客户合同不能为空");
} }
TransportBusinessSupport.validateRequired(contractName, "客户合同不能为空"); TransportBusinessSupport.validateRequired(contractName, "客户合同不能为空");
List<TransportPlanImportExcel> errorList = new ArrayList<>();
// 第一阶段:全部校验
Map<Integer, TransportPlanImportExcel> errorMap = new TreeMap<>();
Map<String, Integer> planNameCountMap = buildImportPlanNameCountMap(data);
Map<String, List<Integer>> planGroupMap = buildImportPlanGroupMap(data);
Long currentDeptId = TransportBusinessSupport.currentDept("运输计划").getId();
List<TransportPlan> importPlans = new ArrayList<>();
for (int index = 0; index < data.size(); index++) { for (int index = 0; index < data.size(); index++) {
TransportPlanImportExcel excel = data.get(index); TransportPlanImportExcel excel = data.get(index);
try { try {
LocalDate planStartDate = parseImportDate(excel.getPlanStartDate(), "计划开始日期"); LocalDate planStartDate = Func.isNotEmpty(excel.getPlanStartDate())
LocalDate planEndDate = parseImportDate(excel.getPlanEndDate(), "计划结束日期"); ? parseImportDate(excel.getPlanStartDate(), "计划开始时间") : null;
validateImportExcel(excel, planStartDate, planEndDate); LocalDate planEndDate = Func.isNotEmpty(excel.getPlanEndDate())
TransportPlan transportPlan = new TransportPlan(); ? parseImportDate(excel.getPlanEndDate(), "计划结束时间") : null;
transportPlan.setProjectId(projectId);
transportPlan.setProjectName(projectName); List<String> validationErrors = validateImportExcel(excel, planStartDate, planEndDate, planNameCountMap, planGroupMap, currentDeptId);
transportPlan.setContractId(contractId); if (Func.isNotEmpty(validationErrors)) {
transportPlan.setContractName(contractName); excel.setErrorMessage(formatImportErrorMessage(validationErrors));
transportPlan.setCustomerName(customerName); errorMap.put(index, excel);
transportPlan.setPlanName(excel.getPlanName()); continue;
transportPlan.setTransportType(excel.getTransportType()); }
transportPlan.setPlanStartDate(planStartDate);
transportPlan.setPlanEndDate(planEndDate); TransportPlan transportPlan = buildImportTransportPlan(excel, projectId, projectName, contractId, contractName, customerName, planStartDate, planEndDate);
transportPlan.setDepartureAddress(excel.getDepartureAddress()); importPlans.add(transportPlan);
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);
} catch (Exception exception) { } catch (Exception exception) {
excel.setErrorMessage(exception.getMessage()); String message = exception instanceof ServiceException ? exception.getMessage() : "数据解析失败";
errorList.add(excel); 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<String, Object> importGoods(TransportPlanImportExcel excel) { private LinkedHashMap<String, Object> importGoods(TransportPlanImportExcel excel) {
@@ -250,36 +269,197 @@ public class TransportPlanServiceImpl extends BaseServiceImpl<TransportPlanMappe
goods.put("quantity", excel.getQuantity()); goods.put("quantity", excel.getQuantity());
goods.put("quantityUnit", excel.getQuantityUnit()); goods.put("quantityUnit", excel.getQuantityUnit());
goods.put("packageType", excel.getPackageType()); goods.put("packageType", excel.getPackageType());
goods.put("brand", excel.getBrand());
goods.put("specification", excel.getSpecification()); goods.put("specification", excel.getSpecification());
goods.put("model", excel.getModel()); goods.put("model", excel.getModel());
goods.put("materialCode", excel.getMaterialCode());
goods.put("deviceCode", excel.getDeviceCode());
return goods; return goods;
} }
private void validateImportExcel(TransportPlanImportExcel excel, LocalDate planStartDate, LocalDate planEndDate) { private Map<String, Integer> buildImportPlanNameCountMap(List<TransportPlanImportExcel> data) {
TransportBusinessSupport.validateRequired(excel.getPlanName(), "计划名称不能为空"); Map<String, Integer> countMap = new HashMap<>();
TransportBusinessSupport.validateRequired(excel.getTransportType(), "运输方式不能为空"); for (TransportPlanImportExcel excel : data) {
TransportBusinessSupport.validateRequired(excel.getCargoType(), "货物类型不能为空"); String planName = trimToEmpty(excel.getPlanName());
TransportBusinessSupport.validateRequired(excel.getDepartureAddress(), "发货地址不能为空"); if (Func.isNotEmpty(planName)) {
TransportBusinessSupport.validateRequired(excel.getArrivalAddress(), "收货地址不能为空"); countMap.merge(planName, 1, Integer::sum);
validateImportLength(excel.getPlanName(), 50, "计划名称"); }
validateImportLength(excel.getDepartureAddress(), 255, "发货地址"); }
validateImportLength(excel.getArrivalAddress(), 255, "收货地址"); return countMap;
validateImportLength(excel.getDepartureContact(), 50, "发货联系人"); }
validateImportLength(excel.getArrivalContact(), 50, "收货联系人");
validateImportLength(excel.getDeparturePhone(), 50, "发货联系方式"); private Map<String, List<Integer>> buildImportPlanGroupMap(List<TransportPlanImportExcel> data) {
validateImportLength(excel.getArrivalPhone(), 50, "收货联系方式"); Map<String, List<Integer>> groupMap = new HashMap<>();
validateImportLength(excel.getRemark(), 200, "备注"); for (int index = 0; index < data.size(); index++) {
TransportBusinessSupport.validatePhone(excel.getDeparturePhone(), "发货联系方式格式不正确"); TransportPlanImportExcel excel = data.get(index);
TransportBusinessSupport.validatePhone(excel.getArrivalPhone(), "收货联系方式格式不正确"); String planGroupId = trimToEmpty(excel.getPlanGroupId());
TransportBusinessSupport.validateNonNegative(excel.getQuantity(), "数量"); if (Func.isNotEmpty(planGroupId)) {
TransportBusinessSupport.validateDateRange(planStartDate, planEndDate, "计划结束日期不能早于计划开始日期"); 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<String> 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<String> validateImportExcel(TransportPlanImportExcel excel, LocalDate planStartDate, LocalDate planEndDate,
Map<String, Integer> planNameCountMap, Map<String, List<Integer>> planGroupMap, Long currentDeptId) {
List<String> 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<String> 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.<TransportPlan>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<String> 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<Integer> 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) { private LocalDate parseImportDate(String value, String fieldName) {
TransportBusinessSupport.validateRequired(value, fieldName + "不能为空"); if (Func.isEmpty(value)) {
return null;
}
try { try {
return LocalDate.parse(value.trim(), DateTimeFormatter.ISO_LOCAL_DATE); return LocalDate.parse(value.trim(), DateTimeFormatter.ISO_LOCAL_DATE);
} catch (Exception exception) { } catch (Exception exception) {
@@ -511,6 +691,7 @@ public class TransportPlanServiceImpl extends BaseServiceImpl<TransportPlanMappe
transportPlan.setDataSource(TransportBusinessSupport.normalizeTransportPlanDataSource(transportPlan.getDataSource())); transportPlan.setDataSource(TransportBusinessSupport.normalizeTransportPlanDataSource(transportPlan.getDataSource()));
transportPlan.setBusinessStatus(TransportBusinessSupport.trimToNull(transportPlan.getBusinessStatus())); transportPlan.setBusinessStatus(TransportBusinessSupport.trimToNull(transportPlan.getBusinessStatus()));
transportPlan.setDeptName(TransportBusinessSupport.trimToNull(transportPlan.getDeptName())); transportPlan.setDeptName(TransportBusinessSupport.trimToNull(transportPlan.getDeptName()));
transportPlan.setPlanGroupId(TransportBusinessSupport.trimToNull(transportPlan.getPlanGroupId()));
transportPlan.setRemark(TransportBusinessSupport.trimToNull(transportPlan.getRemark())); transportPlan.setRemark(TransportBusinessSupport.trimToNull(transportPlan.getRemark()));
if (Func.isEmpty(transportPlan.getDeptId())) { if (Func.isEmpty(transportPlan.getDeptId())) {
Dept dept = TransportBusinessSupport.currentDept("运输计划"); Dept dept = TransportBusinessSupport.currentDept("运输计划");
@@ -548,7 +729,9 @@ public class TransportPlanServiceImpl extends BaseServiceImpl<TransportPlanMappe
TransportBusinessSupport.validateLength(transportPlan.getDataSource(), 255, "数据来源不能超过255字"); TransportBusinessSupport.validateLength(transportPlan.getDataSource(), 255, "数据来源不能超过255字");
TransportBusinessSupport.validateLength(transportPlan.getBusinessStatus(), 255, "业务状态不能超过255字"); TransportBusinessSupport.validateLength(transportPlan.getBusinessStatus(), 255, "业务状态不能超过255字");
TransportBusinessSupport.validateLength(transportPlan.getDeptName(), 255, "所属组织不能超过255字"); TransportBusinessSupport.validateLength(transportPlan.getDeptName(), 255, "所属组织不能超过255字");
TransportBusinessSupport.validateLength(transportPlan.getPlanGroupId(), 255, "同一计划标识号不能超过255字");
TransportBusinessSupport.validateLength(transportPlan.getRemark(), 500, "备注不能超过500字"); TransportBusinessSupport.validateLength(transportPlan.getRemark(), 500, "备注不能超过500字");
TransportBusinessSupport.validateNonNegative(transportPlan.getMileage(), "里程");
TransportBusinessSupport.validateDateRange(transportPlan.getPlanStartDate(), transportPlan.getPlanEndDate(), "计划开始日期不能晚于计划结束日期"); TransportBusinessSupport.validateDateRange(transportPlan.getPlanStartDate(), transportPlan.getPlanEndDate(), "计划开始日期不能晚于计划结束日期");
} }
@@ -55,17 +55,13 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.text.Collator;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException; import java.time.format.DateTimeParseException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.function.Function; import java.util.function.Function;
@@ -399,6 +395,124 @@ public class TransportReconciliationServiceImpl
return validationErrors; return validationErrors;
} }
@Override
public TransportReconciliationVO matchPreview(TransportReconciliationVO request) {
if (request == null || request.getId() == null) throw new ServiceException("运输对账单不存在");
editable(request.getId());
List<TransportReconciliationInternal> internals = request.getInternalDetails();
List<TransportReconciliationExternal> 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<String, List<TransportReconciliationExternal>> externalGroups = externals.stream()
.collect(Collectors.groupingBy(this::matchKey));
Map<String, List<TransportReconciliationInternal>> internalGroups = internals.stream()
.collect(Collectors.groupingBy(this::matchKey));
for (Map.Entry<String, List<TransportReconciliationExternal>> entry : externalGroups.entrySet()) {
List<TransportReconciliationExternal> externalGroup = entry.getValue();
List<TransportReconciliationInternal> 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<TransportReconciliationInternal> incomingInternals,
List<TransportReconciliationExternal> incomingExternals) {
if (incomingInternals == null || incomingExternals == null) {
throw new ServiceException("对账明细不能为空");
}
Map<Long, TransportReconciliationInternal> internalMap = internalRows(bill.getId()).stream()
.collect(Collectors.toMap(TransportReconciliationInternal::getId, Function.identity()));
Map<Long, TransportReconciliationExternal> 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 @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void autoMatch(Long id) { public void autoMatch(Long id) {
@@ -407,16 +521,14 @@ public class TransportReconciliationServiceImpl
List<TransportReconciliationExternal> externals = externalRows(id); List<TransportReconciliationExternal> externals = externalRows(id);
if (externals.isEmpty()) throw new ServiceException("请先导入外部账单"); if (externals.isEmpty()) throw new ServiceException("请先导入外部账单");
resetMatches(internals, externals); resetMatches(internals, externals);
Function<TransportReconciliationExternal, String> externalKey = CARGO.equals(bill.getReconciliationMode()) Map<String, List<TransportReconciliationExternal>> externalGroups = externals.stream()
? this::cargoKey : this::vehicleKey; .collect(Collectors.groupingBy(this::matchKey));
Map<String, List<TransportReconciliationExternal>> externalGroups = externals.stream().collect(Collectors.groupingBy(externalKey)); Map<String, List<TransportReconciliationInternal>> internalGroups = internals.stream()
Map<String, List<TransportReconciliationInternal>> internalGroups = internals.stream().collect(Collectors.groupingBy( .collect(Collectors.groupingBy(this::matchKey));
CARGO.equals(bill.getReconciliationMode()) ? this::cargoKey : this::vehicleKey));
for (Map.Entry<String, List<TransportReconciliationExternal>> entry : externalGroups.entrySet()) { for (Map.Entry<String, List<TransportReconciliationExternal>> entry : externalGroups.entrySet()) {
List<TransportReconciliationExternal> externalGroup = entry.getValue(); List<TransportReconciliationExternal> externalGroup = entry.getValue();
List<TransportReconciliationInternal> internalGroup = internalGroups.getOrDefault(entry.getKey(), List.of()); List<TransportReconciliationInternal> internalGroup = internalGroups.getOrDefault(entry.getKey(), List.of());
if (externalGroup.size() == 1 && internalGroup.size() == 1 if (externalGroup.size() == 1 && internalGroup.size() == 1) {
&& equalMoney(externalGroup.get(0).getSettlementAmount(), internalGroup.get(0).getSettlementAmount())) {
link(internalGroup.get(0), externalGroup.get(0)); link(internalGroup.get(0), externalGroup.get(0));
} else if (externalGroup.size() > 1) { } else if (externalGroup.size() > 1) {
for (TransportReconciliationExternal external : externalGroup) { for (TransportReconciliationExternal external : externalGroup) {
@@ -664,6 +776,13 @@ public class TransportReconciliationServiceImpl
TransportReconciliation bill = existing(id); TransportReconciliation bill = existing(id);
List<TransportReconciliationInternal> internals = internalRows(id); List<TransportReconciliationInternal> internals = internalRows(id);
List<TransportReconciliationExternal> externals = externalRows(id); List<TransportReconciliationExternal> externals = externalRows(id);
refreshPreviewStats(bill, internals, externals);
updateById(bill);
}
private void refreshPreviewStats(TransportReconciliation bill,
List<TransportReconciliationInternal> internals,
List<TransportReconciliationExternal> externals) {
int matched = (int) internals.stream().filter(item -> MATCHED.equals(item.getMatchResult())).count(); int matched = (int) internals.stream().filter(item -> MATCHED.equals(item.getMatchResult())).count();
bill.setInternalBillCount(internals.size()); bill.setExternalBillCount(externals.size()); bill.setInternalBillCount(internals.size()); bill.setExternalBillCount(externals.size());
int internalUnmatched = (int) internals.stream().filter(item -> !MATCHED.equals(item.getMatchResult())).count(); 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.setInternalAmount(sumInternalAmount(internals)); bill.setExternalAmount(sumExternalAmount(externals));
bill.setDifferenceAmount(bill.getInternalAmount().subtract(bill.getExternalAmount()).abs()); 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); 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) { 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); } for (TransportReconciliationExternal external : externals) { external.setMatchedInternalId(null); external.setMatchStatus(UNMATCHED); external.setSuspectedDuplicate(false); externalMapper.updateById(external); }
} }
private void resetPreviewMatches(List<TransportReconciliationInternal> internals, List<TransportReconciliationExternal> 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) { private void resetExternal(Long id) {
List<TransportReconciliationInternal> internals = internalRows(id); List<TransportReconciliationInternal> internals = internalRows(id);
for (TransportReconciliationInternal internal : internals) { internal.setMatchedExternalId(null); internal.setMatchedExternalLineNo(null); internal.setMatchResult(UNMATCHED); internalMapper.updateById(internal); } 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"); 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 matchKey(TransportReconciliationInternal row) {
private String vehicleKey(TransportReconciliationExternal row) { return key(row.getVehicleNo(), sortedCargoNames(row.getCargoName()), row.getActualDepartureTime(), row.getBatchNo(), row.getTransportQuantity()); } return key(row.getVehicleNo(), row.getDepartureAddress(), row.getArrivalAddress(), row.getCargoName(), row.getCargoType());
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(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 key(Object... values) { StringBuilder builder = new StringBuilder(); for (Object value : values) builder.append(normal(value)).append('|'); return builder.toString(); }
private String normal(Object value) { private String normal(Object value) {
if (value == null) return ""; if (value == null) return "";
@@ -779,23 +921,10 @@ public class TransportReconciliationServiceImpl
if (value instanceof List<?> list) return JsonUtil.toJson(list); if (value instanceof List<?> list) return JsonUtil.toJson(list);
return value.toString().trim().replaceAll("\\s+", "").toLowerCase(); return value.toString().trim().replaceAll("\\s+", "").toLowerCase();
} }
private List<String> 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) { private String firstNotEmpty(String... values) {
for (String value : values) if (Func.isNotEmpty(value)) return value; for (String value : values) if (Func.isNotEmpty(value)) return value;
return null; 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 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 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; } private String limit(String value, int max) { if (value != null && value.length() > max) throw new ServiceException("备注不能超过" + max + "个字"); return value; }
@@ -2,6 +2,7 @@ package org.springblade.transport.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import org.springblade.core.log.exception.ServiceException; import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.mp.base.BaseServiceImpl; 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.entity.ProjectApply;
import org.springblade.transport.pojo.vo.VoucherManageVO; import org.springblade.transport.pojo.vo.VoucherManageVO;
import org.springblade.transport.pojo.vo.VoucherFileVO; 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.IVoucherManageService;
import org.springblade.transport.service.IProjectApplyService; import org.springblade.transport.service.IProjectApplyService;
import org.springblade.transport.service.IWaybillService; import org.springblade.transport.service.IWaybillService;
@@ -35,6 +37,7 @@ import org.springframework.transaction.annotation.Transactional;
import io.minio.MinioClient; import io.minio.MinioClient;
import io.minio.GetPresignedObjectUrlArgs; import io.minio.GetPresignedObjectUrlArgs;
import io.minio.PutObjectArgs; import io.minio.PutObjectArgs;
import io.minio.RemoveObjectArgs;
import io.minio.http.Method; import io.minio.http.Method;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@@ -45,6 +48,8 @@ import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.LinkedHashMap;
import java.util.Comparator;
import java.util.Objects; import java.util.Objects;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.io.BufferedInputStream; import java.io.BufferedInputStream;
@@ -60,6 +65,7 @@ import java.util.HashSet;
import java.util.Set; import java.util.Set;
import java.util.zip.ZipEntry; import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream; import java.util.zip.ZipInputStream;
import org.springframework.web.multipart.MultipartFile;
@Service @Service
@Slf4j @Slf4j
@@ -107,10 +113,326 @@ public class VoucherManageServiceImpl extends BaseServiceImpl<VoucherManageMappe
.eq(VoucherFile::getVoucherId, record.getId()) .eq(VoucherFile::getVoucherId, record.getId())
.eq(VoucherFile::getIsDeleted, 0) .eq(VoucherFile::getIsDeleted, 0)
.orderByAsc(VoucherFile::getEntryName)); .orderByAsc(VoucherFile::getEntryName));
result.setVoucherFiles(files.stream().map(this::toVoucherFileVO).toList()); if (files.isEmpty()) {
List<VoucherImage> images = voucherImageMapper.selectList(Wrappers.<VoucherImage>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; return result;
} }
@Override
public IPage<VoucherFolderVO> 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<VoucherFolderVO> 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<VoucherFolderVO> 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<VoucherFile> oldFiles = voucherFileMapper.selectList(Wrappers.<VoucherFile>lambdaQuery()
.eq(VoucherFile::getTenantId, voucher.getTenantId()).eq(VoucherFile::getVoucherId, voucherId)
.eq(VoucherFile::getPlateNo, normalizedPlateNo).eq(VoucherFile::getIsDeleted, 0));
List<VoucherImage> oldImages = voucherImageMapper.selectList(Wrappers.<VoucherImage>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<VoucherFile> files = voucherFileMapper.selectList(Wrappers.<VoucherFile>lambdaQuery().eq(VoucherFile::getTenantId, voucher.getTenantId()).eq(VoucherFile::getVoucherId, voucherId).eq(VoucherFile::getPlateNo, normalizedPlateNo).eq(VoucherFile::getIsDeleted, 0));
List<VoucherImage> images = voucherImageMapper.selectList(Wrappers.<VoucherImage>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<VoucherFolderVO> folderRecords(VoucherManage voucher, boolean includeFiles) {
List<VoucherFile> files = voucherFileMapper.selectList(Wrappers.<VoucherFile>lambdaQuery().eq(VoucherFile::getTenantId, voucher.getTenantId()).eq(VoucherFile::getVoucherId, voucher.getId()).eq(VoucherFile::getIsDeleted, 0));
Map<String, VoucherFolderVO> grouped = new LinkedHashMap<>();
if (!files.isEmpty()) {
files.forEach(file -> addFolderFile(grouped, voucher, toVoucherFileVO(file, includeFiles), file.getCreateTime(), file.getUpdateTime()));
} else {
voucherImageMapper.selectList(Wrappers.<VoucherImage>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<VoucherFolderVO> 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<String, VoucherFolderVO> 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<String> 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<String> 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<VoucherImage> images = voucherImageMapper.selectList(Wrappers.<VoucherImage>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 @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public VoucherManage createUploadDraft(VoucherUploadDraftRequest request) { public VoucherManage createUploadDraft(VoucherUploadDraftRequest request) {
@@ -151,7 +473,19 @@ public class VoucherManageServiceImpl extends BaseServiceImpl<VoucherManageMappe
voucher.setFileName(request.getFileName()); voucher.setFileName(request.getFileName());
voucher.setFileUrl(request.getFileUrl()); voucher.setFileUrl(request.getFileUrl());
voucher.setFileTaskId(request.getFileTaskId()); voucher.setFileTaskId(request.getFileTaskId());
voucher.setUploadSource("内部");
// 判断是否为承运商上传:如果用户组织的顶级组织是"外部组织",则为承运商
Long currentDeptId = Func.firstLong(AuthUtil.getDeptId());
if (currentDeptId != null && isExternalOrganization(currentDeptId)) {
voucher.setUploadSource("承运商");
voucher.setCarrierId(currentDeptId);
voucher.setCarrierName(getOrganizationName(currentDeptId));
} else {
voucher.setUploadSource("内部");
voucher.setCarrierId(null);
voucher.setCarrierName(null);
}
voucher.setProcessStatus("上传中"); voucher.setProcessStatus("上传中");
voucher.setAuditStatus("-"); voucher.setAuditStatus("-");
voucher.setVoucherCount(0); voucher.setVoucherCount(0);
@@ -338,6 +672,8 @@ public class VoucherManageServiceImpl extends BaseServiceImpl<VoucherManageMappe
update.setRelatedWaybillCount(relatedWaybillIds.size()); update.setRelatedWaybillCount(relatedWaybillIds.size());
update.setUnRelatedWaybillCount(Math.max(waybills.size() - relatedWaybillIds.size(), 0)); update.setUnRelatedWaybillCount(Math.max(waybills.size() - relatedWaybillIds.size(), 0));
update.setProcessStatus("处理完成"); update.setProcessStatus("处理完成");
// 处理完成后自动设置为待审核状态
update.setAuditStatus("待审核");
updateById(update); updateById(update);
log.info("[凭证处理] 进度 100%:处理完成 voucherId={}, voucherBatchNo={}, fileCount={}, imageCount={}, relatedWaybillCount={}, unrelatedWaybillCount={}", log.info("[凭证处理] 进度 100%:处理完成 voucherId={}, voucherBatchNo={}, fileCount={}, imageCount={}, relatedWaybillCount={}, unrelatedWaybillCount={}",
voucherId, voucher.getVoucherBatchNo(), fileCount, imageCount, relatedWaybillIds.size(), Math.max(waybills.size() - relatedWaybillIds.size(), 0)); voucherId, voucher.getVoucherBatchNo(), fileCount, imageCount, relatedWaybillIds.size(), Math.max(waybills.size() - relatedWaybillIds.size(), 0));
@@ -482,6 +818,10 @@ public class VoucherManageServiceImpl extends BaseServiceImpl<VoucherManageMappe
} }
private VoucherFileVO toVoucherFileVO(VoucherFile file) { private VoucherFileVO toVoucherFileVO(VoucherFile file) {
return toVoucherFileVO(file, true);
}
private VoucherFileVO toVoucherFileVO(VoucherFile file, boolean includeUrl) {
VoucherFileVO result = new VoucherFileVO(); VoucherFileVO result = new VoucherFileVO();
result.setId(file.getId()); result.setId(file.getId());
result.setVoucherId(file.getVoucherId()); result.setVoucherId(file.getVoucherId());
@@ -497,15 +837,47 @@ public class VoucherManageServiceImpl extends BaseServiceImpl<VoucherManageMappe
result.setContentType(file.getContentType()); result.setContentType(file.getContentType());
result.setFileType(file.getFileType()); result.setFileType(file.getFileType());
result.setMatched(file.getMatched()); result.setMatched(file.getMatched());
try { result.setCreateTime(file.getCreateTime());
result.setUrl(minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder() result.setUpdateTime(file.getUpdateTime());
.method(Method.GET).bucket(minioBucketName).object(file.getObjectKey()).expiry(1, java.util.concurrent.TimeUnit.HOURS).build())); if (includeUrl) result.setUrl(buildVoucherFileUrl(file.getId(), file.getObjectKey()));
} catch (Exception exception) {
log.warn("生成凭证文件预览地址失败 voucherFileId={}, objectKey={}", file.getId(), file.getObjectKey(), exception);
}
return result; return result;
} }
private VoucherFileVO toVoucherFileVO(VoucherImage image) {
return toVoucherFileVO(image, true);
}
private VoucherFileVO toVoucherFileVO(VoucherImage image, boolean includeUrl) {
VoucherFileVO result = new VoucherFileVO();
result.setId(image.getId());
result.setVoucherId(image.getVoucherId());
result.setVoucherBatchNo(image.getVoucherBatchNo());
result.setWaybillId(image.getWaybillId());
result.setWaybillNo(image.getWaybillNo());
result.setPlateNo(image.getPlateNo());
result.setFolderName(image.getPlateNo());
result.setEntryName(image.getImageName());
result.setFileName(image.getImageName());
result.setObjectKey(image.getObjectKey());
result.setContentType(contentType(image.getImageName()));
result.setFileType("image");
result.setMatched(image.getMatched());
result.setCreateTime(image.getCreateTime());
result.setUpdateTime(image.getUpdateTime());
if (includeUrl) result.setUrl(buildVoucherFileUrl(image.getId(), image.getObjectKey()));
return result;
}
private String buildVoucherFileUrl(Long fileId, String objectKey) {
try {
return minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder()
.method(Method.GET).bucket(minioBucketName).object(objectKey).expiry(1, java.util.concurrent.TimeUnit.HOURS).build());
} catch (Exception exception) {
log.warn("生成凭证文件预览地址失败 voucherFileId={}, objectKey={}", fileId, objectKey, exception);
return null;
}
}
private String normalizePlateNo(String plateNo) { private String normalizePlateNo(String plateNo) {
return plateNo == null ? null : plateNo.replaceAll("[\\s-]", "").toUpperCase(); return plateNo == null ? null : plateNo.replaceAll("[\\s-]", "").toUpperCase();
} }
@@ -570,6 +942,14 @@ public class VoucherManageServiceImpl extends BaseServiceImpl<VoucherManageMappe
.like(Func.isNotEmpty(query.getCarrierName()), VoucherManage::getCarrierName, query.getCarrierName()); .like(Func.isNotEmpty(query.getCarrierName()), VoucherManage::getCarrierName, query.getCarrierName());
if (query.getCreateTimeStart() != null) wrapper.ge(VoucherManage::getCreateTime, query.getCreateTimeStart()); if (query.getCreateTimeStart() != null) wrapper.ge(VoucherManage::getCreateTime, query.getCreateTimeStart());
if (query.getCreateTimeEnd() != null) wrapper.le(VoucherManage::getCreateTime, query.getCreateTimeEnd()); if (query.getCreateTimeEnd() != null) wrapper.le(VoucherManage::getCreateTime, query.getCreateTimeEnd());
// 权限控制:如果当前用户所属组织的顶级组织是"外部组织",则只能看到自己上传的数据
Long currentUserId = AuthUtil.getUserId();
Long currentDeptId = Func.firstLong(AuthUtil.getDeptId());
if (currentDeptId != null && isExternalOrganization(currentDeptId)) {
wrapper.eq(VoucherManage::getCreateUser, currentUserId);
}
return wrapper.orderByDesc(VoucherManage::getCreateTime); return wrapper.orderByDesc(VoucherManage::getCreateTime);
} }
@@ -578,4 +958,72 @@ public class VoucherManageServiceImpl extends BaseServiceImpl<VoucherManageMappe
Long maxSequence = baseMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix); Long maxSequence = baseMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix);
return prefix + String.format("%03d", (maxSequence == null ? 0 : maxSequence) + 1); return prefix + String.format("%03d", (maxSequence == null ? 0 : maxSequence) + 1);
} }
@Override
@Transactional(rollbackFor = Exception.class)
public void auditPass(Long id) {
VoucherManage voucher = getById(id);
if (voucher == null || Objects.equals(voucher.getIsDeleted(), 1)) {
throw new ServiceException("凭证批次不存在");
}
if (!"待审核".equals(voucher.getAuditStatus())) {
throw new ServiceException("仅待审核状态的凭证可以审核通过");
}
if (!"处理完成".equals(voucher.getProcessStatus())) {
throw new ServiceException("仅处理完成的凭证可以审核");
}
voucher.setAuditStatus("审核通过");
updateById(voucher);
log.info("凭证批次审核通过 id={}, voucherBatchNo={}, operator={}", id, voucher.getVoucherBatchNo(), AuthUtil.getUserId());
}
@Override
@Transactional(rollbackFor = Exception.class)
public void auditReject(Long id, String rejectReason) {
VoucherManage voucher = getById(id);
if (voucher == null || Objects.equals(voucher.getIsDeleted(), 1)) {
throw new ServiceException("凭证批次不存在");
}
if (!"待审核".equals(voucher.getAuditStatus())) {
throw new ServiceException("仅待审核状态的凭证可以审核驳回");
}
if (!"处理完成".equals(voucher.getProcessStatus())) {
throw new ServiceException("仅处理完成的凭证可以审核");
}
voucher.setAuditStatus("审核驳回");
updateById(voucher);
log.info("凭证批次审核驳回 id={}, voucherBatchNo={}, operator={}, reason={}",
id, voucher.getVoucherBatchNo(), AuthUtil.getUserId(), rejectReason);
}
/**
* 判断是否为外部组织:递归查找顶级组织,判断是否为"外部组织"
*/
private boolean isExternalOrganization(Long deptId) {
if (deptId == null) return false;
try {
// 查询组织的顶级父组织
Long topDeptId = baseMapper.selectTopDeptId(deptId);
if (topDeptId == null) topDeptId = deptId;
// 查询顶级组织名称
String topDeptName = baseMapper.selectDeptName(topDeptId);
return "外部组织".equals(topDeptName);
} catch (Exception e) {
log.warn("判断外部组织失败 deptId={}", deptId, e);
return false;
}
}
/**
* 获取组织名称
*/
private String getOrganizationName(Long deptId) {
if (deptId == null) return null;
try {
return baseMapper.selectDeptName(deptId);
} catch (Exception e) {
log.warn("获取组织名称失败 deptId={}", deptId, e);
return null;
}
}
} }
@@ -32,15 +32,19 @@ import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.support.TransportBusinessSupport; import org.springblade.transport.support.TransportBusinessSupport;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.interceptor.TransactionAspectSupport;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException; import java.time.format.DateTimeParseException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.TreeMap;
import java.util.regex.Pattern;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** 运单批次服务实现。 */ /** 运单批次服务实现。 */
@@ -54,6 +58,11 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
private static final String STATUS_COMPLETED = "completed"; private static final String STATUS_COMPLETED = "completed";
private static final String IMPORT_TYPE_WAYBILL = "waybill"; private static final String IMPORT_TYPE_WAYBILL = "waybill";
private static final String IMPORT_TYPE_SETTLEMENT = "settlement"; private static final String IMPORT_TYPE_SETTLEMENT = "settlement";
/** 车牌号校验正则:首位汉字,次位大写字母,总长度7或8位 */
private static final Pattern VEHICLE_NO_PATTERN = Pattern.compile("^[一-龥][A-Z][A-Z0-9]{5,6}$");
/** 手机号校验正则:11位数字 */
private static final Pattern PHONE_PATTERN = Pattern.compile("^\\d{11}$");
private final IWaybillService waybillService; private final IWaybillService waybillService;
private final ICustomerArchiveService customerArchiveService; private final ICustomerArchiveService customerArchiveService;
private final IProjectApplyService projectApplyService; private final IProjectApplyService projectApplyService;
@@ -90,6 +99,21 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
clearBatchWaybills(batch.getId()); clearBatchWaybills(batch.getId());
List<Map<String, Object>> rows = Func.isEmpty(request.getRows()) ? List.of() : request.getRows(); List<Map<String, Object>> rows = Func.isEmpty(request.getRows()) ? List.of() : request.getRows();
// 执行批量数据校验
Map<Integer, String> 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<Waybill> waybills = new ArrayList<>(); List<Waybill> waybills = new ArrayList<>();
for (int index = 0; index < rows.size(); index++) { for (int index = 0; index < rows.size(); index++) {
try { try {
@@ -179,17 +203,18 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
if (row == null) throw new ServiceException("运单数据不正确"); if (row == null) throw new ServiceException("运单数据不正确");
Waybill waybill = new Waybill(); Waybill waybill = new Waybill();
waybill.setOriginalNo(stringValue(row, "originalNo")); waybill.setOriginalNo(stringValue(row, "originalNo"));
waybill.setLoadingNo(stringValue(row, "loadingIdentifier", "配载标识号"));
waybill.setVehicleNo(stringValue(row, "vehicleNo")); waybill.setVehicleNo(stringValue(row, "vehicleNo"));
waybill.setDriverId(longValue(row, "driverId", "司机ID")); waybill.setDriverId(longValue(row, "driverId", "司机ID"));
waybill.setDriverName(stringValue(row, "driverName")); waybill.setDriverName(stringValue(row, "driverName", "司机/船长姓名", "司机/船长"));
waybill.setDriverPhone(stringValue(row, "driverPhone")); waybill.setDriverPhone(stringValue(row, "driverPhone", "司机/船长手机号"));
waybill.setTransportType(stringValue(row, "transportType", "运输类型", "*运输类型", "运输方式", "*运输方式")); waybill.setTransportType(stringValue(row, "transportType", "运输类型", "*运输类型", "运输方式", "*运输方式"));
waybill.setCargoName(stringValue(row, "cargoName")); waybill.setCargoName(stringValue(row, "cargoName"));
waybill.setCargoType(stringValue(row, "cargoType")); waybill.setCargoType(stringValue(row, "cargoType"));
waybill.setSpecification(stringValue(row, "specification")); waybill.setSpecification(stringValue(row, "specification", "规格"));
waybill.setModel(stringValue(row, "model")); waybill.setModel(stringValue(row, "model", "型号"));
waybill.setQuantity(decimalValue(row, "quantity", "重量")); waybill.setQuantity(decimalValue(row, "quantity", "数量", "重量"));
waybill.setQuantityUnit(stringValue(row, "quantityUnit")); waybill.setQuantityUnit(stringValue(row, "quantityUnit", "数量单位"));
waybill.setDepartureAddressId(longValue(row, "departureAddressId", "发货地址ID")); waybill.setDepartureAddressId(longValue(row, "departureAddressId", "发货地址ID"));
waybill.setDepartureName(stringValue(row, "departureName")); waybill.setDepartureName(stringValue(row, "departureName"));
waybill.setDepartureAddress(stringValue(row, "departureAddress")); waybill.setDepartureAddress(stringValue(row, "departureAddress"));
@@ -198,8 +223,8 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
waybill.setArrivalAddressId(longValue(row, "arrivalAddressId", "收货地址ID")); waybill.setArrivalAddressId(longValue(row, "arrivalAddressId", "收货地址ID"));
waybill.setArrivalName(stringValue(row, "arrivalName")); waybill.setArrivalName(stringValue(row, "arrivalName"));
waybill.setArrivalAddress(stringValue(row, "arrivalAddress")); waybill.setArrivalAddress(stringValue(row, "arrivalAddress"));
waybill.setArrivalContact(stringValue(row, "arrivalContact")); waybill.setArrivalContact(stringValue(row, "arrivalContact", "收货联系人"));
waybill.setArrivalPhone(stringValue(row, "arrivalPhone")); waybill.setArrivalPhone(stringValue(row, "arrivalPhone", "收货联系人电话"));
waybill.setCaptainName(stringValue(row, "captainName")); waybill.setCaptainName(stringValue(row, "captainName"));
waybill.setCabinNo(stringValue(row, "cabinNo")); waybill.setCabinNo(stringValue(row, "cabinNo"));
waybill.setContainerNo(stringValue(row, "containerNo")); waybill.setContainerNo(stringValue(row, "containerNo"));
@@ -207,12 +232,13 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
waybill.setEscortName(stringValue(row, "escortName")); waybill.setEscortName(stringValue(row, "escortName"));
waybill.setEscortPhone(stringValue(row, "escortPhone")); waybill.setEscortPhone(stringValue(row, "escortPhone"));
waybill.setMileage(normalizeImportMileage(decimalValue( waybill.setMileage(normalizeImportMileage(decimalValue(
row, "mileage", "里程", "里程", "里程(km)", "里程(公里)" row, "mileage", "里程", "里程(km)", "里程(公里)"
))); )));
waybill.setUnitPrice(decimalValue(row, "unitPrice", "单价")); waybill.setUnitPrice(decimalValue(row, "unitPrice", "单价"));
waybill.setPriceUnit(stringValue(row, "priceUnit")); waybill.setPriceUnit(stringValue(row, "priceUnit"));
waybill.setOtherFeeTotal(decimalValue(row, "otherFeeTotal", "其他费用合计", "其他费用合计")); waybill.setOtherFeeTotal(decimalValue(row, "otherFeeTotal", "其他费用合计"));
waybill.setRemark(stringValue(row, "remark")); waybill.setRemark(stringValue(row, "remark"));
waybill.setRelationNo(stringValue(row, "waybillIdentifier", "同一运单标识号"));
waybill.setProjectId(batch.getProjectId()); waybill.setProjectId(batch.getProjectId());
waybill.setProjectName(batch.getProjectName()); waybill.setProjectName(batch.getProjectName());
waybill.setCustomerName(batch.getCustomerName()); waybill.setCustomerName(batch.getCustomerName());
@@ -232,8 +258,10 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
waybill.setQuantityUnit(Func.isEmpty(waybill.getQuantityUnit()) ? "" : waybill.getQuantityUnit()); waybill.setQuantityUnit(Func.isEmpty(waybill.getQuantityUnit()) ? "" : waybill.getQuantityUnit());
waybill.setPriceUnit(Func.isEmpty(waybill.getPriceUnit()) ? "" : waybill.getPriceUnit()); waybill.setPriceUnit(Func.isEmpty(waybill.getPriceUnit()) ? "" : waybill.getPriceUnit());
// 草稿允许明细不完整,时间为空时不阻断保存。 // 草稿允许明细不完整,时间为空时不阻断保存。
waybill.setStartDate(parseDate(row.get("startDate"), "开始时间", !draft)); waybill.setStartDate(parseDate(row.get("actualStartDate"), "实际发货时间", !draft, "startDate", "开始时间"));
waybill.setEndDate(parseDate(row.get("endDate"), "结束时间", !draft)); waybill.setEndDate(parseDate(row.get("actualEndDate"), "实际完成时间", !draft, "endDate", "结束时间"));
waybill.setEstimatedStartTime(parseDate(row.get("planStartDate"), "预计发货时间", false, "estimatedStartTime"));
waybill.setEstimatedEndTime(parseDate(row.get("planEndDate"), "预计完成时间", false, "estimatedEndTime"));
return waybill; return waybill;
} }
@@ -275,7 +303,7 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
return mileage != null && mileage.compareTo(BigDecimal.ONE.negate()) == 0 ? null : mileage; return mileage != null && mileage.compareTo(BigDecimal.ONE.negate()) == 0 ? null : mileage;
} }
private LocalDate parseDate(Object value, String fieldName, boolean required) { private LocalDate parseDate(Object value, String fieldName, boolean required, String... aliases) {
if (value == null || String.valueOf(value).isBlank()) { if (value == null || String.valueOf(value).isBlank()) {
if (!required) return null; if (!required) return null;
throw new ServiceException(fieldName + "不能为空"); throw new ServiceException(fieldName + "不能为空");
@@ -323,4 +351,354 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
long count = count(Wrappers.<WaybillImportBatch>lambdaQuery().likeRight(WaybillImportBatch::getBatchNo, prefix)); long count = count(Wrappers.<WaybillImportBatch>lambdaQuery().likeRight(WaybillImportBatch::getBatchNo, prefix));
return prefix + String.format("%04d", count + 1); return prefix + String.format("%04d", count + 1);
} }
/**
* 批量校验导入数据
* @param rows 导入的行数据
* @param importStatus 导入状态
* @return 错误信息映射,key为行号,value为错误信息
*/
private Map<Integer, String> validateImportRows(List<Map<String, Object>> rows, String importStatus) {
Map<Integer, String> errorMap = new TreeMap<>();
boolean isDraft = STATUS_DRAFT.equals(importStatus);
// 加载系统枚举值
List<String> transportTypeOptions = loadTransportTypeOptions();
List<String> quantityUnitOptions = loadQuantityUnitOptions();
// 构建配载标识号和同一运单标识号的映射
Map<String, List<Integer>> loadingIdentifierMap = new HashMap<>();
Map<String, List<Integer>> waybillIdentifierMap = new HashMap<>();
Map<String, Integer> loadingIdentifierCountMap = new HashMap<>();
for (int i = 0; i < rows.size(); i++) {
Map<String, Object> 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<String> errors = new ArrayList<>();
Map<String, Object> 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<String, Object> row, int rowIndex,
Map<String, List<Integer>> loadingIdentifierMap, List<Map<String, Object>> allRows, List<String> errors) {
String loadingIdentifier = stringValue(row, "loadingIdentifier", "配载标识号");
if (Func.isEmpty(loadingIdentifier)) {
return;
}
List<Integer> 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<String, Object> row, List<String> 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<String, Object> row, List<String> transportTypeOptions, List<String> errors) {
String transportType = stringValue(row, "transportType", "运输类型", "*运输类型", "运输方式", "*运输方式");
if (Func.isEmpty(transportType)) {
return;
}
if (!transportTypeOptions.contains(transportType)) {
errors.add("运输方式必须为系统枚举值之一");
}
}
private void validatePhoneNumber(Map<String, Object> row, String field, String fieldName, List<String> 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<String, Object> row, String field, String fieldName, List<String> 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<String, Object> row, List<String> quantityUnitOptions, List<String> errors) {
String quantityUnit = stringValue(row, "quantityUnit", "数量单位");
if (Func.isEmpty(quantityUnit)) {
return;
}
if (!quantityUnitOptions.contains(quantityUnit)) {
errors.add("数量单位必须为系统枚举值之一");
}
}
private void validateFreightTotal(Map<String, Object> row, List<String> 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<String, Object> row, boolean isDraft, List<String> 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<String, Object> row, boolean isDraft, List<String> 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<String, Object> row, List<String> 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<String, Object> row, List<String> 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<String, Object> row, int rowIndex,
Map<String, List<Integer>> waybillIdentifierMap, List<Map<String, Object>> allRows, List<String> errors) {
String waybillIdentifier = stringValue(row, "waybillIdentifier", "同一运单标识号");
if (Func.isEmpty(waybillIdentifier)) {
return;
}
List<Integer> 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<String> 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<String> loadTransportTypeOptions() {
// 常见运输方式枚举值
return List.of(
"公路整车", "公路配载/零担", "铁路整车", "铁路零担",
"水路", "航空", "多式联运", "管道运输", "其他"
);
}
private List<String> loadQuantityUnitOptions() {
// 常见数量单位
return List.of("", "千克", "立方米", "", "", "", "", "", "", "平方米");
}
} }
@@ -252,6 +252,9 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
BeanUtil.copyProperties(record, excel); BeanUtil.copyProperties(record, excel);
excel.setCreateUserName(UserCache.getUserRealName(record.getCreateUser())); excel.setCreateUserName(UserCache.getUserRealName(record.getCreateUser()));
excel.setUpdateUserName(UserCache.getUserRealName(record.getUpdateUser())); excel.setUpdateUserName(UserCache.getUserRealName(record.getUpdateUser()));
excel.setCreateTime(record.getCreateTime());
excel.setUpdateTime(record.getUpdateTime());
excel.setBusinessStatus(WaybillWrapper.businessStatusName(record.getBusinessStatus()));
return excel; return excel;
}).toList(); }).toList();
} }
@@ -56,7 +56,7 @@ public class WaybillWrapper extends BaseEntityWrapper<Waybill, WaybillVO> {
return waybillVO; return waybillVO;
} }
private String businessStatusName(String status) { public static String businessStatusName(String status) {
if (status == null) { if (status == null) {
return "未知"; return "未知";
} }
@@ -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;
@@ -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;