✨ 运单批量导入新增与已有运单重复比对接口

This commit is contained in:
2026-11-06 07:29:59 +08:00
parent 54908de0f3
commit ec4e8fe83b
5 changed files with 296 additions and 0 deletions
@@ -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<String> waybillNos = new ArrayList<>();
}
@@ -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<List<WaybillImportDuplicateVO>> checkImportDuplicates(@RequestBody WaybillImportBatchRequest request) {
return R.data(waybillImportBatchService.checkDuplicates(request));
}
@PostMapping("/import-batch/confirm")
@ApiOperationSupport(order = 7)
@Operation(summary = "确认运单批量导入")
@@ -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> {
WaybillImportBatch saveDraft(WaybillImportBatchRequest request);
@@ -18,4 +21,7 @@ public interface IWaybillImportBatchService extends BaseService<WaybillImportBat
IPage<WaybillImportBatchVO> page(IPage<WaybillImportBatch> page, WaybillImportBatchRequest request);
BusinessRemoveResultVO removeBatches(String ids);
String nextBatchNo();
/** 逐行比对导入行与项目下已有运单,返回命中的运单号。 */
List<WaybillImportDuplicateVO> checkDuplicates(WaybillImportBatchRequest request);
}
@@ -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<WaybillImport
return toVO(batch);
}
@Override
public List<WaybillImportDuplicateVO> checkDuplicates(WaybillImportBatchRequest request) {
List<WaybillImportDuplicateVO> duplicates = new ArrayList<>();
if (request == null || Func.isEmpty(request.getRows()) || Func.isEmpty(request.getProjectId())) {
return duplicates;
}
List<Long> carrierIds = resolveCarrierIds(request);
String carrierName = resolveCarrierNames(carrierIds, request.getCarrierName());
LambdaQueryWrapper<Waybill> wrapper = Wrappers.<Waybill>lambdaQuery()
.eq(Waybill::getIsDeleted, 0)
.eq(Waybill::getProjectId, request.getProjectId());
if (Func.isNotEmpty(request.getId())) {
// 追加导入时,本批次已落库的运单不算历史运单。
wrapper.ne(Waybill::getImportBatchId, request.getId());
}
List<Waybill> candidates = waybillService.list(wrapper);
if (Func.isEmpty(candidates)) {
return duplicates;
}
List<Map<String, Object>> rows = request.getRows();
for (int index = 0; index < rows.size(); index++) {
List<String> 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<String, Object> row, Waybill waybill, List<Long> 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<Long> 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);
@@ -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;
/**
* 批量导入行与历史运单的判重测试。
* <p>
* 口径为导入必填项逐一相同;历史手工运单的承运商档案 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<WaybillImportDuplicateVO> 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<String, Object> row = importRow();
row.put("quantity", "21");
assertTrue(service.checkDuplicates(request(row)).isEmpty());
}
@Test
@DisplayName("车牌大小写与空格不同:仍判为重复")
void ignoresPlateFormatting() {
stubCandidates(waybill("YD-20261023-0012"));
Map<String, Object> 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<String, Object> 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<String, Object> importRow() {
Map<String, Object> 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;
}
}