581 lines
34 KiB
Java
581 lines
34 KiB
Java
package com.baiyue.firstleg.service;
|
|
|
|
import com.baiyue.common.core.api.ApiResponse;
|
|
import com.baiyue.common.core.api.ResultCode;
|
|
import com.baiyue.common.core.exception.BizException;
|
|
import com.baiyue.firstleg.client.WarehousePalletLocationClient;
|
|
import com.baiyue.firstleg.client.OrderFulfillmentSplitClient;
|
|
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
|
|
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
|
|
import com.baiyue.firstleg.mapper.InboundSplitSettlementMapper;
|
|
import com.baiyue.firstleg.mapper.InboundSplitSettlementMapper.MeasurementItemRow;
|
|
import com.baiyue.firstleg.mapper.InboundSplitSettlementMapper.ParentRow;
|
|
import com.baiyue.firstleg.mapper.DropshipPalletizationMapper;
|
|
import com.baiyue.firstleg.repository.InboundPalletAllocationRepository;
|
|
import com.baiyue.firstleg.repository.LoadingPickRepository;
|
|
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
|
import feign.FeignException;
|
|
import jakarta.annotation.Resource;
|
|
import org.springframework.beans.factory.annotation.Value;
|
|
import org.springframework.stereotype.Service;
|
|
import org.springframework.transaction.annotation.Transactional;
|
|
import org.slf4j.Logger;
|
|
import org.slf4j.LoggerFactory;
|
|
|
|
import java.util.ArrayList;
|
|
import java.util.Comparator;
|
|
import java.util.HashMap;
|
|
import java.util.LinkedHashMap;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.Objects;
|
|
import java.util.Set;
|
|
import java.util.stream.Collectors;
|
|
|
|
/** PDA 任务结算:按柜次生成实装/余量履约分片,并结算测量与库位资源。 */
|
|
@Service
|
|
public class PdaPickSettlementService {
|
|
private static final Logger log = LoggerFactory.getLogger(PdaPickSettlementService.class);
|
|
private static final long PLATFORM_WAREHOUSE_RESOURCE_TENANT_ID = 0L;
|
|
private static final String SOURCE_SERVICE = "firstleg-service";
|
|
|
|
@Resource
|
|
private LoadingPickRepository picks;
|
|
@Resource
|
|
private InboundSplitSettlementMapper splits;
|
|
@Resource
|
|
private InboundPalletAllocationRepository allocations;
|
|
@Resource
|
|
private WarehousePalletLocationClient warehouse;
|
|
@Resource
|
|
private OrderFulfillmentSplitClient orderSplits;
|
|
@Resource
|
|
private InboundHoldGuard inboundHoldGuard;
|
|
@Resource
|
|
private DropshipPalletizationMapper dropshipPallets;
|
|
@Resource
|
|
private com.baiyue.firstleg.repository.DropshipSplitRepository dropshipSplits;
|
|
@Value("${baiyue.security.internal-service-token}")
|
|
private String internalToken;
|
|
|
|
/** 每个柜次生成一张 LOADED 分片;存在余量时另生成可继续分柜的 REMAINDER 分片。 */
|
|
@Transactional
|
|
public void settle(long taskId, long operatorId, String operatorName) {
|
|
inboundHoldGuard.requireTaskNotHeld(taskId);
|
|
if (!picks.allTaskItemsResolved(taskId)) {
|
|
return;
|
|
}
|
|
List<LoadingPickItemEntity> taskItems = picks.items(taskId);
|
|
PdaPickSettlementPlan.Resolution resolution = PdaPickSettlementPlan.resolve(taskItems);
|
|
for (Long planItemId : resolution.skippedPlanItemIds()) {
|
|
// 重复 settle(例如复核)时该行已逻辑删除,0 行是可接受的幂等结果。
|
|
int changed = splits.removeSkippedPlanItem(planItemId, operatorId, operatorName);
|
|
if (changed != 0 && changed != 1) {
|
|
throw conflict("跳单明细已被并发修改,请刷新后重试");
|
|
}
|
|
}
|
|
|
|
Map<Long, Integer> pickedByInbound = pickedByInbound(taskItems);
|
|
Set<Long> newlySplitInbounds = new java.util.HashSet<>();
|
|
pickedByInbound.entrySet().stream().sorted(Map.Entry.comparingByKey())
|
|
.forEach(entry -> {
|
|
if (settleInbound(entry.getKey(), entry.getValue(), taskItems, taskId, operatorId, operatorName)) {
|
|
newlySplitInbounds.add(entry.getKey());
|
|
}
|
|
});
|
|
for (List<PdaPickSettlementPlan.SplitLine> lines : resolution.partialByInbound().values()) {
|
|
for (PdaPickSettlementPlan.SplitLine line : lines) {
|
|
if (newlySplitInbounds.contains(line.inboundId())) {
|
|
requireAffected(splits.convergePartialPlanItem(line.loadingPlanItemId(), operatorId, operatorName),
|
|
"部分拣货装柜明细");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Worker boundary: one inbound owner per transaction, so a cabinet never holds 15 owner locks at once. */
|
|
@Transactional
|
|
public void settleOwner(long taskId, long inboundId, long operatorId, String operatorName) {
|
|
inboundHoldGuard.requireTaskNotHeld(taskId);
|
|
if (!picks.allTaskItemsResolved(taskId)) {
|
|
throw conflict("PDA任务仍有未处理明细");
|
|
}
|
|
List<LoadingPickItemEntity> taskItems = picks.items(taskId);
|
|
Map<Long, Integer> pickedByInbound = pickedByInbound(taskItems);
|
|
Integer pickedPackageCount = pickedByInbound.get(inboundId);
|
|
if (pickedPackageCount == null) {
|
|
throw conflict("PDA任务不包含指定入库单");
|
|
}
|
|
PdaPickSettlementPlan.Resolution resolution = PdaPickSettlementPlan.resolve(taskItems);
|
|
for (Long planItemId : resolution.skippedPlanItemIds()) {
|
|
int changed = splits.removeSkippedPlanItem(planItemId, operatorId, operatorName);
|
|
if (changed != 0 && changed != 1) {
|
|
throw conflict("跳单明细已被并发修改,请刷新后重试");
|
|
}
|
|
}
|
|
boolean newlySplit = settleInbound(inboundId, pickedPackageCount, taskItems, taskId, operatorId, operatorName);
|
|
if (newlySplit) {
|
|
for (PdaPickSettlementPlan.SplitLine line : resolution.partialByInbound()
|
|
.getOrDefault(inboundId, List.of())) {
|
|
requireAffected(splits.convergePartialPlanItem(line.loadingPlanItemId(), operatorId, operatorName),
|
|
"部分拣货装柜明细");
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 按柜次实拣数量结算,一件代发保留未装整托供后续柜次使用。 */
|
|
private boolean settleInbound(long parentInboundId, int pickedPackageCount,
|
|
List<LoadingPickItemEntity> taskItems, long taskId,
|
|
long operatorId, String operatorName) {
|
|
InboundSplitSettlementMapper.Lineage lineage = splits.lineage(parentInboundId);
|
|
if (lineage == null) throw conflict("待拆分入库单不存在");
|
|
if ("CHILD".equals(lineage.inboundRole())
|
|
&& !("REMAINDER".equals(lineage.splitPurpose()) && "NOT_SPLIT".equals(lineage.splitStatus()))) {
|
|
throw conflict("仅未消费的余量子单可以继续分柜");
|
|
}
|
|
if (!"MASTER".equals(lineage.inboundRole()) && lineage.rootInboundId() == null) {
|
|
throw conflict("余量子单缺少根主单");
|
|
}
|
|
long rootInboundId = "MASTER".equals(lineage.inboundRole()) ? parentInboundId : lineage.rootInboundId();
|
|
ParentRow root = splits.lockParent(rootInboundId);
|
|
ParentRow parent = rootInboundId == parentInboundId ? root : splits.lockParent(parentInboundId);
|
|
if (parent == null || parent.tenantId() == null || parent.tenantId() <= 0
|
|
|| !("MASTER".equals(parent.inboundRole()) || "CHILD".equals(parent.inboundRole()))
|
|
|| !("WAIT_SPLIT".equals(parent.processStatus()) || "LOADING".equals(parent.processStatus()))
|
|
|| ("MASTER".equals(parent.inboundRole()) && "FULLY_SPLIT".equals(parent.splitStatus()))) {
|
|
throw conflict("待拆分主入库单不在可拆分状态");
|
|
}
|
|
String operationId = "PDA-SPLIT-" + taskId + '-' + parentInboundId;
|
|
Long appliedBatchId = splits.appliedBatch(parent.tenantId(), operationId);
|
|
if (appliedBatchId != null && appliedBatchId > 0) return false;
|
|
var task = picks.task(taskId);
|
|
if (task == null || task.getLoadingPlanId() == null) throw conflict("PDA任务缺少真实装柜计划");
|
|
long loadingPlanId = task.getLoadingPlanId();
|
|
Long containerBookingId = splits.containerBookingId(loadingPlanId);
|
|
if (containerBookingId == null) throw conflict("PDA任务装柜计划缺少真实柜次");
|
|
int parentAvailable = availablePackageCount(parent);
|
|
if (pickedPackageCount <= 0 || pickedPackageCount > parentAvailable) {
|
|
throw conflict("本次已拣箱数与主入库单可用箱数不一致");
|
|
}
|
|
int childPackageCount = parentAvailable - pickedPackageCount;
|
|
List<InboundPalletAllocationEntity> confirmedAllocations = new ArrayList<>(allocations.lockConfirmed(
|
|
parent.tenantId(), parentInboundId));
|
|
confirmedAllocations.sort(Comparator.comparing(InboundPalletAllocationEntity::getAllocationNo,
|
|
Comparator.nullsLast(Integer::compareTo)));
|
|
consumePickedAllocations(parent.tenantId(), parentInboundId, confirmedAllocations, pickedPackageCount);
|
|
long blockId = blockId(confirmedAllocations);
|
|
int allocationRemaining = confirmedAllocations.stream().mapToInt(PdaPickSettlementService::remaining).sum();
|
|
if (allocationRemaining != childPackageCount) {
|
|
throw conflict("父入库单库位剩余箱数与未装柜箱数不一致");
|
|
}
|
|
warehouseId(confirmedAllocations);
|
|
|
|
List<MeasurementItemRow> measurements = splits.lockLatestApprovedItemsForInbound(
|
|
parent.tenantId(), parentInboundId);
|
|
if (measurements.isEmpty()) {
|
|
throw conflict("主入库单缺少已审核测量明细,不能生成子入库单");
|
|
}
|
|
long sourceMeasurementId = measurements.get(0).measurementId();
|
|
if (measurements.stream().anyMatch(item -> !Objects.equals(sourceMeasurementId, item.measurementId()))) {
|
|
throw conflict("主入库单存在多个已审核测量批次,不能自动拆分");
|
|
}
|
|
boolean dropshipWholePallet = "ONE_CLICK_DROPSHIP".equals(
|
|
splits.fulfillmentType(parent.tenantId(), parentInboundId));
|
|
Map<Long, Integer> pickedByDetail = dropshipWholePallet
|
|
? Map.of() : pickedByDetail(taskItems, parentInboundId);
|
|
if (!dropshipWholePallet
|
|
&& pickedByDetail.values().stream().mapToInt(Integer::intValue).sum() != pickedPackageCount) {
|
|
throw conflict("PDA 已拣明细与入库单已拣箱数不一致");
|
|
}
|
|
|
|
List<MeasurementSplitLine> measurementLines = dropshipWholePallet
|
|
? wholePalletMeasurementLines(measurements, pickedPackageCount, parentAvailable)
|
|
: measurementLines(measurements, pickedByDetail);
|
|
int measuredRemaining = measurementLines.stream().mapToInt(MeasurementSplitLine::remainingPackageCount).sum();
|
|
if (measuredRemaining != childPackageCount) {
|
|
throw conflict("主入库单测量明细剩余箱数与未装柜箱数不一致");
|
|
}
|
|
|
|
if (root == null || !"MASTER".equals(root.inboundRole())) throw conflict("根主入库单不存在");
|
|
Map<Long, Integer> orderQuantities = measurementQuantities(measurementLines);
|
|
OrderFulfillmentSplitClient.Response orderSplit = createOrderSplit(parent, root, orderQuantities,
|
|
pickedPackageCount, childPackageCount, loadingPlanId, containerBookingId, operationId,
|
|
"pda-task-" + taskId, operatorId, operatorName);
|
|
long remainderChildId = childPackageCount > 0 ? IdWorker.getId() : 0L;
|
|
long batchId = IdWorker.getId();
|
|
String idempotencyKey = "pda-task-" + taskId;
|
|
int rootVersion = root.version() == null ? 0 : root.version();
|
|
int sourceVersion = parent.version() == null ? 0 : parent.version();
|
|
int sequence = splits.nextSplitSequence(parent.tenantId(), rootInboundId);
|
|
String remainderChildCode = childPackageCount > 0
|
|
? childInboundCode(root.inboundCode(), sequence) : null;
|
|
// Consume the current slice before inserting its successor so the single-active-remainder
|
|
// constraint is released. A local failure rolls this back; the Order split is idempotent on retry.
|
|
requireAffected(splits.markSourceLoaded(parent.tenantId(), parentInboundId, pickedPackageCount,
|
|
operatorId, sourceVersion), "来源分片状态");
|
|
if (parentInboundId != rootInboundId) {
|
|
requireAffected(splits.touchRootSplit(parent.tenantId(), rootInboundId, operatorId, rootVersion), "根单拆分状态");
|
|
}
|
|
if (remainderChildId != 0L) {
|
|
requireAffected(splits.cloneChildWithPurpose(parent.tenantId(), parentInboundId, rootInboundId, remainderChildId,
|
|
orderSplit.remainderChild().orderId(), orderSplit.remainderChild().orderNo(),
|
|
remainderChildCode, childPackageCount, sequence, "REMAINDER", operatorId, operatorName), "余量子入库单");
|
|
if (dropshipWholePallet) {
|
|
var pickedPalletIds = taskItems.stream().filter(item -> "PICKED".equals(item.getItemStatus())
|
|
&& "SEALED_PALLET".equals(item.getUnitType())).map(LoadingPickItemEntity::getPalletId)
|
|
.filter(Objects::nonNull).distinct().toList();
|
|
// 托内箱按实际箱唛移动,不能按箱序号截取,否则会移错已装托盘。
|
|
dropshipSplits.transferRemainder(parent.tenantId(), parentInboundId, remainderChildId,
|
|
orderSplit.remainderChild().orderId(), remainderChildCode, childPackageCount, pickedPalletIds);
|
|
} else {
|
|
splits.transferRemainingCartonLabels(parent.tenantId(), parentInboundId, remainderChildId,
|
|
orderSplit.remainderChild().orderId(), remainderChildCode, pickedPackageCount, childPackageCount);
|
|
}
|
|
splits.normalizeLoadedCartonLabels(parent.tenantId(), parentInboundId, pickedPackageCount);
|
|
}
|
|
requireAffected(splits.insertBatchWithChildren(batchId, parent.tenantId(), rootInboundId, operationId, parentInboundId,
|
|
idempotencyKey, rootVersion, parentAvailable, null, remainderChildId == 0L ? null : remainderChildId,
|
|
pickedPackageCount, childPackageCount, loadingPlanId, containerBookingId,
|
|
"PDA拣货后生成实装/余量子单", operatorId, operatorName), "拆分批次");
|
|
long childMeasurementId = childPackageCount > 0 ? IdWorker.getId() : 0L;
|
|
if (childPackageCount > 0) {
|
|
requireAffected(splits.cloneMeasurement(parent.tenantId(), parentInboundId, sourceMeasurementId,
|
|
remainderChildId, childMeasurementId, batchId, childPackageCount, operatorId), "余量子入库测量批次");
|
|
}
|
|
|
|
int lineNo = 1;
|
|
for (MeasurementSplitLine line : measurementLines) {
|
|
if (line.remainingPackageCount() <= 0) {
|
|
continue;
|
|
}
|
|
long splitItemId = IdWorker.getId();
|
|
requireAffected(splits.insertSplitItem(splitItemId, parent.tenantId(), batchId, rootInboundId,
|
|
remainderChildId, lineNo, line.item().inboundDetailId(), sourceMeasurementId,
|
|
line.item().measurementItemId(), line.remainingPackageCount()), "拆分明细");
|
|
requireAffected(splits.cloneMeasurementItem(parent.tenantId(), sourceMeasurementId,
|
|
line.item().measurementItemId(), childMeasurementId, splitItemId, IdWorker.getId(), lineNo,
|
|
line.remainingPackageCount()), "子入库测量明细");
|
|
lineNo++;
|
|
}
|
|
requireAffected(splits.applyBatch(parent.tenantId(), batchId), "拆分批次生效");
|
|
for (MeasurementSplitLine line : measurementLines) {
|
|
requireAffected(splits.updateMeasurementItemForLoaded(parent.tenantId(),
|
|
line.item().measurementItemId(), line.pickedPackageCount()), "来源测量明细");
|
|
}
|
|
requireAffected(splits.updateMeasurementForLoaded(parent.tenantId(), parentInboundId, sourceMeasurementId,
|
|
pickedPackageCount), "来源测量批次");
|
|
requireAffected(splits.normalizeChildMetrics(parent.tenantId(), parentInboundId, sourceMeasurementId),
|
|
"来源单重量体积归一化");
|
|
if (remainderChildId != 0L) {
|
|
requireAffected(splits.normalizeChildMetrics(parent.tenantId(), remainderChildId, childMeasurementId),
|
|
"余量子单重量体积归一化");
|
|
}
|
|
requireAffected(splits.insertHistory(IdWorker.getId(), parent.tenantId(), batchId,
|
|
operationId + "-APPLY", "{\"sourceInboundId\":" + parentInboundId
|
|
+ ",\"loadedChildInboundId\":null"
|
|
+ ",\"remainderChildInboundId\":" + (remainderChildId == 0L ? "null" : remainderChildId)
|
|
+ ",\"loadedPackageCount\":" + pickedPackageCount
|
|
+ ",\"remainingPackageCount\":" + childPackageCount
|
|
+ ",\"loadingPlanId\":" + loadingPlanId
|
|
+ ",\"containerBookingId\":" + containerBookingId + "}", operatorId, operatorName,
|
|
"PDA拣货后来源单承接实装并生成余量子入库单"), "拆分审计");
|
|
|
|
if (remainderChildId != 0L) {
|
|
allocations.copyBlockToChild(parent.tenantId(), remainderChildId, confirmedAllocations);
|
|
allocations.markBlockTransferred(parent.tenantId(), parentInboundId, blockId);
|
|
} else {
|
|
allocations.markBlockReleased(parent.tenantId(), parentInboundId, blockId);
|
|
}
|
|
settleWarehouseBlock(parentInboundId, remainderChildId,
|
|
remainderChildCode == null ? root.inboundCode() : remainderChildCode,
|
|
taskId, operatorId, blockId);
|
|
return true;
|
|
}
|
|
|
|
private OrderFulfillmentSplitClient.Response createOrderSplit(
|
|
ParentRow source, ParentRow root, Map<Long, Integer> quantitiesByDetail,
|
|
int loadedPackageCount, int remainingPackageCount, long loadingPlanId,
|
|
long containerBookingId, String operationId, String idempotencyKey,
|
|
long operatorId, String operatorName) {
|
|
if (root.orderId() == null || source.orderId() == null) {
|
|
throw conflict("入仓单缺少真实订单引用");
|
|
}
|
|
List<OrderFulfillmentSplitClient.QuantityLine> quantities = quantitiesByDetail.entrySet().stream()
|
|
.map(entry -> new OrderFulfillmentSplitClient.QuantityLine(entry.getKey(), entry.getValue()))
|
|
.toList();
|
|
OrderFulfillmentSplitClient.CreateRequest request = new OrderFulfillmentSplitClient.CreateRequest(
|
|
source.tenantId(), root.orderId(), source.orderId(), loadingPlanId, containerBookingId,
|
|
loadedPackageCount + remainingPackageCount, loadedPackageCount, remainingPackageCount,
|
|
null, idempotencyKey, operationId, quantities, operatorName);
|
|
ApiResponse<OrderFulfillmentSplitClient.Response> response;
|
|
try {
|
|
response = orderSplits.split(internalToken, operatorId, request);
|
|
} catch (FeignException exception) {
|
|
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "订单服务不可用,无法生成真实履约子订单");
|
|
}
|
|
if (response == null || !response.successful() || response.data() == null) {
|
|
ResultCode code = response == null ? ResultCode.SERVICE_UNAVAILABLE : ResultCode.of(response.code());
|
|
throw new BizException(code, response == null || response.message() == null
|
|
? "订单服务生成真实履约子订单失败" : response.message());
|
|
}
|
|
OrderFulfillmentSplitClient.Response split = response.data();
|
|
boolean remainderMatches = remainingPackageCount == 0 ? split.remainderChild() == null
|
|
: validOrderChild(split.remainderChild(), "REMAINDER", source.orderId(), loadingPlanId,
|
|
containerBookingId, remainingPackageCount);
|
|
boolean sourceLoadedMatches = split.loadedChild() == null
|
|
|| (Objects.equals(split.loadedChild().orderId(), source.orderId())
|
|
&& validOrderChild(split.loadedChild(), "LOADED", source.orderId(), loadingPlanId,
|
|
containerBookingId, loadedPackageCount));
|
|
if (!Objects.equals(split.parentOrderId(), root.orderId())
|
|
|| !Objects.equals(split.sourcePackageCount(), loadedPackageCount + remainingPackageCount)
|
|
|| !Objects.equals(split.loadedPackageCount(), loadedPackageCount)
|
|
|| !Objects.equals(split.remainingPackageCount(), remainingPackageCount)
|
|
|| !Objects.equals(split.loadingPlanId(), loadingPlanId)
|
|
|| !Objects.equals(split.containerBookingId(), containerBookingId)
|
|
|| !sourceLoadedMatches
|
|
|| !remainderMatches) {
|
|
throw conflict("订单服务返回的父子订单、柜次或数量不一致");
|
|
}
|
|
return split;
|
|
}
|
|
|
|
private static boolean validOrderChild(OrderFulfillmentSplitClient.Child child, String role,
|
|
Long sourceOrderId, long loadingPlanId,
|
|
long containerBookingId, int packageCount) {
|
|
return child != null && child.orderId() != null && child.orderId() > 0
|
|
&& child.orderNo() != null && !child.orderNo().isBlank()
|
|
&& role.equals(child.splitRole()) && Objects.equals(child.packageCount(), packageCount)
|
|
&& Objects.equals(child.sourceOrderId(), sourceOrderId)
|
|
&& Objects.equals(child.loadingPlanId(), loadingPlanId)
|
|
&& Objects.equals(child.containerBookingId(), containerBookingId);
|
|
}
|
|
|
|
private Map<Long, Integer> pickedByInbound(List<LoadingPickItemEntity> taskItems) {
|
|
Map<Long, Integer> result = new HashMap<>();
|
|
Set<Long> pickedPalletIds = new java.util.LinkedHashSet<>();
|
|
for (LoadingPickItemEntity item : taskItems) {
|
|
if (!("PICKED".equals(item.getItemStatus()) || "PARTIAL".equals(item.getItemStatus()))) continue;
|
|
if ("SEALED_PALLET".equals(item.getUnitType()) && item.getPalletId() != null) {
|
|
pickedPalletIds.add(item.getPalletId());
|
|
} else {
|
|
result.merge(item.getInboundId(), count(item.getPickedPackageCount(), "PDA已拣箱数"), Integer::sum);
|
|
}
|
|
}
|
|
if (pickedPalletIds.isEmpty()) return result;
|
|
Map<Long, List<LoadingPickItemEntity>> palletItems = taskItems.stream()
|
|
.filter(item -> item.getPalletId() != null && pickedPalletIds.contains(item.getPalletId()))
|
|
.collect(Collectors.groupingBy(LoadingPickItemEntity::getPalletId));
|
|
if (palletItems.values().stream().anyMatch(items -> items.stream()
|
|
.anyMatch(item -> !"PICKED".equals(item.getItemStatus())))) {
|
|
throw conflict("一件代发整托存在未完成的 SKU 明细,不能结算物理箱数");
|
|
}
|
|
List<DropshipPalletizationMapper.PlanPalletMemberRow> physical =
|
|
dropshipPallets.palletInboundMembersByPalletIds(pickedPalletIds);
|
|
Set<Long> resolvedPallets = physical.stream().map(DropshipPalletizationMapper.PlanPalletMemberRow::getPalletId)
|
|
.collect(Collectors.toSet());
|
|
if (!resolvedPallets.equals(pickedPalletIds)) {
|
|
throw conflict("一件代发托盘缺少物理箱明细,不能完成装柜结算");
|
|
}
|
|
physical.forEach(row -> result.merge(row.getInboundId(),
|
|
count(row.getAvailablePackageCount(), "托盘物理箱数"), Integer::sum));
|
|
return result;
|
|
}
|
|
|
|
private static Map<Long, Integer> pickedByDetail(List<LoadingPickItemEntity> taskItems, long inboundId) {
|
|
Map<Long, Integer> result = new HashMap<>();
|
|
for (LoadingPickItemEntity item : taskItems) {
|
|
if (item.getInboundId() == null || item.getInboundId() != inboundId
|
|
|| !("PICKED".equals(item.getItemStatus()) || "PARTIAL".equals(item.getItemStatus()))) {
|
|
continue;
|
|
}
|
|
if (item.getInboundDetailId() == null) {
|
|
throw conflict("PDA 已拣明细缺少入库单明细标识");
|
|
}
|
|
result.merge(item.getInboundDetailId(), count(item.getPickedPackageCount(), "PDA已拣箱数"), Integer::sum);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private static List<MeasurementSplitLine> measurementLines(List<MeasurementItemRow> measurements,
|
|
Map<Long, Integer> pickedByDetail) {
|
|
Set<Long> measuredDetailIds = measurements.stream().map(MeasurementItemRow::inboundDetailId)
|
|
.collect(Collectors.toSet());
|
|
if (!measuredDetailIds.containsAll(pickedByDetail.keySet())) {
|
|
throw conflict("PDA 已拣明细未匹配已审核测量明细");
|
|
}
|
|
Map<Long, Integer> remainingPickedByDetail = new HashMap<>(pickedByDetail);
|
|
List<MeasurementSplitLine> result = new ArrayList<>();
|
|
for (MeasurementItemRow item : measurements) {
|
|
int measured = count(item.packageCount(), "测量明细箱数");
|
|
int requested = remainingPickedByDetail.getOrDefault(item.inboundDetailId(), 0);
|
|
int picked = Math.min(measured, requested);
|
|
remainingPickedByDetail.put(item.inboundDetailId(), requested - picked);
|
|
result.add(new MeasurementSplitLine(item, picked, measured - picked));
|
|
}
|
|
if (remainingPickedByDetail.values().stream().anyMatch(remaining -> remaining != 0)) {
|
|
throw conflict("PDA 已拣箱数超过测量明细箱数");
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/** 整托箱数守恒,测量行按箱数分摊重量体积;真实SKU组成仍由封托箱账本冻结。 */
|
|
private static List<MeasurementSplitLine> wholePalletMeasurementLines(List<MeasurementItemRow> measurements,
|
|
int pickedPackageCount,
|
|
int parentAvailable) {
|
|
int measuredPackages = measurements.stream()
|
|
.mapToInt(item -> count(item.packageCount(), "测量明细箱数")).sum();
|
|
if (pickedPackageCount <= 0 || pickedPackageCount > parentAvailable || measuredPackages != parentAvailable) {
|
|
throw conflict("一件代发整托箱数与入库单、测量批次不守恒");
|
|
}
|
|
int left = pickedPackageCount;
|
|
List<MeasurementSplitLine> result = new ArrayList<>();
|
|
for (var item : measurements) {
|
|
int total = count(item.packageCount(), "测量明细箱数");
|
|
int picked = Math.min(total, left);
|
|
result.add(new MeasurementSplitLine(item, picked, total - picked));
|
|
left -= picked;
|
|
}
|
|
return List.copyOf(result);
|
|
}
|
|
|
|
private static Map<Long, Integer> measurementQuantities(List<MeasurementSplitLine> lines) {
|
|
return lines.stream().collect(Collectors.groupingBy(line -> line.item().inboundDetailId(),
|
|
LinkedHashMap::new, Collectors.summingInt(line -> count(line.item().packageCount(), "测量明细箱数"))));
|
|
}
|
|
|
|
private void consumePickedAllocations(long accountId, long inboundId,
|
|
List<InboundPalletAllocationEntity> confirmed, int pickedCount) {
|
|
int toConsume = pickedCount;
|
|
for (InboundPalletAllocationEntity allocation : confirmed) {
|
|
int allocated = count(allocation.getAllocatedPackageCount(), "库位已分配箱数");
|
|
int picked = allocation.getPickedPackageCount() == null ? 0 : allocation.getPickedPackageCount();
|
|
int available = allocated - picked;
|
|
if (available <= 0 || toConsume == 0) {
|
|
continue;
|
|
}
|
|
int consumed = Math.min(available, toConsume);
|
|
if (allocation.getId() == null) {
|
|
throw conflict("父入库单库位分配快照缺少标识");
|
|
}
|
|
allocations.consume(accountId, inboundId, allocation.getId(), consumed);
|
|
allocation.setPickedPackageCount(picked + consumed);
|
|
toConsume -= consumed;
|
|
}
|
|
if (toConsume != 0) {
|
|
throw conflict("父入库单已确认库位可用箱数不足以扣减本次已拣箱数");
|
|
}
|
|
}
|
|
|
|
private static int remaining(InboundPalletAllocationEntity allocation) {
|
|
return count(allocation.getAllocatedPackageCount(), "库位已分配箱数")
|
|
- (allocation.getPickedPackageCount() == null ? 0 : allocation.getPickedPackageCount());
|
|
}
|
|
|
|
private static long blockId(List<InboundPalletAllocationEntity> allocations) {
|
|
if (allocations.isEmpty() || allocations.get(0).getBlockId() == null || allocations.get(0).getBlockId() <= 0
|
|
|| allocations.stream().anyMatch(item -> !Objects.equals(allocations.get(0).getBlockId(), item.getBlockId()))) {
|
|
throw conflict("PDA结算要求父入库单资源属于同一整票库位块");
|
|
}
|
|
return allocations.get(0).getBlockId();
|
|
}
|
|
|
|
private static long warehouseId(List<InboundPalletAllocationEntity> allocations) {
|
|
if (allocations.isEmpty() || allocations.get(0).getWarehouseId() == null || allocations.get(0).getWarehouseId() <= 0
|
|
|| allocations.stream().anyMatch(item -> !Objects.equals(allocations.get(0).getWarehouseId(), item.getWarehouseId()))) {
|
|
throw conflict("PDA结算要求父入库单分配资源属于同一仓库");
|
|
}
|
|
return allocations.get(0).getWarehouseId();
|
|
}
|
|
|
|
private void settleWarehouseBlock(long parentInboundId, long childInboundId, String childCode,
|
|
long taskId, long operatorId, long blockId) {
|
|
ApiResponse<WarehousePalletLocationClient.BlockResult> response;
|
|
String key = "PDA-BLOCK-" + taskId + '-' + parentInboundId;
|
|
try {
|
|
response = childInboundId > 0
|
|
? warehouse.transferBlock(internalToken, new WarehousePalletLocationClient.BlockTransferRequest(
|
|
blockId, parentInboundId, childInboundId, childCode, key, operatorId))
|
|
: warehouse.releaseBlock(internalToken, new WarehousePalletLocationClient.BlockReleaseRequest(
|
|
blockId, parentInboundId, key, operatorId, "ALL_PACKAGES_PICKED"));
|
|
} catch (FeignException exception) {
|
|
log.warn("PDA warehouse block settlement transport failure taskId={} sourceInboundId={} "
|
|
+ "blockId={} operationId={} httpStatus={}",
|
|
taskId, parentInboundId, blockId, key, exception.status());
|
|
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "仓储服务不可用,无法结算PDA库位资源");
|
|
}
|
|
if (response == null || !response.successful() || response.data() == null
|
|
|| response.data().blockId() != blockId || response.data().resources() == null
|
|
|| response.data().resources().isEmpty()
|
|
|| response.data().resources().stream().anyMatch(resource -> resource.blockId() != blockId)
|
|
|| !(childInboundId > 0 ? "OCCUPIED" : "RELEASED").equals(response.data().blockStatus())
|
|
|| response.data().resources().stream().anyMatch(resource ->
|
|
!(childInboundId > 0 ? "OCCUPIED" : "RELEASED").equals(resource.resourceStatus()))) {
|
|
log.warn("PDA warehouse block settlement rejected taskId={} sourceInboundId={} blockId={} "
|
|
+ "operationId={} responseCode={} requestId={} traceId={}",
|
|
taskId, parentInboundId, blockId, key, response == null ? null : response.code(),
|
|
response == null ? null : response.requestId(), response == null ? null : response.traceId());
|
|
ResultCode code = response == null ? ResultCode.SERVICE_UNAVAILABLE : ResultCode.of(response.code());
|
|
throw new SettlementFailure(code, response == null || response.message() == null
|
|
? "仓储服务原子结算PDA库位资源失败" : response.message(),
|
|
response == null ? null : response.requestId(), response == null ? null : response.traceId());
|
|
}
|
|
}
|
|
|
|
/** Carries only correlation facts needed by the durable command diagnosis record. */
|
|
public static final class SettlementFailure extends BizException {
|
|
private final String requestId;
|
|
private final String traceId;
|
|
|
|
SettlementFailure(ResultCode code, String message, String requestId, String traceId) {
|
|
super(code, message);
|
|
this.requestId = requestId;
|
|
this.traceId = traceId;
|
|
}
|
|
|
|
public String requestId() { return requestId; }
|
|
public String traceId() { return traceId; }
|
|
}
|
|
|
|
static String childInboundCode(String rootInboundCode, int splitSequence) {
|
|
if (splitSequence <= 0) {
|
|
throw conflict("子入库单拆分序号不合法");
|
|
}
|
|
String prefix = rootInboundCode == null ? "INBOUND" : rootInboundCode.trim();
|
|
if (prefix.isEmpty()) {
|
|
prefix = "INBOUND";
|
|
}
|
|
String suffix = "-" + splitSequence;
|
|
return prefix.substring(0, Math.min(prefix.length(), 40 - suffix.length())) + suffix;
|
|
}
|
|
|
|
private static int count(Integer value, String resource) {
|
|
if (value == null || value < 0) {
|
|
throw conflict(resource + "不合法");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
private static int availablePackageCount(ParentRow parent) {
|
|
if ("FULLY_SPLIT".equals(parent.splitStatus())) return 0;
|
|
Integer available = parent.packageCount();
|
|
if (available != null && available > 0) return available;
|
|
if (available != null && available == 0 && !"NOT_SPLIT".equals(parent.splitStatus())) return 0;
|
|
return count(parent.totalPackageCount(), "主入库单可用箱数");
|
|
}
|
|
|
|
private static void requireAffected(int affected, String resource) {
|
|
if (affected != 1) {
|
|
throw conflict(resource + "已被并发修改,请刷新后重试");
|
|
}
|
|
}
|
|
|
|
private static BizException conflict(String message) {
|
|
return new BizException(ResultCode.CONFLICT, message);
|
|
}
|
|
|
|
private record MeasurementSplitLine(MeasurementItemRow item, int pickedPackageCount,
|
|
int remainingPackageCount) {
|
|
}
|
|
|
|
}
|