fix(firstleg): update submitted outer dimensions safely

This commit is contained in:
2026-09-10 11:42:05 +08:00
parent e8890389ff
commit 2821c5e9c7
9 changed files with 235 additions and 23 deletions
@@ -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<InboundMeasurementResponses.OuterDimensionsUpdated> 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 = "确认测量并提交审核",
@@ -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
@@ -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 = "测量完成后的推荐托盘类型,仅为建议,不代表已分配托盘",
@@ -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<InboundMeasurementItemEntity> 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,
@@ -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<InboundMeasurementItemEntity> 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();
}
}
/**
* 人工补录会整批替换可编辑测量项,但不能抹掉同一项已冻结的扫码身份与设备取证。
* 只按入库货物明细和明细号精确匹配,避免把一个箱码猜测性地复制给其他测量项。
@@ -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]
@@ -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<String, Object> 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<String, Object> itemProperties = properties(schemas, "InboundMeasurementItem");
assertThat(itemProperties).containsKeys(
@@ -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");
@@ -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();