1、调整导入运单

2、调整对账
This commit is contained in:
2026-09-10 02:01:51 +08:00
parent 049a46227d
commit e7d7e61846
11 changed files with 486 additions and 46 deletions
@@ -0,0 +1,29 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
*/
package org.springblade.transport.pojo.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.List;
/**
* 凭证更换运单批次请求。
*/
@Data
@Schema(description = "凭证更换运单批次请求")
public class VoucherManageChangeBatchRequest implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "凭证批次ID")
private Long voucherId;
@Schema(description = "运单导入批次对应的运单ID")
private List<Long> waybillImportBatchIds;
}
@@ -32,4 +32,6 @@ public class VoucherManage extends TenantEntity {
private Integer relatedWaybillCount;
private Integer unRelatedWaybillCount;
private String auditStatus;
@Schema(description = "审核驳回原因")
private String rejectReason;
}
@@ -40,11 +40,15 @@ import org.springblade.core.tool.api.R;
import org.springblade.core.tool.utils.DateUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.transport.excel.ProcessConfigExportExcel;
import org.springblade.transport.mapper.VoucherFileMapper;
import org.springblade.transport.mapper.VoucherImageMapper;
import org.springblade.transport.mapper.VoucherManageMapper;
import org.springblade.transport.mapper.WaybillMapper;
import org.springblade.transport.pojo.entity.ProcessConfig;
import org.springblade.transport.pojo.entity.VoucherFile;
import org.springblade.transport.pojo.entity.VoucherImage;
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.service.IProcessConfigService;
@@ -60,6 +64,7 @@ import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
/**
@@ -74,16 +79,21 @@ import java.util.concurrent.TimeUnit;
public class ProcessConfigController extends BladeController {
private final IProcessConfigService processConfigService;
private final WaybillMapper waybillMapper;
private final VoucherFileMapper voucherFileMapper;
private final VoucherImageMapper voucherImageMapper;
private final VoucherManageMapper voucherManageMapper;
private final MinioClient minioClient;
@Value("${file.storage.minio.bucket-name:${minio.bucket-name:}}")
private String minioBucketName;
public ProcessConfigController(IProcessConfigService processConfigService, VoucherImageMapper voucherImageMapper,
public ProcessConfigController(IProcessConfigService processConfigService, WaybillMapper waybillMapper,
VoucherFileMapper voucherFileMapper, VoucherImageMapper voucherImageMapper,
VoucherManageMapper voucherManageMapper,
MinioClient minioClient) {
this.processConfigService = processConfigService;
this.waybillMapper = waybillMapper;
this.voucherFileMapper = voucherFileMapper;
this.voucherImageMapper = voucherImageMapper;
this.voucherManageMapper = voucherManageMapper;
this.minioClient = minioClient;
@@ -107,15 +117,20 @@ public class ProcessConfigController extends BladeController {
@ApiOperationSupport(order = 2)
@Operation(summary = "查询运单已关联凭证图片")
public R<List<Map<String, Object>>> voucherImages(
@Parameter(description = "运单主键", required = true) @RequestParam Long waybillId) {
@Parameter(description = "运单主键", required = true) @RequestParam Long waybillId,
@Parameter(description = "凭证批次主键,点击文件夹时传入") @RequestParam(required = false) Long voucherId,
@Parameter(description = "文件夹名称,点击文件夹时传入") @RequestParam(required = false) String folderName) {
List<VoucherImage> imageRecords = voucherImageMapper.selectList(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<VoucherImage>()
.eq(VoucherImage::getWaybillId, waybillId)
.eq(VoucherImage::getMatched, 1)
.eq(VoucherImage::getIsDeleted, 0)
.orderByDesc(VoucherImage::getCreateTime));
if (voucherId != null && Func.isNotEmpty(folderName)) {
return R.data(folderImages(waybillId, folderName));
}
List<Long> voucherIds = imageRecords.stream()
.map(VoucherImage::getVoucherId).filter(java.util.Objects::nonNull).distinct().toList();
.map(VoucherImage::getVoucherId).filter(Objects::nonNull).distinct().toList();
Map<Long, VoucherManage> voucherMap = voucherIds.isEmpty() ? Map.of() : voucherManageMapper.selectBatchIds(voucherIds).stream()
.collect(java.util.stream.Collectors.toMap(VoucherManage::getId, item -> item, (left, right) -> left));
List<Map<String, Object>> images = imageRecords.stream()
@@ -124,7 +139,7 @@ public class ProcessConfigController extends BladeController {
VoucherManage voucher = voucherMap.get(image.getVoucherId());
return voucher == null || !"承运商".equals(voucher.getUploadSource())
|| "审核通过".equals(voucher.getAuditStatus());
}).map(image -> {
}).map(image -> {
Map<String, Object> result = new LinkedHashMap<>();
result.put("id", image.getId());
result.put("imageName", image.getImageName());
@@ -140,7 +155,150 @@ public class ProcessConfigController extends BladeController {
}
return result;
}).toList();
return R.data(images);
return R.data(images.isEmpty() ? fallbackVoucherFolders(waybillId) : images);
}
private List<Map<String, Object>> fallbackVoucherFolders(Long waybillId) {
Waybill waybill = waybillMapper.selectById(waybillId);
if (waybill == null || Func.isEmpty(waybill.getBatchNo())) {
return List.of();
}
List<VoucherManage> vouchers = voucherManageMapper.selectList(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<VoucherManage>()
.eq(VoucherManage::getTenantId, waybill.getTenantId())
.eq(VoucherManage::getIsDeleted, 0));
Map<String, List<VoucherImageRecord>> folderRecords = new LinkedHashMap<>();
Map<String, List<VoucherManage>> folderVouchers = new LinkedHashMap<>();
for (VoucherManage voucher : vouchers) {
if (!containsBatchNo(voucher.getWaybillBatchNo(), waybill.getBatchNo()) || !visibleVoucher(voucher)) {
continue;
}
Map<String, List<VoucherImageRecord>> grouped = new LinkedHashMap<>();
for (VoucherFile file : voucherFileMapper.selectList(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<VoucherFile>()
.eq(VoucherFile::getTenantId, voucher.getTenantId())
.eq(VoucherFile::getVoucherId, voucher.getId())
.eq(VoucherFile::getIsDeleted, 0))) {
if ("image".equals(file.getFileType())) {
String key = Func.isEmpty(file.getFolderName()) ? "未命名文件夹" : file.getFolderName();
grouped.computeIfAbsent(key, ignored -> new ArrayList<>()).add(VoucherImageRecord.from(file));
}
}
if (grouped.isEmpty()) {
for (VoucherImage image : voucherImageMapper.selectList(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<VoucherImage>()
.eq(VoucherImage::getTenantId, voucher.getTenantId())
.eq(VoucherImage::getVoucherId, voucher.getId())
.eq(VoucherImage::getIsDeleted, 0))) {
String key = Func.isEmpty(image.getPlateNo()) ? "未命名文件夹" : image.getPlateNo();
grouped.computeIfAbsent(key, ignored -> new ArrayList<>()).add(VoucherImageRecord.from(image));
}
}
for (Map.Entry<String, List<VoucherImageRecord>> entry : grouped.entrySet()) {
folderRecords.computeIfAbsent(entry.getKey(), ignored -> new ArrayList<>()).addAll(entry.getValue());
folderVouchers.computeIfAbsent(entry.getKey(), ignored -> new ArrayList<>()).add(voucher);
}
}
List<Map<String, Object>> folders = new ArrayList<>();
for (Map.Entry<String, List<VoucherImageRecord>> entry : folderRecords.entrySet()) {
Map<String, Object> folder = new LinkedHashMap<>();
folder.put("type", "folder");
folder.put("isFolder", true);
folder.put("waybillId", waybillId);
folder.put("waybillBatchNo", waybill.getBatchNo());
List<VoucherManage> relatedVouchers = folderVouchers.get(entry.getKey());
folder.put("voucherId", relatedVouchers.get(0).getId());
folder.put("voucherIds", relatedVouchers.stream().map(VoucherManage::getId).toList());
folder.put("voucherBatchNo", relatedVouchers.get(0).getVoucherBatchNo());
folder.put("voucherBatchNos", relatedVouchers.stream().map(VoucherManage::getVoucherBatchNo).toList());
folder.put("folderName", entry.getKey());
folder.put("plateNo", entry.getKey());
folder.put("name", entry.getKey());
folder.put("imageCount", entry.getValue().size());
folder.put("icon", "/img/文件夹.png");
folders.add(folder);
}
return folders;
}
private List<Map<String, Object>> folderImages(Long waybillId, String folderName) {
Waybill waybill = waybillMapper.selectById(waybillId);
if (waybill == null || Func.isEmpty(waybill.getBatchNo()) || Func.isEmpty(folderName)) {
return List.of();
}
List<VoucherManage> vouchers = voucherManageMapper.selectList(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<VoucherManage>()
.eq(VoucherManage::getTenantId, waybill.getTenantId())
.eq(VoucherManage::getIsDeleted, 0));
Map<String, VoucherImageRecord> recordMap = new LinkedHashMap<>();
for (VoucherManage voucher : vouchers) {
if (!containsBatchNo(voucher.getWaybillBatchNo(), waybill.getBatchNo()) || !visibleVoucher(voucher)) {
continue;
}
List<VoucherImageRecord> voucherRecords = voucherFileMapper.selectList(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<VoucherFile>()
.eq(VoucherFile::getTenantId, voucher.getTenantId())
.eq(VoucherFile::getVoucherId, voucher.getId())
.eq(VoucherFile::getFolderName, folderName)
.eq(VoucherFile::getFileType, "image")
.eq(VoucherFile::getIsDeleted, 0)).stream().map(VoucherImageRecord::from).toList();
if (voucherRecords.isEmpty()) {
voucherRecords = voucherImageMapper.selectList(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<VoucherImage>()
.eq(VoucherImage::getTenantId, voucher.getTenantId())
.eq(VoucherImage::getVoucherId, voucher.getId())
.eq(VoucherImage::getPlateNo, folderName)
.eq(VoucherImage::getIsDeleted, 0)).stream().map(VoucherImageRecord::from).toList();
}
for (VoucherImageRecord record : voucherRecords) {
String key = record.id() != null ? String.valueOf(record.id()) : record.objectKey();
recordMap.putIfAbsent(key, record);
}
}
return recordMap.values().stream().map(this::imageMap).toList();
}
private Map<String, Object> imageMap(VoucherImageRecord image) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("type", "image");
result.put("isFolder", false);
result.put("id", image.id());
result.put("imageName", image.fileName());
result.put("voucherBatchNo", image.voucherBatchNo());
result.put("waybillNo", image.waybillNo());
result.put("folderName", image.folderName());
result.put("objectKey", image.objectKey());
result.put("matched", image.matched());
try {
result.put("url", minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder()
.method(Method.GET).bucket(minioBucketName).object(image.objectKey())
.expiry(1, TimeUnit.HOURS).build()));
} catch (Exception exception) {
throw new IllegalStateException("生成凭证图片预览地址失败", exception);
}
return result;
}
private boolean visibleVoucher(VoucherManage voucher) {
return voucher == null || !"承运商".equals(voucher.getUploadSource()) || "审核通过".equals(voucher.getAuditStatus());
}
private boolean containsBatchNo(String batchNos, String batchNo) {
return Func.isNotEmpty(batchNos) && Func.isNotEmpty(batchNo)
&& java.util.Arrays.stream(batchNos.split(",")).map(String::trim).anyMatch(batchNo::equals);
}
private record VoucherImageRecord(Long id, String voucherBatchNo, String waybillNo, String fileName,
String folderName, String objectKey, Integer matched) {
private static VoucherImageRecord from(VoucherFile file) {
return new VoucherImageRecord(file.getId(), file.getVoucherBatchNo(), file.getWaybillNo(), file.getFileName(),
file.getFolderName(), file.getObjectKey(), file.getMatched());
}
private static VoucherImageRecord from(VoucherImage image) {
return new VoucherImageRecord(image.getId(), image.getVoucherBatchNo(), image.getWaybillNo(), image.getImageName(),
image.getPlateNo(), image.getObjectKey(), image.getMatched());
}
}
@GetMapping("/list")
@@ -10,6 +10,7 @@ import org.springblade.core.mp.support.Condition;
import org.springblade.core.mp.support.Query;
import org.springblade.core.secure.annotation.PreAuth;
import org.springblade.core.tool.api.R;
import org.springblade.transport.pojo.dto.VoucherManageChangeBatchRequest;
import org.springblade.transport.pojo.dto.VoucherManageSubmitRequest;
import org.springblade.transport.pojo.dto.VoucherUploadDraftRequest;
import org.springblade.transport.pojo.dto.VoucherFileCompleteRequest;
@@ -70,12 +71,12 @@ public class VoucherManageController extends BladeController {
}
@PostMapping("/folder-replace-object")
@Operation(summary = "替换单个车牌凭证(系统文件上传后处理)")
@Operation(summary = "替换单个车牌凭证(分片上传文件处理)")
public R replaceFolderByObject(@RequestParam Long voucherId, @RequestParam String plateNo,
@RequestParam String objectKey, @RequestParam String fileName,
@RequestParam(required = false) Long size, @RequestParam(required = false) String contentType) {
voucherManageService.replaceFolderByObject(voucherId, plateNo, objectKey, fileName, size, contentType);
return R.success("上传成功");
return R.success("处理成功");
}
@PostMapping("/folder-remove")
@@ -90,8 +91,16 @@ public class VoucherManageController extends BladeController {
@Operation(summary = "上传凭证或更换运输批次")
public R submit(@RequestBody VoucherManageSubmitRequest request) { voucherManageService.submit(request); return R.success("提交成功"); }
@PostMapping("/upload-draft")
@PostMapping("/change-waybill-batch")
@ApiOperationSupport(order = 4)
@Operation(summary = "更换运单批次并重新匹配凭证")
public R changeWaybillBatch(@RequestBody VoucherManageChangeBatchRequest request) {
voucherManageService.changeWaybillBatch(request);
return R.success("运单批次更换成功");
}
@PostMapping("/upload-draft")
@ApiOperationSupport(order = 5)
@Operation(summary = "创建凭证上传草稿")
public R<VoucherManage> createUploadDraft(@RequestBody VoucherUploadDraftRequest request) {
return R.data(voucherManageService.createUploadDraft(request));
@@ -112,12 +121,12 @@ public class VoucherManageController extends BladeController {
}
@PostMapping("/remove")
@ApiOperationSupport(order = 5)
@ApiOperationSupport(order = 6)
@Operation(summary = "删除凭证")
public R remove(@RequestParam Long id) { voucherManageService.removeVoucher(id); return R.success("删除成功"); }
@GetMapping("/waybill-batches")
@ApiOperationSupport(order = 6)
@ApiOperationSupport(order = 7)
@Operation(summary = "可关联运输批次")
public R<IPage<Map<String, Object>>> waybillBatches(Query query, @RequestParam(required = false) String batchNo,
@RequestParam(required = false) String createUser, @RequestParam(required = false) Integer waybillCount,
@@ -126,7 +135,7 @@ public class VoucherManageController extends BladeController {
}
@PostMapping("/audit-pass")
@ApiOperationSupport(order = 7)
@ApiOperationSupport(order = 8)
@Operation(summary = "审核通过")
public R auditPass(@RequestParam Long id) {
voucherManageService.auditPass(id);
@@ -134,7 +143,7 @@ public class VoucherManageController extends BladeController {
}
@PostMapping("/audit-reject")
@ApiOperationSupport(order = 8)
@ApiOperationSupport(order = 9)
@Operation(summary = "审核驳回")
public R auditReject(@RequestParam Long id, @RequestParam(required = false) String rejectReason) {
voucherManageService.auditReject(id, rejectReason);
@@ -14,6 +14,8 @@ public interface VoucherWaybillBatchMapper extends BaseMapper<VoucherWaybillBatc
@Delete("DELETE FROM blade_voucher_waybill_batch WHERE voucher_id = #{voucherId}")
int deletePhysicalByVoucherId(@Param("voucherId") Long voucherId);
IPage<Map<String, Object>> selectVoucherWaybillBatchPage(IPage<?> page, @Param("tenantId") String tenantId, @Param("batchNo") String batchNo, @Param("createUser") String createUser,
@Param("waybillCount") Integer waybillCount, @Param("createTimeStart") String createTimeStart, @Param("createTimeEnd") String createTimeEnd);
List<Map<String, Object>> selectWaybillBatchesByIds(@Param("tenantId") String tenantId, @Param("ids") List<Long> ids);
@Param("waybillCount") Integer waybillCount, @Param("createTimeStart") String createTimeStart, @Param("createTimeEnd") String createTimeEnd,
@Param("restrictCarrier") boolean restrictCarrier, @Param("carrierName") String carrierName);
List<Map<String, Object>> selectWaybillBatchesByIds(@Param("tenantId") String tenantId, @Param("ids") List<Long> ids,
@Param("restrictCarrier") boolean restrictCarrier, @Param("carrierName") String carrierName);
}
@@ -8,6 +8,7 @@
FROM blade_waybill w
LEFT JOIN blade_user u ON u.id = w.create_user
WHERE w.is_deleted = 0 AND w.tenant_id = #{tenantId}
<if test="restrictCarrier">AND w.carrier_name = #{carrierName}</if>
<if test="batchNo != null and batchNo != ''">AND w.batch_no LIKE CONCAT('%', #{batchNo}, '%')</if>
<if test="createUser != null and createUser != ''">AND w.create_user = #{createUser}</if>
<if test="createTimeStart != null and createTimeStart != ''">AND w.create_time <![CDATA[>=]]> #{createTimeStart}</if>
@@ -21,6 +22,7 @@
FROM blade_waybill w
WHERE w.is_deleted = 0 AND w.tenant_id = #{tenantId} AND w.id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
<if test="restrictCarrier">AND w.carrier_name = #{carrierName}</if>
GROUP BY COALESCE(NULLIF(w.batch_no, ''), '未分批运单')
</select>
</mapper>
@@ -2,6 +2,7 @@ package org.springblade.transport.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import org.springblade.core.mp.base.BaseService;
import org.springblade.transport.pojo.dto.VoucherManageChangeBatchRequest;
import org.springblade.transport.pojo.dto.VoucherManageSubmitRequest;
import org.springblade.transport.pojo.dto.VoucherUploadDraftRequest;
import org.springblade.transport.pojo.dto.VoucherFileCompleteRequest;
@@ -22,6 +23,7 @@ public interface IVoucherManageService extends BaseService<VoucherManage> {
void replaceFolderByObject(Long voucherId, String plateNo, String objectKey, String fileName, Long size, String contentType);
void removeFolder(Long voucherId, String plateNo);
void submit(VoucherManageSubmitRequest request);
void changeWaybillBatch(VoucherManageChangeBatchRequest request);
VoucherManage createUploadDraft(VoucherUploadDraftRequest request);
void completeUploadFile(VoucherFileCompleteRequest request);
void processUploadedVoucher(Long voucherId);
@@ -14,6 +14,7 @@ import org.springblade.transport.mapper.VoucherImageMapper;
import org.springblade.transport.mapper.VoucherFileMapper;
import org.springblade.transport.mapper.VoucherWaybillBatchMapper;
import org.springblade.transport.event.VoucherUploadCompletedEvent;
import org.springblade.transport.pojo.dto.VoucherManageChangeBatchRequest;
import org.springblade.transport.pojo.dto.VoucherManageSubmitRequest;
import org.springblade.transport.pojo.dto.VoucherUploadDraftRequest;
import org.springblade.transport.pojo.dto.VoucherFileCompleteRequest;
@@ -101,7 +102,17 @@ public class VoucherManageServiceImpl extends BaseServiceImpl<VoucherManageMappe
@Override
public IPage<VoucherManageVO> selectPage(IPage<VoucherManage> page, VoucherManageVO query) {
return VoucherManageWrapper.build().pageVO(page(page, buildQuery(query)));
IPage<VoucherManageVO> result = VoucherManageWrapper.build().pageVO(page(page, buildQuery(query)));
// 凭证数量按文件夹(车牌目录)统计,兼容历史记录中仍保存图片数量的旧数据。
if (result.getRecords() != null) {
result.getRecords().forEach(item -> {
VoucherFolderCounts folderCounts = countVoucherFolderRelations(item.getTenantId(), item.getId());
item.setVoucherCount(folderCounts.total());
item.setRelatedWaybillCount(folderCounts.related());
item.setUnRelatedWaybillCount(folderCounts.unrelated());
});
}
return result;
}
@Override
@@ -154,9 +165,13 @@ public class VoucherManageServiceImpl extends BaseServiceImpl<VoucherManageMappe
@Override
@Transactional(rollbackFor = Exception.class)
public void replaceFolder(Long voucherId, String plateNo, MultipartFile uploadFile) {
replaceFolder(voucherId, plateNo, uploadFile, true);
}
private void replaceFolder(Long voucherId, String plateNo, MultipartFile uploadFile, boolean enforceSizeLimit) {
VoucherManage voucher = getVoucher(voucherId);
if (uploadFile == null || uploadFile.isEmpty()) throw new ServiceException("请选择凭证文件");
if (uploadFile.getSize() > 50L * 1024 * 1024) throw new ServiceException("凭证文件大小不能超过50M");
if (enforceSizeLimit && uploadFile.getSize() > 50L * 1024 * 1024) throw new ServiceException("凭证文件大小不能超过50M");
String normalizedPlateNo = normalizePlateNo(plateNo);
if (Func.isEmpty(normalizedPlateNo)) throw new ServiceException("车牌号不能为空");
validateMinioConfig();
@@ -200,26 +215,135 @@ public class VoucherManageServiceImpl extends BaseServiceImpl<VoucherManageMappe
@Transactional(rollbackFor = Exception.class)
public void replaceFolderByObject(Long voucherId, String plateNo, String objectKey, String fileName, Long size, String contentType) {
if (Func.isEmpty(objectKey) || Func.isEmpty(fileName)) throw new ServiceException("文件信息不完整");
VoucherManage voucher = getVoucher(voucherId);
validateMinioConfig();
MultipartFile uploadedFile = new MultipartFile() {
if (size != null && size == 0) throw new ServiceException("请选择凭证文件");
String originalFileName = safeFileName(fileName);
if (!originalFileName.toLowerCase(Locale.ROOT).endsWith(".zip")) {
throw new ServiceException("系统上传接口仅支持 zip 压缩包");
}
Path archivePath = null;
try {
// 分片上传完成后对象已存在于 MinIO。先下载到临时文件,避免直接使用远程流导致
// ZIP 解压与 MinIO 分片上传之间的流状态冲突。
archivePath = downloadMinioObject(objectKey);
String effectivePlateNo = resolveReplacementPlateNo(voucher, plateNo, originalFileName, archivePath);
MultipartFile uploadedFile = localArchiveMultipartFile(archivePath, originalFileName, contentType);
replaceFolder(voucherId, effectivePlateNo, uploadedFile, false);
log.info("[分片上传] 压缩包下载、解压并匹配完成 voucherId={}, requestedPlateNo={}, matchedPlateNo={}, objectKey={}",
voucherId, plateNo, effectivePlateNo, objectKey);
} catch (ServiceException exception) {
throw exception;
} catch (Exception exception) {
log.error("[分片上传] 压缩包处理失败 voucherId={}, objectKey={}", voucherId, objectKey, exception);
throw new ServiceException("凭证文件上传失败");
} finally {
deleteTempArchive(archivePath);
// 系统上传接口产生的临时附件仅用于本次替换,复制到凭证目录后清理源对象。
deleteSourceObjectQuietly(objectKey);
}
}
private Path downloadMinioObject(String objectKey) throws Exception {
Path archivePath = Files.createTempFile("voucher-object-", ".zip");
String resolvedObjectKey = resolveMinioObjectKey(objectKey);
Exception lastException = null;
List<String> candidateObjectKeys = resolvedObjectKey.equals(objectKey)
? List.of(objectKey) : List.of(resolvedObjectKey, objectKey);
for (String candidateObjectKey : candidateObjectKeys) {
try (InputStream source = minioClient.getObject(GetObjectArgs.builder().bucket(minioBucketName).object(candidateObjectKey).build());
OutputStream target = Files.newOutputStream(archivePath)) {
source.transferTo(target);
return archivePath;
} catch (Exception exception) {
lastException = exception;
log.warn("读取系统上传对象失败,尝试下一个对象路径 bucket={}, objectKey={}", minioBucketName, candidateObjectKey, exception);
}
}
deleteTempArchive(archivePath);
log.error("读取系统上传文件失败 bucket={}, objectKey={}, candidateObjectKeys={}",
minioBucketName, objectKey, candidateObjectKeys, lastException);
throw new ServiceException("读取系统上传文件失败");
}
private String resolveMinioObjectKey(String objectKey) {
if (Func.isEmpty(minioRootDirectory) || Func.isEmpty(objectKey)) {
return objectKey;
}
String normalizedRoot = minioRootDirectory.endsWith("/")
? minioRootDirectory.substring(0, minioRootDirectory.length() - 1) : minioRootDirectory;
return objectKey.equals(normalizedRoot) || objectKey.startsWith(normalizedRoot + "/")
? objectKey : normalizedRoot + "/" + objectKey;
}
private void deleteSourceObjectQuietly(String objectKey) {
deleteObjectQuietly(resolveMinioObjectKey(objectKey));
}
private MultipartFile localArchiveMultipartFile(Path archivePath, String fileName, String contentType) throws java.io.IOException {
long archiveSize = Files.size(archivePath);
return new MultipartFile() {
@Override public String getName() { return "file"; }
@Override public String getOriginalFilename() { return fileName; }
@Override public String getContentType() { return contentType; }
@Override public boolean isEmpty() { return size != null && size == 0; }
@Override public long getSize() { return size == null ? -1L : size; }
@Override public byte[] getBytes() throws java.io.IOException { try (InputStream input = getInputStream()) { return input.readAllBytes(); } }
@Override public InputStream getInputStream() throws java.io.IOException {
try { return minioClient.getObject(GetObjectArgs.builder().bucket(minioBucketName).object(objectKey).build()); }
catch (Exception exception) { throw new java.io.IOException("读取系统上传文件失败", exception); }
@Override public String getContentType() { return Func.isEmpty(contentType) ? "application/zip" : contentType; }
@Override public boolean isEmpty() { return archiveSize == 0; }
@Override public long getSize() { return archiveSize; }
@Override public byte[] getBytes() throws java.io.IOException { return Files.readAllBytes(archivePath); }
@Override public InputStream getInputStream() throws java.io.IOException { return Files.newInputStream(archivePath); }
@Override public void transferTo(java.io.File destination) throws java.io.IOException {
try (InputStream input = getInputStream(); OutputStream output = Files.newOutputStream(destination.toPath())) {
input.transferTo(output);
}
}
@Override public void transferTo(java.io.File dest) throws java.io.IOException { try (InputStream input = getInputStream(); OutputStream output = Files.newOutputStream(dest.toPath())) { input.transferTo(output); } }
};
try {
replaceFolder(voucherId, plateNo, uploadedFile);
} finally {
// 系统上传接口产生的临时附件仅用于本次替换,复制到凭证目录后清理源对象。
deleteObjectQuietly(objectKey);
}
private String resolveReplacementPlateNo(VoucherManage voucher, String requestedPlateNo, String archiveFileName, Path archivePath) throws Exception {
String normalizedRequested = normalizePlateNo(requestedPlateNo);
Set<String> existingPlates = new HashSet<>();
voucherFileMapper.selectList(Wrappers.<VoucherFile>lambdaQuery()
.eq(VoucherFile::getTenantId, voucher.getTenantId()).eq(VoucherFile::getVoucherId, voucher.getId())
.eq(VoucherFile::getIsDeleted, 0)).forEach(file -> addPlateNumber(existingPlates, file.getPlateNo()));
voucherImageMapper.selectList(Wrappers.<VoucherImage>lambdaQuery()
.eq(VoucherImage::getTenantId, voucher.getTenantId()).eq(VoucherImage::getVoucherId, voucher.getId())
.eq(VoucherImage::getIsDeleted, 0)).forEach(image -> addPlateNumber(existingPlates, image.getPlateNo()));
Map<String, Waybill> waybillByPlate = new HashMap<>();
for (Waybill waybill : listRelatedWaybills(voucher)) {
for (String waybillPlateNo : waybillPlateNumbers(waybill)) {
waybillByPlate.putIfAbsent(waybillPlateNo, waybill);
}
}
if (Func.isNotEmpty(normalizedRequested) && waybillByPlate.containsKey(normalizedRequested)) {
return normalizedRequested;
}
String archiveNamePlate = normalizePlateNo(archiveFileName.replaceFirst("(?i)\\.zip$", ""));
if (Func.isNotEmpty(archiveNamePlate) && (waybillByPlate.containsKey(archiveNamePlate) || existingPlates.contains(archiveNamePlate))) {
return archiveNamePlate;
}
Charset archiveCharset = detectArchiveCharset(archivePath);
Set<String> archivePlates = 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;
List<String> pathParts = Arrays.stream(entry.getName().replace('\\', '/').split("/"))
.filter(Func::isNotEmpty).toList();
if (pathParts.isEmpty()) continue;
String folderName = resolvePlateFolderName(pathParts, waybillByPlate);
String archivePlate = normalizePlateNo(folderName);
if (Func.isNotEmpty(archivePlate)) archivePlates.add(archivePlate);
}
}
Set<String> matchedArchivePlates = archivePlates.stream().filter(waybillByPlate::containsKey).collect(Collectors.toSet());
if (matchedArchivePlates.size() == 1) return matchedArchivePlates.iterator().next();
Set<String> existingArchivePlates = archivePlates.stream().filter(existingPlates::contains).collect(Collectors.toSet());
if (existingArchivePlates.size() == 1) return existingArchivePlates.iterator().next();
if (archivePlates.size() == 1 && !"凭证导入".equals(archivePlates.iterator().next())) {
return archivePlates.iterator().next();
}
if (Func.isNotEmpty(normalizedRequested)) return normalizedRequested;
throw new ServiceException("无法从压缩包目录识别车牌号");
}
@Override
@@ -448,16 +572,40 @@ public class VoucherManageServiceImpl extends BaseServiceImpl<VoucherManageMappe
}
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));
VoucherFolderCounts folderCounts = countVoucherFolderRelations(voucher.getTenantId(), voucher.getId());
VoucherManage update = new VoucherManage(); update.setId(voucher.getId()); update.setVoucherCount(folderCounts.total());
update.setRelatedWaybillCount(folderCounts.related()); update.setUnRelatedWaybillCount(folderCounts.unrelated());
update.setProcessStatus("处理完成"); updateById(update);
}
private int countVoucherFolders(VoucherManage voucher) {
return countVoucherFolderRelations(voucher.getTenantId(), voucher.getId()).total();
}
private VoucherFolderCounts countVoucherFolderRelations(String tenantId, Long voucherId) {
List<VoucherFile> files = voucherFileMapper.selectList(Wrappers.<VoucherFile>lambdaQuery()
.eq(VoucherFile::getTenantId, tenantId).eq(VoucherFile::getVoucherId, voucherId).eq(VoucherFile::getIsDeleted, 0));
if (!files.isEmpty()) {
Set<String> folders = files.stream().map(item -> folderKey(item.getPlateNo())).collect(Collectors.toSet());
Set<String> 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());
}
List<VoucherImage> images = voucherImageMapper.selectList(Wrappers.<VoucherImage>lambdaQuery()
.eq(VoucherImage::getTenantId, tenantId).eq(VoucherImage::getVoucherId, voucherId).eq(VoucherImage::getIsDeleted, 0));
Set<String> folders = images.stream().map(item -> folderKey(item.getPlateNo())).collect(Collectors.toSet());
Set<String> 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());
}
private record VoucherFolderCounts(int total, int related, int unrelated) {}
private String folderKey(String plateNo) {
String normalizedPlateNo = normalizePlateNo(plateNo);
return Func.isEmpty(normalizedPlateNo) ? "未识别车牌" : normalizedPlateNo;
}
private void deleteObjectQuietly(String objectKey) { try { minioClient.removeObject(RemoveObjectArgs.builder().bucket(minioBucketName).object(objectKey).build()); } catch (Exception exception) { log.warn("删除凭证对象失败 objectKey={}", objectKey, exception); } }
@Override
@@ -478,6 +626,7 @@ public class VoucherManageServiceImpl extends BaseServiceImpl<VoucherManageMappe
voucher.setUploadSource("内部");
voucher.setProcessStatus("上传中");
voucher.setAuditStatus("-");
voucher.setRejectReason("");
voucher.setVoucherCount(0);
voucher.setRelatedWaybillCount(0);
voucher.setUnRelatedWaybillCount(0);
@@ -515,6 +664,7 @@ public class VoucherManageServiceImpl extends BaseServiceImpl<VoucherManageMappe
voucher.setProcessStatus("上传中");
voucher.setAuditStatus("-");
voucher.setRejectReason("");
voucher.setVoucherCount(0);
voucher.setRelatedWaybillCount(0);
voucher.setUnRelatedWaybillCount(0);
@@ -547,6 +697,45 @@ public class VoucherManageServiceImpl extends BaseServiceImpl<VoucherManageMappe
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void changeWaybillBatch(VoucherManageChangeBatchRequest request) {
if (request == null || Func.isEmpty(request.getVoucherId())) throw new ServiceException("凭证批次不能为空");
if (Func.isEmpty(request.getWaybillImportBatchIds())) throw new ServiceException("请至少关联一个运输批次");
VoucherManage voucher = getVoucher(request.getVoucherId());
List<Map<String, Object>> batches = selectableWaybillBatchesByIds(request.getWaybillImportBatchIds());
if (batches.size() != request.getWaybillImportBatchIds().size()) throw new ServiceException("存在无效的运输批次");
// 更换批次必须同步重建关联关系,否则后续匹配仍会读取旧批次。
voucherWaybillBatchMapper.deletePhysicalByVoucherId(voucher.getId());
List<VoucherWaybillBatch> relations = new ArrayList<>();
for (Map<String, Object> batch : batches) {
VoucherWaybillBatch relation = new VoucherWaybillBatch();
relation.setVoucherId(voucher.getId());
relation.setWaybillImportBatchId(((Number) batch.get("id")).longValue());
relation.setWaybillBatchNo((String) batch.get("batchNo"));
relation.setWaybillCount(((Number) batch.get("waybillCount")).intValue());
relations.add(relation);
}
relations.forEach(voucherWaybillBatchMapper::insert);
voucher.setWaybillBatchNo(relations.stream().map(VoucherWaybillBatch::getWaybillBatchNo).distinct().collect(Collectors.joining(",")));
voucher.setRelatedWaybillCount(0);
voucher.setUnRelatedWaybillCount(0);
voucher.setAuditStatus("-");
voucher.setRejectReason("");
if (Func.isEmpty(voucher.getFileUrl())) {
// 文件尚未上传完成时只保存批次关系,待上传完成后再执行匹配。
voucher.setProcessStatus("上传中");
updateById(voucher);
return;
}
voucher.setProcessStatus("处理中");
updateById(voucher);
// 事务提交后重新读取凭证文件并按新批次执行车牌匹配。
eventPublisher.publishEvent(new VoucherUploadCompletedEvent(voucher.getId()));
}
@Override
@Transactional(rollbackFor = Exception.class)
public void completeUploadFile(VoucherFileCompleteRequest request) {
@@ -697,17 +886,19 @@ public class VoucherManageServiceImpl extends BaseServiceImpl<VoucherManageMappe
voucherId, fileCount, imageCount, matchedImageCount);
VoucherManage update = new VoucherManage();
update.setId(voucher.getId());
update.setVoucherCount(imageCount);
update.setRelatedWaybillCount(relatedWaybillIds.size());
update.setUnRelatedWaybillCount(Math.max(waybills.size() - relatedWaybillIds.size(), 0));
VoucherFolderCounts folderCounts = countVoucherFolderRelations(voucher.getTenantId(), voucher.getId());
update.setVoucherCount(folderCounts.total());
update.setRelatedWaybillCount(folderCounts.related());
update.setUnRelatedWaybillCount(folderCounts.unrelated());
update.setProcessStatus("处理完成");
// 内部上传的凭证无需人工审核,MQ处理完成后直接通过;承运商上传仍进入待审核流程。
boolean internalUpload = "内部".equals(voucher.getUploadSource());
update.setAuditStatus(internalUpload ? "审核通过" : "待审核");
update.setRejectReason("");
updateById(update);
log.info("[凭证处理] 进度 100%:处理完成 voucherId={}, voucherBatchNo={}, uploadSource={}, auditStatus={}, fileCount={}, imageCount={}, relatedWaybillCount={}, unrelatedWaybillCount={}",
voucherId, voucher.getVoucherBatchNo(), voucher.getUploadSource(), update.getAuditStatus(), fileCount, imageCount,
relatedWaybillIds.size(), Math.max(waybills.size() - relatedWaybillIds.size(), 0));
log.info("[凭证处理] 进度 100%:处理完成 voucherId={}, voucherBatchNo={}, uploadSource={}, auditStatus={}, fileCount={}, imageCount={}, folderCount={}, relatedVoucherCount={}, unrelatedVoucherCount={}",
voucherId, voucher.getVoucherBatchNo(), voucher.getUploadSource(), update.getAuditStatus(), fileCount, imageCount, folderCounts.total(),
folderCounts.related(), folderCounts.unrelated());
} finally {
deleteTempArchive(archivePath);
}
@@ -734,12 +925,22 @@ public class VoucherManageServiceImpl extends BaseServiceImpl<VoucherManageMappe
@Override
public IPage<Map<String, Object>> selectableWaybillBatches(IPage<?> page, String batchNo, String createUser, Integer waybillCount, String createTimeStart, String createTimeEnd) {
return voucherWaybillBatchMapper.selectVoucherWaybillBatchPage(page, AuthUtil.getTenantId(), batchNo, createUser, waybillCount, createTimeStart, createTimeEnd);
return voucherWaybillBatchMapper.selectVoucherWaybillBatchPage(page, AuthUtil.getTenantId(), batchNo, createUser, waybillCount, createTimeStart, createTimeEnd,
!AuthUtil.isAdministrator(), currentCarrierName());
}
private List<Map<String, Object>> selectableWaybillBatchesByIds(List<Long> ids) {
if (Func.isEmpty(ids)) return List.of();
return voucherWaybillBatchMapper.selectWaybillBatchesByIds(AuthUtil.getTenantId(), ids);
return voucherWaybillBatchMapper.selectWaybillBatchesByIds(AuthUtil.getTenantId(), ids, !AuthUtil.isAdministrator(), currentCarrierName());
}
/**
* 普通用户只能关联承运商名称与其当前组织名称一致的运单批次;超级管理员不受此限制。
*/
private String currentCarrierName() {
if (AuthUtil.isAdministrator()) return null;
Long currentDeptId = Func.firstLong(AuthUtil.getDeptId());
return getOrganizationName(currentDeptId);
}
private List<Waybill> listRelatedWaybills(VoucherManage voucher) {
@@ -1029,6 +1230,7 @@ public class VoucherManageServiceImpl extends BaseServiceImpl<VoucherManageMappe
throw new ServiceException("仅处理完成的凭证可以审核");
}
voucher.setAuditStatus("审核通过");
voucher.setRejectReason("");
updateById(voucher);
log.info("凭证批次审核通过 id={}, voucherBatchNo={}, operator={}", id, voucher.getVoucherBatchNo(), AuthUtil.getUserId());
}
@@ -1046,10 +1248,15 @@ public class VoucherManageServiceImpl extends BaseServiceImpl<VoucherManageMappe
if (!"处理完成".equals(voucher.getProcessStatus())) {
throw new ServiceException("仅处理完成的凭证可以审核");
}
String reason = rejectReason == null ? null : rejectReason.trim();
if (reason != null && reason.length() > 200) {
throw new ServiceException("驳回原因不能超过200字");
}
voucher.setAuditStatus("审核驳回");
voucher.setRejectReason(Func.isEmpty(reason) ? "" : reason);
updateById(voucher);
log.info("凭证批次审核驳回 id={}, voucherBatchNo={}, operator={}, reason={}",
id, voucher.getVoucherBatchNo(), AuthUtil.getUserId(), rejectReason);
id, voucher.getVoucherBatchNo(), AuthUtil.getUserId(), reason);
}
/**
@@ -15,6 +15,7 @@ CREATE TABLE IF NOT EXISTS `blade_voucher_manage` (
`related_waybill_count` int NOT NULL DEFAULT 0 COMMENT '已关联运单数',
`un_related_waybill_count` int NOT NULL DEFAULT 0 COMMENT '未关联运单数',
`audit_status` varchar(20) NOT NULL DEFAULT '-' COMMENT '待审核、审核通过、审核驳回',
`reject_reason` varchar(200) DEFAULT NULL COMMENT '审核驳回原因',
`create_user` bigint DEFAULT NULL, `create_dept` bigint DEFAULT NULL,
`create_time` datetime DEFAULT NULL, `update_user` bigint DEFAULT NULL, `update_time` datetime DEFAULT NULL,
`status` tinyint NOT NULL DEFAULT 1, `is_deleted` tinyint NOT NULL DEFAULT 0,
@@ -0,0 +1,25 @@
-- 凭证管理补充审核驳回原因
-- 日期:2026-09-09
DELIMITER $$
DROP PROCEDURE IF EXISTS `upgrade_blade_voucher_manage_reject_reason_20260909`$$
CREATE PROCEDURE `upgrade_blade_voucher_manage_reject_reason_20260909`()
BEGIN
DECLARE db_name varchar(128);
SET db_name = DATABASE();
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema = db_name
AND table_name = 'blade_voucher_manage'
AND column_name = 'reject_reason') THEN
ALTER TABLE `blade_voucher_manage`
ADD COLUMN `reject_reason` varchar(200) DEFAULT NULL COMMENT '审核驳回原因'
AFTER `audit_status`;
END IF;
END$$
CALL `upgrade_blade_voucher_manage_reject_reason_20260909`()$$
DROP PROCEDURE `upgrade_blade_voucher_manage_reject_reason_20260909`$$
DELIMITER ;
@@ -54,6 +54,9 @@ BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = db_name AND table_name = 'blade_voucher_manage' AND column_name = 'audit_status') THEN
ALTER TABLE `blade_voucher_manage` ADD COLUMN `audit_status` varchar(20) NOT NULL DEFAULT '-' COMMENT '待审核、审核通过、审核驳回';
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = db_name AND table_name = 'blade_voucher_manage' AND column_name = 'reject_reason') THEN
ALTER TABLE `blade_voucher_manage` ADD COLUMN `reject_reason` varchar(200) DEFAULT NULL COMMENT '审核驳回原因';
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = db_name AND table_name = 'blade_voucher_manage' AND column_name = 'create_user') THEN
ALTER TABLE `blade_voucher_manage` ADD COLUMN `create_user` bigint DEFAULT NULL COMMENT '创建人';
END IF;