fix(firstleg): preserve sealed pallet identity in atomic PDA picking

This commit is contained in:
2026-09-09 12:33:31 +08:00
parent 44ac24a9e2
commit ac3ce3703e
12 changed files with 323 additions and 47 deletions
@@ -59,11 +59,12 @@ public final class LoadingPickDtos {
public record BatchItemRequest(
@NotNull @Min(1) Long itemId,
@NotBlank @Pattern(regexp = "PICK|PARTIAL|SKIP") String action,
@NotBlank @Pattern(regexp = "PICK|PARTIAL|SKIP|PICK_PALLET") String action,
@Min(1) Integer pickedPackageCount,
@Min(1) Integer finalPickedPackageCount,
@NotNull @Min(0) Integer version,
@Size(max = 500) String reason
@Size(max = 500) String reason,
@Pattern(regexp = "DP-[0-9]{6}-[0-9]{4}") String palletNo
) {
}
@@ -98,6 +99,7 @@ public final class LoadingPickDtos {
public record PickItem(Long id, Long loadingPlanItemId, Long tenantId, Long inboundId, Long inboundDetailId,
String unitType, Long palletId, String palletNo, Boolean indivisible,
String orderNo, String inboundCode, String cargoName, List<String> boxCodes,
List<InventoryResource> inventoryResources,
Integer plannedPackageCount, Integer pickedPackageCount,
@@ -152,6 +154,10 @@ public final class LoadingPickDtos {
public record InventorySnapshot(Long taskId, Long loadingPlanId, Long containerBookingId,
String cabinetNo, Integer taskVersion, String currentContainerNo,
Long warehouseId, LocalDateTime capturedAt, String snapshotToken,
List<InboundInventory> inbounds) {
List<InboundInventory> inbounds, List<InventoryUnit> units) {
}
public record InventoryUnit(Long pickItemId, Long loadingPlanItemId, Long inboundId,
String unitType, Long palletId, String palletNo, Boolean indivisible) {
}
}
@@ -2,6 +2,7 @@ package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.Version;
import lombok.Getter;
@@ -18,6 +19,10 @@ public class LoadingPickItemEntity {
private Long id;
private Long pickTaskId;
private Long loadingPlanItemId;
@TableField(exist = false) private String unitType;
@TableField(exist = false) private Long palletId;
@TableField(exist = false) private String palletNo;
@TableField(exist = false) private Boolean indivisible;
private Long inboundId;
private Long inboundDetailId;
private Integer plannedPackageCount;
@@ -11,6 +11,17 @@ import java.util.List;
@Mapper
public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity> {
@Select("""
SELECT pick_item.*, plan_item.unit_type, plan_item.pallet_id, plan_item.pallet_no, plan_item.indivisible
FROM fl_loading_pick_item pick_item
JOIN fl_loading_pick_task task ON task.id = pick_item.pick_task_id
JOIN fl_loading_plan_item plan_item ON plan_item.id = pick_item.loading_plan_item_id
AND plan_item.loading_plan_id = task.loading_plan_id
WHERE pick_item.pick_task_id = #{taskId}
ORDER BY pick_item.id ASC
""")
List<LoadingPickItemEntity> selectTaskItemsWithPlan(@Param("taskId") long taskId);
@Select("""
SELECT pick_item.id AS pickItemId, plan_item.account_id AS tenantId, plan_item.order_no AS orderNo,
plan_item.inbound_code AS inboundCode, label.cargo_name AS cargoName,
@@ -133,7 +144,7 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
record InboundOwner(Long tenantId, Long inboundId) {}
@Select("""
SELECT pick_item.*
SELECT pick_item.*, plan_item.unit_type, plan_item.pallet_id, plan_item.pallet_no, plan_item.indivisible
FROM fl_loading_pick_item pick_item
JOIN fl_loading_pick_task pick_task ON pick_task.id = pick_item.pick_task_id
JOIN fl_loading_plan_item plan_item ON plan_item.id = pick_item.loading_plan_item_id
@@ -150,7 +161,7 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
@Param("warehouseId") long warehouseId);
@Select("""
SELECT pick_item.*
SELECT pick_item.*, plan_item.unit_type, plan_item.pallet_id, plan_item.pallet_no, plan_item.indivisible
FROM fl_loading_pick_item pick_item
JOIN fl_loading_pick_task pick_task ON pick_task.id = pick_item.pick_task_id
JOIN fl_loading_plan_item plan_item ON plan_item.id = pick_item.loading_plan_item_id
@@ -105,9 +105,7 @@ public class LoadingPickRepository {
}
public List<LoadingPickItemEntity> items(long taskId) {
return items.selectList(new LambdaQueryWrapper<LoadingPickItemEntity>()
.eq(LoadingPickItemEntity::getPickTaskId, taskId)
.orderByAsc(LoadingPickItemEntity::getId));
return items.selectTaskItemsWithPlan(taskId);
}
public List<CartonLabelRow> cartonLabels(long taskId) {
@@ -21,6 +21,7 @@ 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;
@@ -484,11 +485,11 @@ public class LoadingPickService {
List<BatchLine> lines = validateBatchLines(task, request);
validateInventorySnapshot(task, request, lines);
lines.forEach(line -> requirePackageUnit(line.item()));
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()))) {
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)) {
@@ -968,7 +969,8 @@ public class LoadingPickService {
Long tenantId = first == null ? tenantByInbound.get(item.getInboundId()) : first.getTenantId();
return new PickItem(item.getId(), item.getLoadingPlanItemId(), tenantId,
item.getInboundId(),
item.getInboundDetailId(), first == null ? null : first.getOrderNo(),
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(),
@@ -1045,7 +1047,22 @@ public class LoadingPickService {
throw conflict("PDA明细版本已变化,请刷新后重试");
}
String action = requestItem.action().trim().toUpperCase();
if ("PICK".equals(action)) {
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());
@@ -1075,14 +1092,35 @@ public class LoadingPickService {
throw conflict("同一入库单已有拣货记录,不能在整批中跳拣");
}
} else {
throw new BizException(ResultCode.BAD_REQUEST, "action只能是PICK、PARTIAL或SKIP");
throw new BizException(ResultCode.BAD_REQUEST, "action只能是PICK、PARTIAL、SKIP或PICK_PALLET");
}
result.add(new BatchLine(item, requestItem, action, request.warehouseId()));
}
validateWholePallets(result, scopedItems, 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> warehouseScopedItems(LoadingPickTaskEntity task, long warehouseId) {
List<LoadingPickItemEntity> allItems = repository.items(task.getId());
List<LoadingPickItemEntity> scopedItems = repository.items(task.getId(), warehouseId);
@@ -1102,11 +1140,14 @@ public class LoadingPickService {
int available = resources.stream().mapToInt(InventoryResource::availablePackageCount).sum();
return new InboundInventory(inboundId, available, resources);
}).toList();
String token = inventoryToken(task, warehouseId, inbounds);
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);
LocalDateTime.now(), token, inbounds, units);
}
private Map<Long, Long> tenantByInbound(long taskId) {
@@ -1160,7 +1201,7 @@ public class LoadingPickService {
}
private static String inventoryToken(LoadingPickTaskEntity task, long warehouseId,
List<InboundInventory> inbounds) {
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) {
@@ -1171,6 +1212,11 @@ public class LoadingPickService {
.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());
}
@@ -1191,6 +1237,7 @@ public class LoadingPickService {
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);
}
@@ -1207,8 +1254,8 @@ public class LoadingPickService {
BatchItemRequest request = line.request();
int changed;
int pickedCount;
if ("PICK".equals(line.action())) {
pickedCount = request.pickedPackageCount();
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();
@@ -1222,7 +1269,7 @@ public class LoadingPickService {
requireNoTaskOperation(itemOperationId);
if (repository.history(itemOperationId) != null) throw conflict("整批明细已被重复提交");
String itemHash = hash(line.action() + '|' + item.getId() + '|' + request.version()
+ '|' + pickedCount + '|' + trim(request.reason()));
+ '|' + pickedCount + '|' + trim(request.reason()) + '|' + request.palletNo());
insertHistory(taskId, item.getId(), itemOperationId, itemHash, line.action(), pickedCount,
trim(request.reason()), operatorId, operatorName);
}
@@ -1373,7 +1420,7 @@ public class LoadingPickService {
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(item.version()).append('|').append(trim(item.reason())).append('|').append(item.palletNo());
});
return hash(canonical.toString());
}