From ba81dece5e039ca8d4826ed5afa8d28771986921 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 17:24:29 +0800 Subject: [PATCH] feat(firstleg): expose authoritative measurement identifiers --- .../firstleg/client/OrderCargoClient.java | 7 +- .../PlatformInboundMeasurementController.java | 6 +- .../dto/InboundMeasurementResponses.java | 5 ++ .../service/InboundMeasurementService.java | 61 ++++++++++--- .../service/MeasurementBatchService.java | 13 +-- .../main/resources/openapi/firstleg-v1.yaml | 15 +++- ...easurementEvidenceOpenApiContractTest.java | 20 ++++- .../InboundMeasurementServiceTest.java | 88 ++++++++++++++++++- .../service/MeasurementBatchServiceTest.java | 21 +++-- 9 files changed, 190 insertions(+), 46 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java index ac30d0298..4508179b8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java @@ -63,12 +63,13 @@ public interface OrderCargoClient { } record CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, - String skuNo, Integer packageCount, BigDecimal lengthCm, BigDecimal widthCm, - BigDecimal heightCm) { + String skuNo, Integer packageCount, BigDecimal lengthCm, + BigDecimal widthCm, BigDecimal heightCm, String shipmentId) { public CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName, Integer packageCount, BigDecimal lengthCm, BigDecimal widthCm, BigDecimal heightCm) { - this(cargoId, orderId, itemNo, cargoName, null, packageCount, lengthCm, widthCm, heightCm); + this(cargoId, orderId, itemNo, cargoName, null, + packageCount, lengthCm, widthCm, heightCm, null); } } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java index c83eb68ad..569cfa5e5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java @@ -49,7 +49,8 @@ public class PlatformInboundMeasurementController { @Resource private MeasurementSubmissionService submission; @Resource private CartonMeasurementService cartonMeasurements; - @Operation(summary = "查询入库测量详情", description = "返回原单、实测箱规、审核快照和 OCR 重量结果。") + @Operation(summary = "查询入库测量详情", description = "返回唯一入仓码、原单、实测箱规、审核快照和 OCR 重量结果;" + + "箱规行按 inboundDetailId 返回 Order 权威 FBA 货件编号,并保留独立设备实扫值。") @GetMapping("/measurement") @PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:view')") public ApiResponse detail( @@ -78,7 +79,8 @@ public class PlatformInboundMeasurementController { @BusinessAudit(event = "INBOUND_MEASUREMENT_DRAFT_SAVE", resourceType = "INBOUND", resourceId = "#inboundId", description = "保存入库测量草稿") - @Operation(summary = "保存测量草稿", description = "保存人工测量批次和服务端计算明细,不推进入库状态。") + @Operation(summary = "保存测量草稿", description = "仅在待测量、测量中或审核驳回状态保存人工测量批次和服务端计算明细;" + + "进入待审核后批次冻结,确认箱数修正须使用 measurement/items/{itemId}/confirmed-package-count 专用接口。") @PutMapping("/measurement/draft") @PreAuthorize("hasAnyAuthority('platform:firstleg:inbound:warehouse-suggestion:edit'," + " 'platform:firstleg:inbound:measure:edit')") 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 8d118da90..7934f7b4f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java @@ -75,6 +75,8 @@ public final class InboundMeasurementResponses { public record Item( Long id, + @Schema(description = "当前测量详情唯一权威入仓码,与根对象 inboundCode 一致") + String inboundCode, Long inboundDetailId, String itemNo, String boxSpecification, @@ -99,6 +101,9 @@ public final class InboundMeasurementResponses { String coefficientMode, String remark, String boxCode, + @Schema(description = "Order货物明细按inboundDetailId匹配的权威FBA货件编号;匹配失败为空,禁止回退设备实扫值") + String fbaShipmentNo, + @Schema(description = "设备从Amazon FBA箱标实扫的Shipment ID;仅作现场证据,不覆盖或回退权威货件编号") String scannedShipmentId, Integer confirmedPackageCount, String machineNo, 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 50e3a0a46..66c4c636a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java @@ -48,6 +48,8 @@ public class InboundMeasurementService { private static final String WEIGHT_PENDING_CONFIRM = "PENDING_CONFIRM"; /** OCR 结果无效或不完整,必须人工录入或修正。 */ private static final String WEIGHT_NEEDS_MANUAL = "NEEDS_MANUAL"; + private static final Set MEASUREMENT_DRAFT_EDITABLE_STATUSES = Set.of( + "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED"); private static final Set WEIGHT_EDITABLE_STATUSES = Set.of( "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED"); /** 测量完成后的默认普通托盘类型;实际拆托和库位占用由提交编排服务执行。 */ @@ -100,7 +102,7 @@ public class InboundMeasurementService { com.baiyue.firstleg.entity.InboundPalletAllocationEntity::getWeightResultId, java.util.function.Function.identity(), (left, right) -> left)); MeasureReviewRecordEntity review = repository.findLatestReview(tenantId, inbound.getId()); - InboundMeasurementResponses.DeclarationSnapshot latestDeclaration = loadLatestDeclaration(inbound); + LatestDeclaration latestDeclaration = loadLatestDeclaration(inbound); return toDetail(inbound, measurement, items, weights, allocationSlots, allocationsByWeight, review, latestDeclaration); } @@ -122,6 +124,10 @@ public class InboundMeasurementService { long tenantId, long inboundId, long operatorId, InboundMeasurementRequests.SaveDraftRequest request) { InboundOrderEntity inbound = requireInbound(tenantId, inboundId); + if (!MEASUREMENT_DRAFT_EDITABLE_STATUSES.contains(inbound.getProcessStatus())) { + throw new BizException(ResultCode.CONFLICT, + "测量已提交待审核,不能再保存整批测量数据;如需修改请先退回后重新提交"); + } validateInboundDetails(tenantId, inbound, request.items()); request.items().stream().map(InboundMeasurementRequests.ItemRequest::chargeRuleId) .filter(java.util.Objects::nonNull).distinct().forEach(ruleId -> { @@ -468,7 +474,8 @@ public class InboundMeasurementService { List allocationSlots, Map allocationsByWeight, MeasureReviewRecordEntity review, - InboundMeasurementResponses.DeclarationSnapshot latestDeclaration) { + LatestDeclaration latestDeclaration) { + InboundMeasurementResponses.DeclarationSnapshot declaration = latestDeclaration.snapshot(); return new InboundMeasurementResponses.Detail( inbound.getId(), inbound.getInboundCode(), inbound.getCompanyName(), inbound.getChannelCode(), inbound.getChannelName(), inbound.getProcessStatus(), inbound.getMeasureReviewStatus(), @@ -480,21 +487,22 @@ public class InboundMeasurementService { inbound.getVersion(), measurement == null ? null : measurement.getVersion(), measurement != null && StringUtils.hasText(measurement.getOriginalDocumentNo()) - ? measurement.getOriginalDocumentNo() : latestDeclaration.referenceId(), + ? measurement.getOriginalDocumentNo() : declaration.referenceId(), measurement != null && measurement.getOriginalGrossWeightKg() != null - ? measurement.getOriginalGrossWeightKg() : latestDeclaration.grossWeightKg(), + ? measurement.getOriginalGrossWeightKg() : declaration.grossWeightKg(), measurement != null && measurement.getOriginalVolumeCbm() != null - ? measurement.getOriginalVolumeCbm() : latestDeclaration.volumeCbm(), - latestDeclaration.totalPackageCount(), + ? measurement.getOriginalVolumeCbm() : declaration.volumeCbm(), + declaration.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() : latestDeclaration.goodsName(), - latestDeclaration, - items.stream().map(this::toItem).toList(), weights.stream() + ? measurement.getActualGoodsName() : declaration.goodsName(), + declaration, + items.stream().map(item -> toItem(item, inbound.getInboundCode(), item.getInboundDetailId() == null + ? null : latestDeclaration.fbaShipmentNos().get(item.getInboundDetailId()))).toList(), weights.stream() .map(weight -> toWeight(weight, allocationsByWeight.get(weight.getId()))).toList(), toPalletLocationGuide(allocationSlots)); } @@ -526,10 +534,14 @@ public class InboundMeasurementService { * 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( + private LatestDeclaration loadLatestDeclaration( InboundOrderEntity inbound) { OrderCargoClient.MeasurementDeclarationSnapshot aggregate = loadDeclaration(inbound); List cargoes = loadDeclarationCargoes(inbound); + if (cargoes.stream().anyMatch(cargo -> cargo == null || cargo.cargoId() == null + || cargo.cargoId() <= 0 || !java.util.Objects.equals(cargo.orderId(), inbound.getOrderId()))) { + throw new BizException(ResultCode.CONFLICT, "订单服务返回的货物明细归属不一致"); + } long packages = cargoes.stream() .map(OrderCargoClient.CargoLabelBinding::packageCount) .filter(java.util.Objects::nonNull) @@ -538,7 +550,7 @@ public class InboundMeasurementService { if (packages > Integer.MAX_VALUE) { throw new BizException(ResultCode.VALIDATION_FAILED, "客户最新申报总箱数超过系统上限"); } - return new InboundMeasurementResponses.DeclarationSnapshot( + InboundMeasurementResponses.DeclarationSnapshot snapshot = new InboundMeasurementResponses.DeclarationSnapshot( aggregate.referenceId(), (int) packages, aggregate.originalGrossWeightKg(), aggregate.originalVolumeCbm(), aggregate.defaultActualGoodsName(), cargoes.stream() @@ -550,6 +562,19 @@ public class InboundMeasurementService { cargo.cargoId(), cargo.itemNo(), cargo.cargoName(), cargo.packageCount(), cargo.lengthCm(), cargo.widthCm(), cargo.heightCm())) .toList()); + Map fbaShipmentNos = cargoes.stream() + .filter(cargo -> cargo.cargoId() != null && StringUtils.hasText(cargo.shipmentId())) + .collect(java.util.stream.Collectors.toMap( + OrderCargoClient.CargoLabelBinding::cargoId, + cargo -> cargo.shipmentId().trim(), + (left, right) -> { + if (!java.util.Objects.equals(left, right)) { + throw new BizException(ResultCode.CONFLICT, + "订单同一货物明细返回多个FBA货件编号"); + } + return left; + }, java.util.LinkedHashMap::new)); + return new LatestDeclaration(snapshot, Map.copyOf(fbaShipmentNos)); } private List loadDeclarationCargoes(InboundOrderEntity inbound) { @@ -601,9 +626,11 @@ public class InboundMeasurementService { return value; } - private InboundMeasurementResponses.Item toItem(InboundMeasurementItemEntity item) { + private InboundMeasurementResponses.Item toItem(InboundMeasurementItemEntity item, + String inboundCode, + String fbaShipmentNo) { return new InboundMeasurementResponses.Item( - item.getId(), item.getInboundDetailId(), item.getItemNo(), item.getBoxSpecification(), item.getPackageCount(), + item.getId(), inboundCode, item.getInboundDetailId(), item.getItemNo(), item.getBoxSpecification(), item.getPackageCount(), item.getLengthCm(), item.getWidthCm(), item.getHeightCm(), item.getGirthCm(), item.getUnitVolumeCbm(), item.getTotalVolumeCbm(), item.getVolumeWeightKg(), item.getSourceImageFileId(), @@ -612,11 +639,17 @@ public class InboundMeasurementService { item.getOuterLengthCm(), item.getOuterWidthCm(), item.getOuterHeightCm(), item.getCalculationRuleVersion(), item.getChargeRuleId(), item.getCoefficientMode(), - item.getRemark(), item.getBoxCode(), item.getScannedShipmentId(), + item.getRemark(), item.getBoxCode(), fbaShipmentNo, item.getScannedShipmentId(), item.getPackageCount(), item.getMachineNo(), item.getDeviceMeasuredAt(), item.getUpdatedAt()); } + private record LatestDeclaration( + InboundMeasurementResponses.DeclarationSnapshot snapshot, + Map fbaShipmentNos + ) { + } + private void requireConfirmedPackageTotal(InboundOrderEntity inbound, List items) { if (inbound.getTotalPackageCount() == null || inbound.getTotalPackageCount() < 1) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java index 3246f5cdd..bad5a21ad 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java @@ -42,6 +42,8 @@ public class MeasurementBatchService { static final String STATUS_DRAFT = "DRAFT"; /** 已接收第三方测量数据,待平台提交审核。 */ static final String STATUS_RECEIVED = "RECEIVED"; + private static final Set EDITABLE_INBOUND_STATUSES = Set.of( + "WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED"); static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167"); private static final BigDecimal MAX_RAW_VOLUME_CBM = new BigDecimal("999999999999.999999"); private static final BigDecimal MAX_TOTAL_VOLUME_CBM = new BigDecimal("99999999999999.9999"); @@ -60,6 +62,10 @@ public class MeasurementBatchService { if (inbound == null) { throw new BizException(ResultCode.NOT_FOUND, "入库单不存在"); } + if (!EDITABLE_INBOUND_STATUSES.contains(inbound.getProcessStatus())) { + throw new BizException(ResultCode.CONFLICT, + "当前入库状态不允许保存测量批次,已提交批次必须保持冻结"); + } validateSource(command); List calculatedItems = calculateItems( command, dimensionWeighting.load(command.tenantId())); @@ -71,12 +77,7 @@ public class MeasurementBatchService { repository.insertMeasurement(measurement); } else { measurement = requireEditableBatch(command); - // 修正待测量或待审核资料时重新形成可提交的内部草稿;已入库订单只更新资料, - // 不回退其主状态,也不把已经审核的批次重新标成待审核。 - if (Set.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED") - .contains(inbound.getProcessStatus())) { - measurement.setStatus(STATUS_DRAFT); - } + measurement.setStatus(STATUS_DRAFT); applyHeader(measurement, command); applyTotals(measurement, calculatedItems); if (!repository.updateMeasurement(measurement)) { diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 1252ac4b4..bb9ebde90 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -916,6 +916,8 @@ paths: grossWeightKg/netWeightKg。两类证据不得合并为无明细归属的统一图片列表。 actualGrossWeightKg 是服务端确认后的实测毛重,actualNetWeightKg 是扣除托盘皮重后的货物净重; 两者语义独立,消费者不得互相回退,也不得根据图片或 OCR 值自行重算。 + inboundCode 是当前入库详情唯一入仓码;每条 items 按 inboundDetailId 返回 Order 权威 + fbaShipmentNo,并保留独立 scannedShipmentId,匹配失败返回空且禁止两者互相回退或覆盖。 parameters: - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} @@ -931,7 +933,9 @@ paths: summary: 保存测量草稿 description: >- 原单据号码、申报毛重和申报体积由服务端从客户订单生成快照,客户端不得提交覆盖; - actualGoodsName 默认使用客户货物品名汇总,但允许测量人员修正。 + actualGoodsName 默认使用客户货物品名汇总,但允许测量人员修正。仅允许 WAIT_MEASURE、MEASURING + 或 MEASURE_REJECTED 状态保存;进入 MEASURE_PENDING 后整批测量冻结,确认箱数修正使用 + measurement/items/{itemId}/confirmed-package-count 明细专用接口。 parameters: - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} @@ -1749,10 +1753,12 @@ components: type: object description: >- 单条尺寸测量明细。id 是稳定 measurementItemId;intelligentImageFileId 仅表示该明细的 - 尺寸测量原图,不得与 weightResults.fileId 混用。 + 尺寸测量原图,不得与 weightResults.fileId 混用。fbaShipmentNo 是按 inboundDetailId + 从 Order 货物明细实时读取的权威货件编号;scannedShipmentId 是独立设备实扫证据,二者不得回退或覆盖。 required: [id] properties: id: {type: string, pattern: '^[1-9][0-9]*$', description: 稳定 measurementItemId} + inboundCode: {type: string, nullable: true, description: 当前测量详情唯一权威入仓码,与根对象inboundCode一致} inboundDetailId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} itemNo: {type: string, nullable: true} boxSpecification: {type: string, nullable: true} @@ -1781,7 +1787,8 @@ components: coefficientMode: {type: string, enum: [ACTUAL, ADD, MULTIPLY], nullable: true} remark: {type: string, nullable: true} boxCode: {type: string, nullable: true} - scannedShipmentId: {type: string, nullable: true} + fbaShipmentNo: {type: string, nullable: true, description: Order货物明细按inboundDetailId匹配的权威FBA货件编号;匹配失败为空} + scannedShipmentId: {type: string, nullable: true, description: 设备实扫Shipment ID;仅作现场证据,不覆盖或回退fbaShipmentNo} confirmedPackageCount: {type: integer, nullable: true} machineNo: {type: string, nullable: true} deviceMeasuredAt: {type: string, format: date-time, nullable: true} @@ -1793,7 +1800,7 @@ components: required: [inboundId, inboundVersion, items, weightResults] properties: inboundId: {type: string, pattern: '^[1-9][0-9]*$'} - inboundCode: {type: string, nullable: true} + inboundCode: {type: string, nullable: true, description: 当前测量详情唯一入仓码,UI业务主展示字段} companyName: {type: string, nullable: true} channelCode: {type: string, nullable: true} channelName: {type: string, nullable: true} 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 c9adf167b..b033f0119 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java @@ -32,8 +32,10 @@ class InboundMeasurementEvidenceOpenApiContractTest { Map schemas = stringMap(stringMap(document.get("components")).get("schemas")); Map detailProperties = properties(schemas, "InboundMeasurementDetail"); assertThat(detailProperties).containsKeys( - "actualGrossWeightKg", "actualNetWeightKg", "latestDeclaration", "items", "weightResults", + "inboundCode", "actualGrossWeightKg", "actualNetWeightKg", "latestDeclaration", "items", "weightResults", "palletLocationGuide"); + assertThat(stringMap(detailProperties.get("inboundCode")).get("description").toString()) + .contains("唯一入仓码", "UI业务主展示字段"); assertThat(stringMap(detailProperties.get("latestDeclaration"))) .containsEntry("$ref", "#/components/schemas/InboundMeasurementLatestDeclaration"); assertThat(properties(schemas, "InboundMeasurementLatestDeclaration")) @@ -49,8 +51,11 @@ class InboundMeasurementEvidenceOpenApiContractTest { Map draftProperties = properties(schemas, "InboundMeasurementSaveDraftRequest"); assertThat(draftProperties).containsKeys("measurementId", "version", "actualGoodsName", "items") .doesNotContainKeys("originalDocumentNo", "originalGrossWeightKg", "originalVolumeCbm"); - assertThat(paths).containsKey( - "/api/platform/v1/first-leg/inbounds/{inboundId}/measurement/draft"); + String draftPath = "/api/platform/v1/first-leg/inbounds/{inboundId}/measurement/draft"; + assertThat(paths).containsKey(draftPath); + Map draftOperation = stringMap(stringMap(paths.get(draftPath)).get("put")); + assertThat(draftOperation.get("description").toString()) + .contains("MEASURE_PENDING", "冻结", "package-count"); assertThat(stringMap(stringMap(draftProperties.get("items")).get("items"))) .containsEntry("$ref", "#/components/schemas/InboundMeasurementItemRequest"); assertThat(properties(schemas, "InboundMeasurementItemRequest")).containsKeys( @@ -58,7 +63,14 @@ class InboundMeasurementEvidenceOpenApiContractTest { "widthCm", "heightCm", "intelligentImageFileId", "remark"); Map itemProperties = properties(schemas, "InboundMeasurementItem"); - assertThat(itemProperties).containsKeys("id", "intelligentImageFileId"); + assertThat(itemProperties).containsKeys( + "id", "inboundCode", "inboundDetailId", "intelligentImageFileId", "fbaShipmentNo", "scannedShipmentId"); + assertThat(stringMap(itemProperties.get("inboundCode")).get("description").toString()) + .contains("唯一权威入仓码", "根对象inboundCode一致"); + assertThat(stringMap(itemProperties.get("fbaShipmentNo")).get("description").toString()) + .contains("Order货物明细", "inboundDetailId", "匹配失败为空"); + assertThat(stringMap(itemProperties.get("scannedShipmentId")).get("description").toString()) + .contains("设备实扫", "不覆盖或回退fbaShipmentNo"); Map weightProperties = properties(schemas, "InboundWeightResult"); assertThat(weightProperties).containsKeys( "id", "fileId", "recognitionStatus", "grossWeightKg", "netWeightKg", "confirmedAt", 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 ab0c01789..65b4a70dd 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java @@ -75,8 +75,8 @@ class InboundMeasurementServiceTest { "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"))))); + 80001L, 80L, 1, "123123", null, 3, new BigDecimal("40"), + new BigDecimal("30"), new BigDecimal("20"), "FBA-DECLARED-001")))); } @Test @@ -196,7 +196,9 @@ class InboundMeasurementServiceTest { item.setId(40L); item.setTenantId(1L); item.setMeasurementId(30L); + item.setInboundDetailId(80001L); item.setSourceImageFileId(140L); + item.setScannedShipmentId("FBA-SCANNED-001"); item.setOuterLengthCm(new BigDecimal("64.000")); item.setOuterWidthCm(new BigDecimal("54.000")); item.setOuterHeightCm(new BigDecimal("54.000")); @@ -217,11 +219,15 @@ class InboundMeasurementServiceTest { InboundMeasurementResponses.Detail result = service.detail(1L, 10L); + assertThat(result.inboundCode()).isEqualTo("RK-001"); assertThat(result.actualGrossWeightKg()).isEqualByComparingTo("30.000"); assertThat(result.actualNetWeightKg()).isEqualByComparingTo("11.500"); assertThat(result.items()).singleElement().satisfies(dimension -> { assertThat(dimension.id()).isEqualTo(40L); + assertThat(dimension.inboundCode()).isEqualTo("RK-001"); assertThat(dimension.intelligentImageFileId()).isEqualTo(140L); + assertThat(dimension.fbaShipmentNo()).isEqualTo("FBA-DECLARED-001"); + assertThat(dimension.scannedShipmentId()).isEqualTo("FBA-SCANNED-001"); assertThat(dimension.outerLengthCm()).isNull(); assertThat(dimension.outerWidthCm()).isNull(); assertThat(dimension.outerHeightCm()).isNull(); @@ -246,6 +252,63 @@ class InboundMeasurementServiceTest { }); } + @Test + void detailMapsMultipleDeclaredShipmentIdsByInboundDetailWithoutFallingBackToScannedValue() { + InboundOrderEntity inbound = inbound("MEASURE_PENDING"); + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(30L); + InboundMeasurementItemEntity first = new InboundMeasurementItemEntity(); + first.setId(41L); + first.setInboundDetailId(80001L); + first.setScannedShipmentId("SCAN-DIFFERENT-001"); + InboundMeasurementItemEntity second = new InboundMeasurementItemEntity(); + second.setId(42L); + second.setInboundDetailId(80002L); + InboundMeasurementItemEntity unmatched = new InboundMeasurementItemEntity(); + unmatched.setId(43L); + unmatched.setInboundDetailId(89999L); + unmatched.setScannedShipmentId("SCAN-ONLY-999"); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findLatestMeasurement(1L, 10L)).thenReturn(measurement); + when(repository.listItems(1L, 30L)).thenReturn(List.of(first, second, unmatched)); + when(repository.listWeights(1L, 10L)).thenReturn(List.of()); + when(orderCargoClient.listForCartonLabels("internal-token", 1L, 80L)).thenReturn( + ApiResponse.success(List.of( + new OrderCargoClient.CargoLabelBinding(80001L, 80L, 1, "货物1", null, + 1, null, null, null, "FBA-DECLARED-001"), + new OrderCargoClient.CargoLabelBinding(80002L, 80L, 2, "货物2", null, + 1, null, null, null, "FBA-DECLARED-002")))); + + InboundMeasurementResponses.Detail result = service.detail(1L, 10L); + + assertThat(result.inboundCode()).isEqualTo("RK-001"); + assertThat(result.items()) + .extracting(InboundMeasurementResponses.Item::inboundDetailId, + InboundMeasurementResponses.Item::inboundCode, + InboundMeasurementResponses.Item::fbaShipmentNo, + InboundMeasurementResponses.Item::scannedShipmentId) + .containsExactly( + tuple(80001L, "RK-001", "FBA-DECLARED-001", "SCAN-DIFFERENT-001"), + tuple(80002L, "RK-001", "FBA-DECLARED-002", null), + tuple(89999L, "RK-001", null, "SCAN-ONLY-999")); + verify(orderCargoClient, times(1)).listForCartonLabels("internal-token", 1L, 80L); + } + + @Test + void detailRejectsCargoBatchWhoseOrderOwnershipDoesNotMatchInbound() { + InboundOrderEntity inbound = inbound("MEASURE_PENDING"); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.listWeights(1L, 10L)).thenReturn(List.of()); + when(orderCargoClient.listForCartonLabels("internal-token", 1L, 80L)).thenReturn( + ApiResponse.success(List.of(new OrderCargoClient.CargoLabelBinding( + 80001L, 81L, 1, "其他订单货物", null, 1, null, null, null, + "FBA-OTHER-001")))); + + assertThatThrownBy(() -> service.detail(1L, 10L)) + .isInstanceOf(BizException.class) + .hasMessageContaining("货物明细归属不一致"); + } + @Test void detailReturnsWarehouseOuterDimensionsOnlyAfterManualOverrideWasSaved() { InboundOrderEntity inbound = inbound("MEASURING"); @@ -333,8 +396,8 @@ class InboundMeasurementServiceTest { "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"))))); + 80001L, 80L, 1, "最新申报品名", null, 4, new BigDecimal("50"), + new BigDecimal("40"), new BigDecimal("30"), "FBA-LATEST-001")))); InboundMeasurementResponses.Detail result = service.detail(1L, 10L); @@ -356,6 +419,7 @@ class InboundMeasurementServiceTest { assertThat(result.items()).singleElement().satisfies(measured -> { assertThat(measured.id()).isEqualTo(40L); assertThat(measured.boxCode()).isEqualTo("RK-001#2-1"); + assertThat(measured.fbaShipmentNo()).isEqualTo("FBA-LATEST-001"); assertThat(measured.packageCount()).isEqualTo(2); assertThat(measured.lengthCm()).isEqualByComparingTo("45"); }); @@ -1116,6 +1180,21 @@ class InboundMeasurementServiceTest { verify(orderCargoClient).detail("internal-token", 1L, 80L, 80001L); } + @Test + void savingDraftCannotReopenSubmittedMeasurementWhileReviewIsPending() { + InboundOrderEntity inbound = inbound("MEASURE_PENDING"); + inbound.setMeasureReviewStatus("PENDING"); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + + assertThatThrownBy(() -> service.saveDraft(1L, 10L, 9L, + new InboundMeasurementRequests.SaveDraftRequest(30L, 10, null, List.of()))) + .isInstanceOf(BizException.class) + .hasMessageContaining("已提交待审核"); + + verifyNoInteractions(batchService); + verify(orderCargoClient, never()).detail(any(), anyLong(), anyLong(), anyLong()); + } + @Test void savingDraftRejectsOverlongDefaultGoodsNameBeforeDatabaseWrite() { InboundOrderEntity inbound = inbound("WAIT_MEASURE"); @@ -1157,6 +1236,7 @@ class InboundMeasurementServiceTest { inbound.setTenantId(1L); inbound.setOrderId(80L); inbound.setWarehouseId(100L); + inbound.setInboundCode("RK-001"); inbound.setProcessStatus(status); inbound.setTotalPackageCount(3); inbound.setVersion(0); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java index ebdb1dccc..4ba98e21f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java @@ -392,28 +392,31 @@ class MeasurementBatchServiceTest { } @Test - void approvedManualMeasurementCanBeEditedAsDraftBeforeReviewCompletes() { + void submittedMeasurementCannotBeResetToDraftWhileReviewIsPending() { InboundMeasurementEntity existing = new InboundMeasurementEntity(); existing.setId(100L); existing.setTenantId(1L); existing.setInboundId(10L); existing.setSourceType("MANUAL"); - existing.setStatus("APPROVED"); + existing.setStatus("SUBMITTED"); existing.setVersion(3); + InboundOrderEntity inbound = inbound(); + inbound.setProcessStatus("MEASURE_PENDING"); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); when(repository.findMeasurement(1L, 10L, 100L)).thenReturn(existing); - when(repository.updateMeasurement(existing)).thenReturn(true); MeasurementBatchCommand command = new MeasurementBatchCommand( 1L, 10L, 100L, 3, "MANUAL", "PLATFORM", null, null, null, LocalDateTime.now(), null, "CKW-2", new BigDecimal("21"), new BigDecimal("0.2"), "服装", 9L, List.of(item(BigDecimal.ONE))); - InboundMeasurementEntity saved = service.saveBatch(command); - - assertThat(saved.getStatus()).isEqualTo("DRAFT"); - verify(repository).updateMeasurement(existing); - verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L), - org.mockito.ArgumentMatchers.eq(100L), any()); + assertThatThrownBy(() -> service.saveBatch(command)) + .isInstanceOf(BizException.class) + .hasMessageContaining("已提交批次必须保持冻结"); + assertThat(existing.getStatus()).isEqualTo("SUBMITTED"); + verify(repository, never()).updateMeasurement(any()); + verify(repository, never()).replaceItems( + org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(), any()); } @Test