diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/LoadingManage.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/LoadingManage.java index 6393fc0a..e037a882 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/LoadingManage.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/LoadingManage.java @@ -56,7 +56,7 @@ public class LoadingManage extends TenantEntity { @Schema(description = "货物名称") private String cargoName; - @Schema(description = "车/船/航班/班列") + @Schema(description = "车牌号/船班号/船次号/班次号") private String vehicleNo; @Schema(description = "挂车车牌号") diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Waybill.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Waybill.java index 81c481d9..dec1a0b5 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Waybill.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Waybill.java @@ -162,7 +162,7 @@ public class Waybill extends TenantEntity { @TableField(updateStrategy = FieldStrategy.ALWAYS) private String driverRejectReason; - @Schema(description = "车/船/航班/班列号") + @Schema(description = "车牌号/船班号/船次号/班次号") private String vehicleNo; @Schema(description = "船长") @@ -234,6 +234,9 @@ public class Waybill extends TenantEntity { @Schema(description = "配载单号") private String loadingNo; + @Schema(description = "配载标识号") + private String loadingIdentifier; + @Schema(description = "运单批次号") private String batchNo; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/VoucherFolderVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/VoucherFolderVO.java index 1f70c945..6c9ca5a5 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/VoucherFolderVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/VoucherFolderVO.java @@ -25,6 +25,7 @@ public class VoucherFolderVO implements Serializable { private String folderName; private Long waybillId; private String waybillNo; + private String loadingNo; private Integer matched; private String processStatus; private String fileName; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillVO.java index cea37c38..ebba5343 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillVO.java @@ -66,6 +66,10 @@ public class WaybillVO extends Waybill { @Schema(description = "业务状态名称") private String businessStatusName; + @TableField(exist = false) + @Schema(description = "运输方式名称") + private String transportTypeName; + @TableField(exist = false) @Schema(description = "是否需要司机确认接单(过程配置接单节点)") private Boolean requireAccept; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ProcessConfigController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ProcessConfigController.java index c0cb3d30..68381d8c 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ProcessConfigController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ProcessConfigController.java @@ -51,7 +51,9 @@ import org.springblade.transport.pojo.entity.VoucherManage; import org.springblade.transport.pojo.entity.Waybill; import org.springblade.transport.pojo.vo.BusinessRemoveResultVO; import org.springblade.transport.pojo.vo.ProcessConfigVO; +import org.springblade.transport.pojo.vo.VoucherFolderVO; import org.springblade.transport.service.IProcessConfigService; +import org.springblade.transport.service.IVoucherManageService; import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.GetMapping; @@ -80,6 +82,7 @@ import java.util.concurrent.TimeUnit; public class ProcessConfigController extends BladeController { private final IProcessConfigService processConfigService; + private final IVoucherManageService voucherManageService; private final WaybillMapper waybillMapper; private final VoucherFileMapper voucherFileMapper; private final VoucherImageMapper voucherImageMapper; @@ -88,11 +91,13 @@ public class ProcessConfigController extends BladeController { @Value("${file.storage.minio.bucket-name:${minio.bucket-name:}}") private String minioBucketName; - public ProcessConfigController(IProcessConfigService processConfigService, WaybillMapper waybillMapper, + public ProcessConfigController(IProcessConfigService processConfigService, IVoucherManageService voucherManageService, + WaybillMapper waybillMapper, VoucherFileMapper voucherFileMapper, VoucherImageMapper voucherImageMapper, VoucherManageMapper voucherManageMapper, ObjectProvider minioClientProvider) { this.processConfigService = processConfigService; + this.voucherManageService = voucherManageService; this.waybillMapper = waybillMapper; this.voucherFileMapper = voucherFileMapper; this.voucherImageMapper = voucherImageMapper; @@ -128,6 +133,23 @@ public class ProcessConfigController extends BladeController { return R.data(detail); } + @GetMapping("/voucher-folders") + @ApiOperationSupport(order = 2) + @Operation(summary = "查询运单执行凭证文件夹") + public R> voucherFolders( + @Parameter(description = "运单主键", required = true) @RequestParam Long waybillId) { + return R.data(voucherManageService.listWaybillFolders(waybillId)); + } + + @GetMapping("/voucher-folder-detail") + @ApiOperationSupport(order = 2) + @Operation(summary = "查询运单执行凭证文件夹详情") + public R voucherFolderDetail( + @Parameter(description = "凭证批次主键", required = true) @RequestParam Long voucherId, + @Parameter(description = "车牌号", required = true) @RequestParam String plateNo) { + return R.data(voucherManageService.folderDetail(voucherId, plateNo)); + } + @GetMapping("/voucher-images") @ApiOperationSupport(order = 2) @Operation(summary = "查询运单已关联凭证图片") diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/LoadingManageExcel.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/LoadingManageExcel.java index 6380c7a2..f50c2079 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/LoadingManageExcel.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/LoadingManageExcel.java @@ -34,7 +34,7 @@ public class LoadingManageExcel implements Serializable { private String loadingNo; @ExcelProperty("配载子单号") private String loadingSubNos; - @ExcelProperty("车牌号/航班号/船号/班列号") + @ExcelProperty("车牌号/船班号/船次号/班次号") private String vehicleNo; @ExcelProperty("司机") private String driverName; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/MasterOrderWaybillExcel.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/MasterOrderWaybillExcel.java index 82246841..362e3390 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/MasterOrderWaybillExcel.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/MasterOrderWaybillExcel.java @@ -59,7 +59,7 @@ public class MasterOrderWaybillExcel { private String carrierName; @ExcelProperty("司机") private String driverName; - @ExcelProperty("车牌号/航班号/船号/班列号") + @ExcelProperty("车牌号/船班号/船次号/班次号") private String vehicleNo; @ExcelProperty("开始日期") private LocalDate startDate; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/WaybillExcel.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/WaybillExcel.java index c093fdd8..ec1ca37c 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/WaybillExcel.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/WaybillExcel.java @@ -98,7 +98,7 @@ public class WaybillExcel implements Serializable { private String driverName; @ExcelProperty("司机手机号") private String driverPhone; - @ExcelProperty("车/船/航班/班列号") + @ExcelProperty("车牌号/船班号/船次号/班次号") private String vehicleNo; @ExcelProperty("挂车车牌号") private String trailerVehicleNo; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/WaybillImportBatchExcel.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/WaybillImportBatchExcel.java index ac90cc8b..91528040 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/WaybillImportBatchExcel.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/WaybillImportBatchExcel.java @@ -28,7 +28,7 @@ public class WaybillImportBatchExcel implements Serializable { private String originalNo; @ExcelProperty("配载标识号") private String loadingIdentifier; - @ExcelProperty("*车牌号/航班号/船号/班列号") + @ExcelProperty("*车牌号/船班号/船次号/班次号") private String vehicleNo; @ExcelProperty("*运输方式") private String transportType; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IVoucherManageService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IVoucherManageService.java index 92c253a5..b8a16346 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IVoucherManageService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IVoucherManageService.java @@ -19,6 +19,7 @@ public interface IVoucherManageService extends BaseService { VoucherManageVO detail(Long id); IPage folderPage(IPage page, Long voucherId, String plateNo, Integer matched); VoucherFolderVO folderDetail(Long voucherId, String plateNo); + List listWaybillFolders(Long waybillId); void replaceFolder(Long voucherId, String plateNo, MultipartFile file); void replaceFolderByObject(Long voucherId, String plateNo, String objectKey, String fileName, Long size, String contentType); void removeFolder(Long voucherId, String plateNo); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java index df6a10f3..0ed279c4 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java @@ -42,6 +42,7 @@ import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; import java.util.Objects; import java.util.Set; @@ -176,7 +177,11 @@ public class LoadingManageServiceImpl extends BaseServiceImpllambdaUpdate() @@ -229,6 +234,27 @@ public class LoadingManageServiceImpl extends BaseServiceImpl waybills, String loadingNo) { + Set waybillIds = new LinkedHashSet<>(waybillIds(existing.getWaybillIdsJson())); + waybills.stream().map(Waybill::getId).filter(Objects::nonNull).forEach(waybillIds::add); + List mergedWaybillIds = new ArrayList<>(waybillIds); + List linkedWaybills = waybillMapper.selectList(Wrappers.lambdaQuery() + .in(Waybill::getId, mergedWaybillIds) + .eq(Waybill::getIsDeleted, 0)); + existing.setWaybillIdsJson(JsonUtil.toJson(mergedWaybillIds)); + existing.setLoadingSubNos(linkedWaybills.stream() + .map(Waybill::getWaybillNo) + .filter(Func::isNotEmpty) + .collect(Collectors.joining(","))); + updateById(existing); + waybillMapper.update(null, Wrappers.lambdaUpdate() + .in(Waybill::getId, mergedWaybillIds) + .set(Waybill::getLoadingNo, loadingNo)); + } + /** * 根据导入运单构建配载单的途经地: * 组内运单按导入顺序串联路线,上一票的到货地址与下一票的发货地址相同(中途卸货点)时只保留一个点, @@ -644,7 +670,7 @@ public class LoadingManageServiceImpl extends BaseServiceImpl minioClientProvider; private final String minioBucketName; @@ -87,6 +91,7 @@ public class VoucherManageServiceImpl extends BaseServiceImpl minioClientProvider, @Value("${file.storage.minio.bucket-name:${minio.bucket-name:}}") String minioBucketName, @Value("${file.storage.minio.root-directory:${minio.root-directory:}}") String minioRootDirectory) { @@ -95,6 +100,7 @@ public class VoucherManageServiceImpl extends BaseServiceImpl selectPage(IPage page, VoucherManageVO query) { IPage result = VoucherManageWrapper.build().pageVO(page(page, buildQuery(query))); - // 凭证数量按文件夹(车牌目录)统计,兼容历史记录中仍保存图片数量的旧数据。 + // 凭证数量、已关联数量、未关联数量都按压缩包中的车牌文件夹统计,不按图片张数。 if (result.getRecords() != null) { result.getRecords().forEach(item -> { VoucherFolderCounts folderCounts = countVoucherFolderRelations(item.getTenantId(), item.getId()); @@ -182,12 +188,22 @@ public class VoucherManageServiceImpl extends BaseServiceImpl oldFiles = voucherFileMapper.selectList(Wrappers.lambdaQuery() - .eq(VoucherFile::getTenantId, voucher.getTenantId()).eq(VoucherFile::getVoucherId, voucherId) - .eq(VoucherFile::getPlateNo, normalizedPlateNo).eq(VoucherFile::getIsDeleted, 0)); - List oldImages = voucherImageMapper.selectList(Wrappers.lambdaQuery() - .eq(VoucherImage::getTenantId, voucher.getTenantId()).eq(VoucherImage::getVoucherId, voucherId) - .eq(VoucherImage::getPlateNo, normalizedPlateNo).eq(VoucherImage::getIsDeleted, 0)); + if (isZipFile) { + try { + // 压缩包里只要有一个文件夹名对上已有车牌或运单车牌,就替换对应凭证,不必整包都匹配。 + if (replaceZipFoldersByName(voucher, normalizedPlateNo, uploadFile)) { + refreshVoucherCounts(voucher); + return; + } + } catch (ServiceException exception) { + throw exception; + } catch (Exception exception) { + log.error("按文件夹替换车牌凭证失败 voucherId={}, plateNo={}", voucherId, normalizedPlateNo, exception); + throw new ServiceException("凭证文件上传失败"); + } + } + List oldFiles = listNormalizedPlateFiles(voucher, normalizedPlateNo); + List oldImages = listNormalizedPlateImages(voucher, normalizedPlateNo); 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))) { @@ -228,11 +244,10 @@ public class VoucherManageServiceImpl extends BaseServiceImpl listWaybillFolders(Long waybillId) { + if (waybillId == null) return List.of(); + Waybill waybill = waybillService.getById(waybillId); + if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) return List.of(); + Set voucherIds = new HashSet<>(); + voucherImageMapper.selectList(Wrappers.lambdaQuery() + .eq(VoucherImage::getWaybillId, waybillId) + .eq(VoucherImage::getIsDeleted, 0)) + .forEach(image -> { + if (image.getVoucherId() != null) voucherIds.add(image.getVoucherId()); + }); + voucherFileMapper.selectList(Wrappers.lambdaQuery() + .eq(VoucherFile::getWaybillId, waybillId) + .eq(VoucherFile::getIsDeleted, 0)) + .forEach(file -> { + if (file.getVoucherId() != null) voucherIds.add(file.getVoucherId()); + }); + if (voucherIds.isEmpty() && Func.isNotEmpty(waybill.getBatchNo())) { + list(Wrappers.lambdaQuery() + .eq(VoucherManage::getTenantId, waybill.getTenantId()) + .eq(VoucherManage::getIsDeleted, 0)) + .stream() + .filter(voucher -> containsWaybillBatchNo(voucher.getWaybillBatchNo(), waybill.getBatchNo())) + .forEach(voucher -> voucherIds.add(voucher.getId())); + } + List folders = new ArrayList<>(); + for (Long voucherId : voucherIds) { + VoucherManage voucher = getById(voucherId); + if (!visibleWaybillVoucher(voucher)) continue; + folderRecords(voucher, false).stream() + .filter(folder -> folderBelongsToWaybill(folder, waybill)) + .forEach(folders::add); + } + folders.sort(Comparator.comparing(VoucherFolderVO::getCreateTime, Comparator.nullsLast(Comparator.reverseOrder()))); + return folders; + } + + private boolean folderBelongsToWaybill(VoucherFolderVO folder, Waybill waybill) { + if (Objects.equals(folder.getWaybillId(), waybill.getId())) return true; + if (folder.getWaybillId() != null) return false; + String plateNo = normalizePlateNo(folder.getPlateNo()); + return Func.isNotEmpty(plateNo) && waybillPlateNumbers(waybill).contains(plateNo); + } + + /** + * 承运商上传的凭证只在审核通过后展示到运单执行详情;内部上传保持原展示规则。 + */ + private boolean visibleWaybillVoucher(VoucherManage voucher) { + return voucher != null && !Objects.equals(voucher.getIsDeleted(), 1) + && (!"承运商".equals(voucher.getUploadSource()) || "审核通过".equals(voucher.getAuditStatus())); + } + + private boolean containsWaybillBatchNo(String batchNos, String batchNo) { + return Func.isNotEmpty(batchNos) && Func.isNotEmpty(batchNo) + && Arrays.stream(batchNos.split(",")).map(String::trim).anyMatch(batchNo::equals); + } + + private void fillFolderLoadingNo(List folders) { + List waybillIds = folders.stream().map(VoucherFolderVO::getWaybillId).filter(Objects::nonNull).distinct().toList(); + if (waybillIds.isEmpty()) return; + Map waybillMap = waybillService.listByIds(waybillIds).stream() + .collect(Collectors.toMap(Waybill::getId, item -> item, (left, right) -> left)); + Map loadingNoMap = new HashMap<>(); + for (Long waybillId : waybillIds) { + Waybill waybill = waybillMap.get(waybillId); + String loadingNo = waybill == null ? null : waybill.getLoadingNo(); + if (Func.isEmpty(loadingNo)) { + loadingNo = findLoadingNo(waybill == null ? null : waybill.getTenantId(), waybillId); + } + if (Func.isNotEmpty(loadingNo)) loadingNoMap.put(waybillId, loadingNo); + } + folders.forEach(folder -> { + if (folder.getWaybillId() != null) folder.setLoadingNo(loadingNoMap.get(folder.getWaybillId())); + }); + } + + private String findLoadingNo(String tenantId, Long waybillId) { + if (waybillId == null) return null; + String waybillIdText = String.valueOf(waybillId); + LambdaQueryWrapper query = Wrappers.lambdaQuery() + .like(LoadingManage::getWaybillIdsJson, waybillIdText) + .orderByDesc(LoadingManage::getCreateTime) + .last("limit 20"); + if (Func.isNotEmpty(tenantId)) query.eq(LoadingManage::getTenantId, tenantId); + return loadingManageMapper.selectList(query).stream() + .filter(item -> containsJsonId(item.getWaybillIdsJson(), waybillIdText)) + .map(LoadingManage::getLoadingNo) + .filter(Func::isNotEmpty) + .findFirst() + .orElse(null); + } + + private boolean containsJsonId(String json, String idText) { + if (Func.isEmpty(json) || Func.isEmpty(idText)) return false; + return Arrays.stream(json.split("\\D+")).anyMatch(idText::equals); + } + private void addFolderFile(Map grouped, VoucherManage voucher, VoucherFileVO file, java.util.Date createTime, java.util.Date updateTime) { String plate = normalizePlateNo(file.getPlateNo()); if (Func.isEmpty(plate)) plate = "未识别车牌"; @@ -398,6 +512,174 @@ public class VoucherManageServiceImpl extends BaseServiceImpl waybillByPlate = new HashMap<>(); + for (Waybill waybill : listRelatedWaybills(voucher)) { + for (String waybillPlateNo : waybillPlateNumbers(waybill)) { + waybillByPlate.putIfAbsent(waybillPlateNo, waybill); + } + } + Set existingPlates = new HashSet<>(); + voucherFileMapper.selectList(Wrappers.lambdaQuery() + .eq(VoucherFile::getTenantId, voucher.getTenantId()).eq(VoucherFile::getVoucherId, voucher.getId()) + .eq(VoucherFile::getIsDeleted, 0)).forEach(file -> addPlateNumber(existingPlates, file.getPlateNo())); + voucherImageMapper.selectList(Wrappers.lambdaQuery() + .eq(VoucherImage::getTenantId, voucher.getTenantId()).eq(VoucherImage::getVoucherId, voucher.getId()) + .eq(VoucherImage::getIsDeleted, 0)).forEach(image -> addPlateNumber(existingPlates, image.getPlateNo())); + Charset archiveCharset = detectArchiveCharset(archivePath); + Set matchedPlates = new HashSet<>(); + try (InputStream source = Files.newInputStream(archivePath); + ZipInputStream zipInputStream = new ZipInputStream(new BufferedInputStream(source), archiveCharset)) { + ZipEntry entry; + while ((entry = zipInputStream.getNextEntry()) != null) { + if (entry.isDirectory()) continue; + String plateNo = plateFromZipEntry(entry.getName(), requestedPlateNo, existingPlates, waybillByPlate); + if (Func.isNotEmpty(plateNo)) matchedPlates.add(plateNo); + } + } + if (matchedPlates.isEmpty()) return false; + log.info("[单个上传] 压缩包文件夹命中,准备替换 voucherId={}, requestedPlateNo={}, matchedPlates={}", + voucher.getId(), requestedPlateNo, matchedPlates); + matchedPlates.forEach(plateNo -> deletePlateVoucherFiles(voucher, plateNo)); + int fileCount = 0; + int imageCount = 0; + try (InputStream source = Files.newInputStream(archivePath); + ZipInputStream zipInputStream = new ZipInputStream(new BufferedInputStream(source), archiveCharset)) { + ZipEntry entry; + while ((entry = zipInputStream.getNextEntry()) != null) { + if (entry.isDirectory()) continue; + String entryName = entry.getName().replace('\\', '/'); + String plateNo = plateFromZipEntry(entryName, requestedPlateNo, existingPlates, waybillByPlate); + if (!matchedPlates.contains(plateNo)) continue; + List pathParts = Arrays.stream(entryName.split("/")).filter(Func::isNotEmpty).toList(); + if (pathParts.isEmpty()) continue; + String rawFileName = pathParts.get(pathParts.size() - 1); + boolean imageFile = isImageFile(rawFileName); + String storedFileName = safeFileName(rawFileName); + if (Func.isEmpty(storedFileName)) continue; + Waybill matchedWaybill = waybillByPlate.get(plateNo); + if (saveZipVoucherFile(voucher, plateNo, matchedWaybill, entryName, storedFileName, imageFile, entry, zipInputStream)) { + fileCount++; + if (imageFile) imageCount++; + } + } + } + if (fileCount == 0) throw new ServiceException("压缩包内没有找到有效的文件"); + log.info("[单个上传] 按文件夹替换完成 voucherId={}, matchedPlates={}, fileCount={}, imageCount={}", + voucher.getId(), matchedPlates, fileCount, imageCount); + return true; + } finally { + deleteTempArchive(archivePath); + } + } + + /** + * 从压缩包路径的每一层目录识别车牌。车牌文件夹外面还套了其它目录时,也不能漏掉。 + * 优先当前重新上传的车牌,其次关联运单车牌,再其次批次里已有车牌。 + */ + private String plateFromZipEntry(String entryName, String requestedPlateNo, Set existingPlates, Map waybillByPlate) { + if (Func.isEmpty(entryName)) return null; + String normalizedName = entryName.replace('\\', '/'); + if (normalizedName.equals("__MACOSX") || normalizedName.startsWith("__MACOSX/")) return null; + List pathParts = Arrays.stream(normalizedName.split("/")).filter(Func::isNotEmpty).toList(); + if (pathParts.size() <= 1) return null; + List directories = new ArrayList<>(); + for (int index = 0; index < pathParts.size() - 1; index++) { + String plateNo = normalizePlateNo(pathParts.get(index)); + if (Func.isEmpty(plateNo) || "凭证导入".equals(plateNo) || plateNo.startsWith("__MACOSX") || ".".equals(plateNo)) continue; + directories.add(plateNo); + } + if (directories.isEmpty()) return null; + if (Func.isNotEmpty(requestedPlateNo) && directories.contains(requestedPlateNo)) return requestedPlateNo; + for (String plateNo : directories) { + if (waybillByPlate.containsKey(plateNo)) return plateNo; + } + for (String plateNo : directories) { + if (existingPlates.contains(plateNo)) return plateNo; + } + return null; + } + + private List listNormalizedPlateFiles(VoucherManage voucher, String plateNo) { + String normalizedPlateNo = normalizePlateNo(plateNo); + return voucherFileMapper.selectList(Wrappers.lambdaQuery() + .eq(VoucherFile::getTenantId, voucher.getTenantId()) + .eq(VoucherFile::getVoucherId, voucher.getId()) + .eq(VoucherFile::getIsDeleted, 0)) + .stream() + .filter(file -> normalizedPlateNo.equals(normalizePlateNo(file.getPlateNo()))) + .toList(); + } + + private List listNormalizedPlateImages(VoucherManage voucher, String plateNo) { + String normalizedPlateNo = normalizePlateNo(plateNo); + return voucherImageMapper.selectList(Wrappers.lambdaQuery() + .eq(VoucherImage::getTenantId, voucher.getTenantId()) + .eq(VoucherImage::getVoucherId, voucher.getId()) + .eq(VoucherImage::getIsDeleted, 0)) + .stream() + .filter(image -> normalizedPlateNo.equals(normalizePlateNo(image.getPlateNo()))) + .toList(); + } + + private void deletePlateVoucherFiles(VoucherManage voucher, String plateNo) { + List oldFiles = listNormalizedPlateFiles(voucher, plateNo); + List oldImages = listNormalizedPlateImages(voucher, plateNo); + 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); + } + + private boolean saveZipVoucherFile(VoucherManage voucher, String plateNo, Waybill matchedWaybill, String entryName, + String fileName, boolean imageFile, ZipEntry entry, ZipInputStream zipInputStream) throws Exception { + 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); + if (!imageFile) return true; + 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); + return true; + } + 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; @@ -583,23 +865,82 @@ public class VoucherManageServiceImpl extends BaseServiceImpl waybillPlates = new HashSet<>(); + if (voucher != null) { + for (Waybill waybill : listRelatedWaybills(voucher)) { + waybillPlates.addAll(waybillPlateNumbers(waybill)); + } + } List files = voucherFileMapper.selectList(Wrappers.lambdaQuery() .eq(VoucherFile::getTenantId, tenantId).eq(VoucherFile::getVoucherId, voucherId).eq(VoucherFile::getIsDeleted, 0)); + Map relatedByFolder = new LinkedHashMap<>(); if (!files.isEmpty()) { - Set folders = files.stream().map(item -> folderKey(item.getPlateNo())).collect(Collectors.toSet()); - Set relatedFolders = files.stream().filter(item -> Objects.equals(item.getMatched(), 1)) - .map(item -> folderKey(item.getPlateNo())).collect(Collectors.toSet()); - return new VoucherFolderCounts(folders.size(), relatedFolders.size(), folders.size() - relatedFolders.size()); + for (VoucherFile file : files) { + accumulatePlateFolder(relatedByFolder, waybillPlates, file.getPlateNo(), file.getFolderName(), file.getEntryName(), file.getMatched()); + } + } else { + List images = voucherImageMapper.selectList(Wrappers.lambdaQuery() + .eq(VoucherImage::getTenantId, tenantId).eq(VoucherImage::getVoucherId, voucherId).eq(VoucherImage::getIsDeleted, 0)); + for (VoucherImage image : images) { + accumulatePlateFolder(relatedByFolder, waybillPlates, image.getPlateNo(), image.getPlateNo(), null, image.getMatched()); + } } - List images = voucherImageMapper.selectList(Wrappers.lambdaQuery() - .eq(VoucherImage::getTenantId, tenantId).eq(VoucherImage::getVoucherId, voucherId).eq(VoucherImage::getIsDeleted, 0)); - Set folders = images.stream().map(item -> folderKey(item.getPlateNo())).collect(Collectors.toSet()); - Set relatedFolders = images.stream().filter(item -> Objects.equals(item.getMatched(), 1)) - .map(item -> folderKey(item.getPlateNo())).collect(Collectors.toSet()); - return new VoucherFolderCounts(folders.size(), relatedFolders.size(), folders.size() - relatedFolders.size()); + int related = (int) relatedByFolder.values().stream().filter(Boolean::booleanValue).count(); + int total = relatedByFolder.size(); + return new VoucherFolderCounts(total, related, total - related); } + private void accumulatePlateFolder(Map relatedByFolder, Set waybillPlates, + String plateNo, String folderName, String entryName, Integer matched) { + String plateFolder = countedPlateFolder(plateNo, folderName, entryName, waybillPlates); + if (Func.isEmpty(plateFolder)) return; + boolean related = Objects.equals(matched, 1) || waybillPlates.contains(plateFolder); + relatedByFolder.merge(plateFolder, related, (left, right) -> left || right); + } + + /** + * 从车牌字段和压缩包路径中识别车牌文件夹。外层业务目录、内层图片目录都不计入凭证数量。 + */ + private String countedPlateFolder(String plateNo, String folderName, String entryName, Set waybillPlates) { + List candidates = new ArrayList<>(); + addPlateCandidate(candidates, plateNo); + addPlateCandidate(candidates, folderName); + if (Func.isNotEmpty(entryName)) { + List pathParts = Arrays.stream(entryName.replace('\\', '/').split("/")).filter(Func::isNotEmpty).toList(); + int directoryCount = Math.max(pathParts.size() - 1, 0); + for (int index = 0; index < directoryCount; index++) { + addPlateCandidate(candidates, pathParts.get(index)); + } + } + for (String candidate : candidates) { + if (waybillPlates.contains(candidate)) return candidate; + } + for (String candidate : candidates) { + if (isPlateFolderName(candidate)) return candidate; + } + return null; + } + + private void addPlateCandidate(List candidates, String plateNo) { + String normalizedPlateNo = normalizePlateNo(plateNo); + if (Func.isNotEmpty(normalizedPlateNo) && !"未识别车牌".equals(normalizedPlateNo) && !candidates.contains(normalizedPlateNo)) { + candidates.add(normalizedPlateNo); + } + } + + private boolean isPlateFolderName(String plateNo) { + return Func.isNotEmpty(plateNo) && PLATE_FOLDER_PATTERN.matcher(plateNo).matches(); + } + + private static final Pattern PLATE_FOLDER_PATTERN = Pattern.compile( + "^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领][A-Z][A-Z0-9]{4,6}(?:挂|学|警|港|澳)?$"); + private record VoucherFolderCounts(int total, int related, int unrelated) {} private String folderKey(String plateNo) { @@ -1020,13 +1361,19 @@ public class VoucherManageServiceImpl extends BaseServiceImpl implements IWaybillImportBatchService { - /** 批量导入状态仅保留草稿与导入完成两种。 */ + /** 保存草稿仍使用 draft;确认导入的状态与运单业务状态一致。 */ private static final String STATUS_DRAFT = "draft"; + private static final String STATUS_RUNNING = "running"; private static final String STATUS_COMPLETED = "completed"; private static final String IMPORT_TYPE_WAYBILL = "waybill"; private static final String IMPORT_TYPE_SETTLEMENT = "settlement"; @@ -101,7 +103,7 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl waybills = new ArrayList<>(); - Map> loadingWaybills = new TreeMap<>(); for (int index = 0; index < rows.size(); index++) { if (skipRows.contains(index)) continue; Waybill waybill = waybillByRow.getOrDefault(index, rowWaybills.get(index)); - // 配载标识号必须取自行数据:运单落库(prepareForSave)会把新建运单的 loadingNo 置空 - String loadingIdentifier = stringValue(rows.get(index), "loadingIdentifier", "配载标识号"); try { if (draft) waybillService.saveDraft(waybill); else waybillService.submit(waybill); } catch (Exception exception) { throw new ServiceException("第" + (index + 1) + "行" + (draft ? "保存" : "导入") + "失败:" + exception.getMessage()); } - waybills.add(waybill); - if (!draft && IMPORT_TYPE_WAYBILL.equals(batch.getImportType()) && Func.isNotEmpty(loadingIdentifier)) { - loadingWaybills.computeIfAbsent(loadingIdentifier, key -> new ArrayList<>()).add(waybill); + // 进行中导入要保持运单为进行中,避免过程接单配置在查询时把状态改回待执行。 + if (STATUS_RUNNING.equals(importStatus)) { + waybillService.update(Wrappers.lambdaUpdate() + .eq(Waybill::getId, waybill.getId()) + .set(Waybill::getBusinessStatus, STATUS_RUNNING) + .set(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_ACCEPTED)); + waybill.setBusinessStatus(STATUS_RUNNING); + waybill.setDriverAcceptStatus(WaybillProcessSupport.ACCEPT_ACCEPTED); } + waybills.add(waybill); + } + // 同一配载标识号有多条运单时,生成一张配载单并回写运单关联。 + if (!draft && IMPORT_TYPE_WAYBILL.equals(batch.getImportType())) { + createLoadingsForSharedIdentifiers(batch); } - loadingWaybills.forEach(loadingManageService::createFromImportedWaybills); batch.setWaybillCount(existingWaybillCount + waybills.size()); if (append && !draft) { - batch.setImportStatus(STATUS_COMPLETED); + batch.setImportStatus(importStatus); } updateById(batch); - // 导入完成且导入类型为运单时,按合同费用生成模式(系统生成)同步生成应收应付明细。 - if (!draft && IMPORT_TYPE_WAYBILL.equals(batch.getImportType())) { + // 导入状态为已完成时,按合同费用生成模式(系统生成)同步生成应收应付明细。 + if (STATUS_COMPLETED.equals(importStatus) && IMPORT_TYPE_WAYBILL.equals(batch.getImportType())) { receivablePayableDetailService.generateForImportedWaybills(waybills); } return batch; } + /** + * 按批次内已落库运单的配载标识号分组。标识号相同且不少于两条时创建或补齐配载单。 + */ + private void createLoadingsForSharedIdentifiers(WaybillImportBatch batch) { + List batchWaybills = waybillService.list(Wrappers.lambdaQuery() + .eq(Waybill::getImportBatchId, batch.getId()) + .eq(Waybill::getIsDeleted, 0) + .orderByAsc(Waybill::getCreateTime) + .orderByAsc(Waybill::getId)); + Map> loadingWaybills = new java.util.LinkedHashMap<>(); + for (Waybill waybill : batchWaybills) { + if (Func.isEmpty(waybill.getLoadingIdentifier())) continue; + loadingWaybills.computeIfAbsent(waybill.getLoadingIdentifier(), key -> new ArrayList<>()).add(waybill); + } + loadingWaybills.forEach((loadingIdentifier, groupedWaybills) -> { + if (groupedWaybills.size() > 1) { + loadingManageService.createFromImportedWaybills(loadingIdentifier, groupedWaybills); + } + }); + } + private void clearBatchWaybills(Long batchId) { if (Func.isEmpty(batchId)) return; List waybills = waybillService.list(Wrappers.lambdaQuery() @@ -311,6 +340,7 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl page(IPage page, WaybillImportBatchRequest request) { LambdaQueryWrapper queryWrapper = Wrappers.lambdaQuery() .eq(WaybillImportBatch::getIsDeleted, 0) + .eq(request.getId() != null, WaybillImportBatch::getId, request.getId()) .like(Func.isNotEmpty(request.getBatchNo()), WaybillImportBatch::getBatchNo, request.getBatchNo()) .apply(Func.isNotEmpty(request.getCarrierId()), "FIND_IN_SET({0}, carrier_ids)", request.getCarrierId()) .eq(Func.isNotEmpty(request.getCarrierName()), WaybillImportBatch::getCarrierName, request.getCarrierName()) @@ -341,6 +371,23 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl row, List errors) { - String vehicleNo = stringValue(row, "vehicleNo", "车牌号/航班号/船号/班列号"); + String vehicleNo = stringValue(row, "vehicleNo", "车牌号/船班号/船次号/班次号", "车牌号/航班号/船号/班列号"); String transportType = stringValue(row, "transportType", "运输类型", "*运输类型", "运输方式", "*运输方式"); if (Func.isEmpty(transportType)) { return; } - // 公路运输必须填写车牌号;其他运输方式的该字段可填写航班号、船号或班列号。 + // 公路运输必须填写车牌号;其他运输方式的该字段可填写船班号、船次号或班次号。 if (!isRoadTransport(transportType)) { return; } @@ -897,12 +944,12 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl assertNotLoaded(oldRecord); waybill.setWaybillNo(oldRecord.getWaybillNo()); waybill.setLoadingNo(oldRecord.getLoadingNo()); + waybill.setLoadingIdentifier(oldRecord.getLoadingIdentifier()); waybill.setDeptId(oldRecord.getDeptId()); waybill.setDeptName(oldRecord.getDeptName()); waybill.setMileageRemark(oldRecord.getMileageRemark()); @@ -993,6 +994,7 @@ public class WaybillServiceImpl extends BaseServiceImpl target.setPlanName(source.getPlanName()); target.setMasterNo(null); target.setLoadingNo(null); + target.setLoadingIdentifier(null); target.setBatchNo(source.getBatchNo()); target.setRelationNo(source.getRelationNo()); target.setCurrentProcessNode(source.getCurrentProcessNode()); @@ -1418,6 +1420,7 @@ public class WaybillServiceImpl extends BaseServiceImpl waybill.setPlanName(TransportBusinessSupport.trimToNull(waybill.getPlanName())); waybill.setMasterNo(TransportBusinessSupport.trimToNull(waybill.getMasterNo())); waybill.setLoadingNo(TransportBusinessSupport.trimToNull(waybill.getLoadingNo())); + waybill.setLoadingIdentifier(TransportBusinessSupport.trimToNull(waybill.getLoadingIdentifier())); waybill.setBatchNo(TransportBusinessSupport.trimToNull(waybill.getBatchNo())); waybill.setRelationNo(TransportBusinessSupport.trimToNull(waybill.getRelationNo())); waybill.setCurrentProcessNode(TransportBusinessSupport.trimToNull(waybill.getCurrentProcessNode())); @@ -1490,7 +1493,7 @@ public class WaybillServiceImpl extends BaseServiceImpl TransportBusinessSupport.validateRequired(waybill.getQuantityUnit(), "数量单位不能为空"); TransportBusinessSupport.validateRequired(waybill.getCarrierType(), "承运类型不能为空"); TransportBusinessSupport.validateRequired( - waybill.getVehicleNo(), isRoadTransport(waybill.getTransportType()) ? "车牌号不能为空" : "船/航/班列号不能为空" + waybill.getVehicleNo(), isRoadTransport(waybill.getTransportType()) ? "车牌号不能为空" : "船班号/船次号/班次号不能为空" ); if (isRoadTransport(waybill.getTransportType())) { if ("承运商".equals(waybill.getCarrierType())) { @@ -1527,7 +1530,7 @@ public class WaybillServiceImpl extends BaseServiceImpl TransportBusinessSupport.validateLength(waybill.getCarrierName(), 255, "承运商名称不能超过255字"); TransportBusinessSupport.validateLength(waybill.getDriverName(), 255, "司机姓名不能超过255字"); TransportBusinessSupport.validateLength(waybill.getDriverPhone(), 50, "司机手机号不能超过50字"); - TransportBusinessSupport.validateLength(waybill.getVehicleNo(), 255, "车/船/航班/班列号不能超过255字"); + TransportBusinessSupport.validateLength(waybill.getVehicleNo(), 255, "车牌号/船班号/船次号/班次号不能超过255字"); TransportBusinessSupport.validateLength(waybill.getCaptainName(), 20, "船长不能超过20字"); TransportBusinessSupport.validateLength(waybill.getCabinNo(), 30, "舱位不能超过30字"); TransportBusinessSupport.validateLength(waybill.getContainerNo(), 30, "箱号不能超过30字"); @@ -1637,7 +1640,7 @@ public class WaybillServiceImpl extends BaseServiceImpl if (isRoadTransport(waybill.getTransportType())) { return; } - TransportBusinessSupport.validateLength(waybill.getVehicleNo(), 30, "船/航/班列号不能超过30字"); + TransportBusinessSupport.validateLength(waybill.getVehicleNo(), 30, "船班号/船次号/班次号不能超过30字"); TransportBusinessSupport.validateLength(waybill.getCaptainName(), 20, "船长不能超过20字"); TransportBusinessSupport.validateLength(waybill.getCabinNo(), 30, "舱位不能超过30字"); TransportBusinessSupport.validateLength(waybill.getContainerNo(), 30, "箱号不能超过30字"); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/WaybillWrapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/WaybillWrapper.java index 56e803af..c7db3b41 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/WaybillWrapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/WaybillWrapper.java @@ -26,6 +26,8 @@ import org.springblade.core.mp.support.BaseEntityWrapper; import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.utils.BeanUtil; import org.springblade.core.tool.utils.Func; +import org.springblade.system.cache.DictBizCache; +import org.springblade.system.cache.DictCache; import org.springblade.system.cache.UserCache; import org.springblade.transport.pojo.entity.Waybill; import org.springblade.transport.pojo.vo.WaybillVO; @@ -58,6 +60,7 @@ public class WaybillWrapper extends BaseEntityWrapper { waybill.getBusinessStatus(), waybill.getProcessJson(), waybill.getDriverAcceptStatus()); waybillVO.setBusinessStatus(displayStatus); waybillVO.setBusinessStatusName(businessStatusName(displayStatus)); + waybillVO.setTransportTypeName(transportTypeName(waybill.getTransportType())); waybillVO.setRequireAccept(WaybillProcessSupport.requiresDriverAcceptConfirmation(waybill.getProcessJson())); // 未维护里程等占位值 -1 对外展示为空 if (isSentinelMinusOne(waybillVO.getMileage())) { @@ -76,6 +79,29 @@ public class WaybillWrapper extends BaseEntityWrapper { return value != null && value.compareTo(BigDecimal.valueOf(-1)) == 0; } + /** + * 运输方式按业务字典名称展示,系统内仍保存字典键。 + */ + public static String transportTypeName(String value) { + if (Func.isEmpty(value)) { + return value; + } + String name = ""; + try { + name = DictBizCache.getValue("transport_type", value); + } catch (Exception exception) { + name = ""; + } + if (Func.isEmpty(name)) { + try { + name = DictCache.getValue("transport_type", value); + } catch (Exception exception) { + name = ""; + } + } + return Func.isEmpty(name) ? value : name; + } + public static String businessStatusName(String status) { if (status == null) { return "未知"; diff --git a/doc/sql/transport/blade_tms_business.sql b/doc/sql/transport/blade_tms_business.sql index 62a0472c..a91fa295 100644 --- a/doc/sql/transport/blade_tms_business.sql +++ b/doc/sql/transport/blade_tms_business.sql @@ -283,6 +283,7 @@ CREATE TABLE `blade_waybill` ( `plan_name` varchar(100) DEFAULT NULL COMMENT '计划名称', `master_no` varchar(100) DEFAULT NULL COMMENT '多联总单', `loading_no` varchar(100) DEFAULT NULL COMMENT '配载单号', + `loading_identifier` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '配载标识号', `batch_no` varchar(100) DEFAULT NULL COMMENT '运单批次号', `relation_no` varchar(100) DEFAULT NULL COMMENT '关联单号', `current_process_node` varchar(100) DEFAULT NULL COMMENT '当前过程节点', diff --git a/doc/sql/transport/blade_waybill_loading_identifier_20260927.sql b/doc/sql/transport/blade_waybill_loading_identifier_20260927.sql new file mode 100644 index 00000000..aeadca4a --- /dev/null +++ b/doc/sql/transport/blade_waybill_loading_identifier_20260927.sql @@ -0,0 +1,2 @@ +ALTER TABLE `blade_waybill` + ADD COLUMN `loading_identifier` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '配载标识号' AFTER `loading_no`;