From 896564d91114a62c8a4937e3a81b00e74db36f60 Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Thu, 5 Nov 2026 15:09:16 +0800 Subject: [PATCH 1/8] =?UTF-8?q?:sparkles:=20=E4=BB=8E=E4=B8=9A=E8=B5=84?= =?UTF-8?q?=E6=A0=BC=E8=AF=81=E8=AF=86=E5=88=AB=E6=94=B9=E4=B8=BA=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E6=A0=87=E5=87=86=E4=BF=9D=E5=8D=95=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../vo/QualificationCertificateOcrVO.java | 54 +++++ .../controller/BaiduOcrController.java | 17 ++ .../IQualificationCertificateOcrService.java | 45 +++++ ...ualificationCertificateOcrServiceImpl.java | 187 ++++++++++++++++++ doc/nacos/blade-dev.yaml | 2 + doc/nacos/blade-prod.yaml | 2 + doc/nacos/third-party-api.yaml | 2 + 7 files changed, 309 insertions(+) create mode 100644 blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/QualificationCertificateOcrVO.java create mode 100644 blade-service/blade-transport/src/main/java/org/springblade/transport/ocr/service/IQualificationCertificateOcrService.java create mode 100644 blade-service/blade-transport/src/main/java/org/springblade/transport/ocr/service/impl/QualificationCertificateOcrServiceImpl.java diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/QualificationCertificateOcrVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/QualificationCertificateOcrVO.java new file mode 100644 index 00000000..27962d8f --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/QualificationCertificateOcrVO.java @@ -0,0 +1,54 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +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.LinkedHashMap; +import java.util.Map; + +/** + * 从业资格证 OCR 识别结果。 + * + * @author Chill + */ +@Data +@Schema(description = "从业资格证OCR识别结果") +public class QualificationCertificateOcrVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 按第三方返回 key 聚合的字段,例如 qualification_category、certificate_no、valid_from、valid_until。 + */ + @Schema(description = "识别字段,键为第三方 OCR 的 key") + private Map fields = new LinkedHashMap<>(); + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/BaiduOcrController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/BaiduOcrController.java index 56c682b0..0dea728b 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/BaiduOcrController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/BaiduOcrController.java @@ -37,8 +37,10 @@ import org.springblade.core.tool.api.R; import org.springblade.transport.ocr.constant.BaiduOcrType; import org.springblade.transport.ocr.service.IBaiduOcrService; import org.springblade.transport.ocr.service.IBusinessLicenseOcrService; +import org.springblade.transport.ocr.service.IQualificationCertificateOcrService; import org.springblade.transport.pojo.vo.BaiduOcrResultVO; import org.springblade.transport.pojo.vo.BusinessLicenseOcrVO; +import org.springblade.transport.pojo.vo.QualificationCertificateOcrVO; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -63,6 +65,7 @@ public class BaiduOcrController extends BladeController { private final IBaiduOcrService baiduOcrService; private final IBusinessLicenseOcrService businessLicenseOcrService; + private final IQualificationCertificateOcrService qualificationCertificateOcrService; /** * 识别上传的图片。 @@ -126,6 +129,20 @@ public class BaiduOcrController extends BladeController { return R.data(businessLicenseOcrService.recognizeUrl(imageUrl)); } + /** + * 从业资格证图片地址识别(第三方 /Biaozhunbaodan)。 + * + * @param imageUrl 图片地址 + * @return 识别结果 + */ + @PostMapping("/qualification-certificate") + @ApiOperationSupport(order = 4) + @Operation(summary = "从业资格证OCR识别", description = "按图片地址调用第三方道路运输从业人员从业资格证识别接口") + public R recognizeQualificationCertificate( + @Parameter(description = "图片地址", required = true) @RequestParam String imageUrl) { + return R.data(qualificationCertificateOcrService.recognizeUrl(imageUrl)); + } + private BaiduOcrType resolveType(String type) { try { return BaiduOcrType.from(type); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/ocr/service/IQualificationCertificateOcrService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/ocr/service/IQualificationCertificateOcrService.java new file mode 100644 index 00000000..4b61ac87 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/ocr/service/IQualificationCertificateOcrService.java @@ -0,0 +1,45 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.ocr.service; + +import org.springblade.transport.pojo.vo.QualificationCertificateOcrVO; + +/** + * 从业资格证 OCR 服务。 + * + * @author Chill + */ +public interface IQualificationCertificateOcrService { + + /** + * 根据图片地址识别道路运输从业人员从业资格证。 + * + * @param imageUrl 图片地址 + * @return 识别结果 + */ + QualificationCertificateOcrVO recognizeUrl(String imageUrl); + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/ocr/service/impl/QualificationCertificateOcrServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/ocr/service/impl/QualificationCertificateOcrServiceImpl.java new file mode 100644 index 00000000..05026796 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/ocr/service/impl/QualificationCertificateOcrServiceImpl.java @@ -0,0 +1,187 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.ocr.service.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.tool.utils.StringUtil; +import org.springblade.transport.ocr.service.IQualificationCertificateOcrService; +import org.springblade.transport.pojo.vo.QualificationCertificateOcrVO; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 从业资格证 OCR 服务实现,调用第三方 /Biaozhunbaodan 接口。 + * + * @author Chill + */ +@Slf4j +@Service +public class QualificationCertificateOcrServiceImpl implements IQualificationCertificateOcrService { + + private static final String PATH = "/Biaozhunbaodan"; + private static final int SUCCESS_CODE = 200; + + private final ObjectMapper objectMapper; + private final HttpClient httpClient; + + /** + * 从业资格证 OCR 服务地址,Nacos:thirdParty.ocr.qualificationCertificateBaseUrl + */ + @Value("${thirdParty.ocr.qualificationCertificateBaseUrl:http://124.71.22.123:8400}") + private String qualificationCertificateBaseUrl; + + public QualificationCertificateOcrServiceImpl(ObjectMapper objectMapper, + @Qualifier("baiduOcrHttpClient") HttpClient httpClient) { + this.objectMapper = objectMapper; + this.httpClient = httpClient; + } + + @Override + public QualificationCertificateOcrVO recognizeUrl(String imageUrl) { + if (StringUtil.isBlank(imageUrl)) { + throw new ServiceException("OCR图片地址不能为空"); + } + String normalizedUrl = imageUrl.trim(); + validateImageUrl(normalizedUrl); + if (StringUtil.isBlank(qualificationCertificateBaseUrl)) { + throw new ServiceException("从业资格证OCR服务地址未配置(thirdParty.ocr.qualificationCertificateBaseUrl)"); + } + String requestUrl = qualificationCertificateBaseUrl.replaceAll("/+$", "") + PATH; + Map body = new LinkedHashMap<>(); + body.put("img_path", normalizedUrl); + JsonNode response = sendRequest(requestUrl, body); + int code = response.path("code").asInt(Integer.MIN_VALUE); + if (code != SUCCESS_CODE) { + String message = firstNonBlank(response.path("msg").asText(null), response.path("message").asText(null), "未知错误"); + log.warn("从业资格证OCR识别失败,code={},msg={},imageUrl={}", code, message, normalizedUrl); + throw new ServiceException("从业资格证OCR识别失败:" + message); + } + QualificationCertificateOcrVO result = new QualificationCertificateOcrVO(); + result.setFields(extractFields(response.path("result").path("key_info"))); + return result; + } + + private JsonNode sendRequest(String requestUrl, Map body) { + try { + String jsonBody = objectMapper.writeValueAsString(body); + HttpRequest request = HttpRequest.newBuilder(URI.create(requestUrl)) + .timeout(Duration.ofSeconds(60)) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(jsonBody, StandardCharsets.UTF_8)) + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + log.error("从业资格证OCR接口响应异常,status={},url={}", response.statusCode(), requestUrl); + throw new ServiceException("从业资格证OCR接口调用失败,HTTP状态码:" + response.statusCode()); + } + JsonNode result = objectMapper.readTree(response.body()); + if (result == null || !result.isObject()) { + throw new ServiceException("从业资格证OCR响应格式异常"); + } + return result; + } catch (ServiceException exception) { + throw exception; + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + log.error("调用从业资格证OCR接口被中断,url={}", requestUrl, exception); + throw new ServiceException("从业资格证OCR接口调用被中断"); + } catch (IllegalArgumentException exception) { + log.error("从业资格证OCR接口地址配置不正确,url={}", requestUrl); + throw new ServiceException("从业资格证OCR接口地址配置不正确"); + } catch (IOException exception) { + log.error("调用从业资格证OCR接口失败,url={}", requestUrl, exception); + throw new ServiceException("从业资格证OCR接口调用失败"); + } + } + + private Map extractFields(JsonNode keyInfoNode) { + Map fields = new LinkedHashMap<>(); + if (keyInfoNode == null || !keyInfoNode.isArray()) { + return fields; + } + for (JsonNode item : keyInfoNode) { + if (item == null || !item.isObject()) { + continue; + } + String key = item.path("key").asText(null); + String value = item.path("value").asText(null); + if (StringUtil.isBlank(key) || value == null) { + continue; + } + String trimmed = value.trim(); + if (StringUtil.isBlank(trimmed)) { + continue; + } + fields.put(key.trim(), trimmed); + } + return fields; + } + + private void validateImageUrl(String imageUrl) { + if (imageUrl.getBytes(StandardCharsets.UTF_8).length > 1024) { + throw new ServiceException("OCR图片地址长度不能超过1024字节"); + } + try { + URI imageUri = URI.create(imageUrl); + String scheme = imageUri.getScheme(); + if (StringUtil.isBlank(scheme) || StringUtil.isBlank(imageUri.getHost()) + || imageUri.getUserInfo() != null + || !("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme))) { + throw new ServiceException("OCR图片地址必须是有效的HTTP或HTTPS地址"); + } + } catch (IllegalArgumentException exception) { + throw new ServiceException("OCR图片地址格式不正确"); + } + } + + private String firstNonBlank(String... values) { + if (values == null) { + return null; + } + for (String value : values) { + if (StringUtil.isNotBlank(value)) { + return value; + } + } + return null; + } + +} diff --git a/doc/nacos/blade-dev.yaml b/doc/nacos/blade-dev.yaml index 4bfa61ec..d38222fc 100644 --- a/doc/nacos/blade-dev.yaml +++ b/doc/nacos/blade-dev.yaml @@ -98,6 +98,8 @@ thirdParty: idCardBaseUrl: http://124.71.22.123:8500 # 营业执照 OCR 服务地址(POST /businessLicense,JSON:img_path、data_type) businessLicenseBaseUrl: http://124.71.22.123:8888 + # 从业资格证 OCR 服务地址(POST /Biaozhunbaodan,JSON:img_path) + qualificationCertificateBaseUrl: http://124.71.22.123:8400 wps: # WPS开放接口地址 baseUrl: http://127.0.0.1:8080 diff --git a/doc/nacos/blade-prod.yaml b/doc/nacos/blade-prod.yaml index 60508d74..461e6ddd 100644 --- a/doc/nacos/blade-prod.yaml +++ b/doc/nacos/blade-prod.yaml @@ -60,6 +60,8 @@ thirdParty: idCardBaseUrl: http://124.71.22.123:8500 # 营业执照 OCR 服务地址(POST /businessLicense,JSON:img_path、data_type) businessLicenseBaseUrl: http://124.71.22.123:8888 + # 从业资格证 OCR 服务地址(POST /Biaozhunbaodan,JSON:img_path) + qualificationCertificateBaseUrl: http://124.71.22.123:8400 wps: # WPS开放接口地址 baseUrl: http://127.0.0.1:8080 diff --git a/doc/nacos/third-party-api.yaml b/doc/nacos/third-party-api.yaml index a44464a0..322de189 100644 --- a/doc/nacos/third-party-api.yaml +++ b/doc/nacos/third-party-api.yaml @@ -9,6 +9,8 @@ thirdParty: idCardBaseUrl: ${OCR_ID_CARD_BASE_URL:http://124.71.22.123:8500} # 营业执照 OCR 服务地址(POST /businessLicense,JSON:img_path、data_type) businessLicenseBaseUrl: ${OCR_BUSINESS_LICENSE_BASE_URL:http://124.71.22.123:8888} + # 从业资格证 OCR 服务地址(POST /Biaozhunbaodan,JSON:img_path) + qualificationCertificateBaseUrl: ${OCR_QUALIFICATION_CERTIFICATE_BASE_URL:http://124.71.22.123:8400} wps: # WPS开放接口地址 baseUrl: ${WPS_BASE_URL:http://127.0.0.1:8080} From ec4e8fe83b93249cdb8ab959061da995cd23bb57 Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Fri, 6 Nov 2026 07:29:59 +0800 Subject: [PATCH 2/8] =?UTF-8?q?:sparkles:=20=E8=BF=90=E5=8D=95=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E5=AF=BC=E5=85=A5=E6=96=B0=E5=A2=9E=E4=B8=8E=E5=B7=B2?= =?UTF-8?q?=E6=9C=89=E8=BF=90=E5=8D=95=E9=87=8D=E5=A4=8D=E6=AF=94=E5=AF=B9?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pojo/vo/WaybillImportDuplicateVO.java | 28 +++ .../controller/WaybillController.java | 8 + .../service/IWaybillImportBatchService.java | 6 + .../impl/WaybillImportBatchServiceImpl.java | 91 ++++++++++ .../impl/WaybillImportDuplicateTest.java | 163 ++++++++++++++++++ 5 files changed, 296 insertions(+) create mode 100644 blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillImportDuplicateVO.java create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillImportDuplicateTest.java 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; + } +} From f7df68bdd651a4d40265e2df07212a2116eff563 Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Fri, 6 Nov 2026 08:10:37 +0800 Subject: [PATCH 3/8] =?UTF-8?q?:sparkles:=20=E5=87=AD=E8=AF=81=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=85=B3=E8=81=94=E8=BF=90=E5=8D=95=E6=89=B9=E6=AC=A1?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E6=94=AF=E6=8C=81=E9=A1=B9=E7=9B=AE=E7=AD=9B?= =?UTF-8?q?=E9=80=89=E5=B9=B6=E8=BF=94=E5=9B=9E=E9=A1=B9=E7=9B=AE=E5=90=8D?= =?UTF-8?q?=E7=A7=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - waybill-batches 接口新增 projectId 筛选项 - 分页结果增加 projectName,同一批次跨多个项目时返回「多个项目」 --- .../transport/controller/VoucherManageController.java | 7 ++++--- .../transport/mapper/VoucherWaybillBatchMapper.java | 5 +++-- .../transport/mapper/VoucherWaybillBatchMapper.xml | 2 ++ .../transport/service/IVoucherManageService.java | 2 +- .../transport/service/impl/VoucherManageServiceImpl.java | 4 ++-- 5 files changed, 12 insertions(+), 8 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/VoucherManageController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/VoucherManageController.java index 69f53eb0..abcee864 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/VoucherManageController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/VoucherManageController.java @@ -135,9 +135,10 @@ public class VoucherManageController extends BladeController { @ApiOperationSupport(order = 7) @Operation(summary = "可关联运输批次") public R>> waybillBatches(Query query, @RequestParam(required = false) String batchNo, - @RequestParam(required = false) String createUser, @RequestParam(required = false) Integer waybillCount, - @RequestParam(required = false) String createTimeStart, @RequestParam(required = false) String createTimeEnd) { - return R.data(voucherManageService.selectableWaybillBatches(Condition.getPage(query), batchNo, createUser, waybillCount, createTimeStart, createTimeEnd)); + @RequestParam(required = false) Long projectId, @RequestParam(required = false) String createUser, + @RequestParam(required = false) Integer waybillCount, @RequestParam(required = false) String createTimeStart, + @RequestParam(required = false) String createTimeEnd) { + return R.data(voucherManageService.selectableWaybillBatches(Condition.getPage(query), batchNo, projectId, createUser, waybillCount, createTimeStart, createTimeEnd)); } @PostMapping("/audit-pass") diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/VoucherWaybillBatchMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/VoucherWaybillBatchMapper.java index 86e7a57e..7c30968e 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/VoucherWaybillBatchMapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/VoucherWaybillBatchMapper.java @@ -13,8 +13,9 @@ import org.apache.ibatis.annotations.Delete; public interface VoucherWaybillBatchMapper extends BaseMapper { @Delete("DELETE FROM blade_voucher_waybill_batch WHERE voucher_id = #{voucherId}") int deletePhysicalByVoucherId(@Param("voucherId") Long voucherId); - IPage> 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, + IPage> selectVoucherWaybillBatchPage(IPage page, @Param("tenantId") String tenantId, @Param("batchNo") String batchNo, + @Param("projectId") Long projectId, @Param("createUser") String createUser, @Param("waybillCount") Integer waybillCount, + @Param("createTimeStart") String createTimeStart, @Param("createTimeEnd") String createTimeEnd, @Param("restrictCarrier") boolean restrictCarrier, @Param("carrierName") String carrierName); List> selectWaybillBatchesByIds(@Param("tenantId") String tenantId, @Param("ids") List ids, @Param("restrictCarrier") boolean restrictCarrier, @Param("carrierName") String carrierName); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/VoucherWaybillBatchMapper.xml b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/VoucherWaybillBatchMapper.xml index 2eadd452..4b599bb5 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/VoucherWaybillBatchMapper.xml +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/VoucherWaybillBatchMapper.xml @@ -4,12 +4,14 @@