feat(firstleg): expose authoritative measurement identifiers

This commit is contained in:
2026-09-08 17:24:29 +08:00
parent e597bebafd
commit ba81dece5e
9 changed files with 190 additions and 46 deletions
@@ -63,12 +63,13 @@ public interface OrderCargoClient {
}
record CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
String skuNo, Integer packageCount, BigDecimal lengthCm, BigDecimal widthCm,
BigDecimal heightCm) {
String skuNo, Integer packageCount, BigDecimal lengthCm,
BigDecimal widthCm, BigDecimal heightCm, String shipmentId) {
public CargoLabelBinding(Long cargoId, Long orderId, Integer itemNo, String cargoName,
Integer packageCount, BigDecimal lengthCm, BigDecimal widthCm,
BigDecimal heightCm) {
this(cargoId, orderId, itemNo, cargoName, null, packageCount, lengthCm, widthCm, heightCm);
this(cargoId, orderId, itemNo, cargoName, null,
packageCount, lengthCm, widthCm, heightCm, null);
}
}
}
@@ -49,7 +49,8 @@ public class PlatformInboundMeasurementController {
@Resource private MeasurementSubmissionService submission;
@Resource private CartonMeasurementService cartonMeasurements;
@Operation(summary = "查询入库测量详情", description = "返回原单、实测箱规、审核快照和 OCR 重量结果。")
@Operation(summary = "查询入库测量详情", description = "返回唯一入仓码、原单、实测箱规、审核快照和 OCR 重量结果;"
+ "箱规行按 inboundDetailId 返回 Order 权威 FBA 货件编号,并保留独立设备实扫值。")
@GetMapping("/measurement")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:view')")
public ApiResponse<InboundMeasurementResponses.Detail> detail(
@@ -78,7 +79,8 @@ public class PlatformInboundMeasurementController {
@BusinessAudit(event = "INBOUND_MEASUREMENT_DRAFT_SAVE", resourceType = "INBOUND",
resourceId = "#inboundId", description = "保存入库测量草稿")
@Operation(summary = "保存测量草稿", description = "保存人工测量批次和服务端计算明细,不推进入库状态。")
@Operation(summary = "保存测量草稿", description = "仅在待测量、测量中或审核驳回状态保存人工测量批次和服务端计算明细;"
+ "进入待审核后批次冻结,确认箱数修正须使用 measurement/items/{itemId}/confirmed-package-count 专用接口。")
@PutMapping("/measurement/draft")
@PreAuthorize("hasAnyAuthority('platform:firstleg:inbound:warehouse-suggestion:edit',"
+ " 'platform:firstleg:inbound:measure:edit')")
@@ -75,6 +75,8 @@ public final class InboundMeasurementResponses {
public record Item(
Long id,
@Schema(description = "当前测量详情唯一权威入仓码,与根对象 inboundCode 一致")
String inboundCode,
Long inboundDetailId,
String itemNo,
String boxSpecification,
@@ -99,6 +101,9 @@ public final class InboundMeasurementResponses {
String coefficientMode,
String remark,
String boxCode,
@Schema(description = "Order货物明细按inboundDetailId匹配的权威FBA货件编号;匹配失败为空,禁止回退设备实扫值")
String fbaShipmentNo,
@Schema(description = "设备从Amazon FBA箱标实扫的Shipment ID;仅作现场证据,不覆盖或回退权威货件编号")
String scannedShipmentId,
Integer confirmedPackageCount,
String machineNo,
@@ -48,6 +48,8 @@ public class InboundMeasurementService {
private static final String WEIGHT_PENDING_CONFIRM = "PENDING_CONFIRM";
/** OCR 结果无效或不完整,必须人工录入或修正。 */
private static final String WEIGHT_NEEDS_MANUAL = "NEEDS_MANUAL";
private static final Set<String> MEASUREMENT_DRAFT_EDITABLE_STATUSES = Set.of(
"WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED");
private static final Set<String> WEIGHT_EDITABLE_STATUSES = Set.of(
"WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED");
/** 测量完成后的默认普通托盘类型;实际拆托和库位占用由提交编排服务执行。 */
@@ -100,7 +102,7 @@ public class InboundMeasurementService {
com.baiyue.firstleg.entity.InboundPalletAllocationEntity::getWeightResultId,
java.util.function.Function.identity(), (left, right) -> left));
MeasureReviewRecordEntity review = repository.findLatestReview(tenantId, inbound.getId());
InboundMeasurementResponses.DeclarationSnapshot latestDeclaration = loadLatestDeclaration(inbound);
LatestDeclaration latestDeclaration = loadLatestDeclaration(inbound);
return toDetail(inbound, measurement, items, weights, allocationSlots, allocationsByWeight,
review, latestDeclaration);
}
@@ -122,6 +124,10 @@ public class InboundMeasurementService {
long tenantId, long inboundId, long operatorId,
InboundMeasurementRequests.SaveDraftRequest request) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
if (!MEASUREMENT_DRAFT_EDITABLE_STATUSES.contains(inbound.getProcessStatus())) {
throw new BizException(ResultCode.CONFLICT,
"测量已提交待审核,不能再保存整批测量数据;如需修改请先退回后重新提交");
}
validateInboundDetails(tenantId, inbound, request.items());
request.items().stream().map(InboundMeasurementRequests.ItemRequest::chargeRuleId)
.filter(java.util.Objects::nonNull).distinct().forEach(ruleId -> {
@@ -468,7 +474,8 @@ public class InboundMeasurementService {
List<com.baiyue.firstleg.entity.InboundPalletAllocationEntity> allocationSlots,
Map<Long, com.baiyue.firstleg.entity.InboundPalletAllocationEntity> allocationsByWeight,
MeasureReviewRecordEntity review,
InboundMeasurementResponses.DeclarationSnapshot latestDeclaration) {
LatestDeclaration latestDeclaration) {
InboundMeasurementResponses.DeclarationSnapshot declaration = latestDeclaration.snapshot();
return new InboundMeasurementResponses.Detail(
inbound.getId(), inbound.getInboundCode(), inbound.getCompanyName(), inbound.getChannelCode(),
inbound.getChannelName(), inbound.getProcessStatus(), inbound.getMeasureReviewStatus(),
@@ -480,21 +487,22 @@ public class InboundMeasurementService {
inbound.getVersion(),
measurement == null ? null : measurement.getVersion(),
measurement != null && StringUtils.hasText(measurement.getOriginalDocumentNo())
? measurement.getOriginalDocumentNo() : latestDeclaration.referenceId(),
? measurement.getOriginalDocumentNo() : declaration.referenceId(),
measurement != null && measurement.getOriginalGrossWeightKg() != null
? measurement.getOriginalGrossWeightKg() : latestDeclaration.grossWeightKg(),
? measurement.getOriginalGrossWeightKg() : declaration.grossWeightKg(),
measurement != null && measurement.getOriginalVolumeCbm() != null
? measurement.getOriginalVolumeCbm() : latestDeclaration.volumeCbm(),
latestDeclaration.totalPackageCount(),
? measurement.getOriginalVolumeCbm() : declaration.volumeCbm(),
declaration.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() : latestDeclaration.goodsName(),
latestDeclaration,
items.stream().map(this::toItem).toList(), weights.stream()
? measurement.getActualGoodsName() : declaration.goodsName(),
declaration,
items.stream().map(item -> toItem(item, inbound.getInboundCode(), item.getInboundDetailId() == null
? null : latestDeclaration.fbaShipmentNos().get(item.getInboundDetailId()))).toList(), weights.stream()
.map(weight -> toWeight(weight, allocationsByWeight.get(weight.getId()))).toList(),
toPalletLocationGuide(allocationSlots));
}
@@ -526,10 +534,14 @@ public class InboundMeasurementService {
* 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(
private LatestDeclaration loadLatestDeclaration(
InboundOrderEntity inbound) {
OrderCargoClient.MeasurementDeclarationSnapshot aggregate = loadDeclaration(inbound);
List<OrderCargoClient.CargoLabelBinding> cargoes = loadDeclarationCargoes(inbound);
if (cargoes.stream().anyMatch(cargo -> cargo == null || cargo.cargoId() == null
|| cargo.cargoId() <= 0 || !java.util.Objects.equals(cargo.orderId(), inbound.getOrderId()))) {
throw new BizException(ResultCode.CONFLICT, "订单服务返回的货物明细归属不一致");
}
long packages = cargoes.stream()
.map(OrderCargoClient.CargoLabelBinding::packageCount)
.filter(java.util.Objects::nonNull)
@@ -538,7 +550,7 @@ public class InboundMeasurementService {
if (packages > Integer.MAX_VALUE) {
throw new BizException(ResultCode.VALIDATION_FAILED, "客户最新申报总箱数超过系统上限");
}
return new InboundMeasurementResponses.DeclarationSnapshot(
InboundMeasurementResponses.DeclarationSnapshot snapshot = new InboundMeasurementResponses.DeclarationSnapshot(
aggregate.referenceId(), (int) packages, aggregate.originalGrossWeightKg(),
aggregate.originalVolumeCbm(), aggregate.defaultActualGoodsName(),
cargoes.stream()
@@ -550,6 +562,19 @@ public class InboundMeasurementService {
cargo.cargoId(), cargo.itemNo(), cargo.cargoName(), cargo.packageCount(),
cargo.lengthCm(), cargo.widthCm(), cargo.heightCm()))
.toList());
Map<Long, String> fbaShipmentNos = cargoes.stream()
.filter(cargo -> cargo.cargoId() != null && StringUtils.hasText(cargo.shipmentId()))
.collect(java.util.stream.Collectors.toMap(
OrderCargoClient.CargoLabelBinding::cargoId,
cargo -> cargo.shipmentId().trim(),
(left, right) -> {
if (!java.util.Objects.equals(left, right)) {
throw new BizException(ResultCode.CONFLICT,
"订单同一货物明细返回多个FBA货件编号");
}
return left;
}, java.util.LinkedHashMap::new));
return new LatestDeclaration(snapshot, Map.copyOf(fbaShipmentNos));
}
private List<OrderCargoClient.CargoLabelBinding> loadDeclarationCargoes(InboundOrderEntity inbound) {
@@ -601,9 +626,11 @@ public class InboundMeasurementService {
return value;
}
private InboundMeasurementResponses.Item toItem(InboundMeasurementItemEntity item) {
private InboundMeasurementResponses.Item toItem(InboundMeasurementItemEntity item,
String inboundCode,
String fbaShipmentNo) {
return new InboundMeasurementResponses.Item(
item.getId(), item.getInboundDetailId(), item.getItemNo(), item.getBoxSpecification(), item.getPackageCount(),
item.getId(), inboundCode, item.getInboundDetailId(), item.getItemNo(), item.getBoxSpecification(), item.getPackageCount(),
item.getLengthCm(), item.getWidthCm(), item.getHeightCm(), item.getGirthCm(),
item.getUnitVolumeCbm(),
item.getTotalVolumeCbm(), item.getVolumeWeightKg(), item.getSourceImageFileId(),
@@ -612,11 +639,17 @@ public class InboundMeasurementService {
item.getOuterLengthCm(), item.getOuterWidthCm(), item.getOuterHeightCm(),
item.getCalculationRuleVersion(),
item.getChargeRuleId(), item.getCoefficientMode(),
item.getRemark(), item.getBoxCode(), item.getScannedShipmentId(),
item.getRemark(), item.getBoxCode(), fbaShipmentNo, item.getScannedShipmentId(),
item.getPackageCount(),
item.getMachineNo(), item.getDeviceMeasuredAt(), item.getUpdatedAt());
}
private record LatestDeclaration(
InboundMeasurementResponses.DeclarationSnapshot snapshot,
Map<Long, String> fbaShipmentNos
) {
}
private void requireConfirmedPackageTotal(InboundOrderEntity inbound,
List<InboundMeasurementItemEntity> items) {
if (inbound.getTotalPackageCount() == null || inbound.getTotalPackageCount() < 1) {
@@ -42,6 +42,8 @@ public class MeasurementBatchService {
static final String STATUS_DRAFT = "DRAFT";
/** 已接收第三方测量数据,待平台提交审核。 */
static final String STATUS_RECEIVED = "RECEIVED";
private static final Set<String> EDITABLE_INBOUND_STATUSES = Set.of(
"WAIT_MEASURE", "MEASURING", "MEASURE_REJECTED");
static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167");
private static final BigDecimal MAX_RAW_VOLUME_CBM = new BigDecimal("999999999999.999999");
private static final BigDecimal MAX_TOTAL_VOLUME_CBM = new BigDecimal("99999999999999.9999");
@@ -60,6 +62,10 @@ public class MeasurementBatchService {
if (inbound == null) {
throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
}
if (!EDITABLE_INBOUND_STATUSES.contains(inbound.getProcessStatus())) {
throw new BizException(ResultCode.CONFLICT,
"当前入库状态不允许保存测量批次,已提交批次必须保持冻结");
}
validateSource(command);
List<InboundMeasurementItemEntity> calculatedItems = calculateItems(
command, dimensionWeighting.load(command.tenantId()));
@@ -71,12 +77,7 @@ public class MeasurementBatchService {
repository.insertMeasurement(measurement);
} else {
measurement = requireEditableBatch(command);
// 修正待测量或待审核资料时重新形成可提交的内部草稿;已入库订单只更新资料,
// 不回退其主状态,也不把已经审核的批次重新标成待审核。
if (Set.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED")
.contains(inbound.getProcessStatus())) {
measurement.setStatus(STATUS_DRAFT);
}
measurement.setStatus(STATUS_DRAFT);
applyHeader(measurement, command);
applyTotals(measurement, calculatedItems);
if (!repository.updateMeasurement(measurement)) {
@@ -916,6 +916,8 @@ paths:
grossWeightKg/netWeightKg。两类证据不得合并为无明细归属的统一图片列表。
actualGrossWeightKg 是服务端确认后的实测毛重,actualNetWeightKg 是扣除托盘皮重后的货物净重;
两者语义独立,消费者不得互相回退,也不得根据图片或 OCR 值自行重算。
inboundCode 是当前入库详情唯一入仓码;每条 items 按 inboundDetailId 返回 Order 权威
fbaShipmentNo,并保留独立 scannedShipmentId,匹配失败返回空且禁止两者互相回退或覆盖。
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]*$'}}
@@ -931,7 +933,9 @@ paths:
summary: 保存测量草稿
description: >-
原单据号码、申报毛重和申报体积由服务端从客户订单生成快照,客户端不得提交覆盖;
actualGoodsName 默认使用客户货物品名汇总,但允许测量人员修正。
actualGoodsName 默认使用客户货物品名汇总,但允许测量人员修正。仅允许 WAIT_MEASURE、MEASURING
或 MEASURE_REJECTED 状态保存;进入 MEASURE_PENDING 后整批测量冻结,确认箱数修正使用
measurement/items/{itemId}/confirmed-package-count 明细专用接口。
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]*$'}}
@@ -1749,10 +1753,12 @@ components:
type: object
description: >-
单条尺寸测量明细。id 是稳定 measurementItemId;intelligentImageFileId 仅表示该明细的
尺寸测量原图,不得与 weightResults.fileId 混用。
尺寸测量原图,不得与 weightResults.fileId 混用。fbaShipmentNo 是按 inboundDetailId
从 Order 货物明细实时读取的权威货件编号;scannedShipmentId 是独立设备实扫证据,二者不得回退或覆盖。
required: [id]
properties:
id: {type: string, pattern: '^[1-9][0-9]*$', description: 稳定 measurementItemId}
inboundCode: {type: string, nullable: true, description: 当前测量详情唯一权威入仓码,与根对象inboundCode一致}
inboundDetailId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
itemNo: {type: string, nullable: true}
boxSpecification: {type: string, nullable: true}
@@ -1781,7 +1787,8 @@ components:
coefficientMode: {type: string, enum: [ACTUAL, ADD, MULTIPLY], nullable: true}
remark: {type: string, nullable: true}
boxCode: {type: string, nullable: true}
scannedShipmentId: {type: string, nullable: true}
fbaShipmentNo: {type: string, nullable: true, description: Order货物明细按inboundDetailId匹配的权威FBA货件编号;匹配失败为空}
scannedShipmentId: {type: string, nullable: true, description: 设备实扫Shipment ID;仅作现场证据,不覆盖或回退fbaShipmentNo}
confirmedPackageCount: {type: integer, nullable: true}
machineNo: {type: string, nullable: true}
deviceMeasuredAt: {type: string, format: date-time, nullable: true}
@@ -1793,7 +1800,7 @@ components:
required: [inboundId, inboundVersion, items, weightResults]
properties:
inboundId: {type: string, pattern: '^[1-9][0-9]*$'}
inboundCode: {type: string, nullable: true}
inboundCode: {type: string, nullable: true, description: 当前测量详情唯一入仓码,UI业务主展示字段}
companyName: {type: string, nullable: true}
channelCode: {type: string, nullable: true}
channelName: {type: string, nullable: true}
@@ -32,8 +32,10 @@ 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", "latestDeclaration", "items", "weightResults",
"inboundCode", "actualGrossWeightKg", "actualNetWeightKg", "latestDeclaration", "items", "weightResults",
"palletLocationGuide");
assertThat(stringMap(detailProperties.get("inboundCode")).get("description").toString())
.contains("唯一入仓码", "UI业务主展示字段");
assertThat(stringMap(detailProperties.get("latestDeclaration")))
.containsEntry("$ref", "#/components/schemas/InboundMeasurementLatestDeclaration");
assertThat(properties(schemas, "InboundMeasurementLatestDeclaration"))
@@ -49,8 +51,11 @@ class InboundMeasurementEvidenceOpenApiContractTest {
Map<String, Object> draftProperties = properties(schemas, "InboundMeasurementSaveDraftRequest");
assertThat(draftProperties).containsKeys("measurementId", "version", "actualGoodsName", "items")
.doesNotContainKeys("originalDocumentNo", "originalGrossWeightKg", "originalVolumeCbm");
assertThat(paths).containsKey(
"/api/platform/v1/first-leg/inbounds/{inboundId}/measurement/draft");
String draftPath = "/api/platform/v1/first-leg/inbounds/{inboundId}/measurement/draft";
assertThat(paths).containsKey(draftPath);
Map<String, Object> draftOperation = stringMap(stringMap(paths.get(draftPath)).get("put"));
assertThat(draftOperation.get("description").toString())
.contains("MEASURE_PENDING", "冻结", "package-count");
assertThat(stringMap(stringMap(draftProperties.get("items")).get("items")))
.containsEntry("$ref", "#/components/schemas/InboundMeasurementItemRequest");
assertThat(properties(schemas, "InboundMeasurementItemRequest")).containsKeys(
@@ -58,7 +63,14 @@ class InboundMeasurementEvidenceOpenApiContractTest {
"widthCm", "heightCm", "intelligentImageFileId", "remark");
Map<String, Object> itemProperties = properties(schemas, "InboundMeasurementItem");
assertThat(itemProperties).containsKeys("id", "intelligentImageFileId");
assertThat(itemProperties).containsKeys(
"id", "inboundCode", "inboundDetailId", "intelligentImageFileId", "fbaShipmentNo", "scannedShipmentId");
assertThat(stringMap(itemProperties.get("inboundCode")).get("description").toString())
.contains("唯一权威入仓码", "根对象inboundCode一致");
assertThat(stringMap(itemProperties.get("fbaShipmentNo")).get("description").toString())
.contains("Order货物明细", "inboundDetailId", "匹配失败为空");
assertThat(stringMap(itemProperties.get("scannedShipmentId")).get("description").toString())
.contains("设备实扫", "不覆盖或回退fbaShipmentNo");
Map<String, Object> weightProperties = properties(schemas, "InboundWeightResult");
assertThat(weightProperties).containsKeys(
"id", "fileId", "recognitionStatus", "grossWeightKg", "netWeightKg", "confirmedAt",
@@ -75,8 +75,8 @@ class InboundMeasurementServiceTest {
"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")))));
80001L, 80L, 1, "123123", null, 3, new BigDecimal("40"),
new BigDecimal("30"), new BigDecimal("20"), "FBA-DECLARED-001"))));
}
@Test
@@ -196,7 +196,9 @@ class InboundMeasurementServiceTest {
item.setId(40L);
item.setTenantId(1L);
item.setMeasurementId(30L);
item.setInboundDetailId(80001L);
item.setSourceImageFileId(140L);
item.setScannedShipmentId("FBA-SCANNED-001");
item.setOuterLengthCm(new BigDecimal("64.000"));
item.setOuterWidthCm(new BigDecimal("54.000"));
item.setOuterHeightCm(new BigDecimal("54.000"));
@@ -217,11 +219,15 @@ class InboundMeasurementServiceTest {
InboundMeasurementResponses.Detail result = service.detail(1L, 10L);
assertThat(result.inboundCode()).isEqualTo("RK-001");
assertThat(result.actualGrossWeightKg()).isEqualByComparingTo("30.000");
assertThat(result.actualNetWeightKg()).isEqualByComparingTo("11.500");
assertThat(result.items()).singleElement().satisfies(dimension -> {
assertThat(dimension.id()).isEqualTo(40L);
assertThat(dimension.inboundCode()).isEqualTo("RK-001");
assertThat(dimension.intelligentImageFileId()).isEqualTo(140L);
assertThat(dimension.fbaShipmentNo()).isEqualTo("FBA-DECLARED-001");
assertThat(dimension.scannedShipmentId()).isEqualTo("FBA-SCANNED-001");
assertThat(dimension.outerLengthCm()).isNull();
assertThat(dimension.outerWidthCm()).isNull();
assertThat(dimension.outerHeightCm()).isNull();
@@ -246,6 +252,63 @@ class InboundMeasurementServiceTest {
});
}
@Test
void detailMapsMultipleDeclaredShipmentIdsByInboundDetailWithoutFallingBackToScannedValue() {
InboundOrderEntity inbound = inbound("MEASURE_PENDING");
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(30L);
InboundMeasurementItemEntity first = new InboundMeasurementItemEntity();
first.setId(41L);
first.setInboundDetailId(80001L);
first.setScannedShipmentId("SCAN-DIFFERENT-001");
InboundMeasurementItemEntity second = new InboundMeasurementItemEntity();
second.setId(42L);
second.setInboundDetailId(80002L);
InboundMeasurementItemEntity unmatched = new InboundMeasurementItemEntity();
unmatched.setId(43L);
unmatched.setInboundDetailId(89999L);
unmatched.setScannedShipmentId("SCAN-ONLY-999");
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findLatestMeasurement(1L, 10L)).thenReturn(measurement);
when(repository.listItems(1L, 30L)).thenReturn(List.of(first, second, unmatched));
when(repository.listWeights(1L, 10L)).thenReturn(List.of());
when(orderCargoClient.listForCartonLabels("internal-token", 1L, 80L)).thenReturn(
ApiResponse.success(List.of(
new OrderCargoClient.CargoLabelBinding(80001L, 80L, 1, "货物1", null,
1, null, null, null, "FBA-DECLARED-001"),
new OrderCargoClient.CargoLabelBinding(80002L, 80L, 2, "货物2", null,
1, null, null, null, "FBA-DECLARED-002"))));
InboundMeasurementResponses.Detail result = service.detail(1L, 10L);
assertThat(result.inboundCode()).isEqualTo("RK-001");
assertThat(result.items())
.extracting(InboundMeasurementResponses.Item::inboundDetailId,
InboundMeasurementResponses.Item::inboundCode,
InboundMeasurementResponses.Item::fbaShipmentNo,
InboundMeasurementResponses.Item::scannedShipmentId)
.containsExactly(
tuple(80001L, "RK-001", "FBA-DECLARED-001", "SCAN-DIFFERENT-001"),
tuple(80002L, "RK-001", "FBA-DECLARED-002", null),
tuple(89999L, "RK-001", null, "SCAN-ONLY-999"));
verify(orderCargoClient, times(1)).listForCartonLabels("internal-token", 1L, 80L);
}
@Test
void detailRejectsCargoBatchWhoseOrderOwnershipDoesNotMatchInbound() {
InboundOrderEntity inbound = inbound("MEASURE_PENDING");
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.listWeights(1L, 10L)).thenReturn(List.of());
when(orderCargoClient.listForCartonLabels("internal-token", 1L, 80L)).thenReturn(
ApiResponse.success(List.of(new OrderCargoClient.CargoLabelBinding(
80001L, 81L, 1, "其他订单货物", null, 1, null, null, null,
"FBA-OTHER-001"))));
assertThatThrownBy(() -> service.detail(1L, 10L))
.isInstanceOf(BizException.class)
.hasMessageContaining("货物明细归属不一致");
}
@Test
void detailReturnsWarehouseOuterDimensionsOnlyAfterManualOverrideWasSaved() {
InboundOrderEntity inbound = inbound("MEASURING");
@@ -333,8 +396,8 @@ class InboundMeasurementServiceTest {
"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")))));
80001L, 80L, 1, "最新申报品名", null, 4, new BigDecimal("50"),
new BigDecimal("40"), new BigDecimal("30"), "FBA-LATEST-001"))));
InboundMeasurementResponses.Detail result = service.detail(1L, 10L);
@@ -356,6 +419,7 @@ class InboundMeasurementServiceTest {
assertThat(result.items()).singleElement().satisfies(measured -> {
assertThat(measured.id()).isEqualTo(40L);
assertThat(measured.boxCode()).isEqualTo("RK-001#2-1");
assertThat(measured.fbaShipmentNo()).isEqualTo("FBA-LATEST-001");
assertThat(measured.packageCount()).isEqualTo(2);
assertThat(measured.lengthCm()).isEqualByComparingTo("45");
});
@@ -1116,6 +1180,21 @@ class InboundMeasurementServiceTest {
verify(orderCargoClient).detail("internal-token", 1L, 80L, 80001L);
}
@Test
void savingDraftCannotReopenSubmittedMeasurementWhileReviewIsPending() {
InboundOrderEntity inbound = inbound("MEASURE_PENDING");
inbound.setMeasureReviewStatus("PENDING");
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
assertThatThrownBy(() -> service.saveDraft(1L, 10L, 9L,
new InboundMeasurementRequests.SaveDraftRequest(30L, 10, null, List.of())))
.isInstanceOf(BizException.class)
.hasMessageContaining("已提交待审核");
verifyNoInteractions(batchService);
verify(orderCargoClient, never()).detail(any(), anyLong(), anyLong(), anyLong());
}
@Test
void savingDraftRejectsOverlongDefaultGoodsNameBeforeDatabaseWrite() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
@@ -1157,6 +1236,7 @@ class InboundMeasurementServiceTest {
inbound.setTenantId(1L);
inbound.setOrderId(80L);
inbound.setWarehouseId(100L);
inbound.setInboundCode("RK-001");
inbound.setProcessStatus(status);
inbound.setTotalPackageCount(3);
inbound.setVersion(0);
@@ -392,28 +392,31 @@ class MeasurementBatchServiceTest {
}
@Test
void approvedManualMeasurementCanBeEditedAsDraftBeforeReviewCompletes() {
void submittedMeasurementCannotBeResetToDraftWhileReviewIsPending() {
InboundMeasurementEntity existing = new InboundMeasurementEntity();
existing.setId(100L);
existing.setTenantId(1L);
existing.setInboundId(10L);
existing.setSourceType("MANUAL");
existing.setStatus("APPROVED");
existing.setStatus("SUBMITTED");
existing.setVersion(3);
InboundOrderEntity inbound = inbound();
inbound.setProcessStatus("MEASURE_PENDING");
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(repository.findMeasurement(1L, 10L, 100L)).thenReturn(existing);
when(repository.updateMeasurement(existing)).thenReturn(true);
MeasurementBatchCommand command = new MeasurementBatchCommand(
1L, 10L, 100L, 3, "MANUAL", "PLATFORM", null, null, null,
LocalDateTime.now(), null, "CKW-2", new BigDecimal("21"),
new BigDecimal("0.2"), "服装", 9L, List.of(item(BigDecimal.ONE)));
InboundMeasurementEntity saved = service.saveBatch(command);
assertThat(saved.getStatus()).isEqualTo("DRAFT");
verify(repository).updateMeasurement(existing);
verify(repository).replaceItems(org.mockito.ArgumentMatchers.eq(1L),
org.mockito.ArgumentMatchers.eq(100L), any());
assertThatThrownBy(() -> service.saveBatch(command))
.isInstanceOf(BizException.class)
.hasMessageContaining("已提交批次必须保持冻结");
assertThat(existing.getStatus()).isEqualTo("SUBMITTED");
verify(repository, never()).updateMeasurement(any());
verify(repository, never()).replaceItems(
org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(), any());
}
@Test