fix(firstleg): update submitted outer dimensions safely
This commit is contained in:
+18
@@ -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]
|
||||
|
||||
+9
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user