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