装柜回归测试

This commit is contained in:
2026-08-09 20:35:11 +08:00
parent b20cd9bd77
commit 0ce264a257
67 changed files with 3329 additions and 208 deletions
@@ -72,6 +72,33 @@ public class InternalPalletLocationController {
return ApiResponse.success(service.releaseSelected(request));
}
@PostMapping("/settlements")
@Operation(summary = "PDA 资源原子结算", description = "同一事务精确释放已拣满库位,并将未拣满资源转给二级子入库单")
public ApiResponse<PalletAllocationDtos.SettlementResult> settle(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody PalletAllocationDtos.SettlementRequest request) {
verify(token);
return ApiResponse.success(service.settle(request));
}
@PostMapping("/rebind-temporary-location")
@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) {
verify(token);
return ApiResponse.success(service.rebindTemporaryLocation(request));
}
@PostMapping("/transfer")
@Operation(summary = "将未拣货库位转给子入库单", description = "仅更新业务归属,托盘和库位继续保持已占用")
public ApiResponse<List<PalletAllocationDtos.TransferResult>> transfer(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody PalletAllocationDtos.TransferRequest request) {
verify(token);
return ApiResponse.success(service.transfer(request));
}
private void verify(String token) {
byte[] supplied = token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8);
if (!MessageDigest.isEqual(expectedToken, supplied)) {
@@ -9,8 +9,10 @@ import com.baiyue.warehouse.dto.LocationDtos.Detail;
import com.baiyue.warehouse.dto.LocationDtos.PageQuery;
import com.baiyue.warehouse.dto.LocationDtos.SaveRequest;
import com.baiyue.warehouse.dto.LocationDtos.StatusRequest;
import com.baiyue.warehouse.dto.PalletAllocationDtos;
import com.baiyue.warehouse.entity.LocationEntity;
import com.baiyue.warehouse.service.LocationApplicationService;
import com.baiyue.warehouse.service.PalletLocationAllocationService;
import jakarta.annotation.Resource;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@@ -44,6 +46,8 @@ import java.util.List;
public class PlatformLocationController {
@Resource
private LocationApplicationService service;
@Resource
private PalletLocationAllocationService palletLocationService;
@GetMapping
@PreAuthorize("hasAuthority('platform:location:list')")
@@ -131,6 +135,18 @@ public class PlatformLocationController {
return ApiResponse.success(null);
}
@PostMapping("/temporary-occupancies/{occupancyId}/rebind")
@PreAuthorize("hasAuthority('platform:location:edit')")
@Operation(summary = "A0临时库位换绑", description = "将指定A0托盘占用换绑到同仓空闲普通库位")
public ApiResponse<PalletAllocationDtos.RebindTemporaryLocationResult> rebindTemporaryLocation(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Min(1) long occupancyId,
@Valid @RequestBody RebindRequest request) {
requirePlatform(user);
return ApiResponse.success(palletLocationService.rebindTemporaryLocation(
new PalletAllocationDtos.RebindTemporaryLocationRequest(0L, occupancyId, request.targetLocationId(),
requireUserId(user), request.idempotencyKey(), request.reason())));
}
@PatchMapping("/{id}/status")
@PreAuthorize("hasAuthority('platform:location:status')")
@Operation(summary = "启用或停用库位", description = "按 version 条件更新;活动预占或占用中的库位不能停用")
@@ -189,4 +205,9 @@ public class PlatformLocationController {
throw new BizException(ResultCode.BAD_REQUEST, "平台公共资源作用域必须为0");
}
}
public record RebindRequest(@Min(1) long targetLocationId,
@jakarta.validation.constraints.NotBlank String idempotencyKey,
@jakarta.validation.constraints.Size(max = 500) String reason) {
}
}
@@ -77,7 +77,7 @@ public final class LocationDtos {
/** 原子选择并预占 N 个可用库位的命令。 */
public record ReserveCommand(
@Min(1) long tenantId,
@Min(0) long tenantId,
@Min(1) long warehouseId,
@NotBlank @Size(max = 40) String sourceService,
@Min(1) long businessId,
@@ -109,7 +109,7 @@ public final class LocationDtos {
}
public record ConfirmCommand(
@Min(1) long tenantId,
@Min(0) long tenantId,
@Min(1) long warehouseId,
@NotBlank @Size(max = 40) String sourceService,
@Min(1) long businessId,
@@ -130,7 +130,7 @@ public final class LocationDtos {
}
public record ReleaseCommand(
@Min(1) long tenantId,
@Min(0) long tenantId,
@NotBlank @Size(max = 40) String sourceService,
@Min(1) long businessId,
@Min(1) long operatorId,
@@ -139,7 +139,7 @@ public final class LocationDtos {
}
public record SelectiveReleaseCommand(
@Min(1) long tenantId,
@Min(0) long tenantId,
@NotBlank @Size(max = 40) String sourceService,
@Min(1) long businessId,
@Min(1) long operatorId,
@@ -162,6 +162,126 @@ public final class LocationDtos {
) {
}
/** PDA 已拣满一个库位时按占用事实精确释放,箱数不得重新均分。 */
public record ExactSelectiveReleaseCommand(
@Min(0) long tenantId,
@NotBlank @Size(max = 40) String sourceService,
@Min(1) long businessId,
@Min(1) long operatorId,
@NotBlank @Size(max = 500) String reason,
@NotEmpty @Size(max = 200) List<@Min(1) Long> locationIds
) {
}
public record ReservationReleaseCommand(
@Min(0) long tenantId,
@NotBlank @Size(max = 40) String sourceService,
@Min(1) long businessId,
@Min(1) long operatorId,
@NotBlank @Size(max = 500) String reason,
@NotEmpty @Size(max = 200) List<@Min(1) Long> reservationIds
) {
}
public record ExactSelectiveReleaseResult(
long reservationId,
long occupancyId,
int allocationNo,
long warehouseId,
long palletId,
long locationId,
String locationCode,
int allocatedPackageCount
) {
}
/**
* PDA 原子结算前锁定读取的已占用资源事实。
* reservationId 是跨服务结算时唯一可信的资源标识,不能使用可能复用的库位编号。
*/
public record OccupiedResource(
long reservationId,
long occupancyId,
int allocationNo,
long warehouseId,
long palletId,
long locationId,
String locationCode,
boolean sharedOverflow,
int allocatedPackageCount
) {
}
/** 将未拣出的物理占用从主入库单转交给子入库单的单个库位数量。 */
public record TransferItem(
@Min(1) long locationId,
@Min(1) int remainingPackageCount
) {
}
public record ReservationTransferItem(
@Min(1) long reservationId,
@Min(1) int remainingPackageCount
) {
}
public record ReservationTransferCommand(
@Min(0) long tenantId,
@NotBlank @Size(max = 40) String sourceService,
@Min(1) long sourceBusinessId,
@Min(1) long targetBusinessId,
@NotBlank @Size(max = 40) String targetInboundCode,
@Min(1) long operatorId,
@NotEmpty @Size(max = 200) List<@NotNull @jakarta.validation.Valid ReservationTransferItem> items
) {
}
public record RebindTemporaryLocationCommand(@Min(0) long tenantId, @Min(1) long occupancyId,
@Min(1) long targetLocationId, @Min(1) long operatorId) {
}
public record RebindTemporaryLocationResult(long occupancyId, long reservationId, long warehouseId,
long palletId, long previousLocationId, String previousLocationCode,
long locationId, String locationCode) {
}
/**
* 仅变更已占用托盘/库位的业务归属,不释放物理资源。
* {@code items} 只可包含未拣出的库位和对应剩余箱数。
*/
public record TransferCommand(
@Min(0) long tenantId,
@NotBlank @Size(max = 40) String sourceService,
@Min(1) long sourceBusinessId,
@Min(1) long targetBusinessId,
@NotBlank @Size(max = 40) String targetInboundCode,
@Min(1) long operatorId,
@NotEmpty @Size(max = 200) List<@Valid TransferItem> items
) {
}
public record ReactivateReleasedResourceCommand(@Min(0) long tenantId, @Min(1) long warehouseId,
@Min(1) long reservationId, @Min(1) long targetBusinessId, @NotBlank @Size(max = 40) String targetInboundCode,
@Min(1) int packageCount, @Min(1) long operatorId) {
}
/** 子入库单写入仓储分配快照所需的已占用资源数据。 */
public record TransferResult(
long reservationId,
String reservationNo,
long occupancyId,
int allocationNo,
long warehouseId,
long palletId,
String palletCode,
long locationId,
String locationCode,
BigDecimal allocatedVolumeCbm,
int remainingPackageCount,
String state
) {
}
public record ActiveResource(
long reservationId,
String reservationNo,
@@ -17,7 +17,7 @@ public final class PalletAllocationDtos {
}
public record ReserveRequest(
@Min(1) long tenantId,
@Min(0) long tenantId,
@Min(1) long warehouseId,
@NotBlank @Size(max = 40) String sourceService,
@Min(1) long businessId,
@@ -33,7 +33,7 @@ public final class PalletAllocationDtos {
}
public record ConfirmRequest(
@Min(1) long tenantId,
@Min(0) long tenantId,
@Min(1) long warehouseId,
@NotBlank @Size(max = 40) String sourceService,
@Min(1) long businessId,
@@ -44,7 +44,7 @@ public final class PalletAllocationDtos {
}
public record ReleaseRequest(
@Min(1) long tenantId,
@Min(0) long tenantId,
@NotBlank @Size(max = 40) String sourceService,
@Min(1) long businessId,
@Min(1) long operatorId,
@@ -57,14 +57,13 @@ public final class PalletAllocationDtos {
* the first {@code releaseLocationCount} allocations are selected.
*/
public record SelectiveReleaseRequest(
@Min(1) long tenantId,
@Min(0) long tenantId,
@NotBlank @Size(max = 40) String sourceService,
@Min(1) long businessId,
@NotBlank @Size(max = 128) String idempotencyKey,
@Min(1) long operatorId,
@NotBlank @Size(max = 500) String reason,
@Min(1) int totalPackageCount,
@Size(max = 200) List<@Min(1) Long> locationIds,
@Min(1) Integer releaseLocationCount
@NotNull @Size(min = 1, max = 200) List<@Min(1) Long> locationIds
) {
}
@@ -75,7 +74,115 @@ public final class PalletAllocationDtos {
long palletId,
long locationId,
String locationCode,
int releasedPackageCount
int allocatedPackageCount
) {
}
/** PDA 拣货资源结算:已拣满资源释放,未拣满资源转移至二级子入库单。 */
public record SettlementRequest(
@Min(1) long warehouseId,
@NotBlank @Size(max = 40) String sourceService,
@Min(1) long sourceBusinessId,
@Min(0) long targetBusinessId,
@Min(0) int sourcePackageCount,
@Min(0) int targetPackageCount,
@Size(max = 40) String targetInboundCode,
@NotBlank @Size(max = 128) String idempotencyKey,
@Min(1) long operatorId,
@Size(max = 64) String operatorName,
@NotBlank @Size(max = 128) String requestId,
@NotNull @Size(max = 200) List<@Min(1) Long> releasedReservationIds,
@NotNull @Size(max = 200) List<@NotNull @jakarta.validation.Valid SettlementTransferItem> remainingItems
) {
/** 兼容旧内部调用;新调用必须传入仓库与数量守恒字段。 */
public SettlementRequest(long tenantId, String sourceService, long sourceBusinessId, long targetBusinessId,
String targetInboundCode, String idempotencyKey, long operatorId, String reason,
List<Long> releasedReservationIds, List<TransferItem> remainingItems) {
this(1L, sourceService, sourceBusinessId, targetBusinessId,
remainingItems.stream().mapToInt(TransferItem::remainingPackageCount).sum(),
remainingItems.stream().mapToInt(TransferItem::remainingPackageCount).sum(),
targetInboundCode, idempotencyKey, operatorId, reason, idempotencyKey, releasedReservationIds,
remainingItems.stream().map(item -> new SettlementTransferItem(item.locationId(),
item.remainingPackageCount())).toList());
}
/** 兼容已有测试及旧内部调用;服务端不再接受租户范围。 */
public SettlementRequest(long tenantId, long warehouseId, String sourceService, long sourceBusinessId,
long targetBusinessId, int sourcePackageCount, int targetPackageCount,
String targetInboundCode, String idempotencyKey, long operatorId, String reason,
List<Long> releasedReservationIds, List<SettlementTransferItem> remainingItems) {
this(warehouseId, sourceService, sourceBusinessId, targetBusinessId, sourcePackageCount,
targetPackageCount, targetInboundCode, idempotencyKey, operatorId, reason, idempotencyKey,
releasedReservationIds, remainingItems);
}
}
/** 使用 reservationId 唯一定位资源,兼容多个托盘共用 A0。 */
public record SettlementTransferItem(
@Min(1) long reservationId,
@Min(1) int remainingPackageCount
) {
}
public record SettlementResult(
List<SelectiveReleaseResult> released,
List<TransferResult> transferred
) {
}
/** 将共享临时库位 A0 中的一条具体托盘占用换绑到同仓空闲普通库位。 */
public record RebindTemporaryLocationRequest(
@Min(0) long tenantId,
@Min(1) long occupancyId,
@Min(1) long targetLocationId,
@Min(1) long operatorId,
@NotBlank @Size(max = 128) String idempotencyKey,
@Size(max = 500) String reason
) {
}
public record RebindTemporaryLocationResult(long occupancyId, long reservationId,
long warehouseId, long palletId,
long previousLocationId, String previousLocationCode,
long locationId, String locationCode) {
}
/** 主入库单向子入库单转移的单个未拣出库位。 */
public record TransferItem(
@Min(1) long locationId,
@Min(1) int remainingPackageCount
) {
}
/** 仅转移占用归属和未拣出箱数,不释放库位或托盘。 */
public record TransferRequest(
@Min(0) long tenantId,
@NotBlank @Size(max = 40) String sourceService,
@Min(1) long sourceBusinessId,
@Min(1) long targetBusinessId,
@NotBlank @Size(max = 40) String targetInboundCode,
@NotBlank @Size(max = 128) String idempotencyKey,
@Min(1) long operatorId,
@NotNull @Size(min = 1, max = 200) List<@NotNull @jakarta.validation.Valid TransferItem> items
) {
}
/** 子入库单保存托盘库位快照所需的完整仓储分配结果。 */
public record TransferResult(
long reservationId,
String reservationNo,
long occupancyId,
int allocationNo,
long warehouseResourceTenantId,
long warehouseId,
long palletId,
String palletCode,
BigDecimal palletCapacityCbm,
long locationId,
String locationCode,
BigDecimal allocatedVolumeCbm,
int remainingPackageCount,
String state
) {
}
@@ -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_pallet_location_release_operation")
public class PalletLocationReleaseOperationEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private String sourceService;
private Long businessId;
private String idempotencyKey;
private String requestHash;
private String operationStatus;
private String responseSnapshot;
private LocalDateTime createdAt;
private LocalDateTime completedAt;
}
@@ -0,0 +1,28 @@
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;
/** PDA 拣货资源原子结算的持久化幂等操作记录。 */
@Getter
@Setter
@TableName("wh_pallet_location_settlement_operation")
public class PalletLocationSettlementOperationEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private String sourceService;
private Long sourceBusinessId;
private Long targetBusinessId;
private String idempotencyKey;
private String requestHash;
private String operationStatus;
private String responseSnapshot;
private LocalDateTime createdAt;
private LocalDateTime completedAt;
}
@@ -0,0 +1,28 @@
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_pallet_location_transfer_operation")
public class PalletLocationTransferOperationEntity {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long tenantId;
private String sourceService;
private Long sourceBusinessId;
private Long targetBusinessId;
private String idempotencyKey;
private String requestHash;
private String operationStatus;
private String responseSnapshot;
private LocalDateTime createdAt;
private LocalDateTime completedAt;
}
@@ -12,6 +12,28 @@ import java.util.List;
/** 库位占用事实 Mapper。 */
@Mapper
public interface LocationOccupancyMapper extends BaseMapper<LocationOccupancyEntity> {
@Select("SELECT * FROM wh_location_occupancy WHERE id=#{id} AND tenant_id=#{tenantId} FOR UPDATE")
LocationOccupancyEntity lockById(@Param("tenantId") long tenantId, @Param("id") long id);
@Update("""
UPDATE wh_location_occupancy SET location_id=#{locationId}, location_code=#{locationCode}, shared_overflow=0,
updated_at=CURRENT_TIMESTAMP(3), version=version+1
WHERE id=#{id} AND tenant_id=#{tenantId} AND occupancy_status='OCCUPIED' AND version=#{version}
""")
int rebindLocation(@Param("tenantId") long tenantId, @Param("id") long id, @Param("locationId") long locationId,
@Param("locationCode") String locationCode, @Param("version") int version);
@Update("""
UPDATE wh_location_occupancy SET occupancy_status='OCCUPIED', business_id=#{businessId}, inbound_code=#{inboundCode},
location_id=#{locationId}, location_code=#{locationCode}, shared_overflow=#{sharedOverflow},
allocated_package_count=#{packageCount}, released_at=NULL, release_reason=NULL,
updated_at=CURRENT_TIMESTAMP(3), version=version+1
WHERE id=#{id} AND tenant_id=#{tenantId} AND occupancy_status='RELEASED' AND version=#{version}
""")
int reactivateForChild(@Param("tenantId") long tenantId, @Param("id") long id, @Param("businessId") long businessId,
@Param("inboundCode") String inboundCode, @Param("locationId") long locationId,
@Param("locationCode") String locationCode, @Param("sharedOverflow") boolean sharedOverflow,
@Param("packageCount") int packageCount, @Param("version") int version);
@Select("""
SELECT COUNT(1) FROM wh_location_occupancy
WHERE tenant_id = #{tenantId} AND location_id = #{locationId}
@@ -29,10 +51,28 @@ public interface LocationOccupancyMapper extends BaseMapper<LocationOccupancyEnt
int release(@Param("tenantId") long tenantId, @Param("id") long id,
@Param("version") int version, @Param("reason") String reason);
@Update("""
UPDATE wh_location_occupancy
SET business_id = #{targetBusinessId}, inbound_code = #{targetInboundCode},
allocated_package_count = #{remainingPackageCount},
updated_at = CURRENT_TIMESTAMP(3), version = version + 1
WHERE id = #{id} AND tenant_id = #{tenantId}
AND source_service = #{sourceService} AND business_id = #{sourceBusinessId}
AND occupancy_status = 'OCCUPIED' AND version = #{version}
""")
int transferOccupied(@Param("tenantId") long tenantId, @Param("id") long id,
@Param("sourceService") String sourceService,
@Param("sourceBusinessId") long sourceBusinessId,
@Param("targetBusinessId") long targetBusinessId,
@Param("targetInboundCode") String targetInboundCode,
@Param("remainingPackageCount") int remainingPackageCount,
@Param("version") int version);
@Select("""
SELECT * FROM wh_location_occupancy
WHERE tenant_id = #{tenantId} AND source_service = #{sourceService}
AND business_id = #{businessId} AND occupancy_status = 'OCCUPIED'
-- 预占创建顺序与头程入库单 allocation_no 的生成顺序一致,默认释放必须按该顺序。
ORDER BY reservation_id ASC, id ASC
FOR UPDATE
""")
@@ -10,6 +10,24 @@ import org.apache.ibatis.annotations.Update;
/** 库位预占 Mapper。 */
@Mapper
public interface LocationReservationMapper extends BaseMapper<LocationReservationEntity> {
@Update("""
UPDATE wh_location_reservation SET location_id=#{locationId}, location_code=#{locationCode}, shared_overflow=0,
updated_at=CURRENT_TIMESTAMP(3), version=version+1
WHERE id=#{id} AND tenant_id=#{tenantId} AND reservation_status='CONFIRMED' AND version=#{version}
""")
int rebindLocation(@Param("tenantId") long tenantId, @Param("id") long id, @Param("locationId") long locationId,
@Param("locationCode") String locationCode, @Param("version") int version);
@Update("""
UPDATE wh_location_reservation SET business_id=#{businessId}, inbound_code=#{inboundCode},
location_id=#{locationId}, location_code=#{locationCode}, shared_overflow=#{sharedOverflow},
updated_at=CURRENT_TIMESTAMP(3), version=version+1
WHERE id=#{id} AND tenant_id=#{tenantId} AND reservation_status='CONFIRMED' AND version=#{version}
""")
int reactivateForChild(@Param("tenantId") long tenantId, @Param("id") long id, @Param("businessId") long businessId,
@Param("inboundCode") String inboundCode, @Param("locationId") long locationId,
@Param("locationCode") String locationCode, @Param("sharedOverflow") boolean sharedOverflow,
@Param("version") int version);
@Select("""
SELECT COUNT(1) FROM wh_location_reservation
WHERE tenant_id = #{tenantId} AND location_id = #{locationId}
@@ -42,4 +60,19 @@ public interface LocationReservationMapper extends BaseMapper<LocationReservatio
""")
int release(@Param("tenantId") long tenantId, @Param("id") long id,
@Param("version") int version, @Param("reason") String reason);
@Update("""
UPDATE wh_location_reservation
SET business_id = #{targetBusinessId}, inbound_code = #{targetInboundCode},
updated_at = CURRENT_TIMESTAMP(3), version = version + 1
WHERE id = #{id} AND tenant_id = #{tenantId}
AND source_service = #{sourceService} AND business_id = #{sourceBusinessId}
AND reservation_status = 'CONFIRMED' AND version = #{version}
""")
int transferConfirmed(@Param("tenantId") long tenantId, @Param("id") long id,
@Param("sourceService") String sourceService,
@Param("sourceBusinessId") long sourceBusinessId,
@Param("targetBusinessId") long targetBusinessId,
@Param("targetInboundCode") String targetInboundCode,
@Param("version") int version);
}
@@ -0,0 +1,39 @@
package com.baiyue.warehouse.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.warehouse.entity.PalletLocationReleaseOperationEntity;
import org.apache.ibatis.annotations.Insert;
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。 */
@Mapper
public interface PalletLocationReleaseOperationMapper extends BaseMapper<PalletLocationReleaseOperationEntity> {
@Insert("""
INSERT IGNORE INTO wh_pallet_location_release_operation
(id, tenant_id, source_service, business_id, idempotency_key, request_hash, operation_status)
VALUES (#{entity.id}, #{entity.tenantId}, #{entity.sourceService}, #{entity.businessId},
#{entity.idempotencyKey}, #{entity.requestHash}, 'PROCESSING')
""")
int insertIfAbsent(@Param("entity") PalletLocationReleaseOperationEntity entity);
@Select("""
SELECT * FROM wh_pallet_location_release_operation
WHERE tenant_id = #{tenantId} AND source_service = #{sourceService}
AND business_id = #{businessId} AND idempotency_key = #{idempotencyKey}
FOR UPDATE
""")
PalletLocationReleaseOperationEntity lockByKey(@Param("tenantId") long tenantId,
@Param("sourceService") String sourceService, @Param("businessId") long businessId,
@Param("idempotencyKey") String idempotencyKey);
@Update("""
UPDATE wh_pallet_location_release_operation
SET operation_status = 'COMPLETED', response_snapshot = #{responseSnapshot},
completed_at = CURRENT_TIMESTAMP(3)
WHERE id = #{id} AND operation_status = 'PROCESSING'
""")
int complete(@Param("id") long id, @Param("responseSnapshot") String responseSnapshot);
}
@@ -0,0 +1,40 @@
package com.baiyue.warehouse.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.warehouse.entity.PalletLocationSettlementOperationEntity;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
/** PDA 拣货资源原子结算幂等操作 Mapper。 */
@Mapper
public interface PalletLocationSettlementOperationMapper extends BaseMapper<PalletLocationSettlementOperationEntity> {
@Insert("""
INSERT IGNORE INTO wh_pallet_location_settlement_operation
(id, tenant_id, source_service, source_business_id, target_business_id, idempotency_key,
request_hash, operation_status)
VALUES (#{entity.id}, #{entity.tenantId}, #{entity.sourceService}, #{entity.sourceBusinessId},
#{entity.targetBusinessId}, #{entity.idempotencyKey}, #{entity.requestHash}, 'PROCESSING')
""")
int insertIfAbsent(@Param("entity") PalletLocationSettlementOperationEntity entity);
@Select("""
SELECT * FROM wh_pallet_location_settlement_operation
WHERE tenant_id = #{tenantId} AND source_service = #{sourceService}
AND source_business_id = #{sourceBusinessId} AND idempotency_key = #{idempotencyKey}
FOR UPDATE
""")
PalletLocationSettlementOperationEntity lockByKey(@Param("tenantId") long tenantId,
@Param("sourceService") String sourceService, @Param("sourceBusinessId") long sourceBusinessId,
@Param("idempotencyKey") String idempotencyKey);
@Update("""
UPDATE wh_pallet_location_settlement_operation
SET operation_status = 'COMPLETED', response_snapshot = #{responseSnapshot},
completed_at = CURRENT_TIMESTAMP(3)
WHERE id = #{id} AND operation_status = 'PROCESSING'
""")
int complete(@Param("id") long id, @Param("responseSnapshot") String responseSnapshot);
}
@@ -0,0 +1,41 @@
package com.baiyue.warehouse.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.warehouse.entity.PalletLocationTransferOperationEntity;
import org.apache.ibatis.annotations.Insert;
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。 */
@Mapper
public interface PalletLocationTransferOperationMapper extends BaseMapper<PalletLocationTransferOperationEntity> {
@Insert("""
INSERT IGNORE INTO wh_pallet_location_transfer_operation
(id, tenant_id, source_service, source_business_id, target_business_id, idempotency_key,
request_hash, operation_status)
VALUES (#{entity.id}, #{entity.tenantId}, #{entity.sourceService}, #{entity.sourceBusinessId},
#{entity.targetBusinessId}, #{entity.idempotencyKey}, #{entity.requestHash}, 'PROCESSING')
""")
int insertIfAbsent(@Param("entity") PalletLocationTransferOperationEntity entity);
@Select("""
SELECT * FROM wh_pallet_location_transfer_operation
WHERE tenant_id = #{tenantId} AND source_service = #{sourceService}
AND source_business_id = #{sourceBusinessId} AND target_business_id = #{targetBusinessId}
AND idempotency_key = #{idempotencyKey}
FOR UPDATE
""")
PalletLocationTransferOperationEntity lockByKey(@Param("tenantId") long tenantId,
@Param("sourceService") String sourceService, @Param("sourceBusinessId") long sourceBusinessId,
@Param("targetBusinessId") long targetBusinessId, @Param("idempotencyKey") String idempotencyKey);
@Update("""
UPDATE wh_pallet_location_transfer_operation
SET operation_status = 'COMPLETED', response_snapshot = #{responseSnapshot},
completed_at = CURRENT_TIMESTAMP(3)
WHERE id = #{id} AND operation_status = 'PROCESSING'
""")
int complete(@Param("id") long id, @Param("responseSnapshot") String responseSnapshot);
}
@@ -129,6 +129,13 @@ public class LocationRepository {
return reservations.release(entity.getTenantId(), entity.getId(), entity.getVersion(), reason);
}
public int transferConfirmedReservation(LocationReservationEntity entity, String sourceService,
long sourceBusinessId, long targetBusinessId,
String targetInboundCode) {
return reservations.transferConfirmed(entity.getTenantId(), entity.getId(), sourceService,
sourceBusinessId, targetBusinessId, targetInboundCode, entity.getVersion());
}
public List<LocationOccupancyEntity> findOccupanciesByBusiness(long tenantId, String sourceService,
long businessId) {
return occupancies.selectList(new LambdaQueryWrapper<LocationOccupancyEntity>()
@@ -143,6 +150,33 @@ public class LocationRepository {
.eq(LocationOccupancyEntity::getReservationId, reservationId));
}
public LocationOccupancyEntity lockOccupancy(long tenantId, long occupancyId) {
return occupancies.lockById(tenantId, occupancyId);
}
public int rebindOccupancy(LocationOccupancyEntity entity, LocationEntity target) {
return occupancies.rebindLocation(entity.getTenantId(), entity.getId(), target.getId(), target.getLocationCode(),
entity.getVersion());
}
public int rebindReservation(LocationReservationEntity entity, LocationEntity target) {
return reservations.rebindLocation(entity.getTenantId(), entity.getId(), target.getId(), target.getLocationCode(),
entity.getVersion());
}
public int reactivateOccupancy(LocationOccupancyEntity entity, long businessId, String inboundCode,
LocationEntity target, int packageCount) {
return occupancies.reactivateForChild(entity.getTenantId(), entity.getId(), businessId, inboundCode,
target.getId(), target.getLocationCode(), Boolean.TRUE.equals(target.getSharedOverflow()), packageCount,
entity.getVersion());
}
public int reactivateReservation(LocationReservationEntity entity, long businessId, String inboundCode,
LocationEntity target) {
return reservations.reactivateForChild(entity.getTenantId(), entity.getId(), businessId, inboundCode,
target.getId(), target.getLocationCode(), Boolean.TRUE.equals(target.getSharedOverflow()), entity.getVersion());
}
public List<LocationOccupancyEntity> lockOccupiedByBusiness(long tenantId, String sourceService,
long businessId) {
return occupancies.lockOccupiedByBusiness(tenantId, sourceService, businessId);
@@ -155,4 +189,10 @@ public class LocationRepository {
public int releaseOccupancy(LocationOccupancyEntity entity, String reason) {
return occupancies.release(entity.getTenantId(), entity.getId(), entity.getVersion(), reason);
}
public int transferOccupied(LocationOccupancyEntity entity, String sourceService, long sourceBusinessId,
long targetBusinessId, String targetInboundCode, int remainingPackageCount) {
return occupancies.transferOccupied(entity.getTenantId(), entity.getId(), sourceService, sourceBusinessId,
targetBusinessId, targetInboundCode, remainingPackageCount, entity.getVersion());
}
}
@@ -0,0 +1,35 @@
package com.baiyue.warehouse.repository;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.warehouse.entity.PalletLocationReleaseOperationEntity;
import com.baiyue.warehouse.mapper.PalletLocationReleaseOperationMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
/** 已占用托盘库位选择性释放的持久化幂等边界。 */
@Repository
public class PalletLocationReleaseOperationRepository {
@Resource
private PalletLocationReleaseOperationMapper mapper;
public boolean createIfAbsent(long tenantId, String sourceService, long businessId,
String idempotencyKey, String requestHash) {
PalletLocationReleaseOperationEntity entity = new PalletLocationReleaseOperationEntity();
entity.setId(IdWorker.getId());
entity.setTenantId(tenantId);
entity.setSourceService(sourceService);
entity.setBusinessId(businessId);
entity.setIdempotencyKey(idempotencyKey);
entity.setRequestHash(requestHash);
return mapper.insertIfAbsent(entity) == 1;
}
public PalletLocationReleaseOperationEntity lockByKey(long tenantId, String sourceService,
long businessId, String idempotencyKey) {
return mapper.lockByKey(tenantId, sourceService, businessId, idempotencyKey);
}
public void complete(long id, String responseSnapshot) {
mapper.complete(id, responseSnapshot);
}
}
@@ -0,0 +1,37 @@
package com.baiyue.warehouse.repository;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.warehouse.entity.PalletLocationSettlementOperationEntity;
import com.baiyue.warehouse.mapper.PalletLocationSettlementOperationMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
/** PDA 拣货资源原子结算的持久化幂等边界。 */
@Repository
public class PalletLocationSettlementOperationRepository {
private static final long PLATFORM_RESOURCE_TENANT_ID = 0L;
private static final String FIRSTLEG_SOURCE_SERVICE = "firstleg-service";
@Resource
private PalletLocationSettlementOperationMapper mapper;
public boolean createIfAbsent(long sourceBusinessId, long targetBusinessId, String idempotencyKey,
String requestHash) {
PalletLocationSettlementOperationEntity entity = new PalletLocationSettlementOperationEntity();
entity.setId(IdWorker.getId());
entity.setTenantId(PLATFORM_RESOURCE_TENANT_ID);
entity.setSourceService(FIRSTLEG_SOURCE_SERVICE);
entity.setSourceBusinessId(sourceBusinessId);
entity.setTargetBusinessId(targetBusinessId);
entity.setIdempotencyKey(idempotencyKey);
entity.setRequestHash(requestHash);
return mapper.insertIfAbsent(entity) == 1;
}
public PalletLocationSettlementOperationEntity lockByKey(long sourceBusinessId, String idempotencyKey) {
return mapper.lockByKey(PLATFORM_RESOURCE_TENANT_ID, FIRSTLEG_SOURCE_SERVICE, sourceBusinessId, idempotencyKey);
}
public void complete(long id, String responseSnapshot) {
mapper.complete(id, responseSnapshot);
}
}
@@ -0,0 +1,36 @@
package com.baiyue.warehouse.repository;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.warehouse.entity.PalletLocationTransferOperationEntity;
import com.baiyue.warehouse.mapper.PalletLocationTransferOperationMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
/** 托盘库位归属转移的幂等操作持久化边界。 */
@Repository
public class PalletLocationTransferOperationRepository {
@Resource
private PalletLocationTransferOperationMapper mapper;
public boolean createIfAbsent(long tenantId, String sourceService, long sourceBusinessId,
long targetBusinessId, String idempotencyKey, String requestHash) {
PalletLocationTransferOperationEntity entity = new PalletLocationTransferOperationEntity();
entity.setId(IdWorker.getId());
entity.setTenantId(tenantId);
entity.setSourceService(sourceService);
entity.setSourceBusinessId(sourceBusinessId);
entity.setTargetBusinessId(targetBusinessId);
entity.setIdempotencyKey(idempotencyKey);
entity.setRequestHash(requestHash);
return mapper.insertIfAbsent(entity) == 1;
}
public PalletLocationTransferOperationEntity lockByKey(long tenantId, String sourceService,
long sourceBusinessId, long targetBusinessId, String idempotencyKey) {
return mapper.lockByKey(tenantId, sourceService, sourceBusinessId, targetBusinessId, idempotencyKey);
}
public void complete(long id, String responseSnapshot) {
mapper.complete(id, responseSnapshot);
}
}
@@ -3,10 +3,14 @@ package com.baiyue.warehouse.service;
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.LocationDtos;
import com.baiyue.warehouse.dto.LocationDtos.ActiveResource;
import com.baiyue.warehouse.dto.LocationDtos.ConfirmCommand;
import com.baiyue.warehouse.dto.LocationDtos.ExactSelectiveReleaseCommand;
import com.baiyue.warehouse.dto.LocationDtos.ExactSelectiveReleaseResult;
import com.baiyue.warehouse.dto.LocationDtos.OccupancyResult;
import com.baiyue.warehouse.dto.LocationDtos.OccupancyTarget;
import com.baiyue.warehouse.dto.LocationDtos.OccupiedResource;
import com.baiyue.warehouse.dto.LocationDtos.ReleaseCommand;
import com.baiyue.warehouse.dto.LocationDtos.ReservationResult;
import com.baiyue.warehouse.dto.LocationDtos.ReservationTarget;
@@ -31,6 +35,7 @@ import jakarta.validation.constraints.NotBlank;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.stream.Collectors;
@@ -187,11 +192,13 @@ public class LocationResourceService {
@Transactional
public List<SelectiveReleaseResult> releaseSelected(@Valid SelectiveReleaseCommand command) {
List<LocationOccupancyEntity> active = repository.lockOccupiedByBusiness(
command.tenantId(), command.sourceService(), command.businessId());
List<LocationOccupancyEntity> active = new ArrayList<>(repository.lockOccupiedByBusiness(
command.tenantId(), command.sourceService(), command.businessId()));
if (active.isEmpty()) {
throw new BizException(ResultCode.CONFLICT, "No occupied locations are available for this inbound order");
}
active.sort(Comparator.comparingLong(LocationOccupancyEntity::getReservationId)
.thenComparingLong(LocationOccupancyEntity::getId));
requireLimit(active.size());
List<Integer> packages = packageAllocator.allocate(command.totalPackageCount(), active.size());
Set<Long> requestedIds = command.locationIds() == null
@@ -245,6 +252,314 @@ public class LocationResourceService {
return List.copyOf(released);
}
/**
* Releases only the occupied locations explicitly reported as fully picked by PDA.
* The persisted allocated package count is returned verbatim: it must never be recalculated
* from the current active location count.
*/
@Transactional
public List<ExactSelectiveReleaseResult> releaseSelectedExactly(@Valid ExactSelectiveReleaseCommand command) {
List<LocationOccupancyEntity> active = new ArrayList<>(repository.lockOccupiedByBusiness(
command.tenantId(), command.sourceService(), command.businessId()));
if (active.isEmpty()) {
throw new BizException(ResultCode.CONFLICT, "No occupied locations are available for this inbound order");
}
active.sort(Comparator.comparingLong(LocationOccupancyEntity::getReservationId)
.thenComparingLong(LocationOccupancyEntity::getId));
Set<Long> requestedIds = new HashSet<>(command.locationIds());
if (requestedIds.size() != command.locationIds().size()) {
throw new BizException(ResultCode.BAD_REQUEST, "Selected location IDs must be unique");
}
Map<Long, LocationOccupancyEntity> byLocation = active.stream()
.collect(Collectors.toMap(LocationOccupancyEntity::getLocationId, item -> item));
if (!requestedIds.stream().allMatch(byLocation::containsKey)) {
throw new BizException(ResultCode.FORBIDDEN,
"A selected location does not belong to the current inbound order");
}
List<ExactSelectiveReleaseResult> released = new ArrayList<>(requestedIds.size());
for (int index = 0; index < active.size(); index++) {
LocationOccupancyEntity occupancy = active.get(index);
if (!requestedIds.contains(occupancy.getLocationId())) {
continue;
}
Integer allocatedPackageCount = occupancy.getAllocatedPackageCount();
if (allocatedPackageCount == null || allocatedPackageCount < 1) {
throw new BizException(ResultCode.CONFLICT,
"Occupied location has no valid allocated package count and cannot be released exactly");
}
LocationEntity location = repository.findOwned(command.tenantId(), occupancy.getWarehouseId(),
occupancy.getLocationId());
if (location == null || repository.releaseOccupancy(occupancy, command.reason()) != 1
|| (!Boolean.TRUE.equals(occupancy.getSharedOverflow())
&& repository.changeStatus(command.tenantId(), occupancy.getWarehouseId(), occupancy.getLocationId(),
"OCCUPIED", "AVAILABLE", location.getVersion(), command.operatorId()) != 1)) {
throw new BizException(ResultCode.CONFLICT,
"Location occupancy changed concurrently while releasing selected locations");
}
released.add(new ExactSelectiveReleaseResult(occupancy.getReservationId(), occupancy.getId(), index + 1,
occupancy.getWarehouseId(), occupancy.getPalletId(), occupancy.getLocationId(),
occupancy.getLocationCode(), allocatedPackageCount));
}
return List.copyOf(released);
}
/** 按 reservationId 释放,避免 A0 多托盘共享同一个 locationId 时误操作。 */
@Transactional
public List<ExactSelectiveReleaseResult> releaseSelectedReservations(
@Valid LocationDtos.ReservationReleaseCommand command) {
List<LocationOccupancyEntity> active = new ArrayList<>(repository.lockOccupiedByBusiness(
command.tenantId(), command.sourceService(), command.businessId()));
Map<Long, LocationOccupancyEntity> byReservation = active.stream()
.collect(Collectors.toMap(LocationOccupancyEntity::getReservationId, item -> item));
Set<Long> requested = new HashSet<>(command.reservationIds());
if (requested.size() != command.reservationIds().size() || !requested.stream().allMatch(byReservation::containsKey)) {
throw new BizException(ResultCode.BAD_REQUEST, "释放预占资源不存在或重复");
}
List<ExactSelectiveReleaseResult> result = new ArrayList<>();
for (int index = 0; index < active.size(); index++) {
LocationOccupancyEntity occupancy = active.get(index);
if (!requested.contains(occupancy.getReservationId())) continue;
Integer allocated = occupancy.getAllocatedPackageCount();
LocationEntity location = repository.findOwned(command.tenantId(), occupancy.getWarehouseId(), occupancy.getLocationId());
if (allocated == null || allocated < 1 || location == null || repository.releaseOccupancy(occupancy, command.reason()) != 1
|| (!Boolean.TRUE.equals(occupancy.getSharedOverflow()) && repository.changeStatus(command.tenantId(),
occupancy.getWarehouseId(), occupancy.getLocationId(), "OCCUPIED", "AVAILABLE", location.getVersion(),
command.operatorId()) != 1)) {
throw new BizException(ResultCode.CONFLICT, "释放库位资源时发生并发变更");
}
result.add(new ExactSelectiveReleaseResult(occupancy.getReservationId(), occupancy.getId(), index + 1,
occupancy.getWarehouseId(), occupancy.getPalletId(), occupancy.getLocationId(), occupancy.getLocationCode(), allocated));
}
return List.copyOf(result);
}
/**
* 将未拣货的已占用库位从主入库单转给二级子入库单。
* 转移只改变业务归属和剩余箱数,不释放库位或托盘。
*/
@Transactional
public List<LocationDtos.TransferResult> transferToChild(@Valid LocationDtos.TransferCommand command) {
if (command.sourceBusinessId() == command.targetBusinessId()) {
throw new BizException(ResultCode.BAD_REQUEST, "来源和目标入库单不能相同");
}
validateTransferItems(command.items());
List<LocationOccupancyEntity> active = new ArrayList<>(repository.lockOccupiedByBusiness(
command.tenantId(), command.sourceService(), command.sourceBusinessId()));
if (active.isEmpty()) {
throw new BizException(ResultCode.CONFLICT, "来源入库单没有可转移的已占用库位");
}
active.sort(Comparator.comparingLong(LocationOccupancyEntity::getReservationId)
.thenComparingLong(LocationOccupancyEntity::getId));
Map<Long, LocationDtos.TransferItem> requested = command.items().stream()
.collect(Collectors.toMap(LocationDtos.TransferItem::locationId, item -> item));
Map<Long, LocationOccupancyEntity> byLocation = active.stream()
.collect(Collectors.toMap(LocationOccupancyEntity::getLocationId, item -> item));
if (!requested.keySet().stream().allMatch(byLocation::containsKey)) {
throw new BizException(ResultCode.FORBIDDEN, "转移库位不属于来源入库单");
}
List<LocationDtos.TransferResult> results = new ArrayList<>(requested.size());
for (int index = 0; index < active.size(); index++) {
LocationOccupancyEntity occupancy = active.get(index);
LocationDtos.TransferItem item = requested.get(occupancy.getLocationId());
if (item == null) {
continue;
}
LocationReservationEntity reservation = repository.lockReservation(
command.tenantId(), occupancy.getReservationId());
if (reservation == null || !"CONFIRMED".equals(reservation.getReservationStatus())
|| !command.sourceService().equals(reservation.getSourceService())
|| reservation.getBusinessId() != command.sourceBusinessId()
|| !command.sourceService().equals(occupancy.getSourceService())
|| occupancy.getBusinessId() != command.sourceBusinessId()) {
throw new BizException(ResultCode.CONFLICT, "库位占用不是可转移的已确认资源");
}
Integer allocated = occupancy.getAllocatedPackageCount();
if (allocated == null || item.remainingPackageCount() > allocated) {
throw new BizException(ResultCode.BAD_REQUEST, "剩余箱数不能超过库位原分配箱数");
}
if (repository.transferConfirmedReservation(reservation, command.sourceService(),
command.sourceBusinessId(), command.targetBusinessId(), command.targetInboundCode()) != 1) {
throw new BizException(ResultCode.CONFLICT, "库位预占归属并发变更,请刷新后重试");
}
if (repository.transferOccupied(occupancy, command.sourceService(), command.sourceBusinessId(),
command.targetBusinessId(), command.targetInboundCode(), item.remainingPackageCount()) != 1) {
throw new BizException(ResultCode.CONFLICT, "库位占用归属并发变更,请刷新后重试");
}
results.add(new LocationDtos.TransferResult(reservation.getId(), reservation.getReservationNo(),
occupancy.getId(), index + 1, occupancy.getWarehouseId(), occupancy.getPalletId(),
occupancy.getPalletCode(), occupancy.getLocationId(), occupancy.getLocationCode(),
occupancy.getAllocatedVolumeCbm(), item.remainingPackageCount(), "OCCUPIED"));
}
return List.copyOf(results);
}
/** PDA 结算按预约记录转移未拣货资源,物理托盘和库位继续保持占用。 */
@Transactional
public List<LocationDtos.TransferResult> transferToChildByReservation(
@Valid LocationDtos.ReservationTransferCommand command) {
if (command.sourceBusinessId() == command.targetBusinessId()) {
throw new BizException(ResultCode.BAD_REQUEST, "来源和目标入库单不能相同");
}
validateReservationTransferItems(command.items());
List<LocationOccupancyEntity> active = occupiedResources(command.tenantId(), command.sourceService(),
command.sourceBusinessId());
Map<Long, LocationDtos.ReservationTransferItem> requested = command.items().stream()
.collect(Collectors.toMap(LocationDtos.ReservationTransferItem::reservationId, item -> item));
Map<Long, LocationOccupancyEntity> byReservation = active.stream()
.collect(Collectors.toMap(LocationOccupancyEntity::getReservationId, item -> item));
if (!requested.keySet().stream().allMatch(byReservation::containsKey)) {
throw new BizException(ResultCode.FORBIDDEN, "转移预约记录不属于来源入库单");
}
List<LocationDtos.TransferResult> results = new ArrayList<>(requested.size());
for (int index = 0; index < active.size(); index++) {
LocationOccupancyEntity occupancy = active.get(index);
LocationDtos.ReservationTransferItem item = requested.get(occupancy.getReservationId());
if (item == null) {
continue;
}
LocationReservationEntity reservation = repository.lockReservation(command.tenantId(),
occupancy.getReservationId());
if (reservation == null || !"CONFIRMED".equals(reservation.getReservationStatus())
|| !command.sourceService().equals(reservation.getSourceService())
|| reservation.getBusinessId() != command.sourceBusinessId()
|| !command.sourceService().equals(occupancy.getSourceService())
|| occupancy.getBusinessId() != command.sourceBusinessId()) {
throw new BizException(ResultCode.CONFLICT, "预约记录不是可转移的已确认资源");
}
Integer allocated = occupancy.getAllocatedPackageCount();
if (allocated == null || item.remainingPackageCount() > allocated) {
throw new BizException(ResultCode.BAD_REQUEST, "剩余箱数不能超过预约记录原分配箱数");
}
if (repository.transferConfirmedReservation(reservation, command.sourceService(),
command.sourceBusinessId(), command.targetBusinessId(), command.targetInboundCode()) != 1
|| repository.transferOccupied(occupancy, command.sourceService(), command.sourceBusinessId(),
command.targetBusinessId(), command.targetInboundCode(), item.remainingPackageCount()) != 1) {
throw new BizException(ResultCode.CONFLICT, "预约记录归属并发变更,请刷新后重试");
}
results.add(new LocationDtos.TransferResult(reservation.getId(), reservation.getReservationNo(),
occupancy.getId(), index + 1, occupancy.getWarehouseId(), occupancy.getPalletId(),
occupancy.getPalletCode(), occupancy.getLocationId(), occupancy.getLocationCode(),
occupancy.getAllocatedVolumeCbm(), item.remainingPackageCount(), "OCCUPIED"));
}
return List.copyOf(results);
}
/**
* 锁定读取来源入库单的全部已占用资源,供外层原子结算先校验数量守恒后再写入。
* 调用方必须处于同一个写事务中,锁会持续至结算完成或回滚。
*/
public List<OccupiedResource> lockOccupiedResources(long tenantId, String sourceService, long businessId) {
return occupiedResources(tenantId, sourceService, businessId).stream()
.map(item -> new OccupiedResource(item.getReservationId(), item.getId(), 0,
item.getWarehouseId(), item.getPalletId(), item.getLocationId(), item.getLocationCode(),
Boolean.TRUE.equals(item.getSharedOverflow()), requireAllocatedPackageCount(item)))
.toList();
}
/** 将 A0 的一条托盘占用换绑到同仓空闲普通库位。 */
@Transactional
public LocationDtos.RebindTemporaryLocationResult rebindTemporaryLocation(
@Valid LocationDtos.RebindTemporaryLocationCommand command) {
LocationOccupancyEntity occupancy = repository.lockOccupancy(command.tenantId(), command.occupancyId());
if (occupancy == null || !"OCCUPIED".equals(occupancy.getOccupancyStatus())) {
throw new BizException(ResultCode.NOT_FOUND, "临时库位占用不存在");
}
LocationEntity target = repository.findOwned(command.tenantId(), occupancy.getWarehouseId(), command.targetLocationId());
if (target == null || Boolean.TRUE.equals(target.getSharedOverflow())) {
throw new BizException(ResultCode.BAD_REQUEST, "目标库位必须是同仓普通库位");
}
if (!Boolean.TRUE.equals(occupancy.getSharedOverflow())) {
if (occupancy.getLocationId().equals(target.getId())) {
return new LocationDtos.RebindTemporaryLocationResult(occupancy.getId(), occupancy.getReservationId(),
occupancy.getWarehouseId(), occupancy.getPalletId(), occupancy.getLocationId(), occupancy.getLocationCode(),
target.getId(), target.getLocationCode());
}
throw new BizException(ResultCode.CONFLICT, "只有A0临时库位允许换绑");
}
if (!"AVAILABLE".equals(target.getStatus())
|| repository.changeStatus(command.tenantId(), occupancy.getWarehouseId(), target.getId(), "AVAILABLE", "OCCUPIED",
target.getVersion(), command.operatorId()) != 1) {
throw new BizException(ResultCode.CONFLICT, "目标库位已被占用");
}
LocationReservationEntity reservation = repository.lockReservation(command.tenantId(), occupancy.getReservationId());
long oldLocationId = occupancy.getLocationId();
String oldLocationCode = occupancy.getLocationCode();
if (reservation == null || repository.rebindReservation(reservation, target) != 1
|| repository.rebindOccupancy(occupancy, target) != 1) {
throw new BizException(ResultCode.CONFLICT, "临时库位换绑时发生并发变更");
}
return new LocationDtos.RebindTemporaryLocationResult(occupancy.getId(), occupancy.getReservationId(),
occupancy.getWarehouseId(), occupancy.getPalletId(), oldLocationId, oldLocationCode,
target.getId(), target.getLocationCode());
}
/** 全部释放后为子单重新占用同仓普通库位;普通库位不足时使用共享 A0。 */
@Transactional
public LocationDtos.TransferResult reactivateReleasedForChild(
@Valid LocationDtos.ReactivateReleasedResourceCommand command) {
LocationOccupancyEntity found = repository.findOccupancyByReservation(command.tenantId(), command.reservationId());
LocationOccupancyEntity occupancy = found == null ? null : repository.lockOccupancy(command.tenantId(), found.getId());
if (occupancy == null || !"RELEASED".equals(occupancy.getOccupancyStatus())
|| occupancy.getWarehouseId() != command.warehouseId()) {
throw new BizException(ResultCode.CONFLICT, "待重新分配的已释放资源不存在");
}
List<LocationEntity> available = repository.lockAvailable(command.tenantId(), command.warehouseId(), 1);
LocationEntity target = available.isEmpty() ? repository.lockOrCreateSharedOverflow(command.tenantId(),
command.warehouseId(), command.operatorId()) : available.get(0);
if (!Boolean.TRUE.equals(target.getSharedOverflow()) && repository.changeStatus(command.tenantId(), command.warehouseId(),
target.getId(), "AVAILABLE", "OCCUPIED", target.getVersion(), command.operatorId()) != 1) {
throw new BizException(ResultCode.CONFLICT, "重新分配的目标库位发生并发变更");
}
LocationReservationEntity reservation = repository.lockReservation(command.tenantId(), occupancy.getReservationId());
if (reservation == null || repository.reactivateReservation(reservation, command.targetBusinessId(),
command.targetInboundCode(), target) != 1 || repository.reactivateOccupancy(occupancy,
command.targetBusinessId(), command.targetInboundCode(), target, command.packageCount()) != 1) {
throw new BizException(ResultCode.CONFLICT, "重新分配子单库位时发生并发变更");
}
return new LocationDtos.TransferResult(reservation.getId(), reservation.getReservationNo(), occupancy.getId(), 1,
command.warehouseId(), occupancy.getPalletId(), occupancy.getPalletCode(), target.getId(), target.getLocationCode(),
occupancy.getAllocatedVolumeCbm(), command.packageCount(), "OCCUPIED");
}
private static void validateTransferItems(List<LocationDtos.TransferItem> items) {
Set<Long> ids = new HashSet<>();
for (LocationDtos.TransferItem item : items) {
if (!ids.add(item.locationId())) {
throw new BizException(ResultCode.BAD_REQUEST, "转移库位不能重复");
}
}
}
private static void validateReservationTransferItems(List<LocationDtos.ReservationTransferItem> items) {
Set<Long> ids = new HashSet<>();
for (LocationDtos.ReservationTransferItem item : items) {
if (!ids.add(item.reservationId())) {
throw new BizException(ResultCode.BAD_REQUEST, "转移预约记录不能重复");
}
}
}
private List<LocationOccupancyEntity> occupiedResources(long tenantId, String sourceService, long businessId) {
List<LocationOccupancyEntity> active = new ArrayList<>(repository.lockOccupiedByBusiness(tenantId,
sourceService, businessId));
if (active.isEmpty()) {
throw new BizException(ResultCode.CONFLICT, "来源入库单没有可用的已占用库位资源");
}
active.sort(Comparator.comparingLong(LocationOccupancyEntity::getReservationId)
.thenComparingLong(LocationOccupancyEntity::getId));
return active;
}
private static int requireAllocatedPackageCount(LocationOccupancyEntity occupancy) {
Integer allocated = occupancy.getAllocatedPackageCount();
if (allocated == null || allocated < 1) {
throw new BizException(ResultCode.CONFLICT, "已占用资源缺少有效分配箱数");
}
return allocated;
}
@Transactional(readOnly = true)
public List<ActiveResource> findActiveResources(@Min(1) long tenantId,
@NotBlank String sourceService,
@@ -6,7 +6,15 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.LocationDtos;
import com.baiyue.warehouse.dto.PalletAllocationDtos;
import com.baiyue.warehouse.entity.PalletEntity;
import com.baiyue.warehouse.entity.PalletLocationTransferOperationEntity;
import com.baiyue.warehouse.entity.PalletLocationReleaseOperationEntity;
import com.baiyue.warehouse.entity.PalletLocationSettlementOperationEntity;
import com.baiyue.warehouse.repository.PalletRepository;
import com.baiyue.warehouse.repository.PalletLocationTransferOperationRepository;
import com.baiyue.warehouse.repository.PalletLocationReleaseOperationRepository;
import com.baiyue.warehouse.repository.PalletLocationSettlementOperationRepository;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -14,6 +22,10 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
/**
* 托盘拆分和一托一位编排。库位资源操作全部委托给任务6的 {@link LocationResourceService}。
@@ -22,12 +34,24 @@ import java.util.List;
public class PalletLocationAllocationService {
/** 测量完成后默认推荐的普通托盘类型编码。 */
public static final String DEFAULT_PALLET_TYPE = "STANDARD";
/** 仓库、托盘和库位属于平台公共资源域。 */
private static final long PLATFORM_RESOURCE_TENANT_ID = 0L;
/** 仅头程服务可结算头程入库单占用的仓储资源。 */
private static final String FIRSTLEG_SOURCE_SERVICE = "firstleg-service";
@Resource
private PalletRepository palletRepository;
@Resource
private PalletVolumeSplitter splitter;
@Resource
private LocationResourceService locationResourceService;
@Resource
private PalletLocationTransferOperationRepository transferOperations;
@Resource
private PalletLocationReleaseOperationRepository releaseOperations;
@Resource
private PalletLocationSettlementOperationRepository settlementOperations;
@Resource
private ObjectMapper objectMapper;
@Transactional
@BusinessAudit(event = "PALLET_LOCATION_RESERVE", resourceType = "INBOUND", resourceId = "#request.businessId", description = "预占托盘和库位", captureBefore = false)
@@ -134,23 +158,378 @@ public class PalletLocationAllocationService {
resourceId = "#request.businessId", description = "Release selected occupied locations for picking")
public List<PalletAllocationDtos.SelectiveReleaseResult> releaseSelected(
PalletAllocationDtos.SelectiveReleaseRequest request) {
List<LocationDtos.SelectiveReleaseResult> released = locationResourceService.releaseSelected(
new LocationDtos.SelectiveReleaseCommand(request.tenantId(), request.sourceService(), request.businessId(),
request.operatorId(), request.reason(), request.totalPackageCount(), request.locationIds(),
request.releaseLocationCount()));
String key = request.idempotencyKey().trim();
String requestHash = selectiveReleaseRequestHash(request);
boolean created = releaseOperations.createIfAbsent(request.tenantId(), request.sourceService(),
request.businessId(), key, requestHash);
PalletLocationReleaseOperationEntity operation = releaseOperations.lockByKey(request.tenantId(),
request.sourceService(), request.businessId(), key);
if (operation == null) {
throw new BizException(ResultCode.CONFLICT, "Selected release idempotency operation is missing");
}
if (!requestHash.equals(operation.getRequestHash())) {
throw new BizException(ResultCode.CONFLICT, "Idempotency key has already been used for a different selected release request");
}
if (!created) {
if ("COMPLETED".equals(operation.getOperationStatus())) {
return replaySelectiveReleaseResult(operation.getResponseSnapshot());
}
throw new BizException(ResultCode.CONFLICT, "Selected release request is being processed, retry later");
}
List<LocationDtos.ExactSelectiveReleaseResult> released = locationResourceService.releaseSelectedExactly(
new LocationDtos.ExactSelectiveReleaseCommand(request.tenantId(), request.sourceService(), request.businessId(),
request.operatorId(), request.reason(), request.locationIds()));
List<PalletAllocationDtos.SelectiveReleaseResult> result = new ArrayList<>(released.size());
for (LocationDtos.SelectiveReleaseResult resource : released) {
for (LocationDtos.ExactSelectiveReleaseResult resource : released) {
if (palletRepository.transitionStatus(request.tenantId(), resource.warehouseId(), resource.palletId(),
List.of("OCCUPIED"), "AVAILABLE") != 1) {
throw new BizException(ResultCode.CONFLICT, "Pallet changed concurrently during selected release");
}
result.add(new PalletAllocationDtos.SelectiveReleaseResult(resource.reservationId(),
resource.occupancyId(), resource.allocationNo(), resource.palletId(), resource.locationId(),
resource.locationCode(), resource.releasedPackageCount()));
resource.locationCode(), resource.allocatedPackageCount()));
}
List<PalletAllocationDtos.SelectiveReleaseResult> immutable = List.copyOf(result);
releaseOperations.complete(operation.getId(), serializeSelectiveReleaseResult(immutable));
return immutable;
}
/**
* PDA resource settlement is deliberately one transaction. A resource cannot be released
* successfully while the matching remaining resources fail to transfer to the child inbound.
*/
@Transactional
@BusinessAudit(event = "PALLET_LOCATION_SETTLEMENT", resourceType = "INBOUND",
resourceId = "#request.sourceBusinessId", description = "PDA拣货后原子结算托盘库位资源")
public PalletAllocationDtos.SettlementResult settle(PalletAllocationDtos.SettlementRequest request) {
validateSettlementScope(request);
String key = request.idempotencyKey().trim();
String requestHash = settlementRequestHash(request);
boolean created = settlementOperations.createIfAbsent(request.sourceBusinessId(), request.targetBusinessId(),
key, requestHash);
PalletLocationSettlementOperationEntity operation = settlementOperations.lockByKey(request.sourceBusinessId(), key);
if (operation == null) {
throw new BizException(ResultCode.CONFLICT, "PDA settlement idempotency operation is missing");
}
if (!requestHash.equals(operation.getRequestHash())) {
throw new BizException(ResultCode.CONFLICT, "Idempotency key has already been used for a different PDA settlement request");
}
if (!created) {
if ("COMPLETED".equals(operation.getOperationStatus())) {
return replaySettlementResult(operation.getResponseSnapshot());
}
throw new BizException(ResultCode.CONFLICT, "PDA settlement request is being processed, retry later");
}
List<LocationDtos.OccupiedResource> sourceResources = locationResourceService.lockOccupiedResources(
PLATFORM_RESOURCE_TENANT_ID, FIRSTLEG_SOURCE_SERVICE, request.sourceBusinessId());
validateSettlement(request, sourceResources);
List<PalletAllocationDtos.SelectiveReleaseResult> released = releasePickedResources(request);
List<PalletAllocationDtos.TransferResult> transferred = transferRemainingResources(request);
PalletAllocationDtos.SettlementResult result = new PalletAllocationDtos.SettlementResult(released, transferred);
settlementOperations.complete(operation.getId(), serializeSettlementResult(result));
return result;
}
@Transactional
@BusinessAudit(event = "PALLET_LOCATION_REBIND_TEMPORARY", resourceType = "LOCATION_OCCUPANCY",
resourceId = "#request.occupancyId", description = "A0临时库位换绑到普通库位")
public PalletAllocationDtos.RebindTemporaryLocationResult rebindTemporaryLocation(
PalletAllocationDtos.RebindTemporaryLocationRequest request) {
LocationDtos.RebindTemporaryLocationResult result = locationResourceService.rebindTemporaryLocation(
new LocationDtos.RebindTemporaryLocationCommand(request.tenantId(), request.occupancyId(),
request.targetLocationId(), request.operatorId()));
return new PalletAllocationDtos.RebindTemporaryLocationResult(result.occupancyId(), result.reservationId(),
result.warehouseId(), result.palletId(), result.previousLocationId(), result.previousLocationCode(),
result.locationId(), result.locationCode());
}
private List<PalletAllocationDtos.SelectiveReleaseResult> releasePickedResources(
PalletAllocationDtos.SettlementRequest request) {
if (request.releasedReservationIds().isEmpty()) {
return List.of();
}
List<LocationDtos.ExactSelectiveReleaseResult> released = locationResourceService.releaseSelectedReservations(
new LocationDtos.ReservationReleaseCommand(PLATFORM_RESOURCE_TENANT_ID, FIRSTLEG_SOURCE_SERVICE,
request.sourceBusinessId(), request.operatorId(), settlementReason(request), request.releasedReservationIds()));
List<PalletAllocationDtos.SelectiveReleaseResult> result = new ArrayList<>(released.size());
for (LocationDtos.ExactSelectiveReleaseResult resource : released) {
if (palletRepository.transitionStatus(PLATFORM_RESOURCE_TENANT_ID, resource.warehouseId(), resource.palletId(),
List.of("OCCUPIED"), "AVAILABLE") != 1) {
throw new BizException(ResultCode.CONFLICT, "Pallet changed concurrently during PDA settlement");
}
result.add(new PalletAllocationDtos.SelectiveReleaseResult(resource.reservationId(),
resource.occupancyId(), resource.allocationNo(), resource.palletId(), resource.locationId(),
resource.locationCode(), resource.allocatedPackageCount()));
}
return List.copyOf(result);
}
private List<PalletAllocationDtos.TransferResult> transferRemainingResources(
PalletAllocationDtos.SettlementRequest request) {
if (request.remainingItems().isEmpty()) {
return List.of();
}
List<LocationDtos.TransferResult> transferred = locationResourceService.transferToChildByReservation(
new LocationDtos.ReservationTransferCommand(PLATFORM_RESOURCE_TENANT_ID, FIRSTLEG_SOURCE_SERVICE,
request.sourceBusinessId(), request.targetBusinessId(), request.targetInboundCode(), request.operatorId(),
request.remainingItems().stream()
.map(item -> new LocationDtos.ReservationTransferItem(item.reservationId(),
item.remainingPackageCount()))
.toList()));
return transferResults(PLATFORM_RESOURCE_TENANT_ID, transferred);
}
/** Same idempotency key must represent the same resource selection, regardless of request list ordering. */
private static String selectiveReleaseRequestHash(PalletAllocationDtos.SelectiveReleaseRequest request) {
String locationIds = request.locationIds() == null ? "" : request.locationIds().stream()
.sorted()
.map(String::valueOf)
.collect(Collectors.joining(","));
String canonical = request.tenantId() + "|" + request.sourceService().trim() + "|"
+ request.businessId() + "|" + request.operatorId() + "|" + request.reason().trim() + "|"
+ locationIds;
return sha256(canonical);
}
/** 将未拣货的库位和托盘分配转给二级子入库单,物理资源继续保持占用。 */
@Transactional
@BusinessAudit(event = "PALLET_LOCATION_TRANSFER", resourceType = "INBOUND",
resourceId = "#request.sourceBusinessId", description = "将未拣货库位转给子入库单")
public List<PalletAllocationDtos.TransferResult> transfer(PalletAllocationDtos.TransferRequest request) {
String key = request.idempotencyKey().trim();
String requestHash = transferRequestHash(request);
boolean created = transferOperations.createIfAbsent(request.tenantId(), request.sourceService(),
request.sourceBusinessId(), request.targetBusinessId(), key, requestHash);
PalletLocationTransferOperationEntity operation = transferOperations.lockByKey(request.tenantId(),
request.sourceService(), request.sourceBusinessId(), request.targetBusinessId(), key);
if (operation == null) {
throw new BizException(ResultCode.CONFLICT, "调拨幂等操作记录不存在");
}
if (!requestHash.equals(operation.getRequestHash())) {
throw new BizException(ResultCode.CONFLICT, "幂等键已用于不同的调拨请求");
}
if (!created) {
if ("COMPLETED".equals(operation.getOperationStatus())) {
return replayTransferResult(operation.getResponseSnapshot());
}
throw new BizException(ResultCode.CONFLICT, "调拨请求正在处理中,请稍后重试");
}
List<PalletAllocationDtos.TransferResult> immutable = transferResources(request.tenantId(), request.sourceService(),
request.sourceBusinessId(), request.targetBusinessId(), request.targetInboundCode(), request.operatorId(),
request.items());
transferOperations.complete(operation.getId(), serializeTransferResult(immutable));
return immutable;
}
private List<PalletAllocationDtos.TransferResult> transferResources(long tenantId, String sourceService,
long sourceBusinessId, long targetBusinessId, String targetInboundCode, long operatorId,
List<PalletAllocationDtos.TransferItem> items) {
List<LocationDtos.TransferResult> transferred = locationResourceService.transferToChild(
new LocationDtos.TransferCommand(tenantId, sourceService, sourceBusinessId, targetBusinessId,
targetInboundCode, operatorId, items.stream()
.map(item -> new LocationDtos.TransferItem(item.locationId(), item.remainingPackageCount()))
.toList()));
return transferResults(tenantId, transferred);
}
private List<PalletAllocationDtos.TransferResult> transferResults(long tenantId,
List<LocationDtos.TransferResult> transferred) {
List<PalletAllocationDtos.TransferResult> result = new ArrayList<>(transferred.size());
for (LocationDtos.TransferResult item : transferred) {
PalletEntity pallet = palletRepository.findOwned(tenantId, item.warehouseId(), item.palletId());
if (pallet == null || !"OCCUPIED".equals(pallet.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "转移托盘不存在或未处于占用状态");
}
result.add(new PalletAllocationDtos.TransferResult(item.reservationId(), item.reservationNo(),
item.occupancyId(), item.allocationNo(), tenantId, item.warehouseId(), item.palletId(),
item.palletCode(), pallet.getCapacityCbm(), item.locationId(), item.locationCode(),
item.allocatedVolumeCbm(), item.remainingPackageCount(), item.state()));
}
return List.copyOf(result);
}
/** 同一幂等键必须携带完全一致的调拨目标和库位箱数。 */
private static String transferRequestHash(PalletAllocationDtos.TransferRequest request) {
String items = request.items().stream()
.sorted(Comparator.comparingLong(PalletAllocationDtos.TransferItem::locationId))
.map(item -> item.locationId() + ":" + item.remainingPackageCount())
.collect(Collectors.joining(","));
String canonical = request.tenantId() + "|" + request.sourceService().trim() + "|"
+ request.sourceBusinessId() + "|" + request.targetBusinessId() + "|"
+ request.targetInboundCode().trim() + "|" + request.operatorId() + "|" + items;
return sha256(canonical);
}
private static String settlementRequestHash(PalletAllocationDtos.SettlementRequest request) {
String released = request.releasedReservationIds().stream().sorted().map(String::valueOf)
.collect(Collectors.joining(","));
String remaining = request.remainingItems().stream()
.sorted(Comparator.comparingLong(PalletAllocationDtos.SettlementTransferItem::reservationId))
.map(item -> item.reservationId() + ":" + item.remainingPackageCount())
.collect(Collectors.joining(","));
return sha256(request.warehouseId() + "|" + request.sourceService().trim() + "|"
+ request.sourceBusinessId() + "|" + request.targetBusinessId() + "|" + request.sourcePackageCount()
+ "|" + request.targetPackageCount() + "|" + normalize(request.targetInboundCode()) + "|"
+ request.operatorId() + "|" + normalize(request.operatorName()) + "|"
+ request.requestId().trim() + "|" + released + "|" + remaining);
}
private static void validateSettlementScope(PalletAllocationDtos.SettlementRequest request) {
if (!FIRSTLEG_SOURCE_SERVICE.equals(request.sourceService())) {
throw new BizException(ResultCode.FORBIDDEN, "PDA仓储资源仅允许头程服务结算");
}
}
private static String settlementReason(PalletAllocationDtos.SettlementRequest request) {
String operator = normalize(request.operatorName());
return operator.isEmpty() ? "PDA拣货资源结算:" + request.requestId().trim()
: "PDA拣货资源结算:" + request.requestId().trim() + ",操作人:" + operator;
}
private static void validateSettlement(PalletAllocationDtos.SettlementRequest request,
List<LocationDtos.OccupiedResource> sourceResources) {
java.util.Set<Long> releasedIds = new java.util.HashSet<>(request.releasedReservationIds());
if (releasedIds.size() != request.releasedReservationIds().size()) {
throw new BizException(ResultCode.BAD_REQUEST, "已拣满预约记录不能重复");
}
java.util.Map<Long, PalletAllocationDtos.SettlementTransferItem> remainingByReservation =
request.remainingItems().stream().collect(Collectors.toMap(
PalletAllocationDtos.SettlementTransferItem::reservationId, item -> item,
(first, duplicate) -> {
throw new BizException(ResultCode.BAD_REQUEST, "剩余预约记录不能重复");
}));
if (releasedIds.stream().anyMatch(remainingByReservation::containsKey)) {
throw new BizException(ResultCode.BAD_REQUEST, "同一预约记录不能同时释放和转移");
}
if (releasedIds.isEmpty() && remainingByReservation.isEmpty()) {
throw new BizException(ResultCode.BAD_REQUEST, "PDA结算必须指定已拣满或剩余的预约记录");
}
java.util.Map<Long, LocationDtos.OccupiedResource> sourceByReservation = sourceResources.stream()
.collect(Collectors.toMap(LocationDtos.OccupiedResource::reservationId, item -> item,
(first, duplicate) -> {
throw new BizException(ResultCode.CONFLICT, "同一预约记录存在多条活动占用");
}));
java.util.Set<Long> selectedIds = new java.util.HashSet<>(releasedIds);
selectedIds.addAll(remainingByReservation.keySet());
if (!sourceByReservation.keySet().equals(selectedIds)) {
throw new BizException(ResultCode.FORBIDDEN, "PDA结算预约记录必须完整且属于来源入库单");
}
int allocatedTotal = 0;
int pickedTotal = 0;
int remainingTotal = 0;
for (LocationDtos.OccupiedResource resource : sourceResources) {
if (resource.warehouseId() != request.warehouseId()) {
throw new BizException(ResultCode.FORBIDDEN, "PDA结算预约记录不属于指定仓库");
}
allocatedTotal = addPackages(allocatedTotal, resource.allocatedPackageCount());
PalletAllocationDtos.SettlementTransferItem remaining = remainingByReservation.get(resource.reservationId());
if (releasedIds.contains(resource.reservationId())) {
pickedTotal = addPackages(pickedTotal, resource.allocatedPackageCount());
continue;
}
if (remaining.remainingPackageCount() > resource.allocatedPackageCount()) {
throw new BizException(ResultCode.BAD_REQUEST, "剩余箱数不能超过预约记录原分配箱数");
}
remainingTotal = addPackages(remainingTotal, remaining.remainingPackageCount());
pickedTotal = addPackages(pickedTotal, resource.allocatedPackageCount() - remaining.remainingPackageCount());
}
if (request.sourcePackageCount() != allocatedTotal) {
throw new BizException(ResultCode.CONFLICT, "来源入库单箱数与当前库位分配箱数不一致");
}
if (request.targetPackageCount() != remainingTotal) {
throw new BizException(ResultCode.BAD_REQUEST, "目标入库单箱数必须等于剩余预约记录箱数合计");
}
if (request.sourcePackageCount() != addPackages(pickedTotal, request.targetPackageCount())) {
throw new BizException(ResultCode.BAD_REQUEST, "来源箱数必须等于已拣箱数与目标箱数之和");
}
if (request.targetPackageCount() > 0
&& (request.targetBusinessId() < 1 || request.sourceBusinessId() == request.targetBusinessId())) {
throw new BizException(ResultCode.BAD_REQUEST, "存在剩余箱数时必须指定不同的子入库单");
}
if (request.targetPackageCount() > 0 && !org.springframework.util.StringUtils.hasText(request.targetInboundCode())) {
throw new BizException(ResultCode.BAD_REQUEST, "存在剩余箱数时必须指定子入库单号");
}
}
private static int addPackages(int current, int increment) {
try {
return Math.addExact(current, increment);
} catch (ArithmeticException exception) {
throw new BizException(ResultCode.BAD_REQUEST, "箱数合计超出允许范围");
}
}
private static String normalize(String value) {
return value == null ? "" : value.trim();
}
private static String sha256(String canonical) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(canonical.getBytes(StandardCharsets.UTF_8));
StringBuilder value = new StringBuilder(64);
for (byte item : digest) {
value.append(String.format("%02x", item));
}
return value.toString();
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 算法不可用", exception);
}
}
private String serializeSelectiveReleaseResult(List<PalletAllocationDtos.SelectiveReleaseResult> result) {
try {
return objectMapper.writeValueAsString(result);
} catch (Exception exception) {
throw new BizException(ResultCode.CONFLICT, "Selected release result snapshot could not be saved");
}
}
private List<PalletAllocationDtos.SelectiveReleaseResult> replaySelectiveReleaseResult(String snapshot) {
try {
return List.copyOf(objectMapper.readValue(snapshot,
new TypeReference<List<PalletAllocationDtos.SelectiveReleaseResult>>() { }));
} catch (Exception exception) {
throw new BizException(ResultCode.CONFLICT, "Selected release idempotency result snapshot is invalid");
}
}
private String serializeSettlementResult(PalletAllocationDtos.SettlementResult result) {
try {
return objectMapper.writeValueAsString(result);
} catch (Exception exception) {
throw new BizException(ResultCode.CONFLICT, "PDA settlement result snapshot could not be saved");
}
}
private PalletAllocationDtos.SettlementResult replaySettlementResult(String snapshot) {
try {
return objectMapper.readValue(snapshot, PalletAllocationDtos.SettlementResult.class);
} catch (Exception exception) {
throw new BizException(ResultCode.CONFLICT, "PDA settlement idempotency result snapshot is invalid");
}
}
private String serializeTransferResult(List<PalletAllocationDtos.TransferResult> result) {
try {
return objectMapper.writeValueAsString(result);
} catch (Exception exception) {
throw new BizException(ResultCode.CONFLICT, "调拨结果快照保存失败");
}
}
private List<PalletAllocationDtos.TransferResult> replayTransferResult(String snapshot) {
try {
return List.copyOf(objectMapper.readValue(snapshot,
new TypeReference<List<PalletAllocationDtos.TransferResult>>() { }));
} catch (Exception exception) {
throw new BizException(ResultCode.CONFLICT, "调拨幂等结果快照无效");
}
}
private List<LocationDtos.ActiveResource> active(long tenantId, String sourceService, long businessId) {
return new ArrayList<>(locationResourceService.findActiveResources(tenantId, sourceService, businessId));
}
@@ -0,0 +1,19 @@
-- PDA 已拣满库位/托盘精确释放的持久化幂等契约;可在二期库位表建表后重复执行。
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS wh_pallet_location_release_operation (
id BIGINT UNSIGNED NOT NULL COMMENT '精确释放幂等操作ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '仓储公共资源作用域,固定为0',
source_service VARCHAR(40) NOT NULL COMMENT '来源服务',
business_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID',
idempotency_key VARCHAR(128) NOT NULL COMMENT '调用方幂等键',
request_hash CHAR(64) NOT NULL COMMENT '规范化请求SHA-256摘要',
operation_status VARCHAR(20) NOT NULL DEFAULT 'PROCESSING' COMMENT 'PROCESSING处理中,COMPLETED已完成',
response_snapshot JSON NULL COMMENT '完成后的精确释放结果快照,用于重复请求重放',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
completed_at DATETIME(3) NULL COMMENT '完成时间',
PRIMARY KEY (id),
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已拣满托盘库位精确释放的持久化幂等操作记录';
@@ -0,0 +1,27 @@
-- PDA 拣货资源原子结算幂等契约;同一数据库事务内完成精确释放和剩余资源转移。
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS wh_pallet_location_settlement_operation (
id BIGINT UNSIGNED NOT NULL COMMENT 'PDA资源结算幂等操作ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '仓储公共资源作用域,固定为0',
source_service VARCHAR(40) NOT NULL COMMENT '来源服务',
source_business_id BIGINT UNSIGNED NOT NULL COMMENT '主入库单ID',
target_business_id BIGINT UNSIGNED NOT NULL COMMENT '二级子入库单ID;全拣完成时为0,仅作审计快照',
idempotency_key VARCHAR(128) NOT NULL COMMENT 'PDA结算幂等键',
request_hash CHAR(64) NOT NULL COMMENT '规范化请求SHA-256摘要',
operation_status VARCHAR(20) NOT NULL DEFAULT 'PROCESSING' COMMENT 'PROCESSING处理中,COMPLETED已完成',
response_snapshot JSON NULL COMMENT '完成后的释放和转移组合结果快照,用于重复请求重放',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
completed_at DATETIME(3) NULL COMMENT '完成时间',
PRIMARY KEY (id),
UNIQUE KEY uk_pallet_location_settlement_idempotency
(tenant_id, source_service, source_business_id, idempotency_key),
KEY idx_pallet_location_settlement_source
(tenant_id, source_service, source_business_id, operation_status)
) ENGINE=InnoDB COMMENT='PDA拣货资源原子结算的持久化幂等操作记录';
-- 兼容已执行过旧脚本的测试库:目标子单不属于幂等键,同一来源单据和幂等键只能对应一条请求。
ALTER TABLE wh_pallet_location_settlement_operation
DROP INDEX uk_pallet_location_settlement_idempotency,
ADD UNIQUE KEY uk_pallet_location_settlement_idempotency
(tenant_id, source_service, source_business_id, idempotency_key);
@@ -0,0 +1,20 @@
-- 托盘库位转移的持久化幂等契约;可在二期托盘库位表建表后重复执行。
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS wh_pallet_location_transfer_operation (
id BIGINT UNSIGNED NOT NULL COMMENT '调拨幂等操作ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '仓储公共资源域,固定为0',
source_service VARCHAR(40) NOT NULL COMMENT '来源服务',
source_business_id BIGINT UNSIGNED NOT NULL COMMENT '来源入仓单ID',
target_business_id BIGINT UNSIGNED NOT NULL COMMENT '目标子入仓单ID',
idempotency_key VARCHAR(128) NOT NULL COMMENT '调用方幂等键',
request_hash CHAR(64) NOT NULL COMMENT '规范化请求SHA-256摘要',
operation_status VARCHAR(20) NOT NULL DEFAULT 'PROCESSING' COMMENT 'PROCESSING处理中、COMPLETED已完成',
response_snapshot JSON NULL COMMENT '完成后的调拨结果快照,用于重复请求重放',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
completed_at DATETIME(3) NULL COMMENT '完成时间',
PRIMARY KEY (id),
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='托盘库位归属转移的持久化幂等操作记录';
@@ -0,0 +1,78 @@
package com.baiyue.warehouse.controller;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.PalletAllocationDtos;
import com.baiyue.warehouse.service.PalletLocationAllocationService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class InternalPalletLocationControllerTest {
private static final String INTERNAL_TOKEN = "test-internal-service-token";
private final PalletLocationAllocationService service = mock(PalletLocationAllocationService.class);
private final InternalPalletLocationController controller = new InternalPalletLocationController();
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(controller, "service", service);
ReflectionTestUtils.setField(controller, "internalToken", INTERNAL_TOKEN);
controller.initializeToken();
}
@Test
void transfersOnlyForInternalCaller() {
PalletAllocationDtos.TransferRequest request = request();
List<PalletAllocationDtos.TransferResult> expected = List.of(new PalletAllocationDtos.TransferResult(
101L, "LR101", 201L, 1, 10001L, 20L, 30L, "TP-001", null,
40L, "A-01", null, 5, "OCCUPIED"));
when(service.transfer(request)).thenReturn(expected);
var response = controller.transfer(INTERNAL_TOKEN, request);
assertThat(response.data()).isEqualTo(expected);
verify(service).transfer(request);
}
@Test
void rejectsInvalidInternalTokenBeforeTransfer() {
assertThatThrownBy(() -> controller.transfer("invalid", request()))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.FORBIDDEN);
verifyNoInteractions(service);
}
@Test
void settlesPdaResourcesOnlyForInternalCaller() {
PalletAllocationDtos.SettlementRequest request = settlementRequest();
PalletAllocationDtos.SettlementResult expected = new PalletAllocationDtos.SettlementResult(List.of(), List.of());
when(service.settle(request)).thenReturn(expected);
var response = controller.settle(INTERNAL_TOKEN, request);
assertThat(response.data()).isEqualTo(expected);
verify(service).settle(request);
}
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)));
}
private static PalletAllocationDtos.SettlementRequest settlementRequest() {
return new PalletAllocationDtos.SettlementRequest(0L, 20L, "firstleg-service", 88L, 89L,
20, 12, "RK-001-S1", "PDA-SETTLEMENT-88-1", 9001L, "PDA settlement", List.of(101L),
List.of(new PalletAllocationDtos.SettlementTransferItem(102L, 12)));
}
}
@@ -0,0 +1,29 @@
package com.baiyue.warehouse.dto;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class PalletAllocationDtosValidationTest {
private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
@Test
void acceptsPublicResourceScope() {
LocalDateTime expiresAt = LocalDateTime.now().plusMinutes(30);
assertThat(validator.validate(new PalletAllocationDtos.ReserveRequest(0L, 20L, "FIRSTLEG", 88L,
"RK-001", "measure-88", 9L, BigDecimal.ONE, BigDecimal.ONE, "STANDARD", expiresAt, true))).isEmpty();
assertThat(validator.validate(new PalletAllocationDtos.ConfirmRequest(0L, 20L, "FIRSTLEG", 88L,
9L, BigDecimal.ONE, BigDecimal.ONE))).isEmpty();
assertThat(validator.validate(new PalletAllocationDtos.ReleaseRequest(0L, "FIRSTLEG", 88L, 9L,
"入库补偿"))).isEmpty();
assertThat(validator.validate(new PalletAllocationDtos.SelectiveReleaseRequest(0L, "FIRSTLEG", 88L,
"pda-release-88-1", 9L, "PDA 部分拣货", List.of(40L)))).isEmpty();
}
}
@@ -0,0 +1,48 @@
package com.baiyue.warehouse.repository;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class PalletLocationReleaseIdempotencySqlContractTest {
@Test
void containsPersistentSelectedReleaseIdempotencySchema() throws IOException {
String moduleSql = Files.readString(Path.of("src", "main", "resources", "db",
"phase3-pallet-location-release-idempotency.sql"));
String unifiedSql = Files.readString(Path.of("..", "deploy", "sql",
"头程数据库统一增量-MySQL8.sql"));
for (String sql : new String[] {moduleSql, unifiedSql}) {
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS wh_pallet_location_release_operation")
.contains("idempotency_key VARCHAR(128) NOT NULL")
.contains("request_hash CHAR(64) NOT NULL")
.contains("response_snapshot JSON NULL")
.contains("uk_pallet_location_release_idempotency")
.contains("tenant_id, source_service, business_id, idempotency_key");
}
}
@Test
void containsAtomicPdaSettlementSchema() throws IOException {
String moduleSql = Files.readString(Path.of("src", "main", "resources", "db",
"phase3-pallet-location-settlement-idempotency.sql"));
String unifiedSql = Files.readString(Path.of("..", "deploy", "sql",
"头程数据库统一增量-MySQL8.sql"));
for (String sql : new String[] {moduleSql, unifiedSql}) {
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS wh_pallet_location_settlement_operation")
.contains("source_business_id BIGINT UNSIGNED NOT NULL")
.contains("target_business_id BIGINT UNSIGNED NOT NULL")
.contains("response_snapshot JSON NULL")
.contains("uk_pallet_location_settlement_idempotency")
.contains("tenant_id, source_service, source_business_id, idempotency_key")
.contains("DROP INDEX uk_pallet_location_settlement_idempotency");
}
}
}
@@ -146,12 +146,17 @@ class LocationResourceServiceTest {
occupancy(201L, 101L, 9L, "A1", 501L),
occupancy(202L, 102L, 10L, "A2", 502L),
occupancy(203L, 103L, 11L, "A3", 503L));
when(repository.lockOccupiedByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(active);
when(repository.findOwned(10001L, 88L, 10L)).thenReturn(occupiedLocation(10L, "A2"));
active.forEach(item -> {
item.setTenantId(0L);
item.setSourceService("firstleg-service");
item.setBusinessId(901L);
});
when(repository.lockOccupiedByBusiness(0L, "firstleg-service", 901L)).thenReturn(active);
when(repository.findOwned(0L, 88L, 10L)).thenReturn(occupiedLocation(10L, "A2"));
when(repository.releaseOccupancy(active.get(1), "PDA partial pick")).thenReturn(1);
when(repository.changeStatus(10001L, 88L, 10L, "OCCUPIED", "AVAILABLE", 0, 7L)).thenReturn(1);
when(repository.changeStatus(0L, 88L, 10L, "OCCUPIED", "AVAILABLE", 0, 7L)).thenReturn(1);
var result = service.releaseSelected(new SelectiveReleaseCommand(10001L, "FIRSTLEG", 301L, 7L,
var result = service.releaseSelected(new SelectiveReleaseCommand(0L, "firstleg-service", 901L, 7L,
"PDA partial pick", 10, List.of(10L), null));
assertThat(result).singleElement().satisfies(item -> {
@@ -163,6 +168,56 @@ class LocationResourceServiceTest {
verify(repository, never()).releaseOccupancy(active.get(2), "PDA partial pick");
}
@Test
void shouldReleaseSelectedLocationsWithPersistedAllocatedPackageCount() {
LocationOccupancyEntity first = occupancy(201L, 101L, 9L, "A1", 501L);
first.setAllocatedPackageCount(3);
LocationOccupancyEntity second = occupancy(202L, 102L, 10L, "A2", 502L);
second.setAllocatedPackageCount(7);
when(repository.lockOccupiedByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(List.of(first, second));
when(repository.findOwned(10001L, 88L, 10L)).thenReturn(occupiedLocation(10L, "A2"));
when(repository.releaseOccupancy(second, "PDA full pick")).thenReturn(1);
when(repository.changeStatus(10001L, 88L, 10L, "OCCUPIED", "AVAILABLE", 0, 7L)).thenReturn(1);
var result = service.releaseSelectedExactly(new com.baiyue.warehouse.dto.LocationDtos.ExactSelectiveReleaseCommand(
10001L, "FIRSTLEG", 301L, 7L, "PDA full pick", List.of(10L)));
assertThat(result).singleElement().satisfies(item -> {
assertThat(item.locationId()).isEqualTo(10L);
assertThat(item.allocatedPackageCount()).isEqualTo(7);
});
verify(repository, never()).releaseOccupancy(first, "PDA full pick");
}
@Test
void shouldReleasePublicResourcesInAllocationOrderWhenLocationsAreNotSpecified() {
LocationOccupancyEntity allocationThree = occupancy(203L, 103L, 11L, "A3", 503L);
LocationOccupancyEntity allocationOne = occupancy(201L, 101L, 9L, "A1", 501L);
LocationOccupancyEntity allocationTwo = occupancy(202L, 102L, 10L, "A2", 502L);
List<LocationOccupancyEntity> active = List.of(allocationThree, allocationOne, allocationTwo);
active.forEach(item -> {
item.setTenantId(0L);
item.setSourceService("firstleg-service");
item.setBusinessId(901L);
});
when(repository.lockOccupiedByBusiness(0L, "firstleg-service", 901L)).thenReturn(active);
for (LocationOccupancyEntity item : List.of(allocationOne, allocationTwo)) {
when(repository.findOwned(0L, 88L, item.getLocationId()))
.thenReturn(occupiedLocation(item.getLocationId(), item.getLocationCode()));
when(repository.releaseOccupancy(item, "PDA default pick")).thenReturn(1);
when(repository.changeStatus(0L, 88L, item.getLocationId(), "OCCUPIED", "AVAILABLE", 0, 7L))
.thenReturn(1);
}
var result = service.releaseSelected(new SelectiveReleaseCommand(0L, "firstleg-service", 901L, 7L,
"PDA default pick", 8, List.of(), 2));
assertThat(result).extracting(item -> item.allocationNo()).containsExactly(1, 2);
assertThat(result).extracting(item -> item.locationCode()).containsExactly("A1", "A2");
assertThat(result).extracting(item -> item.releasedPackageCount()).containsExactly(3, 3);
verify(repository, never()).releaseOccupancy(allocationThree, "PDA default pick");
}
@Test
void shouldReleaseFirstNAllocationsWhenLocationsAreNotSpecified() {
List<LocationOccupancyEntity> active = List.of(
@@ -213,6 +268,52 @@ class LocationResourceServiceTest {
any(String.class), any(String.class), anyInt(), anyLong());
}
@Test
void shouldTransferConfirmedOccupiedResourcesToChildInbound() {
LocationOccupancyEntity occupancy = occupancy(201L, 101L, 9L, "A1", 501L);
occupancy.setAllocatedPackageCount(8);
occupancy.setAllocatedVolumeCbm(new BigDecimal("1.200000"));
LocationReservationEntity reservation = reservation("CONFIRMED");
reservation.setReservationNo("LR101");
when(repository.lockOccupiedByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(List.of(occupancy));
when(repository.lockReservation(10001L, 101L)).thenReturn(reservation);
when(repository.transferConfirmedReservation(reservation, "FIRSTLEG", 301L, 302L, "RK001-S1"))
.thenReturn(1);
when(repository.transferOccupied(occupancy, "FIRSTLEG", 301L, 302L, "RK001-S1", 5))
.thenReturn(1);
var result = service.transferToChild(new com.baiyue.warehouse.dto.LocationDtos.TransferCommand(
10001L, "FIRSTLEG", 301L, 302L, "RK001-S1", 7L,
List.of(new com.baiyue.warehouse.dto.LocationDtos.TransferItem(9L, 5))));
assertThat(result).singleElement().satisfies(item -> {
assertThat(item.reservationNo()).isEqualTo("LR101");
assertThat(item.occupancyId()).isEqualTo(201L);
assertThat(item.remainingPackageCount()).isEqualTo(5);
assertThat(item.state()).isEqualTo("OCCUPIED");
});
verify(repository).transferConfirmedReservation(reservation, "FIRSTLEG", 301L, 302L, "RK001-S1");
verify(repository).transferOccupied(occupancy, "FIRSTLEG", 301L, 302L, "RK001-S1", 5);
verify(repository, never()).releaseOccupancy(any(), any(String.class));
verify(repository, never()).releaseReservation(any(), any(String.class));
}
@Test
void shouldRejectTransferWhenRemainingPackagesExceedAllocation() {
LocationOccupancyEntity occupancy = occupancy(201L, 101L, 9L, "A1", 501L);
occupancy.setAllocatedPackageCount(4);
LocationReservationEntity reservation = reservation("CONFIRMED");
when(repository.lockOccupiedByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(List.of(occupancy));
when(repository.lockReservation(10001L, 101L)).thenReturn(reservation);
assertThatThrownBy(() -> service.transferToChild(new com.baiyue.warehouse.dto.LocationDtos.TransferCommand(
10001L, "FIRSTLEG", 301L, 302L, "RK001-S1", 7L,
List.of(new com.baiyue.warehouse.dto.LocationDtos.TransferItem(9L, 5)))))
.isInstanceOf(com.baiyue.common.core.exception.BizException.class);
verify(repository, never()).transferConfirmedReservation(any(), any(String.class), anyLong(), anyLong(), any());
verify(repository, never()).transferOccupied(any(), any(String.class), anyLong(), anyLong(), any(), anyInt());
}
private static ReserveCommand reserveCommand() {
return new ReserveCommand(10001L, 88L, "FIRSTLEG", 301L, "RK001", 7L,
LocalDateTime.now().plusMinutes(30), List.of(
@@ -4,7 +4,14 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.LocationDtos;
import com.baiyue.warehouse.dto.PalletAllocationDtos;
import com.baiyue.warehouse.entity.PalletEntity;
import com.baiyue.warehouse.entity.PalletLocationTransferOperationEntity;
import com.baiyue.warehouse.entity.PalletLocationReleaseOperationEntity;
import com.baiyue.warehouse.entity.PalletLocationSettlementOperationEntity;
import com.baiyue.warehouse.repository.PalletRepository;
import com.baiyue.warehouse.repository.PalletLocationTransferOperationRepository;
import com.baiyue.warehouse.repository.PalletLocationReleaseOperationRepository;
import com.baiyue.warehouse.repository.PalletLocationSettlementOperationRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import jakarta.annotation.Resource;
import org.springframework.boot.test.mock.mockito.MockBean;
@@ -19,20 +26,28 @@ import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
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.verifyNoInteractions;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(classes = {PalletLocationAllocationService.class, PalletLocationAllocationServiceTest.TestConfig.class})
class PalletLocationAllocationServiceTest {
@MockBean private PalletRepository pallets;
@MockBean private LocationResourceService locations;
@MockBean private PalletLocationTransferOperationRepository transferOperations;
@MockBean private PalletLocationReleaseOperationRepository releaseOperations;
@MockBean private PalletLocationSettlementOperationRepository settlementOperations;
@Resource private PalletLocationAllocationService service;
@Configuration(proxyBeanMethods = false)
static class TestConfig {
@Bean PalletVolumeSplitter splitter() { return new PalletVolumeSplitter(); }
@Bean ObjectMapper objectMapper() { return new ObjectMapper(); }
}
@Test
@@ -106,21 +121,304 @@ class PalletLocationAllocationServiceTest {
@Test
void shouldReleaseOnlyPalletsReturnedBySelectedLocationRelease() {
var resource = new LocationDtos.SelectiveReleaseResult(
var resource = new LocationDtos.ExactSelectiveReleaseResult(
102L, 202L, 2, 20L, 30L, 40L, "A-02", 4);
when(locations.releaseSelected(any())).thenReturn(List.of(resource));
PalletLocationReleaseOperationEntity operation = new PalletLocationReleaseOperationEntity();
operation.setId(701L);
operation.setOperationStatus("PROCESSING");
when(releaseOperations.createIfAbsent(anyLong(), anyString(), anyLong(), anyString(), anyString()))
.thenAnswer(invocation -> {
operation.setRequestHash(invocation.getArgument(4));
return true;
});
when(releaseOperations.lockByKey(anyLong(), anyString(), anyLong(), anyString())).thenReturn(operation);
when(locations.releaseSelectedExactly(any())).thenReturn(List.of(resource));
when(pallets.transitionStatus(10001L, 20L, 30L, List.of("OCCUPIED"), "AVAILABLE"))
.thenReturn(1);
var result = service.releaseSelected(new PalletAllocationDtos.SelectiveReleaseRequest(
10001L, "FIRSTLEG", 88L, 9001L, "PDA partial pick", 10, List.of(40L), null));
10001L, "FIRSTLEG", 88L, "pda-release-88-202", 9001L, "PDA partial pick", List.of(40L)));
assertThat(result).singleElement().satisfies(item -> {
assertThat(item.allocationNo()).isEqualTo(2);
assertThat(item.locationId()).isEqualTo(40L);
assertThat(item.releasedPackageCount()).isEqualTo(4);
assertThat(item.allocatedPackageCount()).isEqualTo(4);
});
verify(pallets).transitionStatus(10001L, 20L, 30L, List.of("OCCUPIED"), "AVAILABLE");
verify(releaseOperations).complete(org.mockito.ArgumentMatchers.eq(701L), anyString());
}
@Test
void shouldReplayCompletedSelectedReleaseForSameIdempotencyKey() throws Exception {
var expected = List.of(new PalletAllocationDtos.SelectiveReleaseResult(101L, 201L, 1,
30L, 40L, "A-01", 6));
PalletLocationReleaseOperationEntity operation = new PalletLocationReleaseOperationEntity();
operation.setOperationStatus("COMPLETED");
operation.setResponseSnapshot(new ObjectMapper().writeValueAsString(expected));
String[] requestHash = new String[1];
when(releaseOperations.createIfAbsent(anyLong(), anyString(), anyLong(), anyString(), anyString()))
.thenAnswer(invocation -> {
requestHash[0] = invocation.getArgument(4);
return false;
});
when(releaseOperations.lockByKey(anyLong(), anyString(), anyLong(), anyString()))
.thenAnswer(invocation -> {
operation.setRequestHash(requestHash[0]);
return operation;
});
assertThat(service.releaseSelected(selectiveReleaseRequest())).isEqualTo(expected);
verify(locations, never()).releaseSelectedExactly(any());
verify(releaseOperations, never()).complete(anyLong(), anyString());
}
@Test
void shouldRejectSelectedReleaseWhenIdempotencyKeyHasDifferentRequest() {
PalletLocationReleaseOperationEntity operation = new PalletLocationReleaseOperationEntity();
operation.setOperationStatus("COMPLETED");
operation.setRequestHash("different-request-hash");
when(releaseOperations.createIfAbsent(anyLong(), anyString(), anyLong(), anyString(), anyString()))
.thenReturn(false);
when(releaseOperations.lockByKey(anyLong(), anyString(), anyLong(), anyString())).thenReturn(operation);
assertThatThrownBy(() -> service.releaseSelected(selectiveReleaseRequest())).isInstanceOf(BizException.class);
verify(locations, never()).releaseSelectedExactly(any());
}
@Test
void shouldSettlePdaResourcesAtomicallyAndPersistCombinedSnapshot() {
PalletLocationSettlementOperationEntity operation = new PalletLocationSettlementOperationEntity();
operation.setId(801L);
operation.setOperationStatus("PROCESSING");
when(locations.lockOccupiedResources(0L, "firstleg-service", 88L)).thenReturn(sourceResources());
when(settlementOperations.createIfAbsent(anyLong(), anyLong(), anyString(), anyString()))
.thenAnswer(invocation -> {
operation.setRequestHash(invocation.getArgument(3));
return true;
});
when(settlementOperations.lockByKey(anyLong(), anyString()))
.thenReturn(operation);
when(locations.releaseSelectedReservations(any())).thenReturn(List.of(new LocationDtos.ExactSelectiveReleaseResult(
101L, 201L, 1, 20L, 30L, 40L, "A-01", 8)));
when(pallets.transitionStatus(0L, 20L, 30L, List.of("OCCUPIED"), "AVAILABLE")).thenReturn(1);
when(locations.transferToChildByReservation(any())).thenReturn(List.of(new LocationDtos.TransferResult(
102L, "LR102", 202L, 2, 20L, 31L, "TP-002", 41L, "A-02",
new BigDecimal("1.200000"), 12, "OCCUPIED")));
PalletEntity pallet = pallet(31L, "TP-002");
pallet.setStatus("OCCUPIED");
pallet.setCapacityCbm(new BigDecimal("1.500000"));
when(pallets.findOwned(0L, 20L, 31L)).thenReturn(pallet);
var result = service.settle(settlementRequest());
assertThat(result.released()).singleElement().satisfies(item ->
assertThat(item.allocatedPackageCount()).isEqualTo(8));
assertThat(result.transferred()).singleElement().satisfies(item ->
assertThat(item.remainingPackageCount()).isEqualTo(12));
verify(settlementOperations).complete(org.mockito.ArgumentMatchers.eq(801L), anyString());
org.mockito.ArgumentCaptor<LocationDtos.ReservationReleaseCommand> releaseCommand =
org.mockito.ArgumentCaptor.forClass(LocationDtos.ReservationReleaseCommand.class);
verify(locations).releaseSelectedReservations(releaseCommand.capture());
assertThat(releaseCommand.getValue().tenantId()).isZero();
assertThat(releaseCommand.getValue().sourceService()).isEqualTo("firstleg-service");
assertThat(releaseCommand.getValue().reservationIds()).containsExactly(101L);
org.mockito.ArgumentCaptor<LocationDtos.ReservationTransferCommand> transferCommand =
org.mockito.ArgumentCaptor.forClass(LocationDtos.ReservationTransferCommand.class);
verify(locations).transferToChildByReservation(transferCommand.capture());
assertThat(transferCommand.getValue().items()).extracting(LocationDtos.ReservationTransferItem::reservationId)
.containsExactly(102L);
}
@Test
void shouldReplayCompletedPdaSettlementForSameIdempotencyKey() throws Exception {
var expected = new PalletAllocationDtos.SettlementResult(
List.of(new PalletAllocationDtos.SelectiveReleaseResult(101L, 201L, 1, 30L, 40L, "A-01", 8)),
List.of(new PalletAllocationDtos.TransferResult(102L, "LR102", 202L, 2,
0L, 20L, 31L, "TP-002", new BigDecimal("1.5"), 41L, "A-02",
new BigDecimal("1.2"), 12, "OCCUPIED")));
PalletLocationSettlementOperationEntity operation = new PalletLocationSettlementOperationEntity();
operation.setOperationStatus("COMPLETED");
operation.setResponseSnapshot(new ObjectMapper().writeValueAsString(expected));
String[] requestHash = new String[1];
when(locations.lockOccupiedResources(0L, "firstleg-service", 88L)).thenReturn(sourceResources());
when(settlementOperations.createIfAbsent(anyLong(), anyLong(), anyString(), anyString()))
.thenAnswer(invocation -> {
requestHash[0] = invocation.getArgument(3);
return false;
});
when(settlementOperations.lockByKey(anyLong(), anyString()))
.thenAnswer(invocation -> {
operation.setRequestHash(requestHash[0]);
return operation;
});
assertThat(service.settle(settlementRequest())).isEqualTo(expected);
verify(locations, never()).releaseSelectedReservations(any());
verify(locations, never()).transferToChildByReservation(any());
}
@Test
void shouldRejectPdaSettlementWhenIdempotencyKeyHasDifferentRequest() {
PalletLocationSettlementOperationEntity operation = new PalletLocationSettlementOperationEntity();
operation.setOperationStatus("COMPLETED");
operation.setRequestHash("different-request-hash");
when(locations.lockOccupiedResources(0L, "firstleg-service", 88L)).thenReturn(sourceResources());
when(settlementOperations.createIfAbsent(anyLong(), anyLong(), anyString(), anyString()))
.thenReturn(false);
when(settlementOperations.lockByKey(anyLong(), anyString()))
.thenReturn(operation);
assertThatThrownBy(() -> service.settle(settlementRequest())).isInstanceOf(BizException.class);
verify(locations, never()).releaseSelectedReservations(any());
verify(locations, never()).transferToChildByReservation(any());
}
@Test
void shouldRejectPdaSettlementFromNonFirstlegService() {
PalletAllocationDtos.SettlementRequest invalid = new PalletAllocationDtos.SettlementRequest(
0L, 20L, "other-service", 88L, 89L, 20, 12, "RK-001-S1",
"PDA-SETTLEMENT-88-1", 9001L, "PDA settlement", List.of(101L),
List.of(new PalletAllocationDtos.SettlementTransferItem(102L, 12)));
assertThatThrownBy(() -> service.settle(invalid)).isInstanceOf(BizException.class);
verifyNoInteractions(settlementOperations);
verify(locations, never()).lockOccupiedResources(anyLong(), anyString(), anyLong());
}
@Test
void shouldRejectPdaSettlementWhenPackageCountsDoNotBalance() {
PalletAllocationDtos.SettlementRequest invalid = new PalletAllocationDtos.SettlementRequest(
0L, 20L, "firstleg-service", 88L, 89L, 20, 11, "RK-001-S1",
"PDA-SETTLEMENT-88-1", 9001L, "PDA settlement", List.of(101L),
List.of(new PalletAllocationDtos.SettlementTransferItem(102L, 12)));
PalletLocationSettlementOperationEntity operation = new PalletLocationSettlementOperationEntity();
operation.setOperationStatus("PROCESSING");
when(locations.lockOccupiedResources(0L, "firstleg-service", 88L)).thenReturn(sourceResources());
when(settlementOperations.createIfAbsent(anyLong(), anyLong(), anyString(), anyString()))
.thenAnswer(invocation -> {
operation.setRequestHash(invocation.getArgument(3));
return true;
});
when(settlementOperations.lockByKey(88L, "PDA-SETTLEMENT-88-1")).thenReturn(operation);
assertThatThrownBy(() -> service.settle(invalid)).isInstanceOf(BizException.class);
verify(locations, never()).releaseSelectedReservations(any());
verify(locations, never()).transferToChildByReservation(any());
}
@Test
void shouldRejectChangedChildForSamePdaSettlementKey() {
PalletLocationSettlementOperationEntity operation = new PalletLocationSettlementOperationEntity();
operation.setOperationStatus("COMPLETED");
operation.setRequestHash("request-for-another-child");
PalletAllocationDtos.SettlementRequest changedChild = new PalletAllocationDtos.SettlementRequest(
0L, 20L, "firstleg-service", 88L, 90L, 20, 12, "RK-001-S2",
"PDA-SETTLEMENT-88-1", 9001L, "PDA settlement", List.of(101L),
List.of(new PalletAllocationDtos.SettlementTransferItem(102L, 12)));
when(locations.lockOccupiedResources(0L, "firstleg-service", 88L)).thenReturn(sourceResources());
when(settlementOperations.createIfAbsent(eq(88L), eq(90L), eq("PDA-SETTLEMENT-88-1"), anyString()))
.thenReturn(false);
when(settlementOperations.lockByKey(88L, "PDA-SETTLEMENT-88-1")).thenReturn(operation);
assertThatThrownBy(() -> service.settle(changedChild)).isInstanceOf(BizException.class);
verify(settlementOperations).lockByKey(88L, "PDA-SETTLEMENT-88-1");
verify(locations, never()).releaseSelectedReservations(any());
verify(locations, never()).transferToChildByReservation(any());
}
@Test
void shouldTransferOccupiedResourceWithoutReleasingPallet() {
PalletLocationTransferOperationEntity operation = new PalletLocationTransferOperationEntity();
operation.setId(501L);
operation.setOperationStatus("PROCESSING");
when(transferOperations.createIfAbsent(anyLong(), anyString(), anyLong(), anyLong(), anyString(), anyString()))
.thenAnswer(invocation -> {
operation.setRequestHash(invocation.getArgument(5));
return true;
});
when(transferOperations.lockByKey(anyLong(), anyString(), anyLong(), anyLong(), anyString()))
.thenReturn(operation);
when(locations.transferToChild(any())).thenReturn(List.of(new LocationDtos.TransferResult(
101L, "LR101", 201L, 1, 20L, 30L, "TP-001", 40L, "A-01",
new BigDecimal("1.200000"), 5, "OCCUPIED")));
PalletEntity pallet = pallet(30L, "TP-001");
pallet.setStatus("OCCUPIED");
pallet.setCapacityCbm(new BigDecimal("1.500000"));
when(pallets.findOwned(10001L, 20L, 30L)).thenReturn(pallet);
var result = service.transfer(new PalletAllocationDtos.TransferRequest(
10001L, "FIRSTLEG", 88L, 89L, "RK-001-S1", "transfer-88-89", 9001L,
List.of(new PalletAllocationDtos.TransferItem(40L, 5))));
assertThat(result).singleElement().satisfies(item -> {
assertThat(item.occupancyId()).isEqualTo(201L);
assertThat(item.palletCapacityCbm()).isEqualByComparingTo("1.500000");
assertThat(item.remainingPackageCount()).isEqualTo(5);
assertThat(item.state()).isEqualTo("OCCUPIED");
});
verify(pallets, never()).transitionStatus(10001L, 20L, 30L, List.of("OCCUPIED"), "AVAILABLE");
verify(transferOperations).complete(org.mockito.ArgumentMatchers.eq(501L), anyString());
}
@Test
void shouldReplayCompletedTransferForSameIdempotencyKey() throws Exception {
var expected = List.of(new PalletAllocationDtos.TransferResult(101L, "LR101", 201L, 1,
0L, 20L, 30L, "TP-001", new BigDecimal("1.5"), 40L, "A-01",
new BigDecimal("1.2"), 5, "OCCUPIED"));
PalletLocationTransferOperationEntity operation = new PalletLocationTransferOperationEntity();
operation.setOperationStatus("COMPLETED");
operation.setResponseSnapshot(new ObjectMapper().writeValueAsString(expected));
String[] requestHash = new String[1];
when(transferOperations.createIfAbsent(anyLong(), anyString(), anyLong(), anyLong(), anyString(), anyString()))
.thenAnswer(invocation -> {
requestHash[0] = invocation.getArgument(5);
return false;
});
when(transferOperations.lockByKey(anyLong(), anyString(), anyLong(), anyLong(), anyString()))
.thenAnswer(invocation -> {
operation.setRequestHash(requestHash[0]);
return operation;
});
assertThat(service.transfer(transferRequest())).isEqualTo(expected);
verify(locations, never()).transferToChild(any());
verify(transferOperations, never()).complete(anyLong(), anyString());
}
@Test
void shouldRejectSameIdempotencyKeyWithDifferentRequestHash() {
PalletLocationTransferOperationEntity operation = new PalletLocationTransferOperationEntity();
operation.setOperationStatus("COMPLETED");
operation.setRequestHash("different-request-hash");
when(transferOperations.createIfAbsent(anyLong(), anyString(), anyLong(), anyLong(), anyString(), anyString()))
.thenReturn(false);
when(transferOperations.lockByKey(anyLong(), anyString(), anyLong(), anyLong(), anyString()))
.thenReturn(operation);
assertThatThrownBy(() -> service.transfer(transferRequest())).isInstanceOf(BizException.class);
verify(locations, never()).transferToChild(any());
}
private static PalletAllocationDtos.TransferRequest transferRequest() {
return new PalletAllocationDtos.TransferRequest(10001L, "FIRSTLEG", 88L, 89L, "RK-001-S1",
"transfer-88-89", 9001L, List.of(new PalletAllocationDtos.TransferItem(40L, 5)));
}
private static PalletAllocationDtos.SelectiveReleaseRequest selectiveReleaseRequest() {
return new PalletAllocationDtos.SelectiveReleaseRequest(10001L, "FIRSTLEG", 88L,
"pda-release-88-201", 9001L, "PDA full pick", List.of(40L));
}
private static PalletAllocationDtos.SettlementRequest settlementRequest() {
return new PalletAllocationDtos.SettlementRequest(0L, 20L, "firstleg-service", 88L, 89L, 20, 12,
"RK-001-S1", "PDA-SETTLEMENT-88-1", 9001L, "PDA settlement", List.of(101L),
List.of(new PalletAllocationDtos.SettlementTransferItem(102L, 12)));
}
private static List<LocationDtos.OccupiedResource> sourceResources() {
return List.of(
new LocationDtos.OccupiedResource(101L, 201L, 1, 20L, 30L, 40L, "A-01", false, 8),
new LocationDtos.OccupiedResource(102L, 202L, 2, 20L, 31L, 41L, "A-02", false, 12));
}
private static PalletAllocationDtos.ReserveRequest reserve(long businessId) {