This commit is contained in:
2026-08-15 10:27:15 +08:00
parent c6d7fb0d84
commit 9e7cc55eab
22 changed files with 336 additions and 34 deletions
@@ -102,7 +102,7 @@ public class InternalPalletLocationController {
}
@PostMapping("/rebind-temporary-location")
@Operation(summary = "A0临时库位换绑", description = "将一条具体A0托盘占用换绑到同仓空闲普通库位")
@Operation(summary = "A0临时库位换绑", description = "将一条具体A0库位占用记录换绑到同仓空闲普通库位")
public ApiResponse<PalletAllocationDtos.RebindTemporaryLocationResult> rebindTemporaryLocation(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody PalletAllocationDtos.RebindTemporaryLocationRequest request) {
@@ -137,7 +137,7 @@ public class PlatformLocationController {
@PostMapping("/temporary-occupancies/{occupancyId}/rebind")
@PreAuthorize("hasAuthority('platform:location:edit')")
@Operation(summary = "A0临时库位换绑", description = "将指定A0托盘占用换绑到同仓空闲普通库位")
@Operation(summary = "A0临时库位换绑", description = "将指定A0库位占用记录换绑到同仓空闲普通库位")
public ApiResponse<PalletAllocationDtos.RebindTemporaryLocationResult> rebindTemporaryLocation(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Min(1) long occupancyId,
@Valid @RequestBody RebindRequest request) {
@@ -67,7 +67,7 @@ public final class LocationDtos {
public record Detail(LocationEntity location, String warehouseCode, String warehouseName) {
}
/** 单个托盘的幂等预占目标。 */
/** 单条库位预占目标;托盘字段仅引用可复用主数据快照。 */
public record ReservationTarget(
@NotNull @Min(1) Long palletId,
@NotBlank @Size(max = 50) String palletCode,
@@ -131,7 +131,7 @@ public final class PalletAllocationDtos {
) {
}
/** 将共享临时库位 A0 中的一条具体托盘占用换绑到同仓空闲普通库位。 */
/** 将共享临时库位 A0 中的一条具体库位占用换绑到同仓空闲普通库位。 */
public record RebindTemporaryLocationRequest(
@Min(0) long tenantId,
@Min(1) long occupancyId,
@@ -155,7 +155,7 @@ public final class PalletAllocationDtos {
) {
}
/** 仅转移占用归属和未拣出箱数,不释放库位或托盘。 */
/** 仅转移库位占用归属和未拣出箱数;托盘仅作为主数据快照关联。 */
public record TransferRequest(
@Min(0) long tenantId,
@NotBlank @Size(max = 40) String sourceService,
@@ -168,7 +168,7 @@ public final class PalletAllocationDtos {
) {
}
/** 子入库单保存托盘库位快照所需的完整仓储分配结果。 */
/** 子入库单保存托盘主数据和库位快照所需的完整仓储分配结果。 */
public record TransferResult(
long reservationId,
String reservationNo,
@@ -8,7 +8,7 @@ import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
/** 已占用托盘库位选择性释放的幂等操作 Mapper。 */
/** 已占用库位选择性释放的幂等操作 Mapper。 */
@Mapper
public interface PalletLocationReleaseOperationMapper extends BaseMapper<PalletLocationReleaseOperationEntity> {
@Insert("""
@@ -8,7 +8,7 @@ import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
/** 托盘库位转移幂等操作 Mapper。 */
/** 库位归属转移幂等操作 Mapper。 */
@Mapper
public interface PalletLocationTransferOperationMapper extends BaseMapper<PalletLocationTransferOperationEntity> {
@Insert("""
@@ -6,7 +6,7 @@ import com.baiyue.warehouse.mapper.PalletLocationReleaseOperationMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
/** 已占用托盘库位选择性释放的持久化幂等边界。 */
/** 已占用库位选择性释放的持久化幂等边界。 */
@Repository
public class PalletLocationReleaseOperationRepository {
@Resource
@@ -6,7 +6,7 @@ import com.baiyue.warehouse.mapper.PalletLocationTransferOperationMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
/** 托盘库位归属转移的幂等操作持久化边界。 */
/** 库位归属转移的幂等操作持久化边界。 */
@Repository
public class PalletLocationTransferOperationRepository {
@Resource
@@ -108,7 +108,7 @@ public class LocationResourceService {
result.add(toReservationResult(reservation));
}
} catch (DataIntegrityViolationException exception) {
throw new BizException(ResultCode.CONFLICT, "库位预占幂等键、托盘或库位已被占用");
throw new BizException(ResultCode.CONFLICT, "库位预占幂等键重复或库位已存在活动预占");
}
return List.copyOf(result);
}
@@ -151,7 +151,7 @@ public class LocationResourceService {
result.add(toOccupancyResult(occupancy));
}
} catch (DataIntegrityViolationException exception) {
throw new BizException(ResultCode.CONFLICT, "库位或托盘已存在活动占用");
throw new BizException(ResultCode.CONFLICT, "库位已存在活动占用或预占记录已确认");
}
return List.copyOf(result);
}
@@ -336,7 +336,7 @@ public class LocationResourceService {
/**
* 将未拣货的已占用库位从主入库单转给二级子入库单。
* 转移只改变业务归属和剩余箱数,不释放库位或托盘。
* 转移只改变业务归属和剩余箱数,不释放库位;托盘仅作为关联快照。
*/
@Transactional
public List<LocationDtos.TransferResult> transferToChild(@Valid LocationDtos.TransferCommand command) {
@@ -394,7 +394,7 @@ public class LocationResourceService {
return List.copyOf(results);
}
/** PDA 结算按预约记录转移未拣货资源,物理托盘和库位继续保持占用。 */
/** PDA 结算按预约记录转移未拣货资源,库位继续保持占用,托盘仅作为主数据快照关联。 */
@Transactional
public List<LocationDtos.TransferResult> transferToChildByReservation(
@Valid LocationDtos.ReservationTransferCommand command) {
@@ -471,7 +471,7 @@ public class LocationResourceService {
.toList();
}
/** 将 A0 的一条托盘占用换绑到同仓空闲普通库位。 */
/** 将 A0 的一条库位占用记录换绑到同仓空闲普通库位,保留托盘主数据关联。 */
@Transactional
public LocationDtos.RebindTemporaryLocationResult rebindTemporaryLocation(
@Valid LocationDtos.RebindTemporaryLocationCommand command) {
@@ -648,7 +648,7 @@ public class LocationResourceService {
|| reservation.getWarehouseId() != command.warehouseId()
|| reservation.getPalletId() != target.palletId()
|| !reservation.getPalletCode().equals(target.palletCode())) {
throw new BizException(ResultCode.CONFLICT, "库位预占幂等键已用于其他业务或托盘");
throw new BizException(ResultCode.CONFLICT, "库位预占幂等键已用于其他业务或托盘主数据");
}
if ("RELEASED".equals(reservation.getReservationStatus()) || "EXPIRED".equals(reservation.getReservationStatus())) {
throw new BizException(ResultCode.CONFLICT, "库位预占幂等请求已释放或过期");
@@ -684,11 +684,12 @@ public class LocationResourceService {
}
private static void validateUniqueTargets(List<ReservationTarget> targets) {
Set<Long> pallets = new HashSet<>();
Set<String> keys = new HashSet<>();
for (ReservationTarget target : targets) {
if (!pallets.add(target.palletId()) || !keys.add(target.idempotencyKey())) {
throw new BizException(ResultCode.BAD_REQUEST, "同批预占的托盘和幂等键不得重复");
// Pallets are reusable master-data references. Only the idempotency key
// identifies a unique reservation target within one batch.
if (!keys.add(target.idempotencyKey())) {
throw new BizException(ResultCode.BAD_REQUEST, "同批预占的幂等键不得重复");
}
}
}
@@ -185,7 +185,7 @@ public class PalletLocationAllocationService {
*/
@Transactional
@BusinessAudit(event = "PALLET_LOCATION_SETTLEMENT", resourceType = "INBOUND",
resourceId = "#request.sourceBusinessId", description = "PDA拣货后原子结算托盘库位资源")
resourceId = "#request.sourceBusinessId", description = "PDA拣货后原子结算库位资源")
public PalletAllocationDtos.SettlementResult settle(PalletAllocationDtos.SettlementRequest request) {
validateSettlementScope(request);
String key = request.idempotencyKey().trim();
@@ -1,4 +1,4 @@
-- PDA 已拣满库位/托盘精确释放的持久化幂等契约;可在二期库位表建表后重复执行。
-- PDA 已拣满库位精确释放的持久化幂等契约;可在二期库位表建表后重复执行。
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS wh_pallet_location_release_operation (
@@ -16,4 +16,4 @@ CREATE TABLE IF NOT EXISTS wh_pallet_location_release_operation (
UNIQUE KEY uk_pallet_location_release_idempotency
(tenant_id, source_service, business_id, idempotency_key),
KEY idx_pallet_location_release_business (tenant_id, source_service, business_id, operation_status)
) ENGINE=InnoDB COMMENT='PDA已拣满托盘库位精确释放的持久化幂等操作记录';
) ENGINE=InnoDB COMMENT='PDA已拣满库位精确释放的持久化幂等操作记录';
@@ -1,4 +1,4 @@
-- PDA 拣货资源原子结算幂等契约;同一数据库事务内完成精确释放和剩余资源转移。
-- PDA 拣货库位资源原子结算幂等契约;同一数据库事务内完成精确释放和剩余资源转移。
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS wh_pallet_location_settlement_operation (
@@ -1,4 +1,4 @@
-- 托盘库位转移的持久化幂等契约;可在二期托盘库位表建表后重复执行。
-- 库位归属转移的持久化幂等契约;可在二期托盘库位表建表后重复执行。
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS wh_pallet_location_transfer_operation (
@@ -17,4 +17,4 @@ CREATE TABLE IF NOT EXISTS wh_pallet_location_transfer_operation (
UNIQUE KEY uk_pallet_location_transfer_idempotency
(tenant_id, source_service, source_business_id, target_business_id, idempotency_key),
KEY idx_pallet_location_transfer_source (tenant_id, source_service, source_business_id, operation_status)
) ENGINE=InnoDB COMMENT='托盘库位归属转移的持久化幂等操作记录';
) ENGINE=InnoDB COMMENT='库位归属转移的持久化幂等操作记录';
@@ -14,6 +14,7 @@ import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import jakarta.annotation.Resource;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.math.BigDecimal;
@@ -25,6 +26,7 @@ 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;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
@@ -117,6 +119,37 @@ class LocationResourceServiceTest {
.containsExactly("inbound-1-p1", "inbound-1-p2");
}
@Test
void allowsReusablePalletMasterInMultipleReservationTargets() {
when(repository.findWarehouse(10001L, 88L)).thenReturn(warehouse());
when(repository.lockAvailable(10001L, 88L, 2))
.thenReturn(List.of(location(9L, "A1"), location(10L, "A2")));
when(repository.changeStatus(10001L, 88L, 9L, "AVAILABLE", "RESERVED", 0, 7L)).thenReturn(1);
when(repository.changeStatus(10001L, 88L, 10L, "AVAILABLE", "RESERVED", 0, 7L)).thenReturn(1);
var command = new ReserveCommand(10001L, 88L, "FIRSTLEG", 301L, "RK001", 7L,
LocalDateTime.now().plusMinutes(30), List.of(
new ReservationTarget(501L, "P1", "same-pallet-1"),
new ReservationTarget(501L, "P1", "same-pallet-2")));
assertThat(service.reserveAvailable(command)).hasSize(2);
verify(repository, times(2)).insertReservation(any());
}
@Test
void reservationConstraintFailureDoesNotDescribePalletAsAResource() {
when(repository.findWarehouse(10001L, 88L)).thenReturn(warehouse());
when(repository.lockAvailable(10001L, 88L, 2))
.thenReturn(List.of(location(9L, "A1"), location(10L, "A2")));
when(repository.changeStatus(10001L, 88L, 9L, "AVAILABLE", "RESERVED", 0, 7L)).thenReturn(1);
doThrow(new DataIntegrityViolationException("duplicate location reservation"))
.when(repository).insertReservation(any());
assertThatThrownBy(() -> service.reserveAvailable(reserveCommand()))
.hasMessageContaining("库位预占幂等键重复或库位已存在活动预占")
.hasMessageNotContaining("托盘");
}
@Test
void shouldHandleCase04() {
when(repository.findWarehouse(10001L, 88L)).thenReturn(warehouse());