diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillImportDuplicateVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillImportDuplicateVO.java new file mode 100644 index 00000000..66dc148a --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillImportDuplicateVO.java @@ -0,0 +1,28 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + */ +package org.springblade.transport.pojo.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +/** 运单批量导入行与已有运单的重复情况。 */ +@Data +@Schema(description = "运单批量导入行与已有运单的重复情况") +public class WaybillImportDuplicateVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "行号(从0开始,相对当前批次)") + private Integer rowIndex; + + @Schema(description = "命中的已有运单号") + private List waybillNos = new ArrayList<>(); +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/WaybillController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/WaybillController.java index 30af8573..c2c66d05 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/WaybillController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/WaybillController.java @@ -57,6 +57,7 @@ import org.springblade.transport.pojo.dto.WaybillImportBatchRequest; import org.springblade.transport.pojo.vo.BusinessRemoveResultVO; import org.springblade.transport.pojo.vo.LoadingManageVO; import org.springblade.transport.pojo.vo.WaybillImportBatchVO; +import org.springblade.transport.pojo.vo.WaybillImportDuplicateVO; import org.springblade.transport.pojo.vo.WaybillImportValidateResultVO; import org.springblade.transport.pojo.vo.WaybillLocateVO; import org.springblade.transport.pojo.vo.WaybillTrackVO; @@ -230,6 +231,13 @@ public class WaybillController extends BladeController { waybillImportBatchService.exportFailures(request, response); } + @PostMapping("/import-batch/duplicates") + @ApiOperationSupport(order = 6) + @Operation(summary = "查询与已有运单重复的导入行") + public R> checkImportDuplicates(@RequestBody WaybillImportBatchRequest request) { + return R.data(waybillImportBatchService.checkDuplicates(request)); + } + @PostMapping("/import-batch/confirm") @ApiOperationSupport(order = 7) @Operation(summary = "确认运单批量导入") diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillImportBatchService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillImportBatchService.java index 58c60733..0d3e7536 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillImportBatchService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillImportBatchService.java @@ -7,8 +7,11 @@ import org.springblade.transport.pojo.dto.WaybillImportBatchRequest; import org.springblade.transport.pojo.entity.WaybillImportBatch; import org.springblade.transport.pojo.vo.BusinessRemoveResultVO; import org.springblade.transport.pojo.vo.WaybillImportBatchVO; +import org.springblade.transport.pojo.vo.WaybillImportDuplicateVO; import org.springblade.transport.pojo.vo.WaybillImportValidateResultVO; +import java.util.List; + /** 运单批次服务。 */ public interface IWaybillImportBatchService extends BaseService { WaybillImportBatch saveDraft(WaybillImportBatchRequest request); @@ -18,4 +21,7 @@ public interface IWaybillImportBatchService extends BaseService page(IPage page, WaybillImportBatchRequest request); BusinessRemoveResultVO removeBatches(String ids); String nextBatchNo(); + + /** 逐行比对导入行与项目下已有运单,返回命中的运单号。 */ + List checkDuplicates(WaybillImportBatchRequest request); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java index 5eabbc36..9e4c9e1b 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java @@ -12,6 +12,7 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springblade.common.excel.ImportFailureExcelUtil; +import org.springblade.common.excel.LenientDateParser; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.mp.base.BaseServiceImpl; import org.springblade.core.secure.utils.AuthUtil; @@ -39,6 +40,7 @@ import org.springblade.transport.pojo.entity.Waybill; import org.springblade.transport.pojo.entity.WaybillImportBatch; import org.springblade.transport.pojo.vo.BusinessRemoveResultVO; import org.springblade.transport.pojo.vo.WaybillImportBatchVO; +import org.springblade.transport.pojo.vo.WaybillImportDuplicateVO; import org.springblade.transport.pojo.vo.WaybillImportRowErrorVO; import org.springblade.transport.pojo.vo.WaybillImportValidateResultVO; import org.springblade.transport.service.ICustomerArchiveService; @@ -50,6 +52,7 @@ import org.springblade.transport.service.ITransportPlanService; import org.springblade.transport.service.IWaybillImportBatchService; import org.springblade.transport.service.IWaybillService; import org.springblade.transport.support.TransportBusinessSupport; +import org.springblade.transport.support.VehiclePlateSupport; import org.springblade.transport.support.WaybillProcessSupport; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -173,6 +176,94 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl checkDuplicates(WaybillImportBatchRequest request) { + List duplicates = new ArrayList<>(); + if (request == null || Func.isEmpty(request.getRows()) || Func.isEmpty(request.getProjectId())) { + return duplicates; + } + List carrierIds = resolveCarrierIds(request); + String carrierName = resolveCarrierNames(carrierIds, request.getCarrierName()); + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery() + .eq(Waybill::getIsDeleted, 0) + .eq(Waybill::getProjectId, request.getProjectId()); + if (Func.isNotEmpty(request.getId())) { + // 追加导入时,本批次已落库的运单不算历史运单。 + wrapper.ne(Waybill::getImportBatchId, request.getId()); + } + List candidates = waybillService.list(wrapper); + if (Func.isEmpty(candidates)) { + return duplicates; + } + List> rows = request.getRows(); + for (int index = 0; index < rows.size(); index++) { + List waybillNos = new ArrayList<>(); + for (Waybill waybill : candidates) { + if (!isDuplicateRow(rows.get(index), waybill, carrierIds, carrierName)) continue; + String waybillNo = waybill.getWaybillNo(); + if (Func.isNotEmpty(waybillNo) && !waybillNos.contains(waybillNo)) { + waybillNos.add(waybillNo); + } + } + if (waybillNos.isEmpty()) continue; + WaybillImportDuplicateVO duplicate = new WaybillImportDuplicateVO(); + duplicate.setRowIndex(index); + duplicate.setWaybillNos(waybillNos); + duplicates.add(duplicate); + } + return duplicates; + } + + /** + * 判重口径为导入必填项逐一相同:承运商、车牌、运输方式、起止地址、货物名称与类型、 + * 数量及单位、实际发货与实际完成时间。历史运单这些字段常为空,双方都空按相同处理。 + */ + private boolean isDuplicateRow(Map row, Waybill waybill, List carrierIds, String carrierName) { + if (row == null || waybill == null) return false; + if (!carrierMatches(waybill, carrierIds, carrierName)) return false; + if (!samePlate(vehicleNoFromRow(row), waybill.getVehicleNo())) return false; + String transportType = resolveTransportTypeKey( + stringValue(row, "transportType", "运输类型", "*运输类型", "运输方式", "*运输方式")); + if (!sameText(transportType, waybill.getTransportType())) return false; + if (!sameText(stringValue(row, "departureAddress"), waybill.getDepartureAddress())) return false; + if (!sameText(stringValue(row, "arrivalAddress"), waybill.getArrivalAddress())) return false; + if (!sameText(stringValue(row, "cargoName"), waybill.getCargoName())) return false; + if (!sameText(stringValue(row, "cargoType"), waybill.getCargoType())) return false; + BigDecimal quantity = defaultQuantity(decimalValue(row, "quantity", "数量", "重量")); + if (quantity.compareTo(defaultQuantity(waybill.getQuantity())) != 0) return false; + String quantityUnit = stringValue(row, "quantityUnit", "数量单位"); + if (!sameText(Func.isEmpty(quantityUnit) ? "吨" : quantityUnit, waybill.getQuantityUnit())) return false; + if (!Objects.equals(duplicateDate(row.get("actualStartDate")), waybill.getStartDate())) { + return false; + } + return Objects.equals(duplicateDate(row.get("actualEndDate")), waybill.getEndDate()); + } + + /** 承运商优先按档案 ID 比对;历史手工运单只有名称,退化为名称比对。 */ + private boolean carrierMatches(Waybill waybill, List carrierIds, String carrierName) { + if (Func.isNotEmpty(carrierIds) && waybill.getCarrierId() != null) { + return carrierIds.contains(waybill.getCarrierId()); + } + return sameText(waybill.getCarrierName(), carrierName); + } + + private boolean sameText(String left, String right) { + String a = left == null ? "" : left.trim(); + String b = right == null ? "" : right.trim(); + return a.equalsIgnoreCase(b); + } + + private boolean samePlate(String left, String right) { + return VehiclePlateSupport.normalize(left).equals(VehiclePlateSupport.normalize(right)); + } + + /** 判重时的日期解析不阻断查询:无法识别的日期返回 null,按「与空值相同」处理。 */ + private LocalDate duplicateDate(Object value) { + if (value == null) return null; + String text = String.valueOf(value).trim(); + return text.isEmpty() ? null : LenientDateParser.parseDateLenientlyOrNull(text); + } + /** 草稿与确认导入共用落库流程,差异仅在于运单是否走校验以及是否生成应收应付明细。 */ private WaybillImportBatch persist(WaybillImportBatchRequest request, String importStatus) { boolean draft = STATUS_DRAFT.equals(importStatus); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillImportDuplicateTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillImportDuplicateTest.java new file mode 100644 index 00000000..3a05e92e --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillImportDuplicateTest.java @@ -0,0 +1,163 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springblade.system.feign.ISysClient; +import org.springblade.transport.pojo.dto.WaybillImportBatchRequest; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.vo.WaybillImportDuplicateVO; +import org.springblade.transport.service.ICustomerArchiveService; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.ILoadingManageService; +import org.springblade.transport.service.IProjectApplyService; +import org.springblade.transport.service.IReceivablePayableDetailService; +import org.springblade.transport.service.ITransportPlanService; +import org.springblade.transport.service.IWaybillService; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 批量导入行与历史运单的判重测试。 + *

+ * 口径为导入必填项逐一相同;历史手工运单的承运商档案 ID 与时间列常为空, + * 这类字段双方都空时按相同处理,否则手工运单永远判不出重复。 + */ +class WaybillImportDuplicateTest { + + private static final Long PROJECT_ID = 1001L; + private static final Long CARRIER_ID = 2002L; + private static final String CARRIER_NAME = "广西驰远干线运输有限公司"; + private static final String VEHICLE_NO = "桂A5D61N"; + private static final String CARGO_NAME = "混煤"; + private static final String DEPARTURE = "鄂尔多斯市达拉特旗树林召镇"; + private static final String ARRIVAL = "南宁市江南区沙井大道"; + + private IWaybillService waybillService; + private WaybillImportBatchServiceImpl service; + + @BeforeEach + void setUp() { + waybillService = mock(IWaybillService.class); + service = new WaybillImportBatchServiceImpl( + waybillService, + mock(ICustomerArchiveService.class), + mock(IProjectApplyService.class), + mock(ITransportPlanService.class), + mock(IReceivablePayableDetailService.class), + mock(ILoadingManageService.class), + mock(ISysClient.class), + mock(IDriverService.class)); + } + + @Test + @DisplayName("必填项全一致:返回命中的已有运单号") + void reportsDuplicateWaybillNo() { + stubCandidates(waybill("YD-20261023-0012")); + + WaybillImportBatchRequest req = request(importRow()); + List duplicates = service.checkDuplicates(req); + + assertEquals(1, duplicates.size(), "rows=" + req.getRows() + " projectId=" + req.getProjectId()); + assertEquals(0, duplicates.get(0).getRowIndex()); + assertEquals(List.of("YD-20261023-0012"), duplicates.get(0).getWaybillNos()); + } + + @Test + @DisplayName("数量不同:不判为重复") + void ignoresDifferentQuantity() { + stubCandidates(waybill("YD-20261023-0012")); + Map row = importRow(); + row.put("quantity", "21"); + + assertTrue(service.checkDuplicates(request(row)).isEmpty()); + } + + @Test + @DisplayName("车牌大小写与空格不同:仍判为重复") + void ignoresPlateFormatting() { + stubCandidates(waybill("YD-20261023-0012")); + Map row = importRow(); + row.put("vehicleNo", " 桂a5d61n "); + + assertEquals(1, service.checkDuplicates(request(row)).size()); + } + + @Test + @DisplayName("历史运单没有承运商档案 ID:退化为承运商名称比对") + void matchesByCarrierNameWhenHistoryHasNoId() { + Waybill waybill = waybill("YD-20261023-0012"); + waybill.setCarrierId(null); + stubCandidates(waybill); + + assertEquals(1, service.checkDuplicates(request(importRow())).size()); + } + + @Test + @DisplayName("项目为空:直接返回空,不查库") + void returnsEmptyWithoutProject() { + WaybillImportBatchRequest request = request(importRow()); + request.setProjectId(null); + + assertTrue(service.checkDuplicates(request).isEmpty()); + } + + private void stubCandidates(Waybill... waybills) { + when(waybillService.list(any(Wrapper.class))).thenReturn(List.of(waybills)); + } + + private WaybillImportBatchRequest request(Map row) { + WaybillImportBatchRequest request = new WaybillImportBatchRequest(); + request.setProjectId(PROJECT_ID); + request.setCarrierId(CARRIER_ID); + request.setCarrierName(CARRIER_NAME); + request.setRows(List.of(row)); + return request; + } + + private Map importRow() { + Map row = new HashMap<>(); + row.put("vehicleNo", VEHICLE_NO); + // 无 Spring 上下文时字典读不到,运输方式按“名称即键”兜底,故此处直接给内部键。 + row.put("transportType", "road"); + row.put("departureAddress", DEPARTURE); + row.put("arrivalAddress", ARRIVAL); + row.put("cargoName", CARGO_NAME); + row.put("cargoType", "煤制品"); + row.put("quantity", "20"); + row.put("quantityUnit", "吨"); + row.put("actualStartDate", "2026-10-13"); + row.put("actualEndDate", "2026-10-14"); + return row; + } + + private Waybill waybill(String waybillNo) { + Waybill waybill = new Waybill(); + waybill.setWaybillNo(waybillNo); + waybill.setProjectId(PROJECT_ID); + waybill.setCarrierId(CARRIER_ID); + waybill.setCarrierName(CARRIER_NAME); + waybill.setVehicleNo(VEHICLE_NO); + waybill.setTransportType("road"); + waybill.setDepartureAddress(DEPARTURE); + waybill.setArrivalAddress(ARRIVAL); + waybill.setCargoName(CARGO_NAME); + waybill.setCargoType("煤制品"); + waybill.setQuantity(new BigDecimal("20.00")); + waybill.setQuantityUnit("吨"); + waybill.setStartDate(LocalDate.of(2026, 10, 13)); + waybill.setEndDate(LocalDate.of(2026, 10, 14)); + return waybill; + } +}