557 lines
33 KiB
Java
557 lines
33 KiB
Java
package com.baiyue.warehouse.service;
|
|
|
|
import com.baiyue.common.audit.BusinessAudit;
|
|
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.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;
|
|
|
|
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}。
|
|
*/
|
|
@Service
|
|
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)
|
|
public PalletAllocationDtos.BatchResult reserve(PalletAllocationDtos.ReserveRequest request) {
|
|
if (!Boolean.TRUE.equals(request.measurementCompleted())) {
|
|
throw new BizException(ResultCode.CONFLICT, "只能使用已完成测量的总体积拆分托盘并占用库位");
|
|
}
|
|
var split = splitter.split(request.totalVolumeCbm(), request.palletCapacityCbm());
|
|
List<LocationDtos.ActiveResource> existing = active(request.tenantId(), request.sourceService(), request.businessId());
|
|
if (!existing.isEmpty()) {
|
|
return fromActive(existing, split);
|
|
}
|
|
|
|
String palletType = org.springframework.util.StringUtils.hasText(request.palletType())
|
|
? request.palletType() : DEFAULT_PALLET_TYPE;
|
|
List<PalletEntity> pallets = palletRepository.findAvailable(request.tenantId(), request.warehouseId(),
|
|
palletType, request.palletCapacityCbm(), split.palletCount());
|
|
if (pallets.size() != split.palletCount()) {
|
|
throw new BizException(ResultCode.CONFLICT, "可用托盘数量不足");
|
|
}
|
|
|
|
List<LocationDtos.ReservationTarget> targets = new ArrayList<>(pallets.size());
|
|
for (int index = 0; index < pallets.size(); index++) {
|
|
PalletEntity pallet = pallets.get(index);
|
|
if (palletRepository.transitionStatus(request.tenantId(), request.warehouseId(), pallet.getId(),
|
|
List.of("AVAILABLE"), "RESERVED") != 1) {
|
|
throw new BizException(ResultCode.CONFLICT, "托盘被并发预占,请重试");
|
|
}
|
|
targets.add(new LocationDtos.ReservationTarget(pallet.getId(), pallet.getPalletCode(),
|
|
request.idempotencyKey() + ":" + (index + 1)));
|
|
}
|
|
|
|
List<LocationDtos.ReservationResult> reservations = locationResourceService.reserveAvailable(
|
|
new LocationDtos.ReserveCommand(request.tenantId(), request.warehouseId(), request.sourceService(),
|
|
request.businessId(), request.inboundCode(), request.operatorId(), request.expiresAt(), targets));
|
|
if (reservations.size() != split.palletCount()) {
|
|
throw new BizException(ResultCode.CONFLICT, "库位预占结果数量不一致");
|
|
}
|
|
List<PalletAllocationDtos.AllocationResult> results = new ArrayList<>(reservations.size());
|
|
for (int index = 0; index < reservations.size(); index++) {
|
|
var item = reservations.get(index);
|
|
results.add(new PalletAllocationDtos.AllocationResult(item.reservationId(), item.reservationNo(), index + 1,
|
|
item.palletId(), item.palletCode(), item.locationId(), item.locationCode(),
|
|
split.allocations().get(index).allocatedVolumeCbm(), "RESERVED"));
|
|
}
|
|
return new PalletAllocationDtos.BatchResult(split.palletCount(), List.copyOf(results));
|
|
}
|
|
|
|
@Transactional
|
|
@BusinessAudit(event = "PALLET_LOCATION_CONFIRM", resourceType = "INBOUND", resourceId = "#request.businessId", description = "确认托盘和库位占用")
|
|
public PalletAllocationDtos.BatchResult confirm(PalletAllocationDtos.ConfirmRequest request) {
|
|
var split = splitter.split(request.totalVolumeCbm(), request.palletCapacityCbm());
|
|
List<LocationDtos.ActiveResource> active = active(request.tenantId(), request.sourceService(), request.businessId());
|
|
if (active.size() != split.palletCount()) {
|
|
throw new BizException(ResultCode.CONFLICT, "活动预占数量与托盘拆分数量不一致");
|
|
}
|
|
active.sort(Comparator.comparingLong(LocationDtos.ActiveResource::reservationId));
|
|
List<LocationDtos.OccupancyTarget> targets = new ArrayList<>(active.size());
|
|
int perAllocation = (int) Math.ceil((double) request.packageCount() / active.size());
|
|
int remainingPackages = request.packageCount();
|
|
for (int index = 0; index < active.size(); index++) {
|
|
int allocatedPackages = Math.min(perAllocation, remainingPackages);
|
|
remainingPackages -= allocatedPackages;
|
|
targets.add(new LocationDtos.OccupancyTarget(active.get(index).reservationId(),
|
|
split.allocations().get(index).allocatedVolumeCbm(), allocatedPackages));
|
|
}
|
|
List<LocationDtos.OccupancyResult> occupied = locationResourceService.confirmOccupied(
|
|
new LocationDtos.ConfirmCommand(request.tenantId(), request.warehouseId(), request.sourceService(),
|
|
request.businessId(), request.operatorId(), targets));
|
|
for (LocationDtos.ActiveResource resource : active) {
|
|
if ("RESERVED".equals(resource.state())
|
|
&& palletRepository.transitionStatus(request.tenantId(), request.warehouseId(), resource.palletId(),
|
|
List.of("RESERVED"), "OCCUPIED") != 1) {
|
|
throw new BizException(ResultCode.CONFLICT, "托盘确认占用发生并发冲突");
|
|
}
|
|
}
|
|
List<PalletAllocationDtos.AllocationResult> results = new ArrayList<>(occupied.size());
|
|
for (int index = 0; index < occupied.size(); index++) {
|
|
var item = occupied.get(index);
|
|
results.add(new PalletAllocationDtos.AllocationResult(item.reservationId(), active.get(index).reservationNo(), index + 1,
|
|
item.palletId(), active.get(index).palletCode(), item.locationId(), item.locationCode(),
|
|
split.allocations().get(index).allocatedVolumeCbm(), "OCCUPIED"));
|
|
}
|
|
return new PalletAllocationDtos.BatchResult(split.palletCount(), List.copyOf(results));
|
|
}
|
|
|
|
@Transactional
|
|
@BusinessAudit(event = "PALLET_LOCATION_RELEASE", resourceType = "INBOUND", resourceId = "#request.businessId", description = "补偿释放托盘和库位预占")
|
|
public List<LocationDtos.ActiveResource> release(PalletAllocationDtos.ReleaseRequest request) {
|
|
List<LocationDtos.ActiveResource> active = active(request.tenantId(), request.sourceService(), request.businessId());
|
|
if (active.stream().anyMatch(item -> "OCCUPIED".equals(item.state()))) {
|
|
throw new BizException(ResultCode.CONFLICT, "已确认实物入库的占用不能通过补偿接口释放");
|
|
}
|
|
List<LocationDtos.ActiveResource> released = locationResourceService.release(
|
|
new LocationDtos.ReleaseCommand(request.tenantId(), request.sourceService(), request.businessId(),
|
|
request.operatorId(), request.reason()));
|
|
for (LocationDtos.ActiveResource resource : released) {
|
|
if (palletRepository.transitionStatus(request.tenantId(), resource.warehouseId(), resource.palletId(),
|
|
List.of("RESERVED"), "AVAILABLE") != 1) {
|
|
throw new BizException(ResultCode.CONFLICT, "托盘释放发生并发冲突");
|
|
}
|
|
}
|
|
return released;
|
|
}
|
|
|
|
@Transactional
|
|
@BusinessAudit(event = "PALLET_LOCATION_RELEASE_SELECTED", resourceType = "INBOUND",
|
|
resourceId = "#request.businessId", description = "Release selected occupied locations for picking")
|
|
public List<PalletAllocationDtos.SelectiveReleaseResult> releaseSelected(
|
|
PalletAllocationDtos.SelectiveReleaseRequest request) {
|
|
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.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.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));
|
|
}
|
|
|
|
private static PalletAllocationDtos.BatchResult fromActive(List<LocationDtos.ActiveResource> active,
|
|
PalletVolumeSplitter.SplitResult split) {
|
|
if (active.size() != split.palletCount()) {
|
|
throw new BizException(ResultCode.CONFLICT, "幂等请求与已有托盘拆分结果不一致");
|
|
}
|
|
active.sort(Comparator.comparingLong(LocationDtos.ActiveResource::reservationId));
|
|
List<PalletAllocationDtos.AllocationResult> results = new ArrayList<>(active.size());
|
|
for (int index = 0; index < active.size(); index++) {
|
|
var item = active.get(index);
|
|
results.add(new PalletAllocationDtos.AllocationResult(item.reservationId(), item.reservationNo(), index + 1,
|
|
item.palletId(), item.palletCode(), item.locationId(), item.locationCode(),
|
|
split.allocations().get(index).allocatedVolumeCbm(), item.state()));
|
|
}
|
|
return new PalletAllocationDtos.BatchResult(split.palletCount(), List.copyOf(results));
|
|
}
|
|
}
|