This commit is contained in:
2026-09-04 12:18:36 +08:00
parent 566b07be62
commit b697e4c9f0
51 changed files with 1898 additions and 623 deletions
@@ -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] }
@@ -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)));
@@ -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;
}
}