1430 lines
82 KiB
Java
1430 lines
82 KiB
Java
package com.baiyue.firstleg.service;
|
|
|
|
import com.baiyue.common.core.api.ResultCode;
|
|
import com.baiyue.common.core.exception.BizException;
|
|
import com.baiyue.firstleg.dto.LoadingPickDtos.PickItem;
|
|
import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest;
|
|
import com.baiyue.firstleg.dto.LoadingPickDtos.PalletPickRequest;
|
|
import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest;
|
|
import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest;
|
|
import com.baiyue.firstleg.dto.LoadingPickDtos.Task;
|
|
import com.baiyue.firstleg.dto.LoadingPickDtos.Assignment;
|
|
import com.baiyue.firstleg.dto.LoadingPickDtos.Review;
|
|
import com.baiyue.firstleg.dto.LoadingPickDtos.ClaimRequest;
|
|
import com.baiyue.firstleg.dto.LoadingPickDtos.AssignRequest;
|
|
import com.baiyue.firstleg.dto.LoadingPickDtos.ReviewRequest;
|
|
import com.baiyue.firstleg.dto.LoadingPickDtos.CompleteLoadingRequest;
|
|
import com.baiyue.firstleg.dto.LoadingPickDtos.Operation;
|
|
import com.baiyue.firstleg.dto.LoadingPickDtos.CompletionCommand;
|
|
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest;
|
|
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchItemRequest;
|
|
import com.baiyue.firstleg.dto.LoadingPickDtos.InventoryResource;
|
|
import com.baiyue.firstleg.dto.LoadingPickDtos.InboundInventory;
|
|
import com.baiyue.firstleg.dto.LoadingPickDtos.InventorySnapshot;
|
|
import com.baiyue.firstleg.dto.LoadingPickDtos.InventoryUnit;
|
|
import com.baiyue.firstleg.dto.LoadingPickDtos.PendingTask;
|
|
import com.baiyue.firstleg.domain.Iso6346ContainerNumber;
|
|
import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity;
|
|
import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
|
|
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
|
|
import com.baiyue.firstleg.entity.LoadingPickOperationEntity;
|
|
import com.baiyue.firstleg.entity.LoadingCompletionCommandEntity;
|
|
import com.baiyue.firstleg.entity.LoadingPickReviewEntity;
|
|
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
|
|
import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
|
|
import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity;
|
|
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
|
|
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
|
|
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
|
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper;
|
|
import com.baiyue.firstleg.loading.service.LoadingDeliveryService;
|
|
import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.GroupSnapshot;
|
|
import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.Member;
|
|
import com.baiyue.firstleg.loading.coload.port.ColoadGroupAssignmentPort;
|
|
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
|
|
import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link;
|
|
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
|
|
import com.baiyue.firstleg.mapper.LoadingPickItemMapper.CartonLabelRow;
|
|
import com.baiyue.firstleg.mapper.LoadingPickItemMapper;
|
|
import com.baiyue.firstleg.repository.LoadingPickRepository;
|
|
import com.baiyue.firstleg.repository.InboundPalletAllocationRepository;
|
|
import com.baiyue.firstleg.repository.LoadingCompletionCommandRepository;
|
|
import com.baiyue.firstleg.repository.SmartLoadingRepository;
|
|
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
|
|
import jakarta.annotation.Resource;
|
|
import org.springframework.dao.DuplicateKeyException;
|
|
import org.springframework.stereotype.Service;
|
|
import org.springframework.transaction.support.TransactionTemplate;
|
|
import org.springframework.transaction.annotation.Transactional;
|
|
import org.springframework.util.StringUtils;
|
|
import org.slf4j.Logger;
|
|
import org.slf4j.LoggerFactory;
|
|
|
|
import java.nio.charset.StandardCharsets;
|
|
import java.security.MessageDigest;
|
|
import java.security.NoSuchAlgorithmException;
|
|
import java.util.List;
|
|
import java.util.HashSet;
|
|
import java.util.ArrayList;
|
|
import java.util.Comparator;
|
|
import java.util.Map;
|
|
import java.util.HashMap;
|
|
import java.util.LinkedHashMap;
|
|
import java.util.Objects;
|
|
import java.util.Set;
|
|
import java.util.stream.Collectors;
|
|
import java.time.LocalDateTime;
|
|
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
|
|
|
/** 装柜中 PDA 拣货任务及明细同步。 */
|
|
@Service
|
|
public class LoadingPickService {
|
|
private static final Logger log = LoggerFactory.getLogger(LoadingPickService.class);
|
|
@Resource
|
|
private LoadingPickRepository repository;
|
|
@Resource
|
|
private LoadingCompletionCommandRepository completionCommands;
|
|
@Resource
|
|
private TransactionTemplate completionTransaction;
|
|
@Resource
|
|
private SmartLoadingRepository smartLoadingRepository;
|
|
@Resource
|
|
private OceanBookingRepository oceanBookingRepository;
|
|
@Resource
|
|
private LoadingModelBridgeService loadingModelBridge;
|
|
@Resource
|
|
private LoadingListService loadingListService;
|
|
@Resource
|
|
private LoadingDeliveryService loadingDeliveryService;
|
|
@Resource
|
|
private com.baiyue.firstleg.loading.service.CabinetLoadingEvidenceService cabinetEvidenceService;
|
|
@Resource
|
|
private ColoadGroupAssignmentPort coloadGroups;
|
|
@Resource
|
|
private InboundStatusHistoryMapper inboundHistories;
|
|
@Resource
|
|
private PdaPickSettlementService settlementService;
|
|
@Resource
|
|
private InboundPalletAllocationRepository palletAllocations;
|
|
@Resource
|
|
private InboundHoldGuard inboundHoldGuard;
|
|
@Resource
|
|
private com.baiyue.firstleg.mapper.DropshipPalletizationMapper dropshipPalletizationMapper;
|
|
|
|
/** Opens the field task after planning and ocean release; the real container number may be filled later. */
|
|
@Transactional
|
|
public void beginIfReady(long containerBookingId, long operatorId, String operatorName) {
|
|
var booking = smartLoadingRepository.booking(containerBookingId);
|
|
if (booking == null) return;
|
|
var plan = smartLoadingRepository.planByBooking(containerBookingId);
|
|
if (plan == null || !"EDITING".equals(plan.getPlanStatus())) return;
|
|
inboundHoldGuard.requirePlanNotHeld(plan.getId());
|
|
var links = loadingModelBridge.byLoadingPlan(plan.getId());
|
|
if (links.isEmpty() || links.stream().anyMatch(link -> !"CONFIRMED".equals(link.preloadStatus()))) return;
|
|
var allocation = oceanBookingRepository.allocationByContainer(containerBookingId);
|
|
if (allocation == null) return;
|
|
var oceanBooking = oceanBookingRepository.find(allocation.getOceanBookingId());
|
|
if (oceanBooking == null || !"RELEASED".equals(oceanBooking.getStatus())) return;
|
|
if (repository.taskByPlan(plan.getId()) != null) return;
|
|
begin(plan.getId(), booking.getContainerNo(), operatorId, operatorName,
|
|
smartLoadingRepository.items(plan.getId()));
|
|
}
|
|
|
|
public List<Link> requirePreloadConfirmationReady(long loadingPlanId) {
|
|
inboundHoldGuard.requirePlanNotHeld(loadingPlanId);
|
|
List<Link> links = loadingModelBridge.requireAllocatedLinksForConfirmation(loadingPlanId);
|
|
for (Link link : links) {
|
|
if (!Set.of("PENDING", "CONFIRMING").contains(link.preloadStatus())) {
|
|
throw conflict("预装清单已确认或当前状态不允许确认");
|
|
}
|
|
var allocation = oceanBookingRepository.allocationByContainer(link.containerBookingId());
|
|
if (allocation == null) throw conflict("确认预装前必须关联真实订舱柜");
|
|
var oceanBooking = oceanBookingRepository.find(allocation.getOceanBookingId());
|
|
if (oceanBooking == null || !"RELEASED".equals(oceanBooking.getStatus())) {
|
|
throw conflict("确认预装前关联订舱必须已放舱");
|
|
}
|
|
if (repository.taskByPlan(link.loadingPlanId()) != null) {
|
|
throw conflict("PDA任务已创建,不能重复确认预装");
|
|
}
|
|
}
|
|
return links;
|
|
}
|
|
|
|
@Transactional
|
|
public void begin(long planId, String containerNo, long operatorId, String operatorName,
|
|
List<LoadingPlanItemEntity> planItems) {
|
|
inboundHoldGuard.requirePlanNotHeld(planId);
|
|
LoadingPickTaskEntity task = repository.taskByPlan(planId);
|
|
if (task == null) {
|
|
task = new LoadingPickTaskEntity();
|
|
task.setLoadingPlanId(planId);
|
|
var booking = smartLoadingRepository.booking(smartLoadingRepository.plan(planId).getContainerBookingId());
|
|
task.setContainerBookingId(booking.getId());
|
|
task.setCabinetNo(booking.getCabinetNo());
|
|
task.setContainerNo(null);
|
|
task.setTaskStatus("LOADING");
|
|
task.setStartedBy(operatorId);
|
|
task.setStartedByName(operatorName);
|
|
task.setVersion(0);
|
|
try {
|
|
repository.insert(task);
|
|
} catch (DuplicateKeyException exception) {
|
|
task = repository.taskByPlan(planId);
|
|
}
|
|
}
|
|
if (task == null || !"LOADING".equals(task.getTaskStatus())) {
|
|
throw conflict("装柜任务已完成,不能继续同步明细");
|
|
}
|
|
for (LoadingPlanItemEntity planItem : planItems) {
|
|
syncItem(task.getId(), planItem);
|
|
}
|
|
repository.syncInboundProjections(task.getId(), operatorId, operatorName);
|
|
}
|
|
|
|
@Transactional
|
|
public void sync(long planId, LoadingPlanItemEntity planItem) {
|
|
inboundHoldGuard.requirePlanNotHeld(planId);
|
|
LoadingPickTaskEntity task = repository.taskByPlan(planId);
|
|
if (task == null) return;
|
|
task = repository.taskForUpdate(task.getId());
|
|
requireMutableTask(task);
|
|
syncItem(task.getId(), planItem);
|
|
if (repository.touchTask(task.getId(), task.getVersion()) != 1) {
|
|
throw conflict("PDA任务版本已变化,装柜明细同步失败");
|
|
}
|
|
}
|
|
|
|
/** Shared lock used by Admin plan mutations and the final PDA completion transaction. */
|
|
@Transactional
|
|
public void lockMutable(long planId) {
|
|
LoadingPickTaskEntity task = repository.taskByPlan(planId);
|
|
if (task == null) return;
|
|
requireMutableTask(repository.taskForUpdate(task.getId()));
|
|
}
|
|
|
|
/** Read model used by the Admin plan response; mutations still lock the task row separately. */
|
|
public MutationSnapshot mutationSnapshot(long planId) {
|
|
LoadingPickTaskEntity task = repository.taskByPlan(planId);
|
|
if (task == null) return new MutationSnapshot(null, null, null, Map.of());
|
|
Map<Long, Integer> pickedByPlanItem = repository.items(task.getId()).stream()
|
|
.filter(item -> item.getLoadingPlanItemId() != null)
|
|
.collect(Collectors.toMap(LoadingPickItemEntity::getLoadingPlanItemId,
|
|
item -> count(item.getPickedPackageCount()), Math::max));
|
|
return new MutationSnapshot(task.getId(), task.getTaskStatus(), task.getVersion(), pickedByPlanItem);
|
|
}
|
|
|
|
public record MutationSnapshot(Long taskId, String taskStatus, Integer taskVersion,
|
|
Map<Long, Integer> pickedByPlanItem) {
|
|
}
|
|
|
|
public Task task(String cabinetNo, Long loadingPlanId, long warehouseId) {
|
|
int selectors = (StringUtils.hasText(cabinetNo) ? 1 : 0)
|
|
+ (loadingPlanId != null ? 1 : 0);
|
|
if (selectors != 1) throw new BizException(ResultCode.BAD_REQUEST,
|
|
"cabinetNo、loadingPlanId必须且只能提供一个");
|
|
LoadingPickTaskEntity task = StringUtils.hasText(cabinetNo)
|
|
? repository.taskByCabinet(cabinetNo.trim(), warehouseId)
|
|
: repository.taskByPlan(loadingPlanId, warehouseId);
|
|
if (task == null) throw new BizException(ResultCode.NOT_FOUND, "未找到装柜中的PDA拣货任务");
|
|
return view(task, warehouseId);
|
|
}
|
|
|
|
public Task task(long taskId, long warehouseId) {
|
|
return view(requiredTask(taskId), warehouseId);
|
|
}
|
|
|
|
public List<PendingTask> pendingTasks(long operatorId, long warehouseId, String keyword) {
|
|
String normalizedKeyword = StringUtils.hasText(keyword) ? keyword.trim() : null;
|
|
return repository.pendingTasks(operatorId, warehouseId, normalizedKeyword).stream()
|
|
.map(row -> new PendingTask(row.getTaskId(), row.getLoadingPlanId(), row.getContainerBookingId(),
|
|
row.getCabinetNo(), row.getContainerNo(),
|
|
row.getSourceType(), row.getRecordStatus(), row.getMergedIntoContainerBookingId(),
|
|
row.getWarehouseId(), row.getWarehouseName(), row.getTaskStatus(), row.getWorkflowStatus(),
|
|
row.getVersion(), row.getAssignmentVersion(), row.getItemCount(), row.getPendingItemCount()))
|
|
.toList();
|
|
}
|
|
|
|
public InventorySnapshot inventorySnapshot(long taskId, long warehouseId) {
|
|
LoadingPickTaskEntity task = requiredTask(taskId);
|
|
List<LoadingPickItemEntity> items = taskItems(task, warehouseId);
|
|
return buildInventorySnapshot(task, warehouseId, items);
|
|
}
|
|
|
|
@Transactional
|
|
public Task claim(long taskId, ClaimRequest request, long operatorId, String operatorName) {
|
|
inboundHoldGuard.requireTaskNotHeld(taskId);
|
|
String operationId = operation(request.operationId());
|
|
String requestHash = hash("CLAIM|" + taskId + '|' + request.expectedAssignmentVersion());
|
|
Task replay = replayTaskOperation(taskId, operationId, requestHash, operatorId);
|
|
if (replay != null) return replay;
|
|
LoadingPickTaskEntity task = requiredLoadingTaskForUpdate(taskId);
|
|
replay = replayTaskOperationForUpdate(taskId, operationId, requestHash, operatorId);
|
|
if (replay != null) return replay;
|
|
LoadingPickAssignmentEntity existing = repository.assignment(taskId);
|
|
if (existing == null) {
|
|
if (request.expectedAssignmentVersion() != 0
|
|
|| repository.createAssignment(taskId, operatorId, operatorName, operatorId,
|
|
operatorName, "CLAIMED") != 1) {
|
|
throw conflict("PDA任务已被其他作业员领取");
|
|
}
|
|
} else {
|
|
if (!Long.valueOf(operatorId).equals(existing.getAssigneeId())) {
|
|
throw conflict("PDA任务已分配给其他作业员");
|
|
}
|
|
if (!request.expectedAssignmentVersion().equals(existing.getVersion())) {
|
|
throw conflict("PDA任务分配版本已变化");
|
|
}
|
|
}
|
|
startPickingIfNeeded(task, operatorId, operatorName);
|
|
insertOperation(taskId, operationId, requestHash, "CLAIM", operatorId, operatorName);
|
|
return view(task, null);
|
|
}
|
|
|
|
@Transactional
|
|
public Task assign(long taskId, AssignRequest request, long operatorId, String operatorName) {
|
|
inboundHoldGuard.requireTaskNotHeld(taskId);
|
|
String operationId = operation(request.operationId());
|
|
String assigneeName = request.assigneeName().trim();
|
|
String requestHash = hash("ASSIGN|" + taskId + '|' + request.assigneeId() + '|'
|
|
+ assigneeName + '|' + request.expectedAssignmentVersion());
|
|
Task replay = replayTaskOperation(taskId, operationId, requestHash, operatorId);
|
|
if (replay != null) return replay;
|
|
LoadingPickTaskEntity task = requiredLoadingTaskForUpdate(taskId);
|
|
replay = replayTaskOperationForUpdate(taskId, operationId, requestHash, operatorId);
|
|
if (replay != null) return replay;
|
|
LoadingPickAssignmentEntity existing = repository.assignment(taskId);
|
|
int changed = existing == null
|
|
? request.expectedAssignmentVersion() == 0
|
|
? repository.createAssignment(taskId, request.assigneeId(), assigneeName, operatorId,
|
|
operatorName, "ASSIGNED") : 0
|
|
: repository.reassign(taskId, request.assigneeId(), assigneeName, operatorId, operatorName,
|
|
"ASSIGNED", request.expectedAssignmentVersion());
|
|
if (changed != 1) throw conflict("PDA任务分配版本已变化");
|
|
insertOperation(taskId, operationId, requestHash, "ASSIGN", operatorId, operatorName);
|
|
return view(task, null);
|
|
}
|
|
|
|
@Transactional
|
|
public Task pick(long taskId, long itemId, PickRequest request, long operatorId, String operatorName) {
|
|
inboundHoldGuard.requireTaskNotHeld(taskId);
|
|
String operationId = request.operationId().trim();
|
|
String requestHash = hash(taskId + "|" + itemId + "|" + request.warehouseId()
|
|
+ "|" + request.pickedPackageCount());
|
|
requireNoTaskOperation(operationId);
|
|
LoadingPickHistoryEntity replay = repository.history(operationId);
|
|
if (replay != null) {
|
|
if (!replay.getPickTaskId().equals(taskId) || !replay.getPickItemId().equals(itemId)
|
|
|| !replay.getRequestHash().equals(requestHash)) {
|
|
throw conflict("operationId 已用于不同拣货请求");
|
|
}
|
|
return view(requiredTask(taskId), request.warehouseId());
|
|
}
|
|
LoadingPickTaskEntity task = requiredAssignedLoadingTaskForUpdate(taskId, operatorId);
|
|
requireNoTaskOperationForUpdate(operationId);
|
|
replay = repository.historyForUpdate(operationId);
|
|
if (replay != null) {
|
|
assertReplay(replay, taskId, itemId, requestHash);
|
|
return view(task, request.warehouseId());
|
|
}
|
|
LoadingPickItemEntity item = requireTaskItem(taskId, itemId, request.warehouseId());
|
|
requirePackageUnit(item);
|
|
if (repository.pick(itemId, request.pickedPackageCount(), request.version(), request.warehouseId()) != 1) {
|
|
throw conflict("拣货数量超过计划箱数或明细版本已变化");
|
|
}
|
|
LoadingPickHistoryEntity history = new LoadingPickHistoryEntity();
|
|
history.setPickTaskId(taskId);
|
|
history.setPickItemId(itemId);
|
|
history.setOperationId(operationId);
|
|
history.setRequestHash(requestHash);
|
|
history.setActionType("PICK");
|
|
history.setPickedPackageCount(request.pickedPackageCount());
|
|
history.setOperatorId(operatorId);
|
|
history.setOperatorName(operatorName);
|
|
repository.insert(history);
|
|
startPickingIfNeeded(task, operatorId, operatorName);
|
|
touchSettleFinishAndSync(task, operatorId, operatorName);
|
|
return view(requiredTask(taskId), request.warehouseId());
|
|
}
|
|
|
|
/** 一件代发只扫描已封托托盘码,并在同一事务把该托全部计划行结算为已拣。 */
|
|
@Transactional
|
|
public Task pickPallet(long taskId,PalletPickRequest request,long operatorId,String operatorName){
|
|
inboundHoldGuard.requireTaskNotHeld(taskId);
|
|
String operationId=operation(request.operationId());
|
|
String requestHash=hash("PICK_PALLET|"+taskId+'|'+request.warehouseId()+'|'+request.palletNo()
|
|
+'|'+request.expectedTaskVersion());
|
|
Task replay=replayTaskOperation(taskId,operationId,requestHash,operatorId); if(replay!=null)return replay;
|
|
LoadingPickTaskEntity task=requiredAssignedLoadingTaskForUpdate(taskId,operatorId);
|
|
replay=replayTaskOperationForUpdate(taskId,operationId,requestHash,operatorId); if(replay!=null)return replay;
|
|
if(!Objects.equals(task.getVersion(),request.expectedTaskVersion()))throw conflict("PDA任务版本已变化,请刷新后重试");
|
|
List<LoadingPlanItemEntity> planItems=smartLoadingRepository.items(task.getLoadingPlanId()).stream()
|
|
.filter(item->request.palletNo().equals(item.getPalletNo()) && item.getPalletId()!=null
|
|
&& Boolean.TRUE.equals(item.getIndivisible())).toList();
|
|
if(planItems.isEmpty())throw new BizException(ResultCode.NOT_FOUND,"托盘不属于当前装柜任务");
|
|
Long palletId=planItems.get(0).getPalletId();
|
|
if(planItems.stream().anyMatch(item->!Objects.equals(item.getPalletId(),palletId)))throw conflict("托盘装柜快照不一致");
|
|
var physical=dropshipPalletizationMapper.sealedPalletLines(palletId);
|
|
if(physical.isEmpty()||physical.stream().anyMatch(line->!"SEALED".equals(line.getPalletStatus())))throw conflict("托盘已失效或尚未封托");
|
|
for(LoadingPlanItemEntity planItem:planItems){
|
|
LoadingPickItemEntity pickItem=repository.itemByPlanItem(planItem.getId());
|
|
if(pickItem==null||!"PENDING".equals(pickItem.getItemStatus())||count(pickItem.getPickedPackageCount())!=0)
|
|
throw conflict("托盘已有部分作业或明细已变化,禁止拆托处理");
|
|
if(repository.pick(pickItem.getId(),pickItem.getPlannedPackageCount(),pickItem.getVersion(),request.warehouseId())!=1)
|
|
throw conflict("托盘明细版本已变化,请刷新后重试");
|
|
insertHistory(taskId,pickItem.getId(),"PAL-"+hash(operationId+'|'+pickItem.getId()).substring(0,48),
|
|
requestHash,"PICK_PALLET",pickItem.getPlannedPackageCount(),null,operatorId,operatorName);
|
|
}
|
|
startPickingIfNeeded(task,operatorId,operatorName);
|
|
if(repository.touchTask(taskId,request.expectedTaskVersion())!=1)throw conflict("PDA任务版本已变化,请刷新后重试");
|
|
insertOperation(taskId,operationId,requestHash,"PICK_PALLET",operatorId,operatorName);
|
|
settleAndFinish(task,operatorId,operatorName); repository.syncInboundProjections(taskId,operatorId,operatorName);
|
|
return view(requiredTask(taskId),request.warehouseId());
|
|
}
|
|
|
|
@Transactional
|
|
public Task partial(long taskId, long itemId, PartialPickRequest request,
|
|
long operatorId, String operatorName) {
|
|
inboundHoldGuard.requireTaskNotHeld(taskId);
|
|
String operationId = request.operationId().trim();
|
|
String requestHash = hash("PARTIAL|" + taskId + "|" + itemId + "|" + request.warehouseId()
|
|
+ "|" + request.finalPickedPackageCount());
|
|
requireNoTaskOperation(operationId);
|
|
LoadingPickHistoryEntity replay = repository.history(operationId);
|
|
if (replay != null) {
|
|
assertReplay(replay, taskId, itemId, requestHash);
|
|
return view(requiredTask(taskId), request.warehouseId());
|
|
}
|
|
LoadingPickTaskEntity task = requiredAssignedLoadingTaskForUpdate(taskId, operatorId);
|
|
requireNoTaskOperationForUpdate(operationId);
|
|
replay = repository.historyForUpdate(operationId);
|
|
if (replay != null) {
|
|
assertReplay(replay, taskId, itemId, requestHash);
|
|
return view(task, request.warehouseId());
|
|
}
|
|
LoadingPickItemEntity item = requireTaskItem(taskId, itemId, request.warehouseId());
|
|
requirePackageUnit(item);
|
|
if (repository.partial(itemId, request.finalPickedPackageCount(), request.version(),
|
|
request.warehouseId()) != 1) {
|
|
throw conflict("部分拣货最终箱数不能小于已拣箱数且必须小于计划箱数,或明细版本已变化");
|
|
}
|
|
insertHistory(taskId, itemId, operationId, requestHash, "PARTIAL",
|
|
request.finalPickedPackageCount(), null, operatorId, operatorName);
|
|
startPickingIfNeeded(task, operatorId, operatorName);
|
|
touchSettleFinishAndSync(task, operatorId, operatorName);
|
|
return view(requiredTask(taskId), request.warehouseId());
|
|
}
|
|
|
|
@Transactional
|
|
public Task skip(long taskId, long itemId, SkipRequest request, long operatorId, String operatorName) {
|
|
inboundHoldGuard.requireTaskNotHeld(taskId);
|
|
String operationId = request.operationId().trim();
|
|
String reason = trim(request.reason());
|
|
String requestHash = hash("SKIP|" + taskId + "|" + itemId + "|" + request.warehouseId()
|
|
+ "|" + reason);
|
|
requireNoTaskOperation(operationId);
|
|
LoadingPickHistoryEntity replay = repository.history(operationId);
|
|
if (replay != null) {
|
|
assertReplay(replay, taskId, itemId, requestHash);
|
|
return view(requiredTask(taskId), request.warehouseId());
|
|
}
|
|
LoadingPickTaskEntity task = requiredAssignedLoadingTaskForUpdate(taskId, operatorId);
|
|
requireNoTaskOperationForUpdate(operationId);
|
|
replay = repository.historyForUpdate(operationId);
|
|
if (replay != null) {
|
|
assertReplay(replay, taskId, itemId, requestHash);
|
|
return view(task, request.warehouseId());
|
|
}
|
|
LoadingPickItemEntity item = requireTaskItem(taskId, itemId, request.warehouseId());
|
|
requirePackageUnit(item);
|
|
if (repository.hasPickedOrPartialItem(taskId, item.getInboundId())) {
|
|
throw conflict("同一入库单已存在已拣或部分拣货明细,不能跳拣并恢复智能装柜候选");
|
|
}
|
|
if (repository.skip(itemId, request.version(), request.warehouseId()) != 1) {
|
|
throw conflict("跳拣仅允许未开始拣货的待拣明细,或明细版本已变化");
|
|
}
|
|
insertHistory(taskId, itemId, operationId, requestHash, "SKIP", 0, reason,
|
|
operatorId, operatorName);
|
|
touchSettleFinishAndSync(task, operatorId, operatorName);
|
|
return view(requiredTask(taskId), request.warehouseId());
|
|
}
|
|
|
|
/**
|
|
* 原子提交 PDA 在线草稿。所有明细先在任务行锁内完成快照校验,再统一写入和结算;
|
|
* 任一明细或跨服务资源结算失败都会回滚本地批次。
|
|
*/
|
|
@Transactional
|
|
public Task batch(long taskId, String idempotencyKey, BatchRequest request,
|
|
long operatorId, String operatorName) {
|
|
inboundHoldGuard.requireTaskNotHeld(taskId);
|
|
String operationId = operation(idempotencyKey);
|
|
String requestHash = batchRequestHash(taskId, request);
|
|
Task replay = replayTaskOperation(taskId, operationId, requestHash, operatorId);
|
|
if (replay != null) return replay;
|
|
|
|
LoadingPickTaskEntity task = requiredTaskForUpdate(taskId);
|
|
replay = replayTaskOperationForUpdate(taskId, operationId, requestHash, operatorId);
|
|
if (replay != null) return replay;
|
|
if (!"LOADING".equals(task.getTaskStatus())) {
|
|
throw conflict("装柜任务已完成,不能重复提交整批拣货");
|
|
}
|
|
if (!Objects.equals(request.expectedVersion(), task.getVersion())) {
|
|
throw conflict("PDA任务版本已变化,请刷新后重试");
|
|
}
|
|
if (!Objects.equals(request.loadingPlanId(), task.getLoadingPlanId())
|
|
|| !Objects.equals(request.containerBookingId(), task.getContainerBookingId())) {
|
|
throw conflict("装柜计划或柜次归属与PDA任务不一致");
|
|
}
|
|
LoadingPickAssignmentEntity assignment = repository.assignment(taskId);
|
|
if (assignment == null) throw conflict("请先领取PDA拣货任务");
|
|
if (!Long.valueOf(operatorId).equals(assignment.getAssigneeId())) {
|
|
throw conflict("PDA拣货任务已分配给其他作业员");
|
|
}
|
|
|
|
List<BatchLine> lines = validateBatchLines(task, request);
|
|
validateInventorySnapshot(task, request, lines);
|
|
lines.stream().filter(line -> !"PICK_PALLET".equals(line.action())).forEach(line -> requirePackageUnit(line.item()));
|
|
for (BatchLine line : lines) {
|
|
applyBatchLine(taskId, line, operationId, operatorId, operatorName);
|
|
}
|
|
if (lines.stream().anyMatch(line -> "PICK".equals(line.action()) || "PARTIAL".equals(line.action()) || "PICK_PALLET".equals(line.action()))) {
|
|
startPickingIfNeeded(task, operatorId, operatorName);
|
|
}
|
|
if (!repository.allTaskItemsResolved(taskId)) {
|
|
throw conflict("整批提交必须处理本任务全部待拣明细");
|
|
}
|
|
if (repository.touchTask(taskId, request.expectedVersion()) != 1) {
|
|
throw conflict("PDA任务版本已变化,请刷新后重试");
|
|
}
|
|
insertOperation(taskId, operationId, requestHash, "BATCH", operatorId, operatorName);
|
|
settleAndFinish(task, operatorId, operatorName);
|
|
repository.syncInboundProjections(taskId, operatorId, operatorName);
|
|
return view(requiredTask(taskId), request.warehouseId());
|
|
}
|
|
|
|
@Transactional
|
|
public Task review(long taskId, ReviewRequest request, long operatorId, String operatorName) {
|
|
inboundHoldGuard.requireTaskNotHeld(taskId);
|
|
return complete(taskId, new CompleteLoadingRequest(request.expectedTaskVersion(), request.operationId(),
|
|
request.remark()), operatorId, operatorName);
|
|
}
|
|
|
|
/**
|
|
* PDA 人工点击装柜完成后的唯一结算入口。
|
|
* 逐项拣货仅记录和锁定库存;此处才执行拆单、资源释放、计划完成和装柜清单生成。
|
|
*/
|
|
@Transactional
|
|
public Task complete(long taskId, CompleteLoadingRequest request, long operatorId, String operatorName) {
|
|
inboundHoldGuard.requireTaskNotHeld(taskId);
|
|
String operationId = operation(request.operationId());
|
|
String remark = trim(request.remark());
|
|
String requestHash = hash("COMPLETE|" + taskId + '|' + request.expectedTaskVersion() + '|' + remark);
|
|
LoadingCompletionCommandEntity replay = completionCommands.operation(operationId);
|
|
if (replay != null) {
|
|
requireSameCompletionCommand(replay, taskId, requestHash, operatorId);
|
|
if ("FAILED".equals(replay.getCommandStatus())) {
|
|
LoadingPickTaskEntity failed = requiredTaskForUpdate(taskId);
|
|
requireCompletionContainerNumber(taskId);
|
|
if (!"COMPLETION_FAILED".equals(failed.getTaskStatus())
|
|
|| !completionCommands.resume(replay.getId()) || repository.resumeCompletion(taskId) != 1) {
|
|
throw conflict("装柜完成命令状态已变化,请刷新后重试");
|
|
}
|
|
}
|
|
return view(requiredTask(taskId), null);
|
|
}
|
|
LoadingPickTaskEntity task = requiredTaskForUpdate(taskId);
|
|
LoadingCompletionCommandEntity concurrentReplay = completionCommands.operation(operationId);
|
|
if (concurrentReplay != null) {
|
|
requireSameCompletionCommand(concurrentReplay, taskId, requestHash, operatorId);
|
|
return view(task, null);
|
|
}
|
|
LoadingCompletionCommandEntity active = completionCommands.active(taskId);
|
|
if (active != null) {
|
|
throw conflict("已有其他装柜完成命令正在处理");
|
|
}
|
|
if (!"LOADING".equals(task.getTaskStatus())) {
|
|
throw conflict("装柜任务正在完成或已经完成");
|
|
}
|
|
if (!request.expectedTaskVersion().equals(task.getVersion())) {
|
|
throw conflict("PDA任务版本已变化,请刷新后复核");
|
|
}
|
|
requireCompletionContainerNumber(taskId);
|
|
if (!repository.allTaskItemsResolved(taskId)) {
|
|
throw conflict("存在尚未处理的PDA拣货明细,不能确认装柜完成");
|
|
}
|
|
if (repository.review(taskId) != null) throw conflict("PDA任务已经确认装柜完成");
|
|
List<LoadingPickItemMapper.InboundOwner> discoveredOwners = repository.inboundOwners(taskId).stream()
|
|
.filter(owner -> owner.tenantId() != null && owner.inboundId() != null)
|
|
.toList();
|
|
if (discoveredOwners.isEmpty()) throw conflict("PDA任务未关联有效入库单");
|
|
requireColoadConservation(task, discoveredOwners);
|
|
LoadingCompletionCommandEntity command = new LoadingCompletionCommandEntity();
|
|
command.setId(IdWorker.getId());
|
|
command.setPickTaskId(taskId);
|
|
command.setOperationId(operationId);
|
|
command.setRequestHash(requestHash);
|
|
command.setExpectedTaskVersion(request.expectedTaskVersion());
|
|
command.setRemark(remark);
|
|
command.setOperatorId(operatorId);
|
|
command.setOperatorName(trim(operatorName));
|
|
if (completionCommands.insert(command) != 1) {
|
|
LoadingCompletionCommandEntity concurrent = completionCommands.operation(operationId);
|
|
requireSameCompletionCommand(concurrent, taskId, requestHash, operatorId);
|
|
return view(requiredTask(taskId), null);
|
|
}
|
|
if (repository.acceptCompletion(taskId, task.getVersion()) != 1) {
|
|
throw conflict("PDA任务版本已变化,请刷新后复核");
|
|
}
|
|
return view(requiredTask(taskId), null);
|
|
}
|
|
|
|
private void requireCompletionContainerNumber(long taskId) {
|
|
ContainerBookingEntity booking = repository.activeBookingByTask(taskId);
|
|
if (booking == null || !StringUtils.hasText(booking.getContainerNo())) {
|
|
throw conflict("PDA确认装柜完成前必须录入真实集装箱号");
|
|
}
|
|
try {
|
|
Iso6346ContainerNumber.require(booking.getContainerNo());
|
|
} catch (BizException exception) {
|
|
throw conflict("PDA确认装柜完成前必须录入符合ISO 6346的真实集装箱号");
|
|
}
|
|
}
|
|
|
|
/** Called only by the completion worker, after its durable lease has committed. */
|
|
public void processCompletion(LoadingCompletionCommandEntity command, String worker) {
|
|
long taskId = command.getPickTaskId();
|
|
inboundHoldGuard.requireTaskNotHeld(taskId);
|
|
LoadingPickTaskEntity task = requiredTask(taskId);
|
|
List<LoadingPickItemMapper.InboundOwner> owners = repository.inboundOwners(taskId).stream()
|
|
.filter(owner -> owner.tenantId() != null && owner.inboundId() != null)
|
|
.sorted(Comparator.comparing(LoadingPickItemMapper.InboundOwner::tenantId)
|
|
.thenComparing(LoadingPickItemMapper.InboundOwner::inboundId))
|
|
.toList();
|
|
if (owners.isEmpty()) throw new CompletionProcessingException("OWNER_DISCOVERY", conflict("PDA任务未关联有效入库单"));
|
|
List<GroupSnapshot> taskGroups = coloadGroups(owners);
|
|
runCompletionStage("CABINET_EVIDENCE", () ->
|
|
cabinetEvidenceService.requireComplete(task.getContainerBookingId()));
|
|
runCompletionStage("COMPLETED_EVENT_READY", () ->
|
|
loadingListService.requireCompletedEventReady(task.getLoadingPlanId()));
|
|
|
|
// Phase 1 is read-only: cabinet evidence is checked once, then every owner must pass its business gates
|
|
// before any warehouse resource or inbound status can be changed. This prevents a later customer gate from
|
|
// leaving earlier customers partially completed.
|
|
for (int index = 0; index < owners.size(); index++) {
|
|
requireCompletionLease(command.getId(), worker);
|
|
LoadingPickItemMapper.InboundOwner owner = owners.get(index);
|
|
String checkpoint = completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId());
|
|
if (!checkpointAtLeast(checkpoint, "SETTLED")) {
|
|
runCompletionStage("OWNER_READY", () -> loadingDeliveryService.requirePdaCompletionReady(
|
|
owner.tenantId(), owner.inboundId()));
|
|
if (!checkpointAtLeast(checkpoint, "READY")) {
|
|
requireCompletionLease(command.getId(), worker);
|
|
completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId(),
|
|
index + 1, "READY");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Phase 2 is recoverable by per-owner checkpoints and stable idempotency keys. It starts only after the
|
|
// complete cabinet passed phase 1, while prior deployments' completed checkpoints remain safely resumable.
|
|
for (int index = 0; index < owners.size(); index++) {
|
|
requireCompletionLease(command.getId(), worker);
|
|
LoadingPickItemMapper.InboundOwner owner = owners.get(index);
|
|
String checkpoint = completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId());
|
|
if (!checkpointAtLeast(checkpoint, "SETTLED")) {
|
|
runCompletionStage("WAREHOUSE_SETTLEMENT", () -> settlementService.settleOwner(
|
|
taskId, owner.inboundId(), command.getOperatorId(), command.getOperatorName()));
|
|
requireCompletionLease(command.getId(), worker);
|
|
completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId(), index + 1, "SETTLED");
|
|
}
|
|
if (!checkpointAtLeast(checkpoint, "INBOUND_COMPLETED")) {
|
|
runCompletionStage("INBOUND_COMPLETE", () -> loadingDeliveryService.completeAfterPdaSettlement(
|
|
owner.tenantId(), owner.inboundId(),
|
|
command.getOperatorId(), command.getOperatorName(),
|
|
"PDA-" + command.getOperationId() + '-' + owner.inboundId(), command.getRemark()));
|
|
requireCompletionLease(command.getId(), worker);
|
|
completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId(), index + 1,
|
|
"INBOUND_COMPLETED");
|
|
}
|
|
}
|
|
completionTransaction.executeWithoutResult(ignored -> finalizeCompletion(command, worker, task, taskGroups));
|
|
}
|
|
|
|
/** Final Firstleg convergence only; all remote owner settlement calls have completed before this short transaction. */
|
|
private void finalizeCompletion(LoadingCompletionCommandEntity command, String worker,
|
|
LoadingPickTaskEntity task, List<GroupSnapshot> taskGroups) {
|
|
long taskId = task.getId();
|
|
if (!repository.allPlanItemsPicked(task.getLoadingPlanId())) {
|
|
throw new CompletionProcessingException("PLAN_GATE", conflict("柜次仍有未完成PDA拣货的箱数"));
|
|
}
|
|
if (repository.review(taskId) == null && repository.approve(taskId, command.getOperatorId(),
|
|
command.getOperatorName(), command.getRemark()) != 1) {
|
|
throw new CompletionProcessingException("TASK_REVIEW", conflict("PDA任务已经复核"));
|
|
}
|
|
if (repository.operation(command.getOperationId()) == null) {
|
|
insertOperation(taskId, command.getOperationId(), command.getRequestHash(), "COMPLETE_LOADING",
|
|
command.getOperatorId(), command.getOperatorName());
|
|
}
|
|
runCompletionStage("PLAN_COMPLETE", () -> loadingListService.finishFromPda(task.getLoadingPlanId(), taskId,
|
|
command.getOperatorId(), command.getOperatorName()));
|
|
repository.syncInboundProjections(taskId, command.getOperatorId(), command.getOperatorName());
|
|
runCompletionStage("COMPLETED_EVENT", () -> loadingListService.publishCompletedEvent(task.getLoadingPlanId()));
|
|
for (GroupSnapshot group : taskGroups) {
|
|
runCompletionStage("COLOAD_CONSUME", () -> coloadGroups.consumeWholeGroup(group.groupId(),
|
|
task.getLoadingPlanId(), "PDA-CONSUME-" + hash(command.getOperationId() + '|'
|
|
+ group.groupId()).substring(0, 48), command.getOperatorId(), command.getOperatorName()));
|
|
}
|
|
requireCompletionLease(command.getId(), worker);
|
|
LoadingPickTaskEntity current = requiredTask(taskId);
|
|
if (!"COMPLETED".equals(current.getTaskStatus())
|
|
&& repository.completeAccepted(taskId, command.getOperatorId(), command.getOperatorName()) != 1) {
|
|
throw new CompletionProcessingException("TASK_FINALIZE", conflict("PDA任务完成状态已变化"));
|
|
}
|
|
if (!completionCommands.complete(command.getId(), worker)) {
|
|
throw new CompletionProcessingException("COMMAND_FINALIZE", conflict("装柜完成命令租约已变化"));
|
|
}
|
|
}
|
|
|
|
private void requireColoadConservation(LoadingPickTaskEntity task,
|
|
List<LoadingPickItemMapper.InboundOwner> owners) {
|
|
List<LoadingPickItemEntity> items = repository.items(task.getId());
|
|
Map<String, LoadingPickItemMapper.InboundOwner> ownerIndex = owners.stream().collect(Collectors.toMap(
|
|
owner -> owner.tenantId() + "|" + owner.inboundId(), owner -> owner, (left, right) -> left));
|
|
for (GroupSnapshot group : coloadGroups(owners)) {
|
|
if (!"LOCKED".equals(group.status())
|
|
|| !Objects.equals(group.lockedPlanId(), task.getLoadingPlanId())) {
|
|
throw conflict("同柜不可拆组未锁定到当前PDA装柜计划");
|
|
}
|
|
for (Member member : group.members()) {
|
|
if (!ownerIndex.containsKey(member.tenantId() + "|" + member.inboundId())) {
|
|
throw conflict("同柜不可拆组成员未全部进入当前PDA任务");
|
|
}
|
|
int planned = items.stream().filter(item -> member.inboundId() == item.getInboundId())
|
|
.map(LoadingPickItemEntity::getPlannedPackageCount).filter(Objects::nonNull)
|
|
.mapToInt(Integer::intValue).sum();
|
|
int picked = items.stream().filter(item -> member.inboundId() == item.getInboundId())
|
|
.map(LoadingPickItemEntity::getPickedPackageCount).filter(Objects::nonNull)
|
|
.mapToInt(Integer::intValue).sum();
|
|
if (planned != member.fullPackageCount() || picked != member.fullPackageCount()) {
|
|
throw conflict("同柜不可拆组完成前必须全部成员满箱且全组箱数守恒");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private List<GroupSnapshot> coloadGroups(List<LoadingPickItemMapper.InboundOwner> owners) {
|
|
Map<Long, GroupSnapshot> groups = new LinkedHashMap<>();
|
|
for (LoadingPickItemMapper.InboundOwner owner : owners) {
|
|
GroupSnapshot group = coloadGroups.findByInbound(owner.tenantId(), owner.inboundId());
|
|
if (group != null) groups.putIfAbsent(group.groupId(), group);
|
|
}
|
|
return List.copyOf(groups.values());
|
|
}
|
|
|
|
private void requireCompletionLease(long commandId, String worker) {
|
|
if (!completionCommands.renew(commandId, worker, LocalDateTime.now().plusMinutes(10))) {
|
|
throw new CompletionProcessingException("LEASE_LOST", conflict("装柜完成命令租约已变化"));
|
|
}
|
|
}
|
|
|
|
private static void requireSameCompletionCommand(LoadingCompletionCommandEntity command, long taskId,
|
|
String requestHash, long operatorId) {
|
|
if (command == null || !Long.valueOf(taskId).equals(command.getPickTaskId())
|
|
|| !Long.valueOf(operatorId).equals(command.getOperatorId())
|
|
|| !requestHash.equals(command.getRequestHash())) {
|
|
throw conflict("operationId已用于不同装柜完成命令");
|
|
}
|
|
}
|
|
|
|
private static boolean checkpointAtLeast(String checkpoint, String target) {
|
|
List<String> stages = List.of("READY", "SETTLED", "INBOUND_COMPLETED");
|
|
return checkpoint != null && stages.indexOf(checkpoint) >= stages.indexOf(target);
|
|
}
|
|
|
|
private static void runCompletionStage(String stage, Runnable action) {
|
|
try {
|
|
action.run();
|
|
} catch (RuntimeException exception) {
|
|
throw new CompletionProcessingException(stage, exception);
|
|
}
|
|
}
|
|
|
|
public static final class CompletionProcessingException extends RuntimeException {
|
|
private final String stage;
|
|
CompletionProcessingException(String stage, RuntimeException cause) { super(cause); this.stage = stage; }
|
|
public String stage() { return stage; }
|
|
}
|
|
|
|
private void completionStage(long taskId, String stage, Runnable action) {
|
|
try {
|
|
action.run();
|
|
} catch (BizException exception) {
|
|
throw exception;
|
|
} catch (RuntimeException exception) {
|
|
log.error("Unexpected PDA loading completion failure taskId={} stage={}", taskId, stage, exception);
|
|
throw new BizException(ResultCode.INTERNAL_ERROR,
|
|
"装柜完成在【" + stage + "】阶段处理异常,请联系管理员并提供请求编号");
|
|
}
|
|
}
|
|
|
|
public List<Operation> operations(long taskId) {
|
|
requiredTask(taskId);
|
|
return repository.operations(taskId).stream().map(item -> new Operation(item.getId(), item.getPickTaskId(),
|
|
item.getOperationId(), item.getActionType(), item.getOperatorId(), item.getOperatorName(),
|
|
item.getCreatedAt())).toList();
|
|
}
|
|
|
|
@Transactional
|
|
public void remove(long planItemId) {
|
|
LoadingPickItemEntity item = repository.itemByPlanItem(planItemId);
|
|
if (item == null) {
|
|
return;
|
|
}
|
|
LoadingPickTaskEntity task = repository.taskForUpdate(item.getPickTaskId());
|
|
requireMutableTask(task);
|
|
if (item.getPickedPackageCount() != null && item.getPickedPackageCount() > 0) {
|
|
throw conflict("已有拣货记录的明细不能删除");
|
|
}
|
|
repository.deleteItem(item.getId());
|
|
if (repository.touchTask(task.getId(), task.getVersion()) != 1) {
|
|
throw conflict("PDA任务版本已变化,装柜明细删除失败");
|
|
}
|
|
}
|
|
|
|
private static void requireMutableTask(LoadingPickTaskEntity task) {
|
|
if (task == null || !"LOADING".equals(task.getTaskStatus())) {
|
|
throw conflict("PDA任务正在最终确认或已经完成,不能修改预装明细");
|
|
}
|
|
}
|
|
|
|
private void syncItem(long taskId, LoadingPlanItemEntity planItem) {
|
|
LoadingPickItemEntity item = repository.itemByPlanItem(planItem.getId());
|
|
if (item == null) {
|
|
item = new LoadingPickItemEntity();
|
|
item.setPickTaskId(taskId);
|
|
item.setLoadingPlanItemId(planItem.getId());
|
|
item.setInboundId(planItem.getInboundId());
|
|
item.setInboundDetailId(planItem.getInboundDetailId());
|
|
item.setPlannedPackageCount(planItem.getPackageCount());
|
|
item.setPickedPackageCount(0);
|
|
item.setItemStatus("PENDING");
|
|
item.setVersion(0);
|
|
repository.insert(item);
|
|
return;
|
|
}
|
|
if (item.getPickedPackageCount() > planItem.getPackageCount()) {
|
|
throw conflict("已拣货箱数不能低于计划箱数");
|
|
}
|
|
int pickedPackageCount = count(item.getPickedPackageCount());
|
|
String itemStatus = pickedPackageCount == 0 ? "PENDING"
|
|
: pickedPackageCount == planItem.getPackageCount() ? "PICKED" : "PICKING";
|
|
if (Objects.equals(item.getPickTaskId(), taskId)
|
|
&& Objects.equals(item.getInboundId(), planItem.getInboundId())
|
|
&& Objects.equals(item.getInboundDetailId(), planItem.getInboundDetailId())
|
|
&& Objects.equals(item.getPlannedPackageCount(), planItem.getPackageCount())
|
|
&& Objects.equals(item.getItemStatus(), itemStatus)) {
|
|
return;
|
|
}
|
|
item.setPickTaskId(taskId);
|
|
item.setInboundId(planItem.getInboundId());
|
|
item.setInboundDetailId(planItem.getInboundDetailId());
|
|
item.setPlannedPackageCount(planItem.getPackageCount());
|
|
item.setItemStatus(itemStatus);
|
|
repository.update(item);
|
|
}
|
|
|
|
private void settleAndFinish(LoadingPickTaskEntity task, long operatorId, String operatorName) {
|
|
// 拣货完成仅锁定明细。资源结算与装柜完成必须由 PDA 人工点击“装柜完成”触发。
|
|
}
|
|
|
|
/** 进入PDA拣货任务即将预装柜计划推进为拣货中;状态推进和历史写入保持幂等。 */
|
|
private void startPickingIfNeeded(LoadingPickTaskEntity task, long operatorId, String operatorName) {
|
|
if (smartLoadingRepository.startPicking(task.getLoadingPlanId(), operatorId, operatorName) == 1) {
|
|
String operationId = "PDA-START-" + task.getId();
|
|
LoadingPlanHistoryEntity existing = smartLoadingRepository.operation(operationId);
|
|
if (existing != null) {
|
|
if (!Objects.equals(existing.getLoadingPlanId(), task.getLoadingPlanId())
|
|
|| !"START_LOADING".equals(existing.getActionType())
|
|
|| !"EDITING".equals(existing.getFromStatus())
|
|
|| !"LOADING".equals(existing.getToStatus())) {
|
|
throw conflict("PDA开始装柜历史与当前任务不一致");
|
|
}
|
|
return;
|
|
}
|
|
LoadingPlanHistoryEntity history = new LoadingPlanHistoryEntity();
|
|
history.setLoadingPlanId(task.getLoadingPlanId());
|
|
history.setOperationId(operationId);
|
|
history.setActionType("START_LOADING");
|
|
history.setFromStatus("EDITING");
|
|
history.setToStatus("LOADING");
|
|
history.setBeforeSnapshot("{\"source\":\"PDA\"}");
|
|
history.setAfterSnapshot("{\"source\":\"PDA\",\"status\":\"LOADING\"}");
|
|
history.setOperatorId(operatorId);
|
|
history.setOperatorName(operatorName);
|
|
history.setOperationReason("进入PDA拣货任务");
|
|
smartLoadingRepository.insert(history);
|
|
}
|
|
for (var owner : repository.inboundOwners(task.getId())) {
|
|
if (owner.tenantId() == null || owner.inboundId() == null) {
|
|
throw conflict("PDA明细缺少入库单归属");
|
|
}
|
|
LoadingInboundMapper.Snapshot inbound = repository.inbound(owner.tenantId(), owner.inboundId());
|
|
if (inbound == null || inbound.processStatus() == null) {
|
|
throw conflict("PDA关联入库单不存在");
|
|
}
|
|
if ("LOADING".equals(inbound.processStatus())) {
|
|
continue;
|
|
}
|
|
if (!"WAIT_SPLIT".equals(inbound.processStatus()) || inbound.version() == null) {
|
|
throw conflict("入库单当前状态不能进入装柜阶段");
|
|
}
|
|
if (repository.beginInboundLoading(owner.tenantId(), owner.inboundId(), operatorId,
|
|
inbound.version()) != 1) {
|
|
LoadingInboundMapper.Snapshot refreshed = repository.inbound(owner.tenantId(), owner.inboundId());
|
|
if (refreshed == null || !"LOADING".equals(refreshed.processStatus())) {
|
|
throw conflict("入库单状态已变化,请刷新后重试");
|
|
}
|
|
continue;
|
|
}
|
|
InboundStatusHistoryEntity history = new InboundStatusHistoryEntity();
|
|
history.setTenantId(owner.tenantId());
|
|
history.setInboundId(owner.inboundId());
|
|
history.setFromStatus("WAIT_SPLIT");
|
|
history.setToStatus("LOADING");
|
|
history.setAction("PDA_START_LOADING");
|
|
history.setReason("进入PDA拣货任务");
|
|
history.setOperatorId(operatorId);
|
|
history.setOperatorName(operatorName);
|
|
inboundHistories.insert(history);
|
|
}
|
|
}
|
|
|
|
private LoadingPickTaskEntity requiredTask(long taskId) {
|
|
LoadingPickTaskEntity task = repository.task(taskId);
|
|
if (task == null) {
|
|
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货任务不存在");
|
|
}
|
|
return task;
|
|
}
|
|
|
|
private LoadingPickTaskEntity requiredTaskForUpdate(long taskId) {
|
|
LoadingPickTaskEntity task = repository.taskForUpdate(taskId);
|
|
if (task == null) throw new BizException(ResultCode.NOT_FOUND, "PDA拣货任务不存在");
|
|
return task;
|
|
}
|
|
|
|
private LoadingPickTaskEntity requiredLoadingTask(long taskId) {
|
|
LoadingPickTaskEntity task = requiredTask(taskId);
|
|
if (!"LOADING".equals(task.getTaskStatus())) {
|
|
throw conflict("装柜任务已完成,不能继续拣货");
|
|
}
|
|
return task;
|
|
}
|
|
|
|
private LoadingPickTaskEntity requiredLoadingTaskForUpdate(long taskId) {
|
|
LoadingPickTaskEntity task = repository.taskForUpdate(taskId);
|
|
if (task == null) throw new BizException(ResultCode.NOT_FOUND, "PDA拣货任务不存在");
|
|
if (!"LOADING".equals(task.getTaskStatus())) throw conflict("装柜任务已完成,不能继续拣货");
|
|
return task;
|
|
}
|
|
|
|
private LoadingPickTaskEntity requiredAssignedLoadingTaskForUpdate(long taskId, long operatorId) {
|
|
LoadingPickTaskEntity task = requiredLoadingTaskForUpdate(taskId);
|
|
LoadingPickAssignmentEntity assignment = repository.assignment(taskId);
|
|
if (assignment == null) throw conflict("请先领取PDA拣货任务");
|
|
if (!Long.valueOf(operatorId).equals(assignment.getAssigneeId())) {
|
|
throw conflict("PDA拣货任务已分配给其他作业员");
|
|
}
|
|
return task;
|
|
}
|
|
|
|
private LoadingPickItemEntity requireTaskItem(long taskId, long itemId, long warehouseId) {
|
|
LoadingPickItemEntity item = repository.item(taskId, itemId, warehouseId);
|
|
if (item == null) {
|
|
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货明细不存在或不属于当前任务");
|
|
}
|
|
return item;
|
|
}
|
|
|
|
private Task view(LoadingPickTaskEntity task, Long warehouseId) {
|
|
var booking = repository.activeBookingByTask(task.getId());
|
|
if (booking == null || !Objects.equals(task.getContainerBookingId(), booking.getId())) {
|
|
throw new BizException(ResultCode.NOT_FOUND, "PDA拣货任务不存在或柜次已非有效存续记录");
|
|
}
|
|
List<LoadingPickItemEntity> source = warehouseId == null
|
|
? repository.items(task.getId()) : repository.items(task.getId(), warehouseId);
|
|
Map<Long, List<CartonLabelRow>> cartonsByItem = repository.cartonLabels(task.getId()).stream()
|
|
.collect(Collectors.groupingBy(CartonLabelRow::getPickItemId));
|
|
Map<Long, Long> tenantByInbound = tenantByInbound(task.getId());
|
|
Map<Long, List<InventoryResource>> inventoryByInbound = inventoryByInbound(source, tenantByInbound);
|
|
List<PickItem> items = source.stream().map(item -> {
|
|
List<CartonLabelRow> cartons = cartonsByItem.getOrDefault(item.getId(), List.of());
|
|
CartonLabelRow first = cartons.isEmpty() ? null : cartons.get(0);
|
|
Long tenantId = first == null ? tenantByInbound.get(item.getInboundId()) : first.getTenantId();
|
|
return new PickItem(item.getId(), item.getLoadingPlanItemId(), tenantId,
|
|
item.getInboundId(),
|
|
item.getInboundDetailId(), item.getUnitType(), item.getPalletId(), item.getPalletNo(), item.getIndivisible(),
|
|
first == null ? null : first.getOrderNo(),
|
|
first == null ? null : first.getInboundCode(), first == null ? null : first.getCargoName(),
|
|
cartons.stream().map(CartonLabelRow::getBoxCode).toList(),
|
|
inventoryByInbound.getOrDefault(item.getInboundId(), List.of()), item.getPlannedPackageCount(),
|
|
item.getPickedPackageCount(), item.getItemStatus(), item.getVersion());
|
|
}).toList();
|
|
LoadingPickAssignmentEntity assigned = repository.assignment(task.getId());
|
|
LoadingPickReviewEntity reviewed = repository.review(task.getId());
|
|
Assignment assignment = assigned == null ? null : new Assignment(assigned.getAssigneeId(),
|
|
assigned.getAssigneeName(), assigned.getAssignedBy(), assigned.getAssignedByName(),
|
|
assigned.getAssignmentType(), assigned.getVersion());
|
|
Review review = reviewed == null ? null : new Review(reviewed.getReviewStatus(),
|
|
reviewed.getReviewedBy(), reviewed.getReviewedByName(), reviewed.getRemark(), reviewed.getVersion());
|
|
String workflowStatus = "COMPLETED".equals(task.getTaskStatus()) ? "COMPLETED"
|
|
: "COMPLETING".equals(task.getTaskStatus()) ? "PROCESSING"
|
|
: "COMPLETION_FAILED".equals(task.getTaskStatus()) ? "COMPLETION_FAILED"
|
|
: assigned == null ? "UNASSIGNED"
|
|
: repository.allTaskItemsResolved(task.getId()) ? "AWAITING_REVIEW" : "IN_PROGRESS";
|
|
var owners = repository.inboundOwners(task.getId());
|
|
Long detailInboundId = source.isEmpty() ? null : source.get(0).getInboundId();
|
|
var owner = detailInboundId == null ? null : owners.stream()
|
|
.filter(value -> detailInboundId.equals(value.inboundId())).findFirst().orElse(null);
|
|
var inbound = owner == null || owner.tenantId() == null || owner.inboundId() == null
|
|
? null : repository.inbound(owner.tenantId(), owner.inboundId());
|
|
LoadingCompletionCommandEntity command = "LOADING".equals(task.getTaskStatus())
|
|
? null : completionCommands.latest(task.getId());
|
|
CompletionCommand completionCommand = command == null ? null : new CompletionCommand(command.getId(),
|
|
command.getOperationId(), command.getCommandStatus(), command.getExpectedTaskVersion(), command.getRemark(),
|
|
command.getAttemptCount(), command.getNextRetryAt(), command.getFailureStage(), command.getFailureCode(),
|
|
command.getFailureRequestId(), command.getFailureTraceId(), command.getCompletedAt());
|
|
return new Task(task.getId(), task.getLoadingPlanId(), task.getContainerBookingId(),
|
|
owner == null ? null : owner.inboundId(), inbound == null ? null : inbound.version(),
|
|
inbound == null ? null : inbound.version(), task.getCabinetNo(),
|
|
booking.getContainerNo(), booking.getSourceType(), booking.getRecordStatus(),
|
|
booking.getMergedIntoContainerBookingId(), task.getTaskStatus(),
|
|
workflowStatus, task.getVersion(), assignment, review, completionCommand, items);
|
|
}
|
|
|
|
private List<BatchLine> validateBatchLines(LoadingPickTaskEntity task, BatchRequest request) {
|
|
long taskId = task.getId();
|
|
List<LoadingPickItemEntity> allItems = repository.items(taskId);
|
|
List<LoadingPickItemEntity> taskItems = repository.items(taskId, request.warehouseId());
|
|
if (allItems.isEmpty() || taskItems.size() != allItems.size()) {
|
|
throw conflict("PDA任务柜次或客户归属不一致");
|
|
}
|
|
Map<Long, LoadingPickItemEntity> itemsById = taskItems.stream().collect(Collectors.toMap(
|
|
LoadingPickItemEntity::getId, item -> item));
|
|
Set<Long> unresolvedIds = allItems.stream().filter(item -> !isResolved(item))
|
|
.map(LoadingPickItemEntity::getId).collect(Collectors.toSet());
|
|
Set<Long> requestIds = request.items().stream().map(BatchItemRequest::itemId)
|
|
.collect(Collectors.toSet());
|
|
if (requestIds.size() != request.items().size()) {
|
|
throw new BizException(ResultCode.BAD_REQUEST, "整批明细不能包含重复itemId");
|
|
}
|
|
if (!requestIds.equals(unresolvedIds)) {
|
|
throw conflict("整批提交必须且只能包含当前任务全部待拣明细");
|
|
}
|
|
|
|
Map<Long, Boolean> inboundHasWork = new HashMap<>();
|
|
for (BatchItemRequest requestItem : request.items()) {
|
|
LoadingPickItemEntity item = itemsById.get(requestItem.itemId());
|
|
if (item == null || item.getInboundId() == null || item.getLoadingPlanItemId() == null
|
|
|| item.getInboundDetailId() == null) {
|
|
throw conflict("PDA明细不属于当前任务、柜次或客户");
|
|
}
|
|
if (!"SKIP".equals(requestItem.action())) {
|
|
inboundHasWork.put(item.getInboundId(), true);
|
|
}
|
|
}
|
|
|
|
List<BatchLine> result = new ArrayList<>();
|
|
for (BatchItemRequest requestItem : request.items()) {
|
|
LoadingPickItemEntity item = itemsById.get(requestItem.itemId());
|
|
if (!Objects.equals(requestItem.version(), item.getVersion())) {
|
|
throw conflict("PDA明细版本已变化,请刷新后重试");
|
|
}
|
|
String action = requestItem.action().trim().toUpperCase();
|
|
boolean palletUnit = "SEALED_PALLET".equals(item.getUnitType()) || item.getPalletId() != null || Boolean.TRUE.equals(item.getIndivisible());
|
|
if (palletUnit && !"PICK_PALLET".equals(action)) {
|
|
throw conflict("一件代发已封托货物必须整托提交,不能普通拣货、部分拣货或跳拣");
|
|
}
|
|
if (!"PICK_PALLET".equals(action) && requestItem.palletNo() != null) {
|
|
throw new BizException(ResultCode.BAD_REQUEST, "普通箱件动作不能携带托盘码");
|
|
}
|
|
if ("PICK_PALLET".equals(action)) {
|
|
if (!"SEALED_PALLET".equals(item.getUnitType()) || item.getPalletId() == null
|
|
|| !Boolean.TRUE.equals(item.getIndivisible()) || !StringUtils.hasText(item.getPalletNo())
|
|
|| !item.getPalletNo().equals(requestItem.palletNo())) throw conflict("托盘码与当前装柜整托单元不一致");
|
|
if (requestItem.pickedPackageCount() != null || requestItem.finalPickedPackageCount() != null
|
|
|| StringUtils.hasText(requestItem.reason())) throw new BizException(ResultCode.BAD_REQUEST, "整托拣货不能携带箱数或跳拣原因");
|
|
if (!"PENDING".equals(item.getItemStatus()) || count(item.getPickedPackageCount()) != 0)
|
|
throw conflict("托盘已有部分作业,禁止拆托处理");
|
|
} else if ("PICK".equals(action)) {
|
|
requireNoUnexpectedBatchFields(requestItem, false, true);
|
|
int current = count(item.getPickedPackageCount());
|
|
int planned = count(item.getPlannedPackageCount());
|
|
if (requestItem.pickedPackageCount() == null
|
|
|| current + requestItem.pickedPackageCount() != planned) {
|
|
throw conflict("PICK必须提交该明细全部剩余箱数");
|
|
}
|
|
} else if ("PARTIAL".equals(action)) {
|
|
requireNoUnexpectedBatchFields(requestItem, true, false);
|
|
int current = count(item.getPickedPackageCount());
|
|
int planned = count(item.getPlannedPackageCount());
|
|
if (requestItem.finalPickedPackageCount() == null
|
|
|| requestItem.finalPickedPackageCount() < current
|
|
|| requestItem.finalPickedPackageCount() >= planned) {
|
|
throw conflict("PARTIAL最终箱数不能小于已拣且必须小于计划箱数");
|
|
}
|
|
} else if ("SKIP".equals(action)) {
|
|
if (requestItem.pickedPackageCount() != null || requestItem.finalPickedPackageCount() != null) {
|
|
throw new BizException(ResultCode.BAD_REQUEST, "SKIP不能携带拣货数量");
|
|
}
|
|
if (!StringUtils.hasText(requestItem.reason())) {
|
|
throw new BizException(ResultCode.BAD_REQUEST, "SKIP必须填写异常原因");
|
|
}
|
|
if (count(item.getPickedPackageCount()) != 0 || !"PENDING".equals(item.getItemStatus())
|
|
|| Boolean.TRUE.equals(inboundHasWork.get(item.getInboundId()))
|
|
|| hasExistingPickedItem(allItems, item.getInboundId(), item.getId())) {
|
|
throw conflict("同一入库单已有拣货记录,不能在整批中跳拣");
|
|
}
|
|
} else {
|
|
throw new BizException(ResultCode.BAD_REQUEST, "action只能是PICK、PARTIAL、SKIP或PICK_PALLET");
|
|
}
|
|
result.add(new BatchLine(item, requestItem, action, request.warehouseId()));
|
|
}
|
|
validateWholePallets(result, taskItems, request.warehouseId());
|
|
result.sort(Comparator.comparing(line -> line.item().getId()));
|
|
return result;
|
|
}
|
|
|
|
private void validateWholePallets(List<BatchLine> lines, List<LoadingPickItemEntity> allItems, long warehouseId) {
|
|
Map<Long, List<BatchLine>> groups = lines.stream().filter(line -> "PICK_PALLET".equals(line.action()))
|
|
.collect(Collectors.groupingBy(line -> line.item().getPalletId()));
|
|
for (var entry : groups.entrySet()) {
|
|
List<LoadingPickItemEntity> palletItems = allItems.stream().filter(item -> entry.getKey().equals(item.getPalletId())).toList();
|
|
if (palletItems.size() != entry.getValue().size()) throw conflict("整托提交必须包含托盘全部待拣明细,不得拆托");
|
|
var physical = dropshipPalletizationMapper.sealedPalletLines(entry.getKey());
|
|
String palletNo = entry.getValue().get(0).item().getPalletNo();
|
|
if (palletItems.stream().anyMatch(item -> !palletNo.equals(item.getPalletNo()))) throw conflict("托盘装柜快照不一致");
|
|
if (physical.isEmpty() || physical.stream().anyMatch(line -> !"SEALED".equals(line.getPalletStatus())
|
|
|| count(line.getCartonCount()) < 1 || !Objects.equals(line.getWarehouseId(), warehouseId) || !palletNo.equals(line.getPalletNo())))
|
|
throw conflict("托盘已失效、尚未封托或不属于当前仓库");
|
|
Map<String, Integer> physicalCounts = new HashMap<>();
|
|
physical.forEach(line -> physicalCounts.merge(line.getInboundId() + ":" + line.getInboundDetailId(), count(line.getCartonCount()), Integer::sum));
|
|
Map<String, Integer> plannedCounts = new HashMap<>();
|
|
palletItems.forEach(item -> plannedCounts.merge(item.getInboundId() + ":" + item.getInboundDetailId(), count(item.getPlannedPackageCount()), Integer::sum));
|
|
if (!physicalCounts.equals(plannedCounts)) throw conflict("整托计划明细与封托箱数不一致,禁止部分拣货");
|
|
}
|
|
}
|
|
|
|
private List<LoadingPickItemEntity> taskItems(LoadingPickTaskEntity task, long warehouseId) {
|
|
List<LoadingPickItemEntity> allItems = repository.items(task.getId());
|
|
List<LoadingPickItemEntity> taskItems = repository.items(task.getId(), warehouseId);
|
|
if (allItems.isEmpty() || taskItems.size() != allItems.size()) {
|
|
throw conflict("PDA任务柜次或客户归属不一致");
|
|
}
|
|
return taskItems;
|
|
}
|
|
|
|
private InventorySnapshot buildInventorySnapshot(LoadingPickTaskEntity task, long warehouseId,
|
|
List<LoadingPickItemEntity> items) {
|
|
Map<Long, List<InventoryResource>> inventoryByInbound = inventoryByInbound(
|
|
items, tenantByInbound(task.getId()));
|
|
List<InboundInventory> inbounds = items.stream().map(LoadingPickItemEntity::getInboundId)
|
|
.filter(Objects::nonNull).distinct().sorted().map(inboundId -> {
|
|
List<InventoryResource> resources = inventoryByInbound.getOrDefault(inboundId, List.of());
|
|
int available = resources.stream().mapToInt(InventoryResource::availablePackageCount).sum();
|
|
return new InboundInventory(inboundId, available, resources);
|
|
}).toList();
|
|
List<InventoryUnit> units = items.stream().sorted(Comparator.comparing(LoadingPickItemEntity::getId))
|
|
.map(item -> new InventoryUnit(item.getId(), item.getLoadingPlanItemId(), item.getInboundId(),
|
|
item.getUnitType(), item.getPalletId(), item.getPalletNo(), item.getIndivisible())).toList();
|
|
String token = inventoryToken(task, warehouseId, inbounds, units);
|
|
var booking = smartLoadingRepository.booking(task.getContainerBookingId());
|
|
return new InventorySnapshot(task.getId(), task.getLoadingPlanId(), task.getContainerBookingId(), task.getCabinetNo(),
|
|
task.getVersion(), booking == null ? null : booking.getContainerNo(), warehouseId,
|
|
LocalDateTime.now(), token, inbounds, units);
|
|
}
|
|
|
|
private Map<Long, Long> tenantByInbound(long taskId) {
|
|
return repository.inboundOwners(taskId).stream().collect(Collectors.toMap(
|
|
LoadingPickItemMapper.InboundOwner::inboundId,
|
|
LoadingPickItemMapper.InboundOwner::tenantId,
|
|
(left, right) -> {
|
|
if (!Objects.equals(left, right)) throw conflict("PDA任务入库单客户归属冲突");
|
|
return left;
|
|
}));
|
|
}
|
|
|
|
private Map<Long, List<InventoryResource>> inventoryByInbound(List<LoadingPickItemEntity> items,
|
|
Map<Long, Long> tenantByInbound) {
|
|
Map<Long, List<InventoryResource>> result = new HashMap<>();
|
|
for (Long inboundId : items.stream().map(LoadingPickItemEntity::getInboundId)
|
|
.filter(Objects::nonNull).distinct().toList()) {
|
|
Long tenantId = tenantByInbound.get(inboundId);
|
|
if (tenantId == null) throw conflict("PDA任务缺少入库单客户归属");
|
|
List<InventoryResource> resources = palletAllocations.list(tenantId, inboundId).stream()
|
|
.filter(resource -> "CONFIRMED".equals(resource.getAllocationStatus()))
|
|
.sorted(Comparator.comparing(InboundPalletAllocationEntity::getBlockId,
|
|
Comparator.nullsLast(Long::compareTo))
|
|
.thenComparing(InboundPalletAllocationEntity::getSlotNo,
|
|
Comparator.nullsLast(Integer::compareTo)))
|
|
.map(LoadingPickService::inventoryResource)
|
|
.toList();
|
|
result.put(inboundId, resources);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private static InventoryResource inventoryResource(InboundPalletAllocationEntity resource) {
|
|
if (resource.getBlockId() == null || resource.getSlotNo() == null
|
|
|| resource.getReservationId() == null || resource.getLocationId() == null) {
|
|
throw conflict("入库单库位块快照不完整,请重新确认测量入库");
|
|
}
|
|
return new InventoryResource(resource.getBlockId(), resource.getSlotNo(),
|
|
resource.getReservationId(), resource.getWeightResultId(), resource.getPalletSequence(),
|
|
resource.getPalletId(), resource.getLocationId(), resource.getLocationCode(),
|
|
Boolean.TRUE.equals(resource.getSharedOverflow()), resource.getSlotStatus(), available(resource));
|
|
}
|
|
|
|
private static int available(InboundPalletAllocationEntity resource) {
|
|
int allocated = count(resource.getAllocatedPackageCount());
|
|
int picked = count(resource.getPickedPackageCount());
|
|
if (picked > allocated) throw conflict("整票库位槽已拣箱数超过分配箱数");
|
|
return allocated - picked;
|
|
}
|
|
|
|
private static String inventoryToken(LoadingPickTaskEntity task, long warehouseId,
|
|
List<InboundInventory> inbounds, List<InventoryUnit> units) {
|
|
StringBuilder canonical = new StringBuilder("INVENTORY|").append(task.getId()).append('|')
|
|
.append(task.getVersion()).append('|').append(warehouseId);
|
|
for (InboundInventory inbound : inbounds) {
|
|
canonical.append('|').append(inbound.inboundId()).append('|').append(inbound.availablePackageCount());
|
|
for (InventoryResource resource : inbound.resources()) {
|
|
canonical.append('|').append(resource.blockId()).append('|').append(resource.slotNo())
|
|
.append('|').append(resource.reservationId()).append('|').append(resource.locationId())
|
|
.append('|').append(resource.availablePackageCount());
|
|
}
|
|
}
|
|
for (InventoryUnit unit : units) {
|
|
canonical.append("|UNIT|").append(unit.pickItemId()).append('|').append(unit.loadingPlanItemId())
|
|
.append('|').append(unit.inboundId()).append('|').append(unit.unitType())
|
|
.append('|').append(unit.palletId()).append('|').append(unit.palletNo()).append('|').append(unit.indivisible());
|
|
}
|
|
return hash(canonical.toString());
|
|
}
|
|
|
|
private void validateInventorySnapshot(LoadingPickTaskEntity task, BatchRequest request,
|
|
List<BatchLine> lines) {
|
|
List<LoadingPickItemEntity> allItems = taskItems(task, request.warehouseId());
|
|
InventorySnapshot current = buildInventorySnapshot(task, request.warehouseId(), allItems);
|
|
if (!current.snapshotToken().equals(request.inventorySnapshotToken().trim())) {
|
|
throw conflict("库存已变化,请刷新后重试");
|
|
}
|
|
Map<Long, Integer> availableByInbound = current.inbounds().stream().collect(Collectors.toMap(
|
|
InboundInventory::inboundId, InboundInventory::availablePackageCount));
|
|
Map<Long, Integer> requestedByInbound = new HashMap<>();
|
|
Map<Long, BatchLine> lineByItem = lines.stream().collect(Collectors.toMap(
|
|
line -> line.item().getId(), line -> line));
|
|
for (LoadingPickItemEntity item : allItems) {
|
|
BatchLine line = lineByItem.get(item.getId());
|
|
int picked = line == null ? count(item.getPickedPackageCount())
|
|
: "PICK".equals(line.action())
|
|
? count(item.getPickedPackageCount()) + line.request().pickedPackageCount()
|
|
: "PICK_PALLET".equals(line.action()) ? count(item.getPlannedPackageCount())
|
|
: "PARTIAL".equals(line.action()) ? line.request().finalPickedPackageCount() : 0;
|
|
requestedByInbound.merge(item.getInboundId(), picked, Integer::sum);
|
|
}
|
|
requestedByInbound.forEach((inboundId, requested) -> {
|
|
if (requested > availableByInbound.getOrDefault(inboundId, 0)) {
|
|
throw conflict("库存已变化,请刷新后重试");
|
|
}
|
|
});
|
|
}
|
|
|
|
private void applyBatchLine(long taskId, BatchLine line, String batchOperationId,
|
|
long operatorId, String operatorName) {
|
|
LoadingPickItemEntity item = line.item();
|
|
BatchItemRequest request = line.request();
|
|
int changed;
|
|
int pickedCount;
|
|
if ("PICK".equals(line.action()) || "PICK_PALLET".equals(line.action())) {
|
|
pickedCount = "PICK_PALLET".equals(line.action()) ? count(item.getPlannedPackageCount()) : request.pickedPackageCount();
|
|
changed = repository.pick(item.getId(), pickedCount, request.version(), line.warehouseId());
|
|
} else if ("PARTIAL".equals(line.action())) {
|
|
pickedCount = request.finalPickedPackageCount();
|
|
changed = repository.partial(item.getId(), pickedCount, request.version(), line.warehouseId());
|
|
} else {
|
|
pickedCount = 0;
|
|
changed = repository.skip(item.getId(), request.version(), line.warehouseId());
|
|
}
|
|
if (changed != 1) throw conflict("整批明细库存、库位或版本快照已变化,请刷新后重试");
|
|
String itemOperationId = "BATCH-" + hash(batchOperationId + '|' + item.getId()).substring(0, 48);
|
|
requireNoTaskOperation(itemOperationId);
|
|
if (repository.history(itemOperationId) != null) throw conflict("整批明细已被重复提交");
|
|
String itemHash = hash(line.action() + '|' + item.getId() + '|' + request.version()
|
|
+ '|' + pickedCount + '|' + trim(request.reason()) + '|' + request.palletNo());
|
|
insertHistory(taskId, item.getId(), itemOperationId, itemHash, line.action(), pickedCount,
|
|
trim(request.reason()), operatorId, operatorName);
|
|
}
|
|
|
|
private static void requireNoUnexpectedBatchFields(BatchItemRequest request,
|
|
boolean requireFinal, boolean requirePicked) {
|
|
if (requireFinal && request.pickedPackageCount() != null
|
|
|| requirePicked && request.finalPickedPackageCount() != null
|
|
|| StringUtils.hasText(request.reason())) {
|
|
throw new BizException(ResultCode.BAD_REQUEST, "批量拣货明细包含与action不匹配的字段");
|
|
}
|
|
}
|
|
|
|
private static boolean hasExistingPickedItem(List<LoadingPickItemEntity> items, Long inboundId,
|
|
Long excludedItemId) {
|
|
return items.stream().anyMatch(item -> !Objects.equals(item.getId(), excludedItemId)
|
|
&& Objects.equals(item.getInboundId(), inboundId) && count(item.getPickedPackageCount()) > 0);
|
|
}
|
|
|
|
private static boolean isResolved(LoadingPickItemEntity item) {
|
|
return "PICKED".equals(item.getItemStatus()) || "PARTIAL".equals(item.getItemStatus())
|
|
|| "SKIPPED".equals(item.getItemStatus());
|
|
}
|
|
|
|
private static int count(Integer value) {
|
|
if (value == null || value < 0) throw conflict("PDA明细箱数快照不合法");
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* 逐项拣货后仅更新任务版本与投影,不能自动结算或推进装柜计划。
|
|
*/
|
|
private void requirePackageUnit(LoadingPickItemEntity item) {
|
|
if (item.getLoadingPlanItemId() == null) return;
|
|
LoadingPlanItemEntity planItem = smartLoadingRepository.item(
|
|
requiredTask(item.getPickTaskId()).getLoadingPlanId(), item.getLoadingPlanItemId());
|
|
if (planItem != null && (Boolean.TRUE.equals(planItem.getIndivisible())
|
|
|| planItem.getPalletId() != null)) {
|
|
throw conflict("一件代发已封托货物必须扫描托盘码整体拣货,不能按箱数处理");
|
|
}
|
|
}
|
|
|
|
private void touchSettleFinishAndSync(LoadingPickTaskEntity task, long operatorId, String operatorName) {
|
|
if (repository.touchTask(task.getId(), task.getVersion()) != 1) {
|
|
throw conflict("PDA任务版本已变化");
|
|
}
|
|
settleAndFinish(task, operatorId, operatorName);
|
|
repository.syncInboundProjections(task.getId(), operatorId, operatorName);
|
|
}
|
|
|
|
private Task replayTaskOperation(long taskId, String operationId, String requestHash, long operatorId) {
|
|
if (repository.history(operationId) != null) throw conflict("operationId已用于明细拣货操作");
|
|
LoadingPickOperationEntity replay = repository.operation(operationId);
|
|
return replayTaskOperation(taskId, operationId, requestHash, operatorId, replay);
|
|
}
|
|
|
|
private Task replayTaskOperationForUpdate(long taskId, String operationId, String requestHash,
|
|
long operatorId) {
|
|
if (repository.historyForUpdate(operationId) != null) throw conflict("operationId已用于明细拣货操作");
|
|
LoadingPickOperationEntity replay = repository.operationForUpdate(operationId);
|
|
return replayTaskOperation(taskId, operationId, requestHash, operatorId, replay);
|
|
}
|
|
|
|
private Task replayTaskOperation(long taskId, String operationId, String requestHash, long operatorId,
|
|
LoadingPickOperationEntity replay) {
|
|
if (replay == null) return null;
|
|
if (!Long.valueOf(taskId).equals(replay.getPickTaskId())
|
|
|| !Long.valueOf(operatorId).equals(replay.getOperatorId())
|
|
|| !requestHash.equals(replay.getRequestHash())) {
|
|
throw conflict("operationId已用于不同PDA任务操作");
|
|
}
|
|
return view(requiredTask(taskId), null);
|
|
}
|
|
|
|
private void insertOperation(long taskId, String operationId, String requestHash, String actionType,
|
|
long operatorId, String operatorName) {
|
|
LoadingPickOperationEntity entity = new LoadingPickOperationEntity();
|
|
entity.setPickTaskId(taskId); entity.setOperationId(operationId); entity.setRequestHash(requestHash);
|
|
entity.setActionType(actionType); entity.setOperatorId(operatorId); entity.setOperatorName(operatorName);
|
|
try {
|
|
repository.insert(entity);
|
|
} catch (DuplicateKeyException exception) {
|
|
throw conflict("operationId已被并发使用");
|
|
}
|
|
}
|
|
|
|
private void requireNoTaskOperation(String operationId) {
|
|
if (repository.operation(operationId) != null) throw conflict("operationId已用于PDA任务操作");
|
|
}
|
|
|
|
private void requireNoTaskOperationForUpdate(String operationId) {
|
|
if (repository.operationForUpdate(operationId) != null) throw conflict("operationId已用于PDA任务操作");
|
|
}
|
|
|
|
private static String operation(String value) {
|
|
String result = trim(value);
|
|
if (!StringUtils.hasText(result)) throw new BizException(ResultCode.BAD_REQUEST, "operationId不能为空");
|
|
if (result.length() > 128) throw new BizException(ResultCode.BAD_REQUEST, "operationId长度不能超过128");
|
|
return result;
|
|
}
|
|
|
|
private void insertHistory(long taskId, long itemId, String operationId, String requestHash,
|
|
String actionType, int pickedPackageCount, String reason,
|
|
long operatorId, String operatorName) {
|
|
LoadingPickHistoryEntity history = new LoadingPickHistoryEntity();
|
|
history.setPickTaskId(taskId);
|
|
history.setPickItemId(itemId);
|
|
history.setOperationId(operationId);
|
|
history.setRequestHash(requestHash);
|
|
history.setActionType(actionType);
|
|
history.setPickedPackageCount(pickedPackageCount);
|
|
history.setOperationReason(reason);
|
|
history.setOperatorId(operatorId);
|
|
history.setOperatorName(operatorName);
|
|
repository.insert(history);
|
|
}
|
|
|
|
private static void assertReplay(LoadingPickHistoryEntity replay, long taskId, long itemId,
|
|
String requestHash) {
|
|
if (!replay.getPickTaskId().equals(taskId) || !replay.getPickItemId().equals(itemId)
|
|
|| !replay.getRequestHash().equals(requestHash)) {
|
|
throw conflict("operationId已用于不同拣货请求");
|
|
}
|
|
}
|
|
|
|
private static String hash(String value) {
|
|
try {
|
|
return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
|
.digest(value.getBytes(StandardCharsets.UTF_8)));
|
|
} catch (NoSuchAlgorithmException exception) {
|
|
throw new IllegalStateException("JVM 不支持 SHA-256", exception);
|
|
}
|
|
}
|
|
|
|
private static String batchRequestHash(long taskId, BatchRequest request) {
|
|
if (request == null || request.warehouseId() == null
|
|
|| request.expectedVersion() == null || !StringUtils.hasText(request.inventorySnapshotToken())
|
|
|| request.items() == null || request.items().isEmpty()) {
|
|
throw new BizException(ResultCode.BAD_REQUEST, "整批提交参数不完整");
|
|
}
|
|
StringBuilder canonical = new StringBuilder("BATCH|").append(taskId).append('|')
|
|
.append(request.warehouseId()).append('|').append(request.loadingPlanId()).append('|')
|
|
.append(request.containerBookingId()).append('|')
|
|
.append(request.expectedVersion()).append('|').append(request.inventorySnapshotToken().trim());
|
|
request.items().stream().sorted(Comparator.comparing(BatchItemRequest::itemId,
|
|
Comparator.nullsFirst(Long::compareTo)))
|
|
.forEach(item -> {
|
|
canonical.append('|').append(item.itemId()).append('|').append(trim(item.action()))
|
|
.append('|').append(item.pickedPackageCount()).append('|')
|
|
.append(item.finalPickedPackageCount()).append('|')
|
|
.append(item.version()).append('|').append(trim(item.reason())).append('|').append(item.palletNo());
|
|
});
|
|
return hash(canonical.toString());
|
|
}
|
|
|
|
private record BatchLine(LoadingPickItemEntity item, BatchItemRequest request, String action,
|
|
long warehouseId) {}
|
|
|
|
private static String trim(String value) {
|
|
return value == null ? null : value.trim();
|
|
}
|
|
|
|
private static BizException conflict(String message) {
|
|
return new BizException(ResultCode.CONFLICT, message);
|
|
}
|
|
}
|