fix: allocate pallet locations on weight confirmation

This commit is contained in:
2026-09-04 13:21:37 +08:00
parent d6956d396c
commit 944ecb2bf3
21 changed files with 581 additions and 77 deletions
@@ -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);
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(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, "入库单不存在");
@@ -154,7 +232,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
@@ -57,7 +57,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 +74,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 +96,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 +108,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 +129,11 @@ 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
@@ -143,7 +143,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 +157,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 +171,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 +191,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 +284,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);
}
}