From 2821c5e9c7e911548df299109092d8107cbb75f7 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 10 Sep 2026 11:42:05 +0800 Subject: [PATCH] fix(firstleg): update submitted outer dimensions safely --- .../PlatformInboundMeasurementController.java | 18 +++++ .../dto/InboundMeasurementRequests.java | 12 ++++ .../dto/InboundMeasurementResponses.java | 5 ++ .../service/InboundMeasurementService.java | 42 +++++++++-- .../service/MeasurementBatchService.java | 70 ++++++++++++++----- .../main/resources/openapi/firstleg-v1.yaml | 29 ++++++++ ...easurementEvidenceOpenApiContractTest.java | 9 +++ .../InboundMeasurementServiceTest.java | 33 +++++++++ .../service/MeasurementBatchServiceTest.java | 40 +++++++++++ 9 files changed, 235 insertions(+), 23 deletions(-) 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 697053bd9..9d5d3a901 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java @@ -109,6 +109,24 @@ public class PlatformInboundMeasurementController { return ApiResponse.success(service.updateCalculationStrategy(tenantId, inboundId, request)); } + @BusinessAudit(event = "INBOUND_MEASUREMENT_OUTER_DIMENSIONS_UPDATE", + resourceType = "INBOUND_MEASUREMENT_ITEM", resourceId = "#itemId", + description = "更新仓库外箱长宽高") + @Operation(summary = "更新仓库外箱长宽高", + description = "独立更新当前测量批次指定明细的仓库外箱尺寸并重算体积;不改变测量提交或审核状态。" + + "仅当客户已确认费用且该票已装柜完成时冻结。") + @PutMapping("/measurement/items/{itemId}/outer-dimensions") + @PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:edit')") + public ApiResponse updateOuterDimensions( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam long tenantId, + @PathVariable long inboundId, + @PathVariable long itemId, + @Valid @RequestBody InboundMeasurementRequests.UpdateOuterDimensionsRequest request) { + requirePlatformTenant(user, tenantId); + return ApiResponse.success(service.updateOuterDimensions(tenantId, inboundId, itemId, request)); + } + @BusinessAudit(event = "INBOUND_MEASUREMENT_SUBMIT", resourceType = "INBOUND", resourceId = "#inboundId", description = "确认测量并提交审核") @Operation(summary = "确认测量并提交审核", diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java index 92b66a040..afefd5038 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementRequests.java @@ -95,6 +95,18 @@ public final class InboundMeasurementRequests { ) { } + public record UpdateOuterDimensionsRequest( + @NotNull @Positive Long measurementId, + @NotNull @PositiveOrZero Integer version, + @DecimalMin("0.001") @DecimalMax("999999999.999") + @Digits(integer = 9, fraction = 3) BigDecimal outerLengthCm, + @DecimalMin("0.001") @DecimalMax("999999999.999") + @Digits(integer = 9, fraction = 3) BigDecimal outerWidthCm, + @DecimalMin("0.001") @DecimalMax("999999999.999") + @Digits(integer = 9, fraction = 3) BigDecimal outerHeightCm + ) { + } + public record ConfirmedPackageCountRequest( @NotNull @PositiveOrZero Integer measurementVersion, @NotNull @Positive @Max(1000000) Integer confirmedPackageCount 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 8dd559f63..c58ec1b5f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java @@ -188,6 +188,11 @@ public final class InboundMeasurementResponses { ) { } + public record OuterDimensionsUpdated( + Long measurementId, Integer version, String status, Long itemId + ) { + } + public record MeasurementSubmitted(Long measurementId, Integer version, String processStatus, String measureReviewStatus, @Schema(description = "测量完成后的推荐托盘类型,仅为建议,不代表已分配托盘", 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 d08402195..f76ccca26 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java @@ -171,13 +171,7 @@ public class InboundMeasurementService { InboundMeasurementRequests.UpdateCalculationStrategyRequest request) { InboundOrderEntity inbound = repository.lockInbound(tenantId, inboundId); if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在"); - var currentFee = pricingRepository.findLatestFeeForUpdate(tenantId, inboundId); - boolean customerConfirmed = currentFee != null && currentFee.getCustomerConfirmedAt() != null; - boolean loadingCompleted = Set.of("LOADED", "DELIVERED").contains(inbound.getProcessStatus()); - if (customerConfirmed && loadingCompleted) { - throw new BizException(ResultCode.CONFLICT, - "客户已确认且该票已装柜完成,不能修改箱规计算规则"); - } + requireMeasurementRevisionEditable(tenantId, inboundId, inbound, "箱规计算规则"); if ("MULTIPLY".equals(request.coefficientMode()) && request.chargeRuleId() == null) { throw new BizException(ResultCode.VALIDATION_FAILED, "箱规系数模式必须选择计费规则"); } @@ -199,6 +193,40 @@ public class InboundMeasurementService { request.coefficientMode(), appliedRuleId); } + @Transactional + public InboundMeasurementResponses.OuterDimensionsUpdated updateOuterDimensions( + long tenantId, long inboundId, long itemId, + InboundMeasurementRequests.UpdateOuterDimensionsRequest request) { + InboundOrderEntity inbound = repository.lockInbound(tenantId, inboundId); + if (inbound == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在"); + requireMeasurementRevisionEditable(tenantId, inboundId, inbound, "仓库外箱尺寸"); + InboundMeasurementEntity measurement = repository.lockMeasurement( + tenantId, inboundId, request.measurementId()); + if (measurement == null) throw new BizException(ResultCode.NOT_FOUND, "测量批次不存在"); + requireVersion(request.version(), measurement.getVersion()); + List items = repository.listItems(tenantId, measurement.getId()); + batchService.recalculateOuterDimensions(tenantId, measurement, items, itemId, + request.outerLengthCm(), request.outerWidthCm(), request.outerHeightCm()); + measurement.setUpdatedAt(LocalDateTime.now()); + if (!repository.updateMeasurement(measurement)) throw concurrentModification(); + inbound.setActualVolumeCbm(measurement.getTotalVolumeCbm()); + inbound.setVolumeWeightKg(measurement.getVolumeWeightKg()); + if (!repository.updateInbound(inbound)) throw concurrentModification(); + return new InboundMeasurementResponses.OuterDimensionsUpdated( + measurement.getId(), measurement.getVersion(), measurement.getStatus(), itemId); + } + + private void requireMeasurementRevisionEditable(long tenantId, long inboundId, + InboundOrderEntity inbound, String subject) { + var currentFee = pricingRepository.findLatestFeeForUpdate(tenantId, inboundId); + boolean customerConfirmed = currentFee != null && currentFee.getCustomerConfirmedAt() != null; + boolean loadingCompleted = Set.of("LOADED", "DELIVERED").contains(inbound.getProcessStatus()); + if (customerConfirmed && loadingCompleted) { + throw new BizException(ResultCode.CONFLICT, + "客户已确认且该票已装柜完成,不能修改" + subject); + } + } + @Transactional public InboundMeasurementResponses.MeasurementSubmitted submit( long tenantId, long inboundId, long operatorId, 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 2dd2ef2b5..db1f98688 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementBatchService.java @@ -116,26 +116,64 @@ public class MeasurementBatchService { for (int index = 0; index < existingItems.size(); index++) { InboundMeasurementItemEntity target = existingItems.get(index); InboundMeasurementItemEntity source = recalculated.get(index); - target.setGirthCm(source.getGirthCm()); - target.setUnitVolumeCbm(source.getUnitVolumeCbm()); - target.setVolumeRawCbm(source.getVolumeRawCbm()); - target.setVolumeCbm(source.getVolumeCbm()); - target.setTotalVolumeCbm(source.getTotalVolumeCbm()); - target.setVolumeWeightKg(source.getVolumeWeightKg()); - target.setChargeRuleId(source.getChargeRuleId()); - target.setCoefficientMode(source.getCoefficientMode()); - target.setCalculationRuleVersion(source.getCalculationRuleVersion()); - target.setOuterLengthCm(source.getOuterLengthCm()); - target.setOuterWidthCm(source.getOuterWidthCm()); - target.setOuterHeightCm(source.getOuterHeightCm()); - target.setUpdatedAt(LocalDateTime.now()); - if (!repository.updateItem(target)) { - throw concurrentModification(); - } + applyRecalculatedItem(target, source, false); } applyTotals(measurement, existingItems); } + void recalculateOuterDimensions(long tenantId, InboundMeasurementEntity measurement, + List existingItems, long itemId, + BigDecimal outerLengthCm, BigDecimal outerWidthCm, + BigDecimal outerHeightCm) { + InboundMeasurementItemEntity target = existingItems.stream() + .filter(item -> item.getId() != null && item.getId() == itemId) + .findFirst() + .orElseThrow(() -> new BizException(ResultCode.NOT_FOUND, "测量明细不存在")); + MeasurementBatchCommand command = new MeasurementBatchCommand( + tenantId, measurement.getInboundId(), measurement.getId(), measurement.getVersion(), + measurement.getSourceType(), measurement.getSourceCode(), measurement.getExternalEventNo(), + measurement.getDeviceCode(), measurement.getWarehouseCode(), measurement.getMeasuredAt(), + measurement.getRawPayloadJson(), measurement.getOriginalDocumentNo(), + measurement.getOriginalGrossWeightKg(), measurement.getOriginalVolumeCbm(), + measurement.getActualGoodsName(), measurement.getCreatedBy(), + List.of(new MeasurementBatchCommand.Item( + target.getInboundDetailId(), target.getItemNo(), target.getBoxSpecification(), + target.getPackageCount(), target.getLengthCm(), target.getWidthCm(), target.getHeightCm(), + null, target.getSourceImageFileId(), target.getOverLimitWarning(), outerLengthCm, + outerWidthCm, outerHeightCm, target.getChargeRuleId(), target.getCoefficientMode(), + target.getRemark()))); + InboundMeasurementItemEntity recalculated = calculateItems( + command, dimensionWeighting.load(tenantId)).get(0); + applyRecalculatedItem(target, recalculated, true); + applyTotals(measurement, existingItems); + } + + private void applyRecalculatedItem(InboundMeasurementItemEntity target, + InboundMeasurementItemEntity source, + boolean updateManualDimensions) { + target.setGirthCm(source.getGirthCm()); + target.setUnitVolumeCbm(source.getUnitVolumeCbm()); + target.setVolumeRawCbm(source.getVolumeRawCbm()); + target.setVolumeCbm(source.getVolumeCbm()); + target.setTotalVolumeCbm(source.getTotalVolumeCbm()); + target.setVolumeWeightKg(source.getVolumeWeightKg()); + target.setChargeRuleId(source.getChargeRuleId()); + target.setCoefficientMode(source.getCoefficientMode()); + target.setCalculationRuleVersion(source.getCalculationRuleVersion()); + target.setOuterLengthCm(source.getOuterLengthCm()); + target.setOuterWidthCm(source.getOuterWidthCm()); + target.setOuterHeightCm(source.getOuterHeightCm()); + if (updateManualDimensions) { + target.setManualOuterLengthCm(source.getManualOuterLengthCm()); + target.setManualOuterWidthCm(source.getManualOuterWidthCm()); + target.setManualOuterHeightCm(source.getManualOuterHeightCm()); + } + target.setUpdatedAt(LocalDateTime.now()); + if (!repository.updateItem(target)) { + throw concurrentModification(); + } + } + /** * 人工补录会整批替换可编辑测量项,但不能抹掉同一项已冻结的扫码身份与设备取证。 * 只按入库货物明细和明细号精确匹配,避免把一个箱码猜测性地复制给其他测量项。 diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index eb5cf1b36..bf19f4436 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -1015,6 +1015,25 @@ paths: responses: '200': {description: 成功或业务失败,按响应体 code 判断} '403': {description: 无测量编辑权限} + /api/platform/v1/first-leg/inbounds/{inboundId}/measurement/items/{itemId}/outer-dimensions: + put: + summary: 更新仓库外箱长宽高 + description: >- + 独立更新当前测量批次指定明细的仓库外箱尺寸并重算体积,不覆盖整批测量数据, + 不改变测量提交或审核状态。客户未确认或该票尚未装柜完成时均可修改; + 仅当客户已确认费用且该票已装柜完成时冻结。 + 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]*$'}} + - {name: itemId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/InboundMeasurementOuterDimensionsRequest'} + responses: + '200': {description: 成功或业务失败,按响应体 code 判断} + '403': {description: 无测量编辑权限} /api/platform/v1/first-leg/inbounds/{inboundId}/measurement/carton-scans: post: summary: 平台模拟单箱设备扫描 @@ -1992,6 +2011,16 @@ components: version: {type: integer, minimum: 0} coefficientMode: {type: string, enum: [ACTUAL, ADD, MULTIPLY]} chargeRuleId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: MULTIPLY 模式必填} + InboundMeasurementOuterDimensionsRequest: + type: object + required: [measurementId, version] + additionalProperties: false + properties: + measurementId: {type: string, pattern: '^[1-9][0-9]*$'} + version: {type: integer, minimum: 0} + outerLengthCm: {type: number, minimum: 0.001, nullable: true} + outerWidthCm: {type: number, minimum: 0.001, nullable: true} + outerHeightCm: {type: number, minimum: 0.001, nullable: true} InboundMeasurementItemRequest: type: object required: [inboundDetailId, itemNo, boxSpecification, packageCount, lengthCm, widthCm, heightCm] 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 b1a2b50db..6954b2e87 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementEvidenceOpenApiContractTest.java @@ -68,6 +68,15 @@ class InboundMeasurementEvidenceOpenApiContractTest { .contains("不覆盖整批测量数据", "不改变测量提交或审核状态", "客户已确认费用且该票已装柜完成"); assertThat(properties(schemas, "InboundMeasurementCalculationStrategyRequest")) .containsKeys("measurementId", "version", "coefficientMode", "chargeRuleId"); + String outerDimensionsPath = "/api/platform/v1/first-leg/inbounds/{inboundId}" + + "/measurement/items/{itemId}/outer-dimensions"; + assertThat(paths).containsKey(outerDimensionsPath); + Map outerDimensionsOperation = stringMap( + stringMap(paths.get(outerDimensionsPath)).get("put")); + assertThat(outerDimensionsOperation.get("description").toString()) + .contains("不覆盖整批测量数据", "不改变测量提交或审核状态", "客户已确认费用且该票已装柜完成"); + assertThat(properties(schemas, "InboundMeasurementOuterDimensionsRequest")) + .containsKeys("measurementId", "version", "outerLengthCm", "outerWidthCm", "outerHeightCm"); Map itemProperties = properties(schemas, "InboundMeasurementItem"); assertThat(itemProperties).containsKeys( 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 0036f2af1..d0666670a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java @@ -139,6 +139,39 @@ class InboundMeasurementServiceTest { verify(repository, never()).lockMeasurement(anyLong(), anyLong(), anyLong()); } + @Test + void updatesSubmittedWarehouseOuterDimensionsWithoutReplacingMeasurementBatch() { + InboundOrderEntity inbound = inbound("MEASURE_PENDING"); + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(30L); + measurement.setTenantId(1L); + measurement.setInboundId(10L); + measurement.setStatus("SUBMITTED"); + measurement.setVersion(5); + measurement.setTotalVolumeCbm(new BigDecimal("1.8000")); + measurement.setVolumeWeightKg(new BigDecimal("300.600")); + InboundMeasurementItemEntity item = new InboundMeasurementItemEntity(); + item.setId(31L); + item.setTenantId(1L); + item.setMeasurementId(30L); + when(repository.lockInbound(1L, 10L)).thenReturn(inbound); + when(repository.lockMeasurement(1L, 10L, 30L)).thenReturn(measurement); + when(repository.listItems(1L, 30L)).thenReturn(List.of(item)); + when(repository.updateMeasurement(measurement)).thenReturn(true); + when(repository.updateInbound(inbound)).thenReturn(true); + + var response = service.updateOuterDimensions(1L, 10L, 31L, + new InboundMeasurementRequests.UpdateOuterDimensionsRequest( + 30L, 5, new BigDecimal("30"), new BigDecimal("20"), new BigDecimal("20"))); + + assertThat(response.status()).isEqualTo("SUBMITTED"); + assertThat(response.itemId()).isEqualTo(31L); + verify(batchService).recalculateOuterDimensions(1L, measurement, List.of(item), 31L, + new BigDecimal("30"), new BigDecimal("20"), new BigDecimal("20")); + verify(repository, never()).replaceItems(anyLong(), anyLong(), any()); + assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("1.8000"); + } + @Test void correctingWeightKeepsOcrOriginalAndWritesImmutableRevision() { InboundOrderEntity inbound = inbound("MEASURING"); 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 6fd4e1f37..34c2f2a2a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementBatchServiceTest.java @@ -133,6 +133,46 @@ class MeasurementBatchServiceTest { verify(repository, never()).replaceItems(anyLong(), anyLong(), any()); } + @Test + void recalculatesOneSubmittedWarehouseOuterDimensionRowInPlace() { + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(100L); + measurement.setTenantId(1L); + measurement.setInboundId(10L); + measurement.setSourceType("MANUAL"); + measurement.setStatus("SUBMITTED"); + measurement.setVersion(4); + measurement.setCreatedBy(9L); + InboundMeasurementItemEntity existing = new InboundMeasurementItemEntity(); + existing.setId(200L); + existing.setTenantId(1L); + existing.setMeasurementId(100L); + existing.setInboundDetailId(80001L); + existing.setBoxCode("RK-001#1"); + existing.setItemNo("001"); + existing.setBoxSpecification("40x30x20cm"); + existing.setPackageCount(2); + existing.setLengthCm(new BigDecimal("40")); + existing.setWidthCm(new BigDecimal("30")); + existing.setHeightCm(new BigDecimal("20")); + when(repository.updateItem(existing)).thenReturn(true); + + service.recalculateOuterDimensions(1L, measurement, List.of(existing), 200L, + new BigDecimal("30"), new BigDecimal("20"), new BigDecimal("20")); + + assertThat(existing.getId()).isEqualTo(200L); + assertThat(existing.getBoxCode()).isEqualTo("RK-001#1"); + assertThat(existing.getManualOuterLengthCm()).isEqualByComparingTo("30"); + assertThat(existing.getManualOuterWidthCm()).isEqualByComparingTo("20"); + assertThat(existing.getManualOuterHeightCm()).isEqualByComparingTo("20"); + assertThat(existing.getOuterLengthCm()).isEqualByComparingTo("30.000"); + assertThat(existing.getUnitVolumeCbm()).isEqualByComparingTo("0.0120"); + assertThat(measurement.getStatus()).isEqualTo("SUBMITTED"); + assertThat(measurement.getTotalVolumeCbm()).isEqualByComparingTo("0.0240"); + verify(repository).updateItem(existing); + verify(repository, never()).replaceItems(anyLong(), anyLong(), any()); + } + @Test void automaticallyAppliesCustomerDefaultRuleToActualMeasurement() { CustomerChargeRuleEntity rule = new CustomerChargeRuleEntity();