装柜回归测试
This commit is contained in:
+27
@@ -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)) {
|
||||
|
||||
+21
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
+27
@@ -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;
|
||||
}
|
||||
+28
@@ -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;
|
||||
}
|
||||
+28
@@ -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);
|
||||
}
|
||||
|
||||
+39
@@ -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);
|
||||
}
|
||||
+40
@@ -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);
|
||||
}
|
||||
+41
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+35
@@ -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);
|
||||
}
|
||||
}
|
||||
+37
@@ -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);
|
||||
}
|
||||
}
|
||||
+36
@@ -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,
|
||||
|
||||
+385
-6
@@ -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='托盘库位归属转移的持久化幂等操作记录';
|
||||
+78
@@ -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)));
|
||||
}
|
||||
}
|
||||
+29
@@ -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();
|
||||
}
|
||||
}
|
||||
+48
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+105
-4
@@ -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(
|
||||
|
||||
+302
-4
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user