From b9cb5555e257653ed2844b6faaad981f313c0e17 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 20:59:30 +0800 Subject: [PATCH] fix(firstleg): isolate declarations from measurement facts --- .../dto/InboundMeasurementResponses.java | 23 +++++++ .../service/InboundMeasurementService.java | 67 ++++++++++++++---- .../main/resources/openapi/firstleg-v1.yaml | 35 ++++++++-- ...easurementEvidenceOpenApiContractTest.java | 9 ++- .../InboundMeasurementServiceTest.java | 69 +++++++++++++++++++ 5 files changed, 185 insertions(+), 18 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java index 6ea5847c8..a4893f879 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java @@ -44,11 +44,34 @@ public final class InboundMeasurementResponses { BigDecimal actualVolumeCbm, BigDecimal volumeWeightKg, String actualGoodsName, + @Schema(description = "Order 服务中的最新客户申报,只用于和既有实测事实对比;不得回写测量、箱码或审核结果") + DeclarationSnapshot latestDeclaration, List items, List weightResults ) { } + public record DeclarationSnapshot( + String referenceId, + Integer totalPackageCount, + BigDecimal grossWeightKg, + BigDecimal volumeCbm, + String goodsName, + List items + ) { + } + + public record DeclarationItem( + Long inboundDetailId, + Integer itemNo, + String cargoName, + Integer packageCount, + BigDecimal lengthCm, + BigDecimal widthCm, + BigDecimal heightCm + ) { + } + public record Item( Long id, Long inboundDetailId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java index d833afbbc..079ab913f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java @@ -69,9 +69,8 @@ public class InboundMeasurementService { ? List.of() : repository.listItems(tenantId, measurement.getId()); List weights = repository.listWeights(tenantId, inboundId); MeasureReviewRecordEntity review = repository.findLatestReview(tenantId, inboundId); - OrderCargoClient.MeasurementDeclarationSnapshot declaration = needsDeclarationFallback(measurement) - ? loadDeclaration(inbound) : null; - return toDetail(inbound, measurement, items, weights, review, declaration); + InboundMeasurementResponses.DeclarationSnapshot latestDeclaration = loadLatestDeclaration(inbound); + return toDetail(inbound, measurement, items, weights, review, latestDeclaration); } public PageResult weightPalletOptions( @@ -416,7 +415,8 @@ public class InboundMeasurementService { private InboundMeasurementResponses.Detail toDetail( InboundOrderEntity inbound, InboundMeasurementEntity measurement, List items, List weights, - MeasureReviewRecordEntity review, OrderCargoClient.MeasurementDeclarationSnapshot declaration) { + MeasureReviewRecordEntity review, + InboundMeasurementResponses.DeclarationSnapshot latestDeclaration) { return new InboundMeasurementResponses.Detail( inbound.getId(), inbound.getInboundCode(), inbound.getCompanyName(), inbound.getChannelCode(), inbound.getChannelName(), inbound.getProcessStatus(), inbound.getMeasureReviewStatus(), @@ -428,26 +428,67 @@ public class InboundMeasurementService { inbound.getVersion(), measurement == null ? null : measurement.getVersion(), measurement != null && StringUtils.hasText(measurement.getOriginalDocumentNo()) - ? measurement.getOriginalDocumentNo() : declaration == null ? null : declaration.referenceId(), + ? measurement.getOriginalDocumentNo() : latestDeclaration.referenceId(), measurement != null && measurement.getOriginalGrossWeightKg() != null - ? measurement.getOriginalGrossWeightKg() : declaration == null ? null : declaration.originalGrossWeightKg(), + ? measurement.getOriginalGrossWeightKg() : latestDeclaration.grossWeightKg(), measurement != null && measurement.getOriginalVolumeCbm() != null - ? measurement.getOriginalVolumeCbm() : declaration == null ? null : declaration.originalVolumeCbm(), - inbound.getTotalPackageCount(), + ? measurement.getOriginalVolumeCbm() : latestDeclaration.volumeCbm(), + latestDeclaration.totalPackageCount(), measurement == null ? inbound.getTotalPackageCount() : measurement.getTotalPackageCount(), inbound.getActualGrossWeightKg(), inbound.getActualNetWeightKg(), measurement == null ? inbound.getActualVolumeCbm() : measurement.getTotalVolumeCbm(), measurement == null ? inbound.getVolumeWeightKg() : measurement.getVolumeWeightKg(), measurement != null && StringUtils.hasText(measurement.getActualGoodsName()) - ? measurement.getActualGoodsName() : declaration == null ? null : declaration.defaultActualGoodsName(), + ? measurement.getActualGoodsName() : latestDeclaration.goodsName(), + latestDeclaration, items.stream().map(this::toItem).toList(), weights.stream().map(this::toWeight).toList()); } - private boolean needsDeclarationFallback(InboundMeasurementEntity measurement) { - return measurement == null || !StringUtils.hasText(measurement.getOriginalDocumentNo()) - || measurement.getOriginalGrossWeightKg() == null || measurement.getOriginalVolumeCbm() == null - || !StringUtils.hasText(measurement.getActualGoodsName()); + /** + * Latest customer declaration is a live, read-only Order view. It must never be persisted over + * the measurement batch, carton-label mapping, weight results or review decision. + */ + private InboundMeasurementResponses.DeclarationSnapshot loadLatestDeclaration( + InboundOrderEntity inbound) { + OrderCargoClient.MeasurementDeclarationSnapshot aggregate = loadDeclaration(inbound); + List cargoes = loadDeclarationCargoes(inbound); + long packages = cargoes.stream() + .map(OrderCargoClient.CargoLabelBinding::packageCount) + .filter(java.util.Objects::nonNull) + .mapToLong(Integer::longValue) + .sum(); + if (packages > Integer.MAX_VALUE) { + throw new BizException(ResultCode.VALIDATION_FAILED, "客户最新申报总箱数超过系统上限"); + } + return new InboundMeasurementResponses.DeclarationSnapshot( + aggregate.referenceId(), (int) packages, aggregate.originalGrossWeightKg(), + aggregate.originalVolumeCbm(), aggregate.defaultActualGoodsName(), + cargoes.stream() + .sorted(java.util.Comparator.comparing(OrderCargoClient.CargoLabelBinding::itemNo, + java.util.Comparator.nullsLast(Integer::compareTo)) + .thenComparing(OrderCargoClient.CargoLabelBinding::cargoId, + java.util.Comparator.nullsLast(Long::compareTo))) + .map(cargo -> new InboundMeasurementResponses.DeclarationItem( + cargo.cargoId(), cargo.itemNo(), cargo.cargoName(), cargo.packageCount(), + cargo.lengthCm(), cargo.widthCm(), cargo.heightCm())) + .toList()); + } + + private List loadDeclarationCargoes(InboundOrderEntity inbound) { + try { + ApiResponse> response = orderCargoClient.listForCartonLabels( + internalToken, inbound.getTenantId(), inbound.getOrderId()); + if (response == null || !response.successful() || response.data() == null) { + String message = response == null || !StringUtils.hasText(response.message()) + ? "客户订单申报明细查询失败" : response.message(); + throw new BizException(response == null ? ResultCode.SERVICE_UNAVAILABLE + : ResultCode.of(response.code()), message); + } + return response.data(); + } catch (FeignException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "订单服务暂不可用,无法读取客户最新申报明细"); + } } private OrderCargoClient.MeasurementDeclarationSnapshot loadDeclaration(InboundOrderEntity inbound) { diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index f8a26e50f..79dcf97c0 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -1415,10 +1415,10 @@ components: measurementStatus: {type: string, nullable: true} inboundVersion: {type: integer, minimum: 0} version: {type: integer, minimum: 0, nullable: true} - originalDocumentNo: {type: string, nullable: true, description: 客户订单 referenceId 的服务端快照;旧空快照按订单事实回退} - originalGrossWeightKg: {type: number, format: double, nullable: true, description: 客户订单有效货物行 grossWeightKg 合计的服务端快照} - originalVolumeCbm: {type: number, format: double, nullable: true, description: 客户订单有效货物行 volumeCbm 合计的服务端快照} - originalPackageCount: {type: integer, nullable: true} + originalDocumentNo: {type: string, nullable: true, description: 测量批次开始时冻结的客户订单 referenceId;批次为空时才以最新申报回退} + originalGrossWeightKg: {type: number, format: double, nullable: true, description: 测量批次开始时冻结的客户申报毛重;不得被后续订单编辑覆盖} + originalVolumeCbm: {type: number, format: double, nullable: true, description: 测量批次开始时冻结的客户申报体积;不得被后续订单编辑覆盖} + originalPackageCount: {type: integer, nullable: true, description: 最新客户申报总箱数;仅用于与 actualPackageCount 对比} actualPackageCount: {type: integer, nullable: true} actualGrossWeightKg: type: number @@ -1435,6 +1435,8 @@ components: actualVolumeCbm: {type: number, format: double, nullable: true} volumeWeightKg: {type: number, format: double, nullable: true} actualGoodsName: {type: string, nullable: true, description: 默认由客户货物品名去重汇总,测量阶段允许人工修正} + latestDeclaration: + $ref: '#/components/schemas/InboundMeasurementLatestDeclaration' items: type: array description: DIMENSION 尺寸测量明细及其图片证据 @@ -1443,6 +1445,31 @@ components: type: array description: WEIGHT 称重结果及其图片、确认状态和服务端重量事实 items: {$ref: '#/components/schemas/InboundWeightResult'} + InboundMeasurementLatestDeclaration: + type: object + description: Order 服务中的最新客户申报只读视图;订单编辑只能改变本对象,不得覆盖箱码、实测尺寸/重量或审核结果 + required: [totalPackageCount, items] + properties: + referenceId: {type: string, nullable: true} + totalPackageCount: {type: integer, minimum: 0} + grossWeightKg: {type: number, format: double, nullable: true} + volumeCbm: {type: number, format: double, nullable: true} + goodsName: {type: string, nullable: true} + items: + type: array + items: {$ref: '#/components/schemas/InboundMeasurementDeclarationItem'} + InboundMeasurementDeclarationItem: + type: object + description: 最新订单货物申报行;inboundDetailId 仅用于与实测明细关联,不作为用户展示编号 + required: [inboundDetailId, itemNo, packageCount] + properties: + inboundDetailId: {type: string, pattern: '^[1-9][0-9]*$'} + itemNo: {type: integer, minimum: 1} + cargoName: {type: string, nullable: true} + packageCount: {type: integer, minimum: 0} + lengthCm: {type: number, format: double, nullable: true} + widthCm: {type: number, format: double, nullable: true} + heightCm: {type: number, format: double, nullable: true} InboundMeasurementSaveDraftRequest: type: object required: [version, items] diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java index ce62549c7..93537fe22 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java @@ -24,7 +24,14 @@ class InboundMeasurementEvidenceOpenApiContractTest { Map schemas = stringMap(stringMap(document.get("components")).get("schemas")); Map detailProperties = properties(schemas, "InboundMeasurementDetail"); assertThat(detailProperties).containsKeys( - "actualGrossWeightKg", "actualNetWeightKg", "items", "weightResults"); + "actualGrossWeightKg", "actualNetWeightKg", "latestDeclaration", "items", "weightResults"); + assertThat(stringMap(detailProperties.get("latestDeclaration"))) + .containsEntry("$ref", "#/components/schemas/InboundMeasurementLatestDeclaration"); + assertThat(properties(schemas, "InboundMeasurementLatestDeclaration")) + .containsKeys("referenceId", "totalPackageCount", "grossWeightKg", "volumeCbm", "goodsName", "items"); + assertThat(properties(schemas, "InboundMeasurementDeclarationItem")) + .containsKeys("inboundDetailId", "itemNo", "cargoName", "packageCount", + "lengthCm", "widthCm", "heightCm"); assertThat(stringMap(detailProperties.get("items"))) .containsEntry("description", "DIMENSION 尺寸测量明细及其图片证据"); assertThat(stringMap(detailProperties.get("weightResults"))) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java index 0d43b500d..38c2be50e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java @@ -65,6 +65,10 @@ class InboundMeasurementServiceTest { when(orderCargoClient.measurementDeclaration("internal-token", 1L, 80L)).thenReturn( ApiResponse.success(new OrderCargoClient.MeasurementDeclarationSnapshot( "REF-50", new BigDecimal("2000"), new BigDecimal("20"), "123123"))); + when(orderCargoClient.listForCartonLabels("internal-token", 1L, 80L)).thenReturn( + ApiResponse.success(List.of(new OrderCargoClient.CargoLabelBinding( + 80001L, 80L, 1, "123123", 3, new BigDecimal("40"), + new BigDecimal("30"), new BigDecimal("20"))))); } @Test @@ -213,6 +217,71 @@ class InboundMeasurementServiceTest { }); } + @Test + void detailComparesLatestDeclarationWithoutMutatingFrozenMeasurementFacts() { + InboundOrderEntity inbound = inbound("WAIT_SPLIT"); + inbound.setMeasureReviewStatus("APPROVED"); + inbound.setActualGrossWeightKg(new BigDecimal("30.000")); + inbound.setActualNetWeightKg(new BigDecimal("11.500")); + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(30L); + measurement.setStatus("APPROVED"); + measurement.setOriginalDocumentNo("OLD-REF"); + measurement.setOriginalGrossWeightKg(new BigDecimal("25.000")); + measurement.setOriginalVolumeCbm(new BigDecimal("0.5000")); + measurement.setTotalPackageCount(2); + measurement.setTotalVolumeCbm(new BigDecimal("0.6000")); + InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + item.setId(40L); + item.setTenantId(1L); + item.setMeasurementId(30L); + item.setInboundDetailId(80001L); + item.setBoxCode("RK-001#2-1"); + item.setPackageCount(2); + item.setLengthCm(new BigDecimal("45")); + item.setWidthCm(new BigDecimal("35")); + item.setHeightCm(new BigDecimal("25")); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findLatestMeasurement(1L, 10L)).thenReturn(measurement); + when(repository.listItems(1L, 30L)).thenReturn(List.of(item)); + when(repository.listWeights(1L, 10L)).thenReturn(List.of()); + when(orderCargoClient.measurementDeclaration("internal-token", 1L, 80L)).thenReturn( + ApiResponse.success(new OrderCargoClient.MeasurementDeclarationSnapshot( + "NEW-REF", new BigDecimal("40.000"), new BigDecimal("0.8000"), "最新申报品名"))); + when(orderCargoClient.listForCartonLabels("internal-token", 1L, 80L)).thenReturn( + ApiResponse.success(List.of(new OrderCargoClient.CargoLabelBinding( + 80001L, 80L, 1, "最新申报品名", 4, new BigDecimal("50"), + new BigDecimal("40"), new BigDecimal("30"))))); + + InboundMeasurementResponses.Detail result = service.detail(1L, 10L); + + assertThat(result.originalDocumentNo()).isEqualTo("OLD-REF"); + assertThat(result.originalGrossWeightKg()).isEqualByComparingTo("25.000"); + assertThat(result.originalVolumeCbm()).isEqualByComparingTo("0.5000"); + assertThat(result.actualPackageCount()).isEqualTo(2); + assertThat(result.latestDeclaration()).satisfies(latest -> { + assertThat(latest.referenceId()).isEqualTo("NEW-REF"); + assertThat(latest.totalPackageCount()).isEqualTo(4); + assertThat(latest.grossWeightKg()).isEqualByComparingTo("40.000"); + assertThat(latest.volumeCbm()).isEqualByComparingTo("0.8000"); + assertThat(latest.items()).singleElement().satisfies(declared -> { + assertThat(declared.inboundDetailId()).isEqualTo(80001L); + assertThat(declared.packageCount()).isEqualTo(4); + assertThat(declared.lengthCm()).isEqualByComparingTo("50"); + }); + }); + assertThat(result.items()).singleElement().satisfies(measured -> { + assertThat(measured.id()).isEqualTo(40L); + assertThat(measured.boxCode()).isEqualTo("RK-001#2-1"); + assertThat(measured.packageCount()).isEqualTo(2); + assertThat(measured.lengthCm()).isEqualByComparingTo("45"); + }); + verify(repository, never()).updateInbound(any()); + verify(repository, never()).updateMeasurement(any()); + verify(repository, never()).updateItem(any()); + verify(repository, never()).deleteWeight(anyLong(), anyLong(), anyLong(), anyInt()); + } + @Test void creatingWeightResultNormalizesProviderConfidenceAtBusinessBoundary() { InboundOrderEntity inbound = inbound("WAIT_MEASURE");