fix(firstleg): isolate declarations from measurement facts
This commit is contained in:
@@ -44,11 +44,34 @@ public final class InboundMeasurementResponses {
|
||||
BigDecimal actualVolumeCbm,
|
||||
BigDecimal volumeWeightKg,
|
||||
String actualGoodsName,
|
||||
@Schema(description = "Order 服务中的最新客户申报,只用于和既有实测事实对比;不得回写测量、箱码或审核结果")
|
||||
DeclarationSnapshot latestDeclaration,
|
||||
List<Item> items,
|
||||
List<WeightResult> weightResults
|
||||
) {
|
||||
}
|
||||
|
||||
public record DeclarationSnapshot(
|
||||
String referenceId,
|
||||
Integer totalPackageCount,
|
||||
BigDecimal grossWeightKg,
|
||||
BigDecimal volumeCbm,
|
||||
String goodsName,
|
||||
List<DeclarationItem> 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,
|
||||
|
||||
@@ -69,9 +69,8 @@ public class InboundMeasurementService {
|
||||
? List.of() : repository.listItems(tenantId, measurement.getId());
|
||||
List<InboundWeightResultEntity> 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<InboundMeasurementResponses.PalletSnapshot> weightPalletOptions(
|
||||
@@ -416,7 +415,8 @@ public class InboundMeasurementService {
|
||||
private InboundMeasurementResponses.Detail toDetail(
|
||||
InboundOrderEntity inbound, InboundMeasurementEntity measurement,
|
||||
List<InboundMeasurementItemEntity> items, List<InboundWeightResultEntity> 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<OrderCargoClient.CargoLabelBinding> 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<OrderCargoClient.CargoLabelBinding> loadDeclarationCargoes(InboundOrderEntity inbound) {
|
||||
try {
|
||||
ApiResponse<List<OrderCargoClient.CargoLabelBinding>> 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) {
|
||||
|
||||
@@ -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]
|
||||
|
||||
+8
-1
@@ -24,7 +24,14 @@ class InboundMeasurementEvidenceOpenApiContractTest {
|
||||
Map<String, Object> schemas = stringMap(stringMap(document.get("components")).get("schemas"));
|
||||
Map<String, Object> 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")))
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user