fix(firstleg): isolate declarations from measurement facts

This commit is contained in:
2026-09-01 20:59:30 +08:00
parent 858061452c
commit b9cb5555e2
5 changed files with 185 additions and 18 deletions
@@ -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]
@@ -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");