Merge remote-tracking branch 'origin/test' into dev

This commit is contained in:
2026-09-04 13:47:47 +08:00
21 changed files with 613 additions and 80 deletions
+1 -1
View File
@@ -84,7 +84,7 @@
- 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间并将该费用版本永久锁定,平台不得再调整;旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。
- PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
- PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。
- 测量提交按 `max(已确认称重图片数, ceil(实测体积 / 默认托盘容量))` 预留整票库位块;实际称重图片数与预留槽位数独立。Firstleg 必须显式保存 `weightResultId -> blockId/slotNo/reservationId/locationId/locationCode`,按 `confirmedAt + weightResultId` 稳定顺序绑定块内最小空槽,禁止按列表下标推断。普通位不足时 Warehouse 可将剩余槽位放入共享 A0,不阻断测量提交。
- 首张称重图片确认时按 `max(已确认称重图片数, ceil(实测体积 / 默认托盘容量))` 预留并确认整票库位块;后续确认绑定最小空槽,所需槽数增长时通过 Warehouse `/blocks/expand` 原子追加,既有 `blockId/slotNo/reservationId/locationId` 不得变化。测量提交只校验并复用已存在权威块,不得首次分配。Firstleg 必须显式保存 `weightResultId -> blockId/slotNo/reservationId/locationId/locationCode`,按 `confirmedAt + weightResultId` 稳定顺序绑定,禁止按列表下标推断;普通连续位不足的新增槽由 Warehouse 放入共享 A0,不阻断确认。
- 删除称重图片只解除图片和托盘快照绑定,将槽位恢复为 `RESERVED_EMPTY`;槽位和整票块继续归当前入仓码。解绑失败时不得调用文件删除,文件删除失败时本地解绑和重量结果删除必须回滚。
- 头程快照必须保存 Warehouse `blockId + slotNo + reservationId`;A0 可被多个整票块共享 `locationId`,业务资源身份始终使用 `reservationId`,不得把 `locationId` 当作唯一标识。
- PDA 部分拣货不得释放块内单槽;存在余量子单时通过 `/internal/warehouse/v1/pallet-locations/blocks/transfer` 将整段转移给子单。整票全部拣完时只通过 `/internal/warehouse/v1/pallet-locations/blocks/release` 一次性释放全部普通位和 A0 预约,禁止恢复逐槽 `settlements/release-selected` 协议。
@@ -38,6 +38,10 @@ public interface WarehousePalletLocationClient {
ApiResponse<BlockResult> confirmBlock(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody BlockConfirmRequest request);
@PostMapping("/pallet-locations/blocks/expand")
ApiResponse<BlockResult> expandBlock(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody BlockExpandRequest request);
@PostMapping("/pallet-locations/blocks/transfer")
ApiResponse<BlockResult> transferBlock(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody BlockTransferRequest request);
@@ -51,6 +55,8 @@ public interface WarehousePalletLocationClient {
long operatorId, LocalDateTime expiresAt) {}
record BlockConfirmRequest(long blockId, long businessId, String idempotencyKey,
long operatorId) {}
record BlockExpandRequest(long blockId, long businessId, int requiredSlotCount,
String idempotencyKey, long operatorId) {}
record BlockTransferRequest(long blockId, long sourceBusinessId, long targetBusinessId,
String targetInboundCode, String idempotencyKey, long operatorId) {}
record BlockReleaseRequest(long blockId, long businessId, String idempotencyKey,
@@ -67,6 +67,8 @@ public class InboundMeasurementService {
private WarehousePalletLocationClient warehousePallets;
@Resource
private InboundPalletAllocationRepository palletAllocations;
@Resource
private MeasurementLocationOccupationService locationOccupation;
public InboundMeasurementResponses.Detail detail(long tenantId, long inboundId) {
InboundOrderEntity inbound = requireInbound(tenantId, inboundId);
@@ -296,6 +298,8 @@ public class InboundMeasurementService {
request.adjustedWeightValue(), request.adjustedWeightUnit(),
request.adjustmentReason(), operatorId, operatorName);
} else if (WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus())) {
locationOccupation.ensureForConfirmedWeight(
tenantId, inboundId, operatorId, weight.getId(), weight.getVersion());
return toWeight(weight);
}
BigDecimal value = effectiveValue(weight);
@@ -324,10 +328,9 @@ public class InboundMeasurementService {
weight.setVersion(confirmedVersion);
insertRevision(weight, confirmedVersion, "CONFIRM", value, unit, value, unit,
grossWeightKg, netWeightKg, null, operatorId, operatorName);
if (!palletAllocations.list(tenantId, inboundId).isEmpty()) {
palletAllocations.bindNextEmpty(tenantId, inboundId, weight);
}
recalculateConfirmedWeight(inbound, operatorId);
locationOccupation.ensureForConfirmedWeight(
tenantId, inboundId, operatorId, weight.getId(), confirmedVersion);
return toWeight(weight);
}
@@ -23,7 +23,6 @@ import java.util.UUID;
@Service
public class MeasurementLocationOccupationService {
private static final String SOURCE_SERVICE = "firstleg-service";
private static final long PLATFORM_WAREHOUSE_RESOURCE_TENANT_ID = 0L;
@Resource
private WarehousePalletLocationClient warehouse;
@Resource
@@ -37,26 +36,43 @@ public class MeasurementLocationOccupationService {
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
public void occupy(long tenantId, long inboundId, long operatorId, long measurementId,
int measurementVersion) {
public void ensureForConfirmedWeight(long tenantId, long inboundId, long operatorId,
long weightResultId, int weightVersion) {
InboundOrderEntity order = requireOrder(tenantId, inboundId);
long warehouseId = requireWarehouse(order);
BigDecimal volume = requireVolume(order);
BigDecimal volume = requireVolume(tenantId, inboundId, order);
var confirmedWeights = confirmedWeights(tenantId, inboundId);
int requiredSlotCount = requiredSlotCount(volume, confirmedWeights.size());
int packageCount = requirePackageCount(order);
var current = allocations.list(tenantId, inboundId);
if (!current.isEmpty()) {
long blockId = requireActiveBlock(current, warehouseId);
if (current.size() < requiredSlotCount) {
String expandKey = "measure-block-expand-" + inboundId + "-" + weightResultId + "-" + weightVersion;
WarehousePalletLocationClient.BlockResult expanded = requireSuccess(call(() -> warehouse.expandBlock(
internalToken, new WarehousePalletLocationClient.BlockExpandRequest(blockId, inboundId,
requiredSlotCount, expandKey, operatorId))), "称重确认后整票库位块扩容失败");
requireBlockResult(expanded, requiredSlotCount, "OCCUPIED");
if (expanded.blockId() != blockId) {
throw new BizException(ResultCode.CONFLICT, "Warehouse扩容返回了不同整票库位块");
}
requirePreservedSlots(current, expanded);
allocations.replaceBlock(tenantId, inboundId, warehouseId, packageCount, volume, expanded);
}
allocations.bindConfirmedWeights(tenantId, inboundId, confirmedWeights);
return;
}
// Warehouse retains released blocks for audit. A compensated attempt therefore needs a new
// reservation key, otherwise a retry would replay the released block instead of reserving again.
String key = "measure-block-" + inboundId + "-" + measurementId + "-" + measurementVersion
String key = "measure-block-" + inboundId + "-" + weightResultId + "-" + weightVersion
+ "-" + UUID.randomUUID();
WarehousePalletLocationClient.BlockResult reserved = requireSuccess(call(() -> warehouse.reserveBlock(
internalToken, new WarehousePalletLocationClient.BlockReserveRequest(warehouseId,
SOURCE_SERVICE, inboundId, order.getInboundCode(), requiredSlotCount, key, operatorId,
LocalDateTime.now().plus(properties.getReservationTtl())))),
"测量完成后库位预占失败");
int packageCount = requirePackageCount(order);
requireBlockResult(reserved, requiredSlotCount, "RESERVED");
allocations.replaceBlock(tenantId, inboundId, warehouseId, packageCount, volume, reserved);
allocations.bindConfirmedWeights(tenantId, inboundId, confirmedWeights);
try {
WarehousePalletLocationClient.BlockResult occupied = requireSuccess(call(() -> warehouse.confirmBlock(
internalToken, new WarehousePalletLocationClient.BlockConfirmRequest(reserved.blockId(), inboundId,
@@ -75,6 +91,68 @@ public class MeasurementLocationOccupationService {
}
}
public void requireOccupied(long tenantId, long inboundId) {
InboundOrderEntity order = requireOrder(tenantId, inboundId);
long warehouseId = requireWarehouse(order);
var confirmedWeights = confirmedWeights(tenantId, inboundId);
int requiredSlotCount = requiredSlotCount(requireVolume(tenantId, inboundId, order), confirmedWeights.size());
var current = allocations.list(tenantId, inboundId);
requireActiveBlock(current, warehouseId);
if (current.size() < requiredSlotCount) {
throw new BizException(ResultCode.CONFLICT, "整票库位块容量不足,请重新确认称重图片");
}
requireAllWeightsBound(current, confirmedWeights);
}
private long requireActiveBlock(java.util.List<com.baiyue.firstleg.entity.InboundPalletAllocationEntity> current,
long warehouseId) {
if (current.isEmpty() || current.get(0).getBlockId() == null || current.get(0).getBlockId() <= 0) {
throw new BizException(ResultCode.CONFLICT, "称重图片尚未建立整票库位块");
}
long blockId = current.get(0).getBlockId();
if (current.stream().anyMatch(slot -> !Long.valueOf(blockId).equals(slot.getBlockId())
|| !Long.valueOf(warehouseId).equals(slot.getWarehouseId())
|| !"CONFIRMED".equals(slot.getAllocationStatus())
|| slot.getSlotNo() == null || slot.getSlotNo() <= 0
|| slot.getReservationId() == null || slot.getReservationId() <= 0
|| slot.getLocationId() == null || slot.getLocationId() <= 0)) {
throw new BizException(ResultCode.CONFLICT, "整票库位块快照不完整或状态异常");
}
java.util.List<Integer> slotNos = current.stream().map(item -> item.getSlotNo()).sorted().toList();
if (!slotNos.equals(java.util.stream.IntStream.rangeClosed(1, current.size()).boxed().toList())) {
throw new BizException(ResultCode.CONFLICT, "整票库位块槽序不连续");
}
return blockId;
}
private static void requireAllWeightsBound(
java.util.List<com.baiyue.firstleg.entity.InboundPalletAllocationEntity> current,
java.util.List<InboundWeightResultEntity> confirmedWeights) {
java.util.Set<Long> bound = current.stream()
.filter(item -> "BOUND".equals(item.getSlotStatus()))
.map(item -> item.getWeightResultId()).filter(java.util.Objects::nonNull)
.collect(java.util.stream.Collectors.toSet());
if (confirmedWeights.stream().map(InboundWeightResultEntity::getId).anyMatch(id -> !bound.contains(id))) {
throw new BizException(ResultCode.CONFLICT, "已确认称重图片未完整绑定整票库位槽");
}
}
private static void requirePreservedSlots(
java.util.List<com.baiyue.firstleg.entity.InboundPalletAllocationEntity> current,
WarehousePalletLocationClient.BlockResult expanded) {
java.util.Map<Integer, WarehousePalletLocationClient.BlockResource> bySlot = expanded.resources().stream()
.collect(java.util.stream.Collectors.toMap(WarehousePalletLocationClient.BlockResource::slotNo,
java.util.function.Function.identity()));
if (current.stream().anyMatch(slot -> {
WarehousePalletLocationClient.BlockResource resource = bySlot.get(slot.getSlotNo());
return resource == null || resource.reservationId() != slot.getReservationId()
|| resource.locationId() != slot.getLocationId()
|| resource.sharedOverflow() != Boolean.TRUE.equals(slot.getSharedOverflow());
})) {
throw new BizException(ResultCode.CONFLICT, "Warehouse扩容改变了既有库位槽");
}
}
private InboundOrderEntity requireOrder(long tenantId, long inboundId) {
InboundOrderEntity order = workflow.findOwned(tenantId, inboundId);
if (order == null) throw new BizException(ResultCode.NOT_FOUND, "入库单不存在");
@@ -88,9 +166,14 @@ public class MeasurementLocationOccupationService {
return order.getWarehouseId();
}
private static BigDecimal requireVolume(InboundOrderEntity order) {
private BigDecimal requireVolume(long tenantId, long inboundId, InboundOrderEntity order) {
var measurement = measurements.findLatestMeasurement(tenantId, inboundId);
if (measurement != null && measurement.getOriginalVolumeCbm() != null
&& measurement.getOriginalVolumeCbm().signum() > 0) {
return measurement.getOriginalVolumeCbm();
}
if (order.getActualVolumeCbm() == null || order.getActualVolumeCbm().signum() <= 0) {
throw new BizException(ResultCode.CONFLICT, "入库单缺少有效实测体积");
throw new BizException(ResultCode.CONFLICT, "入库单缺少有效客户申报体积");
}
return order.getActualVolumeCbm();
}
@@ -154,7 +237,8 @@ public class MeasurementLocationOccupationService {
int requiredSlotCount, String expectedStatus) {
if (result.blockId() <= 0 || !expectedStatus.equals(result.blockStatus())
|| result.resources() == null || result.resources().size() != requiredSlotCount
|| result.resources().stream().anyMatch(resource -> resource.blockId() != result.blockId())) {
|| result.resources().stream().anyMatch(resource -> resource.blockId() != result.blockId()
|| !expectedStatus.equals(resource.resourceStatus()))) {
throw new BizException(ResultCode.CONFLICT, "Warehouse返回的整票库位块不完整");
}
java.util.List<Integer> slotNos = result.resources().stream()
@@ -19,7 +19,7 @@ public class MeasurementSubmissionService {
long tenantId, long inboundId, long operatorId,
InboundMeasurementRequests.SubmitRequest request) {
var result = measurements.submit(tenantId, inboundId, operatorId, request);
locations.occupy(tenantId, inboundId, operatorId, result.measurementId(), result.version());
locations.requireOccupied(tenantId, inboundId);
return result;
}
}
@@ -996,6 +996,9 @@ paths:
分别写入 CORRECT 和 CONFIRM 修订记录。输入表示含托盘毛重;服务端统一换算为 KG,
使用创建时固化的托盘皮重计算 netWeightKg,且毛重必须大于皮重。
入库单 actualGrossWeightKg 为实测毛重;actualNetWeightKg 为扣除托盘皮重后的货物净重,两者不得混用。
首张图片确认成功时按实测体积容量与已确认图片数建立并占用整票库位块;后续图片绑定下一个空槽。
所需槽数增长时原子扩容原块,既有 blockId、slotNo、reservationId 和 locationId 保持不变,
普通连续位不足的新增槽使用同仓共享 A0。确认响应立即返回图片对应的权威块槽绑定。
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]*$'}}
@@ -1081,12 +1084,12 @@ paths:
/api/platform/v1/inbounds/{inboundId}/measurements/submit:
post:
summary: 提交测量完成
description: 测量提交后立即按总体积拆分普通托盘、分配并确认占用库位,再进入客服待审核;无普通空闲位时共享 A0。
description: 提交只校验并复用称重图片确认阶段已经 OCCUPIED 且绑定完整的权威整票库位块,然后进入客服待审核;不会首次分配、重建或更换已显示槽位。
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]*$'}}
responses:
'200': {description: 成功时 processStatus=MEASURE_PENDING、measureReviewStatus=PENDING,且库位已 OCCUPIED}
'200': {description: 成功时 processStatus=MEASURE_PENDING、measureReviewStatus=PENDING,且复用的整票库位块保持 OCCUPIED}
'403': {description: 无测量提交权限}
/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing:
get:
@@ -1728,7 +1731,7 @@ components:
palletLocationGuide:
allOf: [{$ref: '#/components/schemas/InboundPalletLocationGuide'}]
nullable: true
description: 服务端权威的整票上架引导;尚未预留库位时为空,禁止由 weightResults 数量或数组下标推导
description: 服务端权威的整票上架引导;首张称重图片确认前为空,确认成功后立即返回并在后续确认时原位扩容,禁止由 weightResults 数量或数组下标推导
InboundPalletLocationGuide:
type: object
required: [blockId, reservedSlotCount, actualPalletCount, reservedEmptySlotCount, slots]
@@ -20,6 +20,14 @@ class InboundMeasurementEvidenceOpenApiContractTest {
Map<String, Object> detailPath = stringMap(paths.get(
"/api/platform/v1/first-leg/inbounds/{inboundId}/measurement"));
assertThat(detailPath).containsKey("get");
Map<String, Object> confirmOperation = stringMap(stringMap(paths.get(
"/api/platform/v1/first-leg/inbounds/{inboundId}/weight-results/{resultId}/confirm")).get("post"));
assertThat(confirmOperation.get("description").toString())
.contains("首张图片确认成功时", "原子扩容原块", "确认响应立即返回");
Map<String, Object> submitOperation = stringMap(stringMap(paths.get(
"/api/platform/v1/inbounds/{inboundId}/measurements/submit")).get("post"));
assertThat(submitOperation.get("description").toString())
.contains("只校验并复用", "不会首次分配");
Map<String, Object> schemas = stringMap(stringMap(document.get("components")).get("schemas"));
Map<String, Object> detailProperties = properties(schemas, "InboundMeasurementDetail");
@@ -61,6 +61,7 @@ class InboundMeasurementServiceTest {
@MockBean private MeasurementImageClient imageClient;
@MockBean private WarehousePalletLocationClient warehousePallets;
@MockBean private InboundPalletAllocationRepository palletAllocations;
@MockBean private MeasurementLocationOccupationService locationOccupation;
@Resource private InboundMeasurementService service;
@BeforeEach
@@ -555,7 +556,7 @@ class InboundMeasurementServiceTest {
}
@Test
void confirmingAWeightBindsItToTheNextEmptySlotWhenTheTicketAlreadyHasABlock() {
void confirmingAWeightReturnsTheAuthoritativeSlotBoundByTheLocationOrchestrator() {
InboundOrderEntity inbound = inbound("MEASURE_REJECTED");
InboundWeightResultEntity weight = weight(20L, "30.000", "KG", "PENDING_CONFIRM", 0);
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
@@ -564,11 +565,18 @@ class InboundMeasurementServiceTest {
when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight));
when(palletAllocations.list(1L, 10L)).thenReturn(List.of(
locationSlot(1, 801L, "A01", "RESERVED_EMPTY", null)));
when(palletAllocations.findByWeightResult(1L, 10L, 20L)).thenReturn(
locationSlot(1, 801L, "A01", "BOUND", 20L));
service.confirmWeightResult(1L, 10L, 20L, 9L, "操作员",
InboundMeasurementResponses.WeightResult result = service.confirmWeightResult(
1L, 10L, 20L, 9L, "操作员",
new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, null));
verify(palletAllocations).bindNextEmpty(1L, 10L, weight);
verify(locationOccupation).ensureForConfirmedWeight(1L, 10L, 9L, 20L, 1);
assertThat(result.blockId()).isEqualTo(700L);
assertThat(result.slotNo()).isEqualTo(1);
assertThat(result.locationCode()).isEqualTo("A01");
assertThat(result.slotStatus()).isEqualTo("BOUND");
}
@Test
@@ -5,6 +5,7 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.WarehousePalletLocationClient;
import com.baiyue.firstleg.config.InboundProperties;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundMeasurementEntity;
import com.baiyue.firstleg.entity.InboundWeightResultEntity;
import com.baiyue.firstleg.repository.InboundMeasurementRepository;
import com.baiyue.firstleg.repository.InboundPalletAllocationRepository;
@@ -57,7 +58,7 @@ class MeasurementLocationOccupationServiceTest {
when(warehouse.confirmBlock(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("OCCUPIED", 3)));
service.occupy(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3);
service.ensureForConfirmedWeight(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3);
ArgumentCaptor<WarehousePalletLocationClient.BlockReserveRequest> reserve =
ArgumentCaptor.forClass(WarehousePalletLocationClient.BlockReserveRequest.class);
@@ -74,14 +75,14 @@ class MeasurementLocationOccupationServiceTest {
assertThat(confirm.getValue().idempotencyKey())
.isEqualTo(reserve.getValue().idempotencyKey() + "-confirm");
verify(allocations, times(2)).replaceBlock(
verify(allocations).replaceBlock(
org.mockito.ArgumentMatchers.eq(BUSINESS_TENANT),
org.mockito.ArgumentMatchers.eq(INBOUND),
org.mockito.ArgumentMatchers.eq(WAREHOUSE),
org.mockito.ArgumentMatchers.eq(20),
org.mockito.ArgumentMatchers.eq(new BigDecimal("1.0000")),
org.mockito.ArgumentMatchers.any());
verify(allocations, times(2)).bindConfirmedWeights(
verify(allocations).bindConfirmedWeights(
org.mockito.ArgumentMatchers.eq(BUSINESS_TENANT),
org.mockito.ArgumentMatchers.eq(INBOUND), org.mockito.ArgumentMatchers.anyList());
}
@@ -96,7 +97,7 @@ class MeasurementLocationOccupationServiceTest {
when(warehouse.confirmBlock(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("OCCUPIED", 3)));
service.occupy(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3);
service.ensureForConfirmedWeight(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3);
ArgumentCaptor<WarehousePalletLocationClient.BlockReserveRequest> reserve =
ArgumentCaptor.forClass(WarehousePalletLocationClient.BlockReserveRequest.class);
@@ -108,7 +109,7 @@ class MeasurementLocationOccupationServiceTest {
verify(warehouse).confirmBlock(org.mockito.ArgumentMatchers.eq("internal-token"), confirm.capture());
assertThat(confirm.getValue().businessId()).isEqualTo(INBOUND);
verify(allocations, times(2)).replaceBlock(
verify(allocations).replaceBlock(
org.mockito.ArgumentMatchers.eq(BUSINESS_TENANT),
org.mockito.ArgumentMatchers.eq(INBOUND),
org.mockito.ArgumentMatchers.eq(WAREHOUSE),
@@ -129,11 +130,34 @@ class MeasurementLocationOccupationServiceTest {
when(warehouse.confirmBlock(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("OCCUPIED", 3)));
service.occupy(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3);
service.ensureForConfirmedWeight(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3);
verify(warehouse).reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.argThat(request -> request.requiredSlotCount() == 3));
verify(allocations, times(2)).bindConfirmedWeights(BUSINESS_TENANT, INBOUND, twoWeights);
verify(allocations).bindConfirmedWeights(BUSINESS_TENANT, INBOUND, twoWeights);
}
@Test
void reservesByCustomerDeclaredVolumeBeforeMeasuredVolumeIsFrozen() {
InboundOrderEntity inbound = inbound();
inbound.setActualVolumeCbm(null);
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setOriginalVolumeCbm(new BigDecimal("2.5000"));
when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound);
when(measurements.findLatestMeasurement(BUSINESS_TENANT, INBOUND)).thenReturn(measurement);
when(measurements.listWeights(BUSINESS_TENANT, INBOUND))
.thenReturn(confirmedWeights().subList(0, 2));
when(warehouse.reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("RESERVED", 3)));
when(warehouse.confirmBlock(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("OCCUPIED", 3)));
service.ensureForConfirmedWeight(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3);
verify(warehouse).reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.argThat(request -> request.requiredSlotCount() == 3));
verify(allocations).replaceBlock(BUSINESS_TENANT, INBOUND, WAREHOUSE,
20, new BigDecimal("2.5000"), block("OCCUPIED", 3));
}
@Test
@@ -143,7 +167,7 @@ class MeasurementLocationOccupationServiceTest {
when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound());
when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(List.of(weight));
assertThatThrownBy(() -> service.occupy(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3))
assertThatThrownBy(() -> service.ensureForConfirmedWeight(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3))
.isInstanceOf(BizException.class)
.hasMessageContaining("缺少确认时间");
@@ -157,7 +181,7 @@ class MeasurementLocationOccupationServiceTest {
when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound());
when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(List.of(weight));
assertThatThrownBy(() -> service.occupy(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3))
assertThatThrownBy(() -> service.ensureForConfirmedWeight(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3))
.isInstanceOf(BizException.class)
.hasMessageContaining("缺少有效净重");
@@ -171,7 +195,7 @@ class MeasurementLocationOccupationServiceTest {
when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound());
when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(List.of(weight));
assertThatThrownBy(() -> service.occupy(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3))
assertThatThrownBy(() -> service.ensureForConfirmedWeight(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3))
.isInstanceOf(BizException.class)
.hasMessageContaining("缺少有效托盘");
@@ -191,15 +215,64 @@ class MeasurementLocationOccupationServiceTest {
when(warehouse.releaseBlock(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("RELEASED", 3)));
assertThatThrownBy(() -> service.occupy(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3))
assertThatThrownBy(() -> service.ensureForConfirmedWeight(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3))
.isSameAs(failure);
ArgumentCaptor<WarehousePalletLocationClient.BlockReleaseRequest> release =
ArgumentCaptor.forClass(WarehousePalletLocationClient.BlockReleaseRequest.class);
verify(warehouse).releaseBlock(org.mockito.ArgumentMatchers.eq("internal-token"), release.capture());
assertThat(release.getValue().businessId()).isEqualTo(INBOUND);
verify(allocations, never()).replaceBlock(org.mockito.ArgumentMatchers.anyLong(),
org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(),
org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.any());
}
@Test
void expandsOccupiedBlockWithoutChangingExistingSlotsWhenConfirmedCountGrows() {
InboundOrderEntity inbound = inbound();
List<InboundWeightResultEntity> weights = confirmedWeights();
List<com.baiyue.firstleg.entity.InboundPalletAllocationEntity> current =
java.util.stream.IntStream.rangeClosed(1, 2).mapToObj(MeasurementLocationOccupationServiceTest::slot)
.toList();
when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound);
when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(weights);
when(allocations.list(BUSINESS_TENANT, INBOUND)).thenReturn(current);
when(warehouse.expandBlock(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("OCCUPIED", 3)));
service.ensureForConfirmedWeight(BUSINESS_TENANT, INBOUND, OPERATOR, 803L, 1);
verify(warehouse).expandBlock(org.mockito.ArgumentMatchers.eq("internal-token"),
org.mockito.ArgumentMatchers.argThat(request -> request.blockId() == 500L
&& request.businessId() == INBOUND && request.requiredSlotCount() == 3
&& request.idempotencyKey().equals("measure-block-expand-88-803-1")));
verify(allocations).replaceBlock(BUSINESS_TENANT, INBOUND, WAREHOUSE,
20, new BigDecimal("1.0000"), block("RESERVED", 3));
20, new BigDecimal("1.0000"), block("OCCUPIED", 3));
verify(allocations).bindConfirmedWeights(BUSINESS_TENANT, INBOUND, weights);
}
@Test
void submitValidationReusesExistingBoundBlockWithoutWarehouseMutation() {
List<InboundWeightResultEntity> weights = confirmedWeights().subList(0, 2);
var first = slot(1);
var second = slot(2);
first.setWeightResultId(weights.get(0).getId());
second.setWeightResultId(weights.get(1).getId());
first.setSlotStatus("BOUND");
second.setSlotStatus("BOUND");
when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound());
when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(weights);
when(allocations.list(BUSINESS_TENANT, INBOUND)).thenReturn(List.of(first, second));
service.requireOccupied(BUSINESS_TENANT, INBOUND);
verify(warehouse, never()).reserveBlock(org.mockito.ArgumentMatchers.anyString(),
org.mockito.ArgumentMatchers.any());
verify(warehouse, never()).confirmBlock(org.mockito.ArgumentMatchers.anyString(),
org.mockito.ArgumentMatchers.any());
verify(warehouse, never()).expandBlock(org.mockito.ArgumentMatchers.anyString(),
org.mockito.ArgumentMatchers.any());
}
private static InboundProperties properties() {
@@ -235,6 +308,20 @@ class MeasurementLocationOccupationServiceTest {
.toList());
}
private static com.baiyue.firstleg.entity.InboundPalletAllocationEntity slot(int slotNo) {
var value = new com.baiyue.firstleg.entity.InboundPalletAllocationEntity();
value.setBlockId(500L);
value.setSlotNo(slotNo);
value.setWarehouseId(WAREHOUSE);
value.setReservationId(600L + slotNo);
value.setLocationId(700L + slotNo);
value.setLocationCode("A-00" + slotNo);
value.setSharedOverflow(false);
value.setAllocationStatus("CONFIRMED");
value.setSlotStatus("RESERVED_EMPTY");
return value;
}
private static List<InboundWeightResultEntity> confirmedWeights() {
return java.util.stream.LongStream.rangeClosed(1, 3).mapToObj(index -> {
InboundWeightResultEntity weight = new InboundWeightResultEntity();
@@ -35,7 +35,7 @@ class MeasurementSubmissionServiceTest {
assertThat(service.submit(10001L, 88L, 9L, request)).isSameAs(submitted);
verify(locations).occupy(10001L, 88L, 9L, 30L, 3);
verify(locations).requireOccupied(10001L, 88L);
}
@Test
@@ -45,6 +45,6 @@ class MeasurementSubmissionServiceTest {
30L, 3, "MEASURE_PENDING", "PENDING", "STANDARD", null, null, null);
when(measurements.submit(10001L, 88L, 9L, request)).thenReturn(submitted);
assertThat(service.submit(10001L, 88L, 9L, request)).isSameAs(submitted);
verify(locations).occupy(10001L, 88L, 9L, 30L, 3);
verify(locations).requireOccupied(10001L, 88L);
}
}
+3 -2
View File
@@ -129,10 +129,11 @@ node scripts/admin-test-env-acceptance.mjs --modules merchant-qualification-fixt
```
整票库位块专项可在只跑 `orders,inbound-measurement` 时追加
`--verify-pallet-location-guide true`。它在测量提交后核对
`--verify-pallet-location-guide true`。它在测量仍可编辑、正式提交前核对
`palletLocationGuide` 计数、每槽 `blockId/slotNo/reservationId/locationCode/slotStatus`、
称重结果按 `weightResultId` 显式绑定槽位,并真实验证删除图片后原 reservation/location
保留且转为 `RESERVED_EMPTY`。脚本只观测自然出现的共享 A0;不会为制造普通位不足批量停用库位。
保留且转为 `RESERVED_EMPTY`,再上传替代图片并确认复用原槽后继续提交与审核。脚本只观测自然出现的共享 A0;
不会为制造普通位不足批量停用库位。
只验证真实集装箱号晚补门禁时,使用正式可选参数 `LATE_CONTAINER_GATE_ONLY=1` 并仍从总入口执行 `phase3-loading`。该模式使用本轮 marker 独立造数,固定验证无号 push、订舱关联、整份预装确认、PDA claim/batch、无号 complete `A0409`、LOADING 阶段首次补号和补号后 complete 受理;随后允许后台命令停在 `CABINET_EVIDENCE/A0409`,以证明柜号门禁已经消失且下一门禁来自未上传的五类柜级证据。该模式不继续完整报关或 LOADED 链,也不得与完整链同时解释为同一终态验收。
@@ -76,6 +76,9 @@ test('opt-in HTTP acceptance verifies whole-ticket slot identity and delete-to-e
assert.match(source, /\['RESERVED_EMPTY', 'BOUND', 'TRANSFERRED', 'RELEASED'\]/)
assert.match(source, /deleteSucceeded/)
assert.match(source, /retainedSlot\?\.slotStatus === 'RESERVED_EMPTY'/)
assert.match(source, /recreateConfirmedWeightEvidence/)
assert.match(source, /reboundSlot\?\.slotStatus === 'BOUND'/)
assert.match(source, /replacementWeightResultId/)
assert.match(source, /不批量停用普通库位制造容量不足/)
})
@@ -332,6 +332,47 @@ function verifyPalletLocationGuide(context, response, workspace, expectedWeightR
return { guide, slots, weights, identityComplete: Boolean(identityComplete), bindingsStable }
}
async function recreateConfirmedWeightEvidence(context, {
inboundId, tenantId, palletId, grossWeightKg,
}) {
const uploadResponse = await context.requestMultipart('POST',
`/api/platform/v1/files/measurements/${tenantId}/${inboundId}`, {
content: WEIGHT_IMAGE, type: 'image/png', name: `weight-${inboundId}-replacement.png`,
})
const upload = context.apiSuccess(MODULE, '重新上传替代称重图片', uploadResponse,
`inboundId=${inboundId}`, OPTIONS)
const fileId = asId(upload?.fileId)
if (!fileId) return null
const createResponse = await context.request('POST',
`/api/platform/v1/first-leg/inbounds/${inboundId}/weight-results?${query(tenantId)}`, {
fileId, palletId, ocrProvider: 'ADMIN_ACCEPTANCE', ocrRawText: null,
ocrWeightValue: null, ocrWeightUnit: null, confidence: null,
})
const created = context.apiSuccess(MODULE, '登记替代称重图片与托盘快照', createResponse,
`fileId=${fileId}, palletId=${palletId}`, OPTIONS)
if (!created?.id) return null
const confirmResponse = await context.request('POST',
`/api/platform/v1/first-leg/inbounds/${inboundId}/weight-results/${created.id}/confirm?${query(tenantId)}`, {
version: Number(created.version), adjustedWeightValue: Number(grossWeightKg),
adjustedWeightUnit: 'KG', adjustmentReason: `${context.marker} 删除后替代称重确认`,
})
const confirmed = context.apiSuccess(MODULE, '确认替代称重图片并复用空槽', confirmResponse,
`resultId=${asId(created.id)}, palletId=${palletId}`, OPTIONS)
const valid = confirmed?.recognitionStatus === 'CONFIRMED'
&& Number.isFinite(Number(confirmed?.grossWeightKg))
&& Number.isFinite(Number(confirmed?.netWeightKg))
context.assert(MODULE, '替代图片形成新的已确认称重结果', confirmResponse, valid,
`resultId=${asId(confirmed?.id) || 'missing'}, status=${confirmed?.recognitionStatus || 'missing'}`,
OPTIONS)
return valid ? {
id: asId(confirmed.id), palletId: asId(confirmed.palletId),
grossWeightKg: Number(confirmed.grossWeightKg), netWeightKg: Number(confirmed.netWeightKg),
version: Number(confirmed.version),
} : null
}
function findIsolatedInbound(context) {
const roots = [context.createdResources, context.context]
const visited = new Set()
@@ -736,21 +777,20 @@ export async function run(context) {
return
}
const submitResponse = await context.request('POST',
`/api/platform/v1/first-leg/inbounds/${inboundId}/measurement/submit?${query(tenantId)}`,
{ measurementId: draft.measurementId, version: Number(draft.version) })
const submitted = context.apiSuccess(MODULE, '提交测量审核', submitResponse,
`measurementId=${asId(draft.measurementId)}`, OPTIONS)
context.assert(MODULE, '提交后进入待测量审核状态', submitResponse,
submitted?.processStatus === 'MEASURE_PENDING' && submitted?.measureReviewStatus === 'PENDING',
`processStatus=${submitted?.processStatus ?? 'missing'}, reviewStatus=${submitted?.measureReviewStatus ?? 'missing'}`, OPTIONS)
if (context.options?.verifyPalletLocationGuide) {
const guideResponse = await context.request('GET', measurementPath)
const guideWorkspace = context.apiSuccess(MODULE, '提交后读取整票库位块与称重图片槽绑定', guideResponse,
`inboundId=${inboundId}`, OPTIONS)
const guideWorkspace = context.apiSuccess(MODULE, '提交前读取整票库位块与称重图片槽绑定', guideResponse,
`inboundId=${inboundId}, processStatus=${guideResponse.body?.data?.processStatus ?? 'missing'}`, OPTIONS)
const beforeDelete = verifyPalletLocationGuide(context, guideResponse, guideWorkspace,
weightEvidence.confirmedResults.map((item) => item.id), '删除前')
if (!beforeDelete.identityComplete || !beforeDelete.bindingsStable) {
context.createdResources.inboundMeasurementLocationGuide = {
marker: context.marker, inboundId, tenantId, blockId: asId(beforeDelete.guide?.blockId),
preSubmitGuideAvailable: false, processStatus: guideWorkspace?.processStatus || null,
}
finishScenario(context, start)
return
}
const overflowSlots = beforeDelete.slots.filter((slot) => slot?.sharedOverflow === true)
context.addStep(MODULE, '正式数据 A0 共享溢出观测', guideResponse, true,
overflowSlots.length > 0
@@ -759,48 +799,92 @@ export async function run(context) {
{ scenario: SCENARIO })
const deletedWeight = weightEvidence.confirmedResults[0]
const previousSlot = beforeDelete.slots.find((slot) =>
asId(slot?.boundWeightResultId) === deletedWeight.id)
const deleteResponse = await context.request('DELETE',
`/api/platform/v1/first-leg/inbounds/${inboundId}/weight-results/${deletedWeight.id}?${query(tenantId)}&version=${deletedWeight.version}`)
const deleteSucceeded = deleteResponse.http === 200 && String(deleteResponse.body?.code) === '200'
context.assert(MODULE, '删除称重图片后保留原 reservation/location 并释放为空槽', deleteResponse,
deleteSucceeded,
context.assert(MODULE, '可编辑状态删除一张已确认称重图片', deleteResponse, deleteSucceeded,
`HTTP=${deleteResponse.http}, code=${deleteResponse.body?.code ?? 'missing'}, message=${deleteResponse.body?.message ?? 'missing'}, requestId=${deleteResponse.requestId || 'missing'}, traceId=${deleteResponse.traceId || 'missing'}`,
OPTIONS)
if (deleteSucceeded) {
const afterResponse = await context.request('GET', measurementPath)
const afterWorkspace = context.apiSuccess(MODULE, '删除后回查整票库位块', afterResponse,
`deletedWeightResultId=${deletedWeight.id}`, OPTIONS)
const afterDelete = verifyPalletLocationGuide(context, afterResponse, afterWorkspace,
weightEvidence.confirmedResults.slice(1).map((item) => item.id), '删除后')
const previousSlot = beforeDelete.slots.find((slot) => asId(slot?.boundWeightResultId) === deletedWeight.id)
const retainedSlot = afterDelete.slots.find((slot) =>
asId(slot?.reservationId) === asId(previousSlot?.reservationId)
&& asId(slot?.locationId) === asId(previousSlot?.locationId))
context.assert(MODULE, '删除只解绑图片且原槽变为 RESERVED_EMPTY', afterResponse,
Boolean(previousSlot) && retainedSlot?.slotStatus === 'RESERVED_EMPTY'
&& !asId(retainedSlot?.boundWeightResultId)
&& Number(afterDelete.guide?.reservedSlotCount) === Number(beforeDelete.guide?.reservedSlotCount),
`reservationId=${asId(retainedSlot?.reservationId) || 'missing'}, locationId=${asId(retainedSlot?.locationId) || 'missing'}, slotStatus=${retainedSlot?.slotStatus || 'missing'}`,
OPTIONS)
if (!deleteSucceeded) {
context.createdResources.inboundMeasurementLocationGuide = {
marker: context.marker, inboundId, tenantId, blockId: asId(afterDelete.guide?.blockId),
deletedWeightResultId: deletedWeight.id, deleteSucceeded: true,
sharedOverflowObserved: afterDelete.slots.some((slot) => slot?.sharedOverflow === true),
marker: context.marker, inboundId, tenantId, blockId: asId(beforeDelete.guide?.blockId),
deletedWeightResultId: deletedWeight.id, deleteSucceeded: false,
deleteCode: deleteResponse.body?.code || null, deleteMessage: deleteResponse.body?.message || null,
deleteRequestId: deleteResponse.requestId || null, deleteTraceId: deleteResponse.traceId || null,
sharedOverflowObserved: overflowSlots.length > 0,
}
finishScenario(context, start)
return
}
const afterDeleteResponse = await context.request('GET', measurementPath)
const afterDeleteWorkspace = context.apiSuccess(MODULE, '删除后回查整票库位块', afterDeleteResponse,
`deletedWeightResultId=${deletedWeight.id}`, OPTIONS)
const afterDelete = verifyPalletLocationGuide(context, afterDeleteResponse, afterDeleteWorkspace,
weightEvidence.confirmedResults.slice(1).map((item) => item.id), '删除后')
const retainedSlot = afterDelete.slots.find((slot) =>
asId(slot?.reservationId) === asId(previousSlot?.reservationId)
&& asId(slot?.locationId) === asId(previousSlot?.locationId))
const retainedEmpty = Boolean(previousSlot) && retainedSlot?.slotStatus === 'RESERVED_EMPTY'
&& !asId(retainedSlot?.boundWeightResultId)
&& Number(afterDelete.guide?.reservedSlotCount) === Number(beforeDelete.guide?.reservedSlotCount)
context.assert(MODULE, '删除只解绑图片且原 reservation/location 变为 RESERVED_EMPTY', afterDeleteResponse,
retainedEmpty,
`reservationId=${asId(retainedSlot?.reservationId) || 'missing'}, locationId=${asId(retainedSlot?.locationId) || 'missing'}, slotStatus=${retainedSlot?.slotStatus || 'missing'}`,
OPTIONS)
if (!retainedEmpty) {
finishScenario(context, start)
return
}
const replacementWeight = await recreateConfirmedWeightEvidence(context, {
inboundId, tenantId, palletId: deletedWeight.palletId, grossWeightKg: deletedWeight.grossWeightKg,
})
if (!replacementWeight) {
finishScenario(context, start)
return
}
const reboundResponse = await context.request('GET', measurementPath)
const reboundWorkspace = context.apiSuccess(MODULE, '替代图片确认后回查空槽复用', reboundResponse,
`replacementWeightResultId=${replacementWeight.id}`, OPTIONS)
const activeResults = [...weightEvidence.confirmedResults.slice(1), replacementWeight]
const rebound = verifyPalletLocationGuide(context, reboundResponse, reboundWorkspace,
activeResults.map((item) => item.id), '重绑后')
const reboundSlot = rebound.slots.find((slot) =>
asId(slot?.boundWeightResultId) === replacementWeight.id)
const reusedSlot = reboundSlot?.slotStatus === 'BOUND'
&& asId(reboundSlot?.reservationId) === asId(previousSlot?.reservationId)
&& asId(reboundSlot?.locationId) === asId(previousSlot?.locationId)
&& Number(rebound.guide?.reservedEmptySlotCount) === Number(beforeDelete.guide?.reservedEmptySlotCount)
context.assert(MODULE, '替代图片按新 resultId 复用原空槽', reboundResponse, reusedSlot,
`oldResultId=${deletedWeight.id}, newResultId=${replacementWeight.id}, reservationId=${asId(reboundSlot?.reservationId) || 'missing'}, locationId=${asId(reboundSlot?.locationId) || 'missing'}, slotStatus=${reboundSlot?.slotStatus || 'missing'}`,
OPTIONS)
if (!reusedSlot) {
finishScenario(context, start)
return
}
weightEvidence.confirmedResults = activeResults
weightEvidence.grossWeightKg = activeResults.reduce((sum, item) => sum + item.grossWeightKg, 0)
weightEvidence.netWeightKg = activeResults.reduce((sum, item) => sum + item.netWeightKg, 0)
context.createdResources.inboundMeasurementLocationGuide = {
marker: context.marker, inboundId, tenantId, blockId: asId(beforeDelete.guide?.blockId),
deletedWeightResultId: deletedWeight.id, deleteSucceeded: false,
deleteCode: deleteResponse.body?.code || null,
deleteMessage: deleteResponse.body?.message || null,
deleteRequestId: deleteResponse.requestId || null,
deleteTraceId: deleteResponse.traceId || null,
sharedOverflowObserved: overflowSlots.length > 0,
marker: context.marker, inboundId, tenantId, blockId: asId(rebound.guide?.blockId),
deletedWeightResultId: deletedWeight.id, replacementWeightResultId: replacementWeight.id,
deleteSucceeded: true, slotReused: true, reservationId: asId(reboundSlot?.reservationId),
locationId: asId(reboundSlot?.locationId), sharedOverflowObserved: overflowSlots.length > 0,
}
}
const submitResponse = await context.request('POST',
`/api/platform/v1/first-leg/inbounds/${inboundId}/measurement/submit?${query(tenantId)}`,
{ measurementId: draft.measurementId, version: Number(draft.version) })
const submitted = context.apiSuccess(MODULE, '提交测量审核', submitResponse,
`measurementId=${asId(draft.measurementId)}`, OPTIONS)
context.assert(MODULE, '提交后进入待测量审核状态', submitResponse,
submitted?.processStatus === 'MEASURE_PENDING' && submitted?.measureReviewStatus === 'PENDING',
`processStatus=${submitted?.processStatus ?? 'missing'}, reviewStatus=${submitted?.measureReviewStatus ?? 'missing'}`, OPTIONS)
const rejectedScanResponse = await multipartRequest(context, scanPath, {
boxCode, ...replacement, machineNo: 'MJ-ADMIN-E2E-03', measuredAt: new Date().toISOString().slice(0, 19),
})
@@ -70,6 +70,15 @@ public class InternalPalletLocationController {
return ApiResponse.success(blockService.confirm(request));
}
@PostMapping("/blocks/expand")
@Operation(summary = "原子扩容整票库位块", description = "保留原槽资源标识,新增槽优先紧邻原连续普通位,缺口共享A0")
public ApiResponse<LocationBlockDtos.BlockResult> expandBlock(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody LocationBlockDtos.ExpandRequest request) {
verify(token);
return ApiResponse.success(blockService.expand(request));
}
@PostMapping("/blocks/transfer")
public ApiResponse<LocationBlockDtos.BlockResult> transferBlock(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@@ -35,6 +35,15 @@ public final class LocationBlockDtos {
) {
}
public record ExpandRequest(
@Min(1) long blockId,
@Min(1) long businessId,
@Min(1) @Max(200) int requiredSlotCount,
@NotBlank @Size(max = 128) String idempotencyKey,
@Min(1) long operatorId
) {
}
public record TransferRequest(
@Min(1) long blockId,
@Min(1) long sourceBusinessId,
@@ -31,4 +31,14 @@ public interface LocationBlockMapper extends BaseMapper<LocationBlockEntity> {
int transfer(@Param("id") long id, @Param("sourceBusinessId") long sourceBusinessId,
@Param("targetBusinessId") long targetBusinessId, @Param("targetInboundCode") String targetInboundCode,
@Param("operationKey") String operationKey, @Param("version") int version);
@Update("""
UPDATE wh_location_block SET slot_count=#{slotCount}, last_operation_key=#{operationKey},
updated_at=CURRENT_TIMESTAMP(3), version=version+1
WHERE id=#{id} AND tenant_id=0 AND business_id=#{businessId}
AND block_status IN ('RESERVED','OCCUPIED') AND slot_count=#{previousSlotCount} AND version=#{version}
""")
int expand(@Param("id") long id, @Param("businessId") long businessId,
@Param("previousSlotCount") int previousSlotCount, @Param("slotCount") int slotCount,
@Param("operationKey") String operationKey, @Param("version") int version);
}
@@ -76,7 +76,7 @@ public class LocationBlockService {
"AVAILABLE", "RESERVED", location.getVersion(), request.operatorId()) != 1) {
throw new BizException(ResultCode.CONFLICT, "整票库位块候选被并发占用");
}
LocationBlockSlotEntity slot = slot(blockId, index + 1, location);
LocationBlockSlotEntity slot = slot(blockId, index + 1, location, "RESERVED");
slots.insert(slot);
allocated.add(slot);
}
@@ -86,6 +86,62 @@ public class LocationBlockService {
}
}
@Transactional
public LocationBlockDtos.BlockResult expand(LocationBlockDtos.ExpandRequest request) {
LocationBlockEntity block = lockOwned(request.blockId(), request.businessId());
List<LocationBlockSlotEntity> allocated = new ArrayList<>(slots.lockByBlockId(block.getId()));
if (request.idempotencyKey().equals(block.getLastOperationKey())) {
if (block.getSlotCount() != request.requiredSlotCount() || allocated.size() != block.getSlotCount()) {
throw new BizException(ResultCode.CONFLICT, "整票库位块扩容幂等键已用于不同请求");
}
return result(block, allocated);
}
if (!("RESERVED".equals(block.getBlockStatus()) || "OCCUPIED".equals(block.getBlockStatus()))) {
throw new BizException(ResultCode.CONFLICT, "只有活动整票库位块允许扩容");
}
if (request.requiredSlotCount() <= block.getSlotCount()) {
throw new BizException(ResultCode.BAD_REQUEST, "整票库位块只允许扩容,不允许缩容或无变化提交");
}
if (allocated.size() != block.getSlotCount()) {
throw new BizException(ResultCode.CONFLICT, "整票库位块槽位数量不完整");
}
requireActiveWarehouse(block.getWarehouseId());
int additional = request.requiredSlotCount() - block.getSlotCount();
List<LocationEntity> available = locations.lockAvailable(RESOURCE_TENANT_ID, block.getWarehouseId(), CANDIDATE_LIMIT);
List<LocationEntity> adjacent = selectAdjacentLocations(block, allocated, available, additional);
LocationEntity overflow = adjacent.size() < additional
? locations.lockOrCreateSharedOverflow(RESOURCE_TENANT_ID, block.getWarehouseId(), request.operatorId()) : null;
if (adjacent.size() < additional && overflow == null) {
throw new BizException(ResultCode.CONFLICT, "系统共享溢出位 A0 创建或读取失败");
}
String resourceStatus = block.getBlockStatus();
try {
for (int offset = 0; offset < additional; offset++) {
LocationEntity location = offset < adjacent.size() ? adjacent.get(offset) : overflow;
if (!Boolean.TRUE.equals(location.getSharedOverflow())
&& locations.changeStatus(RESOURCE_TENANT_ID, block.getWarehouseId(), location.getId(),
"AVAILABLE", resourceStatus, location.getVersion(), request.operatorId()) != 1) {
throw new BizException(ResultCode.CONFLICT, "整票库位块扩容候选被并发占用");
}
LocationBlockSlotEntity added = slot(block.getId(), block.getSlotCount() + offset + 1,
location, resourceStatus);
slots.insert(added);
allocated.add(added);
}
if (blocks.expand(block.getId(), request.businessId(), block.getSlotCount(), request.requiredSlotCount(),
request.idempotencyKey(), block.getVersion()) != 1) {
throw new BizException(ResultCode.CONFLICT, "整票库位块扩容时状态发生并发变更");
}
} catch (DataIntegrityViolationException exception) {
throw new BizException(ResultCode.CONFLICT, "整票库位块扩容槽位重复或普通库位已被占用");
}
block.setSlotCount(request.requiredSlotCount());
block.setLastOperationKey(request.idempotencyKey());
return result(block, allocated);
}
@Transactional
public LocationBlockDtos.BlockResult confirm(LocationBlockDtos.ConfirmRequest request) {
LocationBlockEntity block = lockOwned(request.blockId(), request.businessId());
@@ -220,6 +276,54 @@ public class LocationBlockService {
return true;
}
private List<LocationEntity> selectAdjacentLocations(LocationBlockEntity block,
List<LocationBlockSlotEntity> allocated,
List<LocationEntity> available, int required) {
LocationBlockSlotEntity lastNormal = allocated.stream()
.filter(item -> !Boolean.TRUE.equals(item.getSharedOverflow()))
.max(java.util.Comparator.comparingInt(LocationBlockSlotEntity::getSlotNo))
.orElse(null);
if (lastNormal == null) {
return List.of();
}
LocationEntity anchor = locations.lockOwned(RESOURCE_TENANT_ID, block.getWarehouseId(), lastNormal.getLocationId());
if (anchor == null) {
throw new BizException(ResultCode.CONFLICT, "整票库位块连续段锚点不存在");
}
Matcher anchorCode = NUMBERED_CODE.matcher(anchor.getLocationCode());
if (!anchorCode.matches()) {
return List.of();
}
String prefix = anchorCode.group(1);
int expected;
try {
expected = Math.addExact(Integer.parseInt(anchorCode.group(2)), 1);
} catch (ArithmeticException exception) {
return List.of();
}
List<LocationEntity> result = new ArrayList<>(required);
for (int offset = 0; offset < required; offset++) {
int target = expected + offset;
LocationEntity next = available.stream().filter(item -> {
Matcher code = NUMBERED_CODE.matcher(item.getLocationCode());
if (!code.matches() || !Objects.equals(anchor.getZoneCode(), item.getZoneCode())
|| !prefix.equals(code.group(1))) {
return false;
}
try {
return Integer.parseInt(code.group(2)) == target;
} catch (NumberFormatException exception) {
return false;
}
}).findFirst().orElse(null);
if (next == null) {
break;
}
result.add(next);
}
return List.copyOf(result);
}
private static LocationBlockEntity block(long id, LocationBlockDtos.ReserveRequest request) {
LocationBlockEntity entity = new LocationBlockEntity();
entity.setId(id);
@@ -237,7 +341,8 @@ public class LocationBlockService {
return entity;
}
private static LocationBlockSlotEntity slot(long blockId, int slotNo, LocationEntity location) {
private static LocationBlockSlotEntity slot(long blockId, int slotNo, LocationEntity location,
String resourceStatus) {
LocationBlockSlotEntity entity = new LocationBlockSlotEntity();
entity.setId(IdWorker.getId());
entity.setTenantId(RESOURCE_TENANT_ID);
@@ -246,7 +351,7 @@ public class LocationBlockService {
entity.setLocationId(location.getId());
entity.setLocationCode(location.getLocationCode());
entity.setSharedOverflow(Boolean.TRUE.equals(location.getSharedOverflow()));
entity.setResourceStatus("RESERVED");
entity.setResourceStatus(resourceStatus);
entity.setVersion(0);
return entity;
}
@@ -23,6 +23,16 @@ paths:
content: { application/json: { schema: { $ref: '#/components/schemas/ConfirmRequest' } } }
responses:
'200': { $ref: '#/components/responses/BlockResponse' }
/internal/warehouse/v1/pallet-locations/blocks/expand:
post:
summary: 原子扩容整票库位块
description: 仅允许活动块从N扩至更大N;保留原资源标识,新增槽优先紧邻原连续普通位,缺口共享A0。
security: [{ internalServiceToken: [] }]
requestBody:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/ExpandRequest' } } }
responses:
'200': { $ref: '#/components/responses/BlockResponse' }
/internal/warehouse/v1/pallet-locations/blocks/transfer:
post:
summary: 将整段资源归属转移给余量子入库单
@@ -81,6 +91,15 @@ components:
businessId: { type: integer, format: int64, minimum: 1 }
idempotencyKey: { type: string, maxLength: 128 }
operatorId: { type: integer, format: int64, minimum: 1 }
ExpandRequest:
type: object
required: [blockId, businessId, requiredSlotCount, idempotencyKey, operatorId]
properties:
blockId: { type: integer, format: int64, minimum: 1 }
businessId: { type: integer, format: int64, minimum: 1 }
requiredSlotCount: { type: integer, minimum: 1, maximum: 200 }
idempotencyKey: { type: string, maxLength: 128 }
operatorId: { type: integer, format: int64, minimum: 1 }
TransferRequest:
type: object
required: [blockId, sourceBusinessId, targetBusinessId, targetInboundCode, idempotencyKey, operatorId]
@@ -98,6 +98,22 @@ class InternalPalletLocationControllerTest {
.isEqualTo(ResultCode.FORBIDDEN);
}
@Test
void expandsWholeBlockOnlyForInternalCaller() {
LocationBlockDtos.ExpandRequest request = new LocationBlockDtos.ExpandRequest(
700L, 88L, 4, "expand-block-1", 9001L);
LocationBlockDtos.BlockResult expected = new LocationBlockDtos.BlockResult(700L, "OCCUPIED", List.of(
new LocationBlockDtos.Resource(700L, 704L, 43L, "A04", 4, false, "OCCUPIED")));
when(blockService.expand(request)).thenReturn(expected);
assertThat(controller.expandBlock(INTERNAL_TOKEN, request).data()).isEqualTo(expected);
verify(blockService).expand(request);
assertThatThrownBy(() -> controller.expandBlock("invalid", request))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.FORBIDDEN);
}
private static PalletAllocationDtos.TransferRequest request() {
return new PalletAllocationDtos.TransferRequest(10001L, "FIRSTLEG", 88L, 89L,
"RK-001-S1", "transfer-88-89", 9001L, List.of(new PalletAllocationDtos.TransferItem(40L, 5)));
@@ -0,0 +1,24 @@
package com.baiyue.warehouse.controller;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class LocationBlockOpenApiContractTest {
@Test
void publishesAtomicExpansionWithoutShrinkOrReplacementFields() throws Exception {
String yaml = Files.readString(Path.of("src/main/resources/openapi/location-block-v1.yaml"));
assertThat(yaml)
.contains("/internal/warehouse/v1/pallet-locations/blocks/expand:")
.contains("required: [blockId, businessId, requiredSlotCount, idempotencyKey, operatorId]")
.contains("requiredSlotCount: { type: integer, minimum: 1, maximum: 200 }")
.contains("保留原资源标识")
.contains("缺口共享A0")
.doesNotContain("replacementBlockId")
.doesNotContain("releaseSlotNos");
}
}
@@ -17,6 +17,7 @@ import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
@@ -122,6 +123,59 @@ class LocationBlockServiceTest {
verify(locations, never()).lockOwned(0L, 20L, 99L);
}
@Test
void expandsOccupiedBlockWithAdjacentLocationThenA0WithoutChangingExistingSlots() {
LocationBlockEntity block = block("OCCUPIED", 88L, null);
LocationBlockSlotEntity first = slot(701L, 1, 1L, "A01", false, "OCCUPIED");
LocationBlockSlotEntity second = slot(702L, 2, 2L, "A02", false, "OCCUPIED");
LocationEntity overflow = location(99L, "OVERFLOW", "A0");
overflow.setSharedOverflow(true);
when(blocks.lockById(700L)).thenReturn(block);
when(slots.lockByBlockId(700L)).thenReturn(List.of(first, second));
when(locations.lockOwned(0L, 20L, 2L)).thenReturn(location(2L, "A", "A02", "OCCUPIED"));
when(locations.lockAvailable(0L, 20L, 1000)).thenReturn(List.of(
location(3L, "A", "A03"), location(5L, "A", "A05"), location(20L, "B", "B01")));
when(locations.lockOrCreateSharedOverflow(0L, 20L, 9001L)).thenReturn(overflow);
when(blocks.expand(700L, 88L, 2, 5, "expand-1", 0)).thenReturn(1);
LocationBlockDtos.BlockResult result = service.expand(
new LocationBlockDtos.ExpandRequest(700L, 88L, 5, "expand-1", 9001L));
assertThat(result.blockId()).isEqualTo(700L);
assertThat(result.blockStatus()).isEqualTo("OCCUPIED");
assertThat(result.resources()).extracting(LocationBlockDtos.Resource::reservationId)
.startsWith(701L, 702L);
assertThat(result.resources()).extracting(LocationBlockDtos.Resource::locationCode)
.containsExactly("A01", "A02", "A03", "A0", "A0");
assertThat(result.resources()).extracting(LocationBlockDtos.Resource::slotNo)
.containsExactly(1, 2, 3, 4, 5);
assertThat(result.resources()).extracting(LocationBlockDtos.Resource::resourceStatus)
.containsOnly("OCCUPIED");
verify(locations).changeStatus(0L, 20L, 3L, "AVAILABLE", "OCCUPIED", 0, 9001L);
verify(locations, never()).changeStatus(eq(0L), eq(20L), eq(5L), any(), any(), anyInt(), anyLong());
verify(blocks).expand(700L, 88L, 2, 5, "expand-1", 0);
}
@Test
void replaysSameExpansionAndRejectsShrink() {
LocationBlockEntity replay = block("RESERVED", 88L, "expand-1");
replay.setSlotCount(3);
List<LocationBlockSlotEntity> expanded = List.of(
slot(701L, 1, 1L, "A01", false, "RESERVED"),
slot(702L, 2, 2L, "A02", false, "RESERVED"),
slot(703L, 3, 3L, "A03", false, "RESERVED"));
when(blocks.lockById(700L)).thenReturn(replay);
when(slots.lockByBlockId(700L)).thenReturn(expanded);
assertThat(service.expand(new LocationBlockDtos.ExpandRequest(700L, 88L, 3, "expand-1", 9001L))
.resources()).hasSize(3);
assertThatThrownBy(() -> service.expand(
new LocationBlockDtos.ExpandRequest(700L, 88L, 2, "expand-2", 9001L)))
.isInstanceOf(com.baiyue.common.core.exception.BizException.class)
.hasMessageContaining("不允许缩容");
verify(blocks, never()).expand(anyLong(), anyLong(), anyInt(), anyInt(), any(), anyInt());
}
private static LocationBlockDtos.ReserveRequest request(int slots) {
return new LocationBlockDtos.ReserveRequest(20L, "firstleg-service", 88L, "RK-001", slots,
"reserve-1", 9001L, LocalDateTime.now().plusHours(1));