fix: allocate pallet locations on weight confirmation
This commit is contained in:
+9
@@ -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]
|
||||
|
||||
+16
@@ -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)));
|
||||
|
||||
+24
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user