优化
This commit is contained in:
+37
@@ -4,8 +4,10 @@ import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.warehouse.dto.LocationDtos;
|
||||
import com.baiyue.warehouse.dto.LocationBlockDtos;
|
||||
import com.baiyue.warehouse.dto.PalletAllocationDtos;
|
||||
import com.baiyue.warehouse.service.PalletLocationAllocationService;
|
||||
import com.baiyue.warehouse.service.LocationBlockService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
@@ -31,6 +33,8 @@ import java.util.List;
|
||||
public class InternalPalletLocationController {
|
||||
@Resource
|
||||
private PalletLocationAllocationService service;
|
||||
@Resource
|
||||
private LocationBlockService blockService;
|
||||
@Value("${baiyue.security.internal-service-token}")
|
||||
private String internalToken;
|
||||
private byte[] expectedToken;
|
||||
@@ -49,6 +53,39 @@ public class InternalPalletLocationController {
|
||||
return ApiResponse.success(service.reserve(request));
|
||||
}
|
||||
|
||||
@PostMapping("/blocks/reservations")
|
||||
@Operation(summary = "原子预占整票库位块", description = "优先同库区同前缀连续编号,普通位不足的槽位共享A0")
|
||||
public ApiResponse<LocationBlockDtos.BlockResult> reserveBlock(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@Valid @RequestBody LocationBlockDtos.ReserveRequest request) {
|
||||
verify(token);
|
||||
return ApiResponse.success(blockService.reserve(request));
|
||||
}
|
||||
|
||||
@PostMapping("/blocks/confirm")
|
||||
public ApiResponse<LocationBlockDtos.BlockResult> confirmBlock(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@Valid @RequestBody LocationBlockDtos.ConfirmRequest request) {
|
||||
verify(token);
|
||||
return ApiResponse.success(blockService.confirm(request));
|
||||
}
|
||||
|
||||
@PostMapping("/blocks/transfer")
|
||||
public ApiResponse<LocationBlockDtos.BlockResult> transferBlock(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@Valid @RequestBody LocationBlockDtos.TransferRequest request) {
|
||||
verify(token);
|
||||
return ApiResponse.success(blockService.transfer(request));
|
||||
}
|
||||
|
||||
@PostMapping("/blocks/release")
|
||||
public ApiResponse<LocationBlockDtos.BlockResult> releaseBlock(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@Valid @RequestBody LocationBlockDtos.ReleaseRequest request) {
|
||||
verify(token);
|
||||
return ApiResponse.success(blockService.release(request));
|
||||
}
|
||||
|
||||
@PostMapping("/confirm")
|
||||
@Operation(summary = "确认库位占用", description = "托盘只保存主数据快照;本接口不推进头程入库单状态")
|
||||
public ApiResponse<PalletAllocationDtos.BatchResult> confirm(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.baiyue.warehouse.dto;
|
||||
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/** Firstleg 可消费的整票连续库位块内部契约。 */
|
||||
public final class LocationBlockDtos {
|
||||
private LocationBlockDtos() {
|
||||
}
|
||||
|
||||
public record ReserveRequest(
|
||||
@Min(1) long warehouseId,
|
||||
@NotBlank @Size(max = 40) String sourceService,
|
||||
@Min(1) long businessId,
|
||||
@NotBlank @Size(max = 40) String inboundCode,
|
||||
@Min(1) @Max(200) int requiredSlotCount,
|
||||
@NotBlank @Size(max = 128) String idempotencyKey,
|
||||
@Min(1) long operatorId,
|
||||
@NotNull LocalDateTime expiresAt
|
||||
) {
|
||||
}
|
||||
|
||||
public record ConfirmRequest(
|
||||
@Min(1) long blockId,
|
||||
@Min(1) long businessId,
|
||||
@NotBlank @Size(max = 128) String idempotencyKey,
|
||||
@Min(1) long operatorId
|
||||
) {
|
||||
}
|
||||
|
||||
public record TransferRequest(
|
||||
@Min(1) long blockId,
|
||||
@Min(1) long sourceBusinessId,
|
||||
@Min(1) long targetBusinessId,
|
||||
@NotBlank @Size(max = 40) String targetInboundCode,
|
||||
@NotBlank @Size(max = 128) String idempotencyKey,
|
||||
@Min(1) long operatorId
|
||||
) {
|
||||
}
|
||||
|
||||
public record ReleaseRequest(
|
||||
@Min(1) long blockId,
|
||||
@Min(1) long businessId,
|
||||
@NotBlank @Size(max = 128) String idempotencyKey,
|
||||
@Min(1) long operatorId,
|
||||
@NotBlank @Pattern(regexp = "^(ALL_PACKAGES_PICKED|TICKET_VOID|MANUAL_ADJUSTMENT)$") String reason
|
||||
) {
|
||||
}
|
||||
|
||||
public record Resource(
|
||||
long blockId,
|
||||
long reservationId,
|
||||
long locationId,
|
||||
String locationCode,
|
||||
int slotNo,
|
||||
boolean sharedOverflow,
|
||||
String resourceStatus
|
||||
) {
|
||||
}
|
||||
|
||||
public record BlockResult(long blockId, String blockStatus, List<Resource> resources) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.baiyue.warehouse.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("wh_location_block")
|
||||
public class LocationBlockEntity {
|
||||
@TableId(type = IdType.INPUT)
|
||||
private Long id;
|
||||
private Long tenantId;
|
||||
private Long warehouseId;
|
||||
private String sourceService;
|
||||
private Long businessId;
|
||||
private String inboundCode;
|
||||
private Integer slotCount;
|
||||
private String idempotencyKey;
|
||||
private String blockStatus;
|
||||
private LocalDateTime expiresAt;
|
||||
private String lastOperationKey;
|
||||
private Long createdBy;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
private Integer version;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.baiyue.warehouse.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("wh_location_block_slot")
|
||||
public class LocationBlockSlotEntity {
|
||||
@TableId(type = IdType.INPUT)
|
||||
private Long id;
|
||||
private Long tenantId;
|
||||
private Long blockId;
|
||||
private Integer slotNo;
|
||||
private Long locationId;
|
||||
private String locationCode;
|
||||
private Boolean sharedOverflow;
|
||||
private String resourceStatus;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
private Integer version;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.baiyue.warehouse.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.warehouse.entity.LocationBlockEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
@Mapper
|
||||
public interface LocationBlockMapper extends BaseMapper<LocationBlockEntity> {
|
||||
@Select("SELECT * FROM wh_location_block WHERE id=#{id} AND tenant_id=0 FOR UPDATE")
|
||||
LocationBlockEntity lockById(@Param("id") long id);
|
||||
|
||||
@Update("""
|
||||
UPDATE wh_location_block SET block_status=#{targetStatus}, 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=#{expectedStatus} AND version=#{version}
|
||||
""")
|
||||
int changeStatus(@Param("id") long id, @Param("businessId") long businessId,
|
||||
@Param("expectedStatus") String expectedStatus, @Param("targetStatus") String targetStatus,
|
||||
@Param("operationKey") String operationKey, @Param("version") int version);
|
||||
|
||||
@Update("""
|
||||
UPDATE wh_location_block SET business_id=#{targetBusinessId}, inbound_code=#{targetInboundCode},
|
||||
last_operation_key=#{operationKey}, updated_at=CURRENT_TIMESTAMP(3), version=version+1
|
||||
WHERE id=#{id} AND tenant_id=0 AND business_id=#{sourceBusinessId}
|
||||
AND block_status IN ('RESERVED','OCCUPIED') AND version=#{version}
|
||||
""")
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.warehouse.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.warehouse.entity.LocationBlockSlotEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface LocationBlockSlotMapper extends BaseMapper<LocationBlockSlotEntity> {
|
||||
@Select("SELECT * FROM wh_location_block_slot WHERE tenant_id=0 AND block_id=#{blockId} ORDER BY slot_no FOR UPDATE")
|
||||
List<LocationBlockSlotEntity> lockByBlockId(@Param("blockId") long blockId);
|
||||
|
||||
@Update("""
|
||||
UPDATE wh_location_block_slot SET resource_status=#{targetStatus}, updated_at=CURRENT_TIMESTAMP(3), version=version+1
|
||||
WHERE tenant_id=0 AND block_id=#{blockId} AND resource_status=#{expectedStatus}
|
||||
""")
|
||||
int changeBlockStatus(@Param("blockId") long blockId, @Param("expectedStatus") String expectedStatus,
|
||||
@Param("targetStatus") String targetStatus);
|
||||
}
|
||||
@@ -69,6 +69,11 @@ public interface LocationMapper extends BaseMapper<LocationEntity> {
|
||||
WHERE o.tenant_id = l.tenant_id AND o.location_id = l.id
|
||||
AND o.occupancy_status = 'OCCUPIED'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM wh_location_block_slot bs
|
||||
WHERE bs.tenant_id = l.tenant_id AND bs.location_id = l.id
|
||||
AND bs.resource_status IN ('RESERVED','OCCUPIED') AND bs.shared_overflow = 0
|
||||
)
|
||||
ORDER BY l.zone_code,
|
||||
REGEXP_REPLACE(l.location_code, '[0-9]+$', ''),
|
||||
CAST(COALESCE(REGEXP_SUBSTR(l.location_code, '[0-9]+$'), '0') AS UNSIGNED),
|
||||
@@ -98,6 +103,11 @@ public interface LocationMapper extends BaseMapper<LocationEntity> {
|
||||
WHERE o.tenant_id = l.tenant_id AND o.location_id = l.id
|
||||
AND o.occupancy_status = 'OCCUPIED'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM wh_location_block_slot bs
|
||||
WHERE bs.tenant_id = l.tenant_id AND bs.location_id = l.id
|
||||
AND bs.resource_status IN ('RESERVED','OCCUPIED') AND bs.shared_overflow = 0
|
||||
)
|
||||
ORDER BY l.zone_code,
|
||||
REGEXP_REPLACE(l.location_code, '[0-9]+$', ''),
|
||||
CAST(COALESCE(REGEXP_SUBSTR(l.location_code, '[0-9]+$'), '0') AS UNSIGNED),
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
package com.baiyue.warehouse.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.warehouse.dto.LocationBlockDtos;
|
||||
import com.baiyue.warehouse.entity.LocationBlockEntity;
|
||||
import com.baiyue.warehouse.entity.LocationBlockSlotEntity;
|
||||
import com.baiyue.warehouse.entity.LocationEntity;
|
||||
import com.baiyue.warehouse.entity.WarehouseEntity;
|
||||
import com.baiyue.warehouse.mapper.LocationBlockMapper;
|
||||
import com.baiyue.warehouse.mapper.LocationBlockSlotMapper;
|
||||
import com.baiyue.warehouse.repository.LocationRepository;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** 整票库位块的唯一写入边界;块内槽位不能单独释放或转移。 */
|
||||
@Service
|
||||
public class LocationBlockService {
|
||||
private static final long RESOURCE_TENANT_ID = 0L;
|
||||
private static final int CANDIDATE_LIMIT = 1000;
|
||||
private static final Pattern NUMBERED_CODE = Pattern.compile("^(.*?)(\\d+)$");
|
||||
|
||||
@Resource
|
||||
private LocationRepository locations;
|
||||
@Resource
|
||||
private LocationBlockMapper blocks;
|
||||
@Resource
|
||||
private LocationBlockSlotMapper slots;
|
||||
|
||||
@Transactional
|
||||
public LocationBlockDtos.BlockResult reserve(LocationBlockDtos.ReserveRequest request) {
|
||||
requireActiveWarehouse(request.warehouseId());
|
||||
if (!request.expiresAt().isAfter(LocalDateTime.now())) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "整票库位块过期时间必须晚于当前时间");
|
||||
}
|
||||
LocationBlockEntity existing = blocks.selectOne(new LambdaQueryWrapper<LocationBlockEntity>()
|
||||
.eq(LocationBlockEntity::getTenantId, RESOURCE_TENANT_ID)
|
||||
.eq(LocationBlockEntity::getSourceService, request.sourceService())
|
||||
.eq(LocationBlockEntity::getIdempotencyKey, request.idempotencyKey()));
|
||||
if (existing != null) {
|
||||
requireSameReservation(existing, request);
|
||||
return result(existing, slots.selectList(new LambdaQueryWrapper<LocationBlockSlotEntity>()
|
||||
.eq(LocationBlockSlotEntity::getTenantId, RESOURCE_TENANT_ID)
|
||||
.eq(LocationBlockSlotEntity::getBlockId, existing.getId())
|
||||
.orderByAsc(LocationBlockSlotEntity::getSlotNo)));
|
||||
}
|
||||
|
||||
List<LocationEntity> available = locations.lockAvailable(RESOURCE_TENANT_ID, request.warehouseId(), CANDIDATE_LIMIT);
|
||||
List<LocationEntity> selected = selectLocations(available, request.requiredSlotCount());
|
||||
LocationEntity overflow = selected.size() < request.requiredSlotCount()
|
||||
? locations.lockOrCreateSharedOverflow(RESOURCE_TENANT_ID, request.warehouseId(), request.operatorId()) : null;
|
||||
if (selected.size() < request.requiredSlotCount() && overflow == null) {
|
||||
throw new BizException(ResultCode.CONFLICT, "系统共享溢出位 A0 创建或读取失败");
|
||||
}
|
||||
|
||||
long blockId = IdWorker.getId();
|
||||
LocationBlockEntity block = block(blockId, request);
|
||||
try {
|
||||
blocks.insert(block);
|
||||
List<LocationBlockSlotEntity> allocated = new ArrayList<>(request.requiredSlotCount());
|
||||
for (int index = 0; index < request.requiredSlotCount(); index++) {
|
||||
LocationEntity location = index < selected.size() ? selected.get(index) : overflow;
|
||||
if (!Boolean.TRUE.equals(location.getSharedOverflow())
|
||||
&& locations.changeStatus(RESOURCE_TENANT_ID, request.warehouseId(), location.getId(),
|
||||
"AVAILABLE", "RESERVED", location.getVersion(), request.operatorId()) != 1) {
|
||||
throw new BizException(ResultCode.CONFLICT, "整票库位块候选被并发占用");
|
||||
}
|
||||
LocationBlockSlotEntity slot = slot(blockId, index + 1, location);
|
||||
slots.insert(slot);
|
||||
allocated.add(slot);
|
||||
}
|
||||
return result(block, allocated);
|
||||
} catch (DataIntegrityViolationException exception) {
|
||||
throw new BizException(ResultCode.CONFLICT, "整票库位块幂等键重复或普通库位已被占用");
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public LocationBlockDtos.BlockResult confirm(LocationBlockDtos.ConfirmRequest request) {
|
||||
LocationBlockEntity block = lockOwned(request.blockId(), request.businessId());
|
||||
List<LocationBlockSlotEntity> allocated = slots.lockByBlockId(block.getId());
|
||||
if ("OCCUPIED".equals(block.getBlockStatus()) && request.idempotencyKey().equals(block.getLastOperationKey())) {
|
||||
return result(block, allocated);
|
||||
}
|
||||
if (!"RESERVED".equals(block.getBlockStatus()) || allocated.size() != block.getSlotCount()) {
|
||||
throw new BizException(ResultCode.CONFLICT, "整票库位块不是可确认的完整预占状态");
|
||||
}
|
||||
for (LocationBlockSlotEntity slot : allocated) {
|
||||
if (!Boolean.TRUE.equals(slot.getSharedOverflow())) {
|
||||
LocationEntity location = locations.lockOwned(RESOURCE_TENANT_ID, block.getWarehouseId(), slot.getLocationId());
|
||||
if (location == null || locations.changeStatus(RESOURCE_TENANT_ID, block.getWarehouseId(), location.getId(),
|
||||
"RESERVED", "OCCUPIED", location.getVersion(), request.operatorId()) != 1) {
|
||||
throw new BizException(ResultCode.CONFLICT, "整票库位块确认时库位状态发生并发变更");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (slots.changeBlockStatus(block.getId(), "RESERVED", "OCCUPIED") != allocated.size()
|
||||
|| blocks.changeStatus(block.getId(), request.businessId(), "RESERVED", "OCCUPIED",
|
||||
request.idempotencyKey(), block.getVersion()) != 1) {
|
||||
throw new BizException(ResultCode.CONFLICT, "整票库位块确认时状态发生并发变更");
|
||||
}
|
||||
block.setBlockStatus("OCCUPIED");
|
||||
block.setLastOperationKey(request.idempotencyKey());
|
||||
allocated.forEach(item -> item.setResourceStatus("OCCUPIED"));
|
||||
return result(block, allocated);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public LocationBlockDtos.BlockResult transfer(LocationBlockDtos.TransferRequest request) {
|
||||
if (request.sourceBusinessId() == request.targetBusinessId()) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "来源与余量子入库单不能相同");
|
||||
}
|
||||
LocationBlockEntity block = blocks.lockById(request.blockId());
|
||||
if (block != null && request.idempotencyKey().equals(block.getLastOperationKey())
|
||||
&& block.getBusinessId() == request.targetBusinessId()
|
||||
&& request.targetInboundCode().equals(block.getInboundCode())) {
|
||||
return result(block, slots.lockByBlockId(block.getId()));
|
||||
}
|
||||
if (block == null || block.getBusinessId() != request.sourceBusinessId()
|
||||
|| "RELEASED".equals(block.getBlockStatus())) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "整票库位块不属于来源入库单或已释放");
|
||||
}
|
||||
if (blocks.transfer(block.getId(), request.sourceBusinessId(), request.targetBusinessId(),
|
||||
request.targetInboundCode(), request.idempotencyKey(), block.getVersion()) != 1) {
|
||||
throw new BizException(ResultCode.CONFLICT, "整票库位块归属发生并发变更");
|
||||
}
|
||||
block.setBusinessId(request.targetBusinessId());
|
||||
block.setInboundCode(request.targetInboundCode());
|
||||
block.setLastOperationKey(request.idempotencyKey());
|
||||
return result(block, slots.lockByBlockId(block.getId()));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public LocationBlockDtos.BlockResult release(LocationBlockDtos.ReleaseRequest request) {
|
||||
LocationBlockEntity block = lockOwned(request.blockId(), request.businessId());
|
||||
List<LocationBlockSlotEntity> allocated = slots.lockByBlockId(block.getId());
|
||||
if ("RELEASED".equals(block.getBlockStatus()) && request.idempotencyKey().equals(block.getLastOperationKey())) {
|
||||
return result(block, allocated);
|
||||
}
|
||||
if (!("RESERVED".equals(block.getBlockStatus()) || "OCCUPIED".equals(block.getBlockStatus()))) {
|
||||
throw new BizException(ResultCode.CONFLICT, "整票库位块已释放或状态非法");
|
||||
}
|
||||
String previous = block.getBlockStatus();
|
||||
for (LocationBlockSlotEntity slot : allocated) {
|
||||
if (!Boolean.TRUE.equals(slot.getSharedOverflow())) {
|
||||
LocationEntity location = locations.lockOwned(RESOURCE_TENANT_ID, block.getWarehouseId(), slot.getLocationId());
|
||||
if (location == null || locations.changeStatus(RESOURCE_TENANT_ID, block.getWarehouseId(), location.getId(),
|
||||
previous, "AVAILABLE", location.getVersion(), request.operatorId()) != 1) {
|
||||
throw new BizException(ResultCode.CONFLICT, "整票库位块释放时库位状态发生并发变更");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (slots.changeBlockStatus(block.getId(), previous, "RELEASED") != allocated.size()
|
||||
|| blocks.changeStatus(block.getId(), request.businessId(), previous, "RELEASED",
|
||||
request.idempotencyKey(), block.getVersion()) != 1) {
|
||||
throw new BizException(ResultCode.CONFLICT, "整票库位块释放时状态发生并发变更");
|
||||
}
|
||||
block.setBlockStatus("RELEASED");
|
||||
block.setLastOperationKey(request.idempotencyKey());
|
||||
allocated.forEach(item -> item.setResourceStatus("RELEASED"));
|
||||
return result(block, allocated);
|
||||
}
|
||||
|
||||
private void requireActiveWarehouse(long warehouseId) {
|
||||
WarehouseEntity warehouse = locations.findWarehouse(RESOURCE_TENANT_ID, warehouseId);
|
||||
if (warehouse == null || !"ACTIVE".equals(warehouse.getStatus())) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "可用平台公共仓库不存在");
|
||||
}
|
||||
}
|
||||
|
||||
private LocationBlockEntity lockOwned(long blockId, long businessId) {
|
||||
LocationBlockEntity block = blocks.lockById(blockId);
|
||||
if (block == null || block.getBusinessId() != businessId) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "整票库位块不属于指定入库单");
|
||||
}
|
||||
return block;
|
||||
}
|
||||
|
||||
private static List<LocationEntity> selectLocations(List<LocationEntity> available, int required) {
|
||||
if (available.size() < required) {
|
||||
return List.copyOf(available);
|
||||
}
|
||||
for (int start = 0; start <= available.size() - required; start++) {
|
||||
List<LocationEntity> run = available.subList(start, start + required);
|
||||
if (isContinuous(run)) {
|
||||
return List.copyOf(run);
|
||||
}
|
||||
}
|
||||
return List.copyOf(available.subList(0, required));
|
||||
}
|
||||
|
||||
private static boolean isContinuous(List<LocationEntity> run) {
|
||||
Matcher first = NUMBERED_CODE.matcher(run.get(0).getLocationCode());
|
||||
if (!first.matches()) {
|
||||
return false;
|
||||
}
|
||||
String zone = run.get(0).getZoneCode();
|
||||
String prefix = first.group(1);
|
||||
int previous = Integer.parseInt(first.group(2));
|
||||
for (int index = 1; index < run.size(); index++) {
|
||||
LocationEntity item = run.get(index);
|
||||
Matcher current = NUMBERED_CODE.matcher(item.getLocationCode());
|
||||
if (!current.matches() || !Objects.equals(zone, item.getZoneCode()) || !prefix.equals(current.group(1))
|
||||
|| Integer.parseInt(current.group(2)) != previous + 1) {
|
||||
return false;
|
||||
}
|
||||
previous++;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static LocationBlockEntity block(long id, LocationBlockDtos.ReserveRequest request) {
|
||||
LocationBlockEntity entity = new LocationBlockEntity();
|
||||
entity.setId(id);
|
||||
entity.setTenantId(RESOURCE_TENANT_ID);
|
||||
entity.setWarehouseId(request.warehouseId());
|
||||
entity.setSourceService(request.sourceService());
|
||||
entity.setBusinessId(request.businessId());
|
||||
entity.setInboundCode(request.inboundCode());
|
||||
entity.setSlotCount(request.requiredSlotCount());
|
||||
entity.setIdempotencyKey(request.idempotencyKey());
|
||||
entity.setBlockStatus("RESERVED");
|
||||
entity.setExpiresAt(request.expiresAt());
|
||||
entity.setCreatedBy(request.operatorId());
|
||||
entity.setVersion(0);
|
||||
return entity;
|
||||
}
|
||||
|
||||
private static LocationBlockSlotEntity slot(long blockId, int slotNo, LocationEntity location) {
|
||||
LocationBlockSlotEntity entity = new LocationBlockSlotEntity();
|
||||
entity.setId(IdWorker.getId());
|
||||
entity.setTenantId(RESOURCE_TENANT_ID);
|
||||
entity.setBlockId(blockId);
|
||||
entity.setSlotNo(slotNo);
|
||||
entity.setLocationId(location.getId());
|
||||
entity.setLocationCode(location.getLocationCode());
|
||||
entity.setSharedOverflow(Boolean.TRUE.equals(location.getSharedOverflow()));
|
||||
entity.setResourceStatus("RESERVED");
|
||||
entity.setVersion(0);
|
||||
return entity;
|
||||
}
|
||||
|
||||
private static void requireSameReservation(LocationBlockEntity block, LocationBlockDtos.ReserveRequest request) {
|
||||
if (block.getWarehouseId() != request.warehouseId() || block.getBusinessId() != request.businessId()
|
||||
|| block.getSlotCount() != request.requiredSlotCount() || !block.getInboundCode().equals(request.inboundCode())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "整票库位块幂等键已用于不同请求");
|
||||
}
|
||||
}
|
||||
|
||||
private static LocationBlockDtos.BlockResult result(LocationBlockEntity block, List<LocationBlockSlotEntity> slots) {
|
||||
return new LocationBlockDtos.BlockResult(block.getId(), block.getBlockStatus(), slots.stream()
|
||||
.map(item -> new LocationBlockDtos.Resource(block.getId(), item.getId(), item.getLocationId(),
|
||||
item.getLocationCode(), item.getSlotNo(), Boolean.TRUE.equals(item.getSharedOverflow()),
|
||||
item.getResourceStatus()))
|
||||
.toList());
|
||||
}
|
||||
}
|
||||
@@ -133,4 +133,54 @@ CREATE TABLE IF NOT EXISTS wh_location_occupancy (
|
||||
CONSTRAINT ck_occupancy_volume CHECK (allocated_volume_cbm > 0)
|
||||
) ENGINE=InnoDB COMMENT='已确认的库位占用事实记录,托盘字段仅为主数据快照';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wh_location_block (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '整票库位块ID',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共仓储资源域,固定为0',
|
||||
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '仓库ID',
|
||||
source_service VARCHAR(40) NOT NULL COMMENT '来源服务',
|
||||
business_id BIGINT UNSIGNED NOT NULL COMMENT '当前归属入库单业务ID',
|
||||
inbound_code VARCHAR(40) NOT NULL COMMENT '当前归属入仓码',
|
||||
slot_count INT UNSIGNED NOT NULL COMMENT '整票独占槽位数',
|
||||
idempotency_key VARCHAR(128) NOT NULL COMMENT '预占幂等键',
|
||||
block_status VARCHAR(20) NOT NULL COMMENT 'RESERVED、OCCUPIED或RELEASED',
|
||||
expires_at DATETIME(3) NOT NULL COMMENT '预占过期时间',
|
||||
last_operation_key VARCHAR(128) NULL COMMENT '最近确认、转移或释放幂等键',
|
||||
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人或系统主体ID',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
active_business_block TINYINT GENERATED ALWAYS AS
|
||||
(CASE WHEN block_status IN ('RESERVED','OCCUPIED') THEN 1 ELSE NULL END) STORED
|
||||
COMMENT '同一来源入库单仅允许一个活动整票块',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_location_block_idempotency (tenant_id, source_service, idempotency_key),
|
||||
UNIQUE KEY uk_location_block_active_business (tenant_id, source_service, business_id, active_business_block),
|
||||
KEY idx_location_block_business (tenant_id, source_service, business_id, block_status),
|
||||
CONSTRAINT fk_location_block_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id),
|
||||
CONSTRAINT ck_location_block_slot_count CHECK (slot_count BETWEEN 1 AND 200)
|
||||
) ENGINE=InnoDB COMMENT='一票入仓码原子持有的稳定库位块';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wh_location_block_slot (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '槽位预占ID,对外作为reservationId',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '平台公共仓储资源域,固定为0',
|
||||
block_id BIGINT UNSIGNED NOT NULL COMMENT '整票库位块ID',
|
||||
slot_no INT UNSIGNED NOT NULL COMMENT '块内稳定槽位序号,从1开始',
|
||||
location_id BIGINT UNSIGNED NOT NULL COMMENT '库位ID',
|
||||
location_code VARCHAR(50) NOT NULL COMMENT '库位编码快照',
|
||||
shared_overflow TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否落入同仓共享A0',
|
||||
resource_status VARCHAR(20) NOT NULL COMMENT 'RESERVED、OCCUPIED或RELEASED',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
active_normal_slot TINYINT GENERATED ALWAYS AS
|
||||
(CASE WHEN resource_status IN ('RESERVED','OCCUPIED') AND shared_overflow = 0 THEN 1 ELSE NULL END) STORED
|
||||
COMMENT '普通库位活动块槽唯一标记;A0返回NULL',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_location_block_slot_no (tenant_id, block_id, slot_no),
|
||||
UNIQUE KEY uk_location_block_active_normal (tenant_id, location_id, active_normal_slot),
|
||||
KEY idx_location_block_slot_block (tenant_id, block_id, resource_status),
|
||||
CONSTRAINT fk_location_block_slot_block FOREIGN KEY (block_id) REFERENCES wh_location_block (id),
|
||||
CONSTRAINT fk_location_block_slot_location FOREIGN KEY (location_id) REFERENCES wh_location (id)
|
||||
) ENGINE=InnoDB COMMENT='整票库位块槽位;空槽也由当前入仓码独占';
|
||||
|
||||
-- 头程入库单属于其他数据库,不建立跨库外键。
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Warehouse Internal Location Block API
|
||||
version: 1.0.0
|
||||
paths:
|
||||
/internal/warehouse/v1/pallet-locations/blocks/reservations:
|
||||
post:
|
||||
summary: 原子预占整票库位块
|
||||
security: [{ internalServiceToken: [] }]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/ReserveRequest' }
|
||||
responses:
|
||||
'200': { $ref: '#/components/responses/BlockResponse' }
|
||||
/internal/warehouse/v1/pallet-locations/blocks/confirm:
|
||||
post:
|
||||
summary: 整段确认占用
|
||||
security: [{ internalServiceToken: [] }]
|
||||
requestBody:
|
||||
required: true
|
||||
content: { application/json: { schema: { $ref: '#/components/schemas/ConfirmRequest' } } }
|
||||
responses:
|
||||
'200': { $ref: '#/components/responses/BlockResponse' }
|
||||
/internal/warehouse/v1/pallet-locations/blocks/transfer:
|
||||
post:
|
||||
summary: 将整段资源归属转移给余量子入库单
|
||||
security: [{ internalServiceToken: [] }]
|
||||
requestBody:
|
||||
required: true
|
||||
content: { application/json: { schema: { $ref: '#/components/schemas/TransferRequest' } } }
|
||||
responses:
|
||||
'200': { $ref: '#/components/responses/BlockResponse' }
|
||||
/internal/warehouse/v1/pallet-locations/blocks/release:
|
||||
post:
|
||||
summary: 整段释放
|
||||
description: 仅整票剩余箱数为0、整票作废或显式人工调整可调用;不提供单槽释放接口。
|
||||
security: [{ internalServiceToken: [] }]
|
||||
requestBody:
|
||||
required: true
|
||||
content: { application/json: { schema: { $ref: '#/components/schemas/ReleaseRequest' } } }
|
||||
responses:
|
||||
'200': { $ref: '#/components/responses/BlockResponse' }
|
||||
components:
|
||||
securitySchemes:
|
||||
internalServiceToken:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: X-Internal-Service-Token
|
||||
responses:
|
||||
BlockResponse:
|
||||
description: 统一 ApiResponse,data 为整票块快照
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [code, message, data]
|
||||
properties:
|
||||
code: { type: string }
|
||||
message: { type: string }
|
||||
data: { $ref: '#/components/schemas/BlockResult' }
|
||||
schemas:
|
||||
ReserveRequest:
|
||||
type: object
|
||||
required: [warehouseId, sourceService, businessId, inboundCode, requiredSlotCount, idempotencyKey, operatorId, expiresAt]
|
||||
properties:
|
||||
warehouseId: { type: integer, format: int64, minimum: 1 }
|
||||
sourceService: { type: string, maxLength: 40 }
|
||||
businessId: { type: integer, format: int64, minimum: 1 }
|
||||
inboundCode: { type: string, maxLength: 40 }
|
||||
requiredSlotCount: { type: integer, minimum: 1, maximum: 200 }
|
||||
idempotencyKey: { type: string, maxLength: 128 }
|
||||
operatorId: { type: integer, format: int64, minimum: 1 }
|
||||
expiresAt: { type: string, format: date-time }
|
||||
ConfirmRequest:
|
||||
type: object
|
||||
required: [blockId, businessId, idempotencyKey, operatorId]
|
||||
properties:
|
||||
blockId: { type: integer, format: int64, minimum: 1 }
|
||||
businessId: { type: integer, format: int64, minimum: 1 }
|
||||
idempotencyKey: { type: string, maxLength: 128 }
|
||||
operatorId: { type: integer, format: int64, minimum: 1 }
|
||||
TransferRequest:
|
||||
type: object
|
||||
required: [blockId, sourceBusinessId, targetBusinessId, targetInboundCode, idempotencyKey, operatorId]
|
||||
properties:
|
||||
blockId: { type: integer, format: int64, minimum: 1 }
|
||||
sourceBusinessId: { type: integer, format: int64, minimum: 1 }
|
||||
targetBusinessId: { type: integer, format: int64, minimum: 1 }
|
||||
targetInboundCode: { type: string, maxLength: 40 }
|
||||
idempotencyKey: { type: string, maxLength: 128 }
|
||||
operatorId: { type: integer, format: int64, minimum: 1 }
|
||||
ReleaseRequest:
|
||||
type: object
|
||||
required: [blockId, businessId, idempotencyKey, operatorId, reason]
|
||||
properties:
|
||||
blockId: { type: integer, format: int64, minimum: 1 }
|
||||
businessId: { type: integer, format: int64, minimum: 1 }
|
||||
idempotencyKey: { type: string, maxLength: 128 }
|
||||
operatorId: { type: integer, format: int64, minimum: 1 }
|
||||
reason: { type: string, enum: [ALL_PACKAGES_PICKED, TICKET_VOID, MANUAL_ADJUSTMENT] }
|
||||
BlockResult:
|
||||
type: object
|
||||
required: [blockId, blockStatus, resources]
|
||||
properties:
|
||||
blockId: { type: integer, format: int64 }
|
||||
blockStatus: { type: string, enum: [RESERVED, OCCUPIED, RELEASED] }
|
||||
resources:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/Resource' }
|
||||
Resource:
|
||||
type: object
|
||||
required: [blockId, reservationId, locationId, locationCode, slotNo, sharedOverflow, resourceStatus]
|
||||
properties:
|
||||
blockId: { type: integer, format: int64 }
|
||||
reservationId: { type: integer, format: int64 }
|
||||
locationId: { type: integer, format: int64 }
|
||||
locationCode: { type: string }
|
||||
slotNo: { type: integer, minimum: 1 }
|
||||
sharedOverflow: { type: boolean }
|
||||
resourceStatus: { type: string, enum: [RESERVED, OCCUPIED, RELEASED] }
|
||||
+20
@@ -4,7 +4,9 @@ import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.warehouse.dto.PalletAllocationDtos;
|
||||
import com.baiyue.warehouse.dto.LocationDtos;
|
||||
import com.baiyue.warehouse.dto.LocationBlockDtos;
|
||||
import com.baiyue.warehouse.service.PalletLocationAllocationService;
|
||||
import com.baiyue.warehouse.service.LocationBlockService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
@@ -22,11 +24,13 @@ class InternalPalletLocationControllerTest {
|
||||
private static final String INTERNAL_TOKEN = "test-internal-service-token";
|
||||
|
||||
private final PalletLocationAllocationService service = mock(PalletLocationAllocationService.class);
|
||||
private final LocationBlockService blockService = mock(LocationBlockService.class);
|
||||
private final InternalPalletLocationController controller = new InternalPalletLocationController();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ReflectionTestUtils.setField(controller, "service", service);
|
||||
ReflectionTestUtils.setField(controller, "blockService", blockService);
|
||||
ReflectionTestUtils.setField(controller, "internalToken", INTERNAL_TOKEN);
|
||||
controller.initializeToken();
|
||||
}
|
||||
@@ -78,6 +82,22 @@ class InternalPalletLocationControllerTest {
|
||||
verify(service).occupiedResources(0L, "firstleg-service", 88L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reservesWholeBlockOnlyForInternalCaller() {
|
||||
LocationBlockDtos.ReserveRequest request = new LocationBlockDtos.ReserveRequest(20L, "firstleg-service",
|
||||
88L, "RK-001", 3, "reserve-block-1", 9001L, java.time.LocalDateTime.now().plusHours(1));
|
||||
LocationBlockDtos.BlockResult expected = new LocationBlockDtos.BlockResult(700L, "RESERVED", List.of(
|
||||
new LocationBlockDtos.Resource(700L, 701L, 40L, "A01", 1, false, "RESERVED")));
|
||||
when(blockService.reserve(request)).thenReturn(expected);
|
||||
|
||||
assertThat(controller.reserveBlock(INTERNAL_TOKEN, request).data()).isEqualTo(expected);
|
||||
verify(blockService).reserve(request);
|
||||
assertThatThrownBy(() -> controller.reserveBlock("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)));
|
||||
|
||||
+7
@@ -47,6 +47,13 @@ class WarehouseSchemaContractTest {
|
||||
@Test
|
||||
void shouldHandleCase04() throws IOException {
|
||||
String sql = read("phase2-pallet-location-schema.sql");
|
||||
assertThat(sql).contains("CREATE TABLE IF NOT EXISTS wh_location_block (")
|
||||
.contains("CREATE TABLE IF NOT EXISTS wh_location_block_slot (")
|
||||
.contains("UNIQUE KEY uk_location_block_active_normal")
|
||||
.contains("UNIQUE KEY uk_location_block_active_business")
|
||||
.contains("block_status IN ('RESERVED','OCCUPIED')")
|
||||
.contains("resource_status IN ('RESERVED','OCCUPIED')")
|
||||
.contains("COMMENT='整票库位块槽位;空槽也由当前入仓码独占'");
|
||||
assertColumns(sql, "wh_pallet", "tenant_id", "warehouse_id", "pallet_code", "pallet_type",
|
||||
"capacity_cbm", "weight_kg", "color", "remark", "deleted", "version");
|
||||
assertThat(tableDefinition(sql, "wh_pallet"))
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package com.baiyue.warehouse.service;
|
||||
|
||||
import com.baiyue.warehouse.dto.LocationBlockDtos;
|
||||
import com.baiyue.warehouse.entity.LocationBlockEntity;
|
||||
import com.baiyue.warehouse.entity.LocationBlockSlotEntity;
|
||||
import com.baiyue.warehouse.entity.LocationEntity;
|
||||
import com.baiyue.warehouse.entity.WarehouseEntity;
|
||||
import com.baiyue.warehouse.mapper.LocationBlockMapper;
|
||||
import com.baiyue.warehouse.mapper.LocationBlockSlotMapper;
|
||||
import com.baiyue.warehouse.repository.LocationRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class LocationBlockServiceTest {
|
||||
private final LocationRepository locations = mock(LocationRepository.class);
|
||||
private final LocationBlockMapper blocks = mock(LocationBlockMapper.class);
|
||||
private final LocationBlockSlotMapper slots = mock(LocationBlockSlotMapper.class);
|
||||
private final LocationBlockService service = new LocationBlockService();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ReflectionTestUtils.setField(service, "locations", locations);
|
||||
ReflectionTestUtils.setField(service, "blocks", blocks);
|
||||
ReflectionTestUtils.setField(service, "slots", slots);
|
||||
WarehouseEntity warehouse = new WarehouseEntity();
|
||||
warehouse.setId(20L);
|
||||
warehouse.setTenantId(0L);
|
||||
warehouse.setStatus("ACTIVE");
|
||||
when(locations.findWarehouse(0L, 20L)).thenReturn(warehouse);
|
||||
when(blocks.selectOne(any())).thenReturn(null);
|
||||
when(blocks.insert(any(LocationBlockEntity.class))).thenReturn(1);
|
||||
when(slots.insert(any(LocationBlockSlotEntity.class))).thenReturn(1);
|
||||
when(locations.changeStatus(anyLong(), anyLong(), anyLong(), any(), any(), anyInt(), anyLong())).thenReturn(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reservesSameZoneAndPrefixContinuousRunAheadOfEarlierGap() {
|
||||
when(locations.lockAvailable(0L, 20L, 1000)).thenReturn(List.of(
|
||||
location(1L, "A", "A01"), location(2L, "A", "A03"),
|
||||
location(3L, "B", "B01"), location(4L, "B", "B02"), location(5L, "B", "B03")));
|
||||
|
||||
LocationBlockDtos.BlockResult result = service.reserve(request(3));
|
||||
|
||||
assertThat(result.blockStatus()).isEqualTo("RESERVED");
|
||||
assertThat(result.resources()).extracting(LocationBlockDtos.Resource::locationCode)
|
||||
.containsExactly("B01", "B02", "B03");
|
||||
assertThat(result.resources()).extracting(LocationBlockDtos.Resource::slotNo).containsExactly(1, 2, 3);
|
||||
assertThat(result.resources()).allSatisfy(item -> {
|
||||
assertThat(item.blockId()).isEqualTo(result.blockId());
|
||||
assertThat(item.sharedOverflow()).isFalse();
|
||||
assertThat(item.resourceStatus()).isEqualTo("RESERVED");
|
||||
});
|
||||
verify(locations, never()).lockOrCreateSharedOverflow(anyLong(), anyLong(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsAllOrdinaryLocationsAndUsesSharedA0ForOnlyTheMissingSlots() {
|
||||
LocationEntity ordinary = location(1L, "A", "A01");
|
||||
LocationEntity overflow = location(99L, "OVERFLOW", "A0");
|
||||
overflow.setSharedOverflow(true);
|
||||
when(locations.lockAvailable(0L, 20L, 1000)).thenReturn(List.of(ordinary));
|
||||
when(locations.lockOrCreateSharedOverflow(0L, 20L, 9001L)).thenReturn(overflow);
|
||||
|
||||
LocationBlockDtos.BlockResult result = service.reserve(request(3));
|
||||
|
||||
assertThat(result.resources()).extracting(LocationBlockDtos.Resource::locationCode)
|
||||
.containsExactly("A01", "A0", "A0");
|
||||
assertThat(result.resources()).extracting(LocationBlockDtos.Resource::sharedOverflow)
|
||||
.containsExactly(false, true, true);
|
||||
verify(locations).changeStatus(0L, 20L, 1L, "AVAILABLE", "RESERVED", 0, 9001L);
|
||||
verify(locations, never()).changeStatus(eq(0L), eq(20L), eq(99L), any(), any(), anyInt(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void transfersOwnershipWithoutReleasingAnySlot() {
|
||||
LocationBlockEntity block = block("OCCUPIED", 88L, null);
|
||||
when(blocks.lockById(700L)).thenReturn(block);
|
||||
when(blocks.transfer(700L, 88L, 89L, "RK-S1", "transfer-1", 0)).thenReturn(1);
|
||||
when(slots.lockByBlockId(700L)).thenReturn(List.of(slot(701L, 1, 1L, "A01", false, "OCCUPIED")));
|
||||
|
||||
LocationBlockDtos.BlockResult result = service.transfer(
|
||||
new LocationBlockDtos.TransferRequest(700L, 88L, 89L, "RK-S1", "transfer-1", 9001L));
|
||||
|
||||
assertThat(result.blockStatus()).isEqualTo("OCCUPIED");
|
||||
verify(blocks).transfer(700L, 88L, 89L, "RK-S1", "transfer-1", 0);
|
||||
verify(slots, never()).changeBlockStatus(anyLong(), any(), any());
|
||||
verify(locations, never()).changeStatus(anyLong(), anyLong(), anyLong(), any(), any(), anyInt(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void releasesEveryNormalSlotTogetherAndLeavesSharedA0Available() {
|
||||
LocationBlockEntity block = block("OCCUPIED", 88L, null);
|
||||
LocationBlockSlotEntity first = slot(701L, 1, 1L, "A01", false, "OCCUPIED");
|
||||
LocationBlockSlotEntity overflow = slot(702L, 2, 99L, "A0", true, "OCCUPIED");
|
||||
when(blocks.lockById(700L)).thenReturn(block);
|
||||
when(slots.lockByBlockId(700L)).thenReturn(List.of(first, overflow));
|
||||
when(locations.lockOwned(0L, 20L, 1L)).thenReturn(location(1L, "A", "A01", "OCCUPIED"));
|
||||
when(slots.changeBlockStatus(700L, "OCCUPIED", "RELEASED")).thenReturn(2);
|
||||
when(blocks.changeStatus(700L, 88L, "OCCUPIED", "RELEASED", "release-1", 0)).thenReturn(1);
|
||||
|
||||
LocationBlockDtos.BlockResult result = service.release(new LocationBlockDtos.ReleaseRequest(
|
||||
700L, 88L, "release-1", 9001L, "ALL_PACKAGES_PICKED"));
|
||||
|
||||
assertThat(result.resources()).extracting(LocationBlockDtos.Resource::resourceStatus)
|
||||
.containsOnly("RELEASED");
|
||||
verify(locations).changeStatus(0L, 20L, 1L, "OCCUPIED", "AVAILABLE", 0, 9001L);
|
||||
verify(locations, never()).lockOwned(0L, 20L, 99L);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
private static LocationEntity location(long id, String zone, String code) {
|
||||
return location(id, zone, code, "AVAILABLE");
|
||||
}
|
||||
|
||||
private static LocationEntity location(long id, String zone, String code, String status) {
|
||||
LocationEntity entity = new LocationEntity();
|
||||
entity.setId(id);
|
||||
entity.setTenantId(0L);
|
||||
entity.setWarehouseId(20L);
|
||||
entity.setZoneCode(zone);
|
||||
entity.setLocationCode(code);
|
||||
entity.setSharedOverflow(false);
|
||||
entity.setStatus(status);
|
||||
entity.setVersion(0);
|
||||
return entity;
|
||||
}
|
||||
|
||||
private static LocationBlockEntity block(String status, long businessId, String operationKey) {
|
||||
LocationBlockEntity entity = new LocationBlockEntity();
|
||||
entity.setId(700L);
|
||||
entity.setTenantId(0L);
|
||||
entity.setWarehouseId(20L);
|
||||
entity.setSourceService("firstleg-service");
|
||||
entity.setBusinessId(businessId);
|
||||
entity.setInboundCode("RK-001");
|
||||
entity.setSlotCount(2);
|
||||
entity.setBlockStatus(status);
|
||||
entity.setLastOperationKey(operationKey);
|
||||
entity.setVersion(0);
|
||||
return entity;
|
||||
}
|
||||
|
||||
private static LocationBlockSlotEntity slot(long id, int slotNo, long locationId, String code,
|
||||
boolean overflow, String status) {
|
||||
LocationBlockSlotEntity entity = new LocationBlockSlotEntity();
|
||||
entity.setId(id);
|
||||
entity.setTenantId(0L);
|
||||
entity.setBlockId(700L);
|
||||
entity.setSlotNo(slotNo);
|
||||
entity.setLocationId(locationId);
|
||||
entity.setLocationCode(code);
|
||||
entity.setSharedOverflow(overflow);
|
||||
entity.setResourceStatus(status);
|
||||
entity.setVersion(0);
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user