fix: materialize container pallet plans

This commit is contained in:
2026-09-10 15:40:55 +08:00
parent a5db6fbbb6
commit f0b8514890
8 changed files with 321 additions and 25 deletions
@@ -25,7 +25,8 @@ public interface InboundPlanOrderMapper {
java.math.BigDecimal volumeCbm, String overseasWarehouseId, String overseasWarehouseCode,
String overseasWarehouseName, String fbaWarehouseCode, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName, int destinationCount,
boolean detained, int version, String planNo, String planStatus, Integer plannedPalletCount
boolean detained, int version, String planNo, String planStatus,
Integer plannedPalletCount, Integer materializedPalletCount
) { }
@Select("SELECT o.customer_id AS customerId,o.id AS orderId,o.order_no AS orderNo," +
@@ -40,7 +41,10 @@ public interface InboundPlanOrderMapper {
"COALESCE(NULLIF(o.transfer_warehouse_name,''),(SELECT MIN(NULLIF(d.destination_warehouse_name,'')) FROM ll_order_detail d WHERE d.order_id=o.id)) AS destinationWarehouseName," +
"CASE WHEN COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) IS NOT NULL THEN 1 ELSE " +
"(SELECT COUNT(DISTINCT COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#')) FROM ll_order_detail d WHERE d.order_id=o.id) END AS destinationCount," +
"o.detained AS detained,o.version AS version,p.plan_no AS planNo,p.status AS planStatus,p.pallet_count AS plannedPalletCount " +
"o.detained AS detained,o.version AS version,p.plan_no AS planNo,p.status AS planStatus," +
"p.pallet_count AS plannedPalletCount," +
"(SELECT COUNT(*) FROM ll_inbound_plan_pallet pp WHERE pp.customer_id=p.customer_id " +
"AND pp.plan_id=p.id AND pp.warehouse_pallet_id IS NOT NULL) AS materializedPalletCount " +
"FROM ll_order o LEFT JOIN ll_inbound_plan p ON p.customer_id=o.customer_id AND p.source_order_id=o.id " +
"WHERE o.source_container_booking_id=#{sourceContainerBookingId} ORDER BY o.customer_id,o.id")
List<ContainerPreviewOrderRow> containerPreviewOrders(
@@ -30,6 +30,10 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.HashSet;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@Service
@@ -116,7 +120,7 @@ public class ContainerInboundPlanConversionCoordinator {
WarehouseResourceGateway.PalletBatchEnsure ensure = new WarehouseResourceGateway.PalletBatchEnsure(
preparation.warehouseId(), command.getBatchKey(), preparation.items().stream().map(item ->
new WarehouseResourceGateway.PalletBatchItemRequest(Long.parseLong(item.customerId()),
item.businessKey(), item.planNo(), item.globalSequenceNo(), "STANDARD",
item.businessKey(), item.planNo(), item.globalSequenceNo(), "WOOD",
item.fbaWarehouseCode(), item.palletCode())).toList());
WarehouseResourceGateway.PalletBatchResponse reservation;
try {
@@ -201,21 +205,76 @@ public class ContainerInboundPlanConversionCoordinator {
catch (RuntimeException exception) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "柜级接柜仓 ID 无效", exception);
}
List<PreparedItem> items = new ArrayList<>();
int globalSequence = 0;
for (ContainerInboundPlanPreviewResponse.Order order : preview.groups().stream()
.flatMap(group -> group.orders().stream()).filter(item -> "READY".equals(item.readiness())).toList()) {
long planId = IdWorker.getId();
String planNo = numbers.next("INBOUND_PLAN", order.destinationWarehouseCode(), planId);
List<PreparedOrder> preparedOrders = new ArrayList<>();
Map<String, String> prefixByFba = new LinkedHashMap<>();
Map<String, Integer> nextIndexByFba = new LinkedHashMap<>();
Set<String> usedPalletCodes = new HashSet<>();
List<ContainerInboundPlanPreviewResponse.Order> convertibleOrders = preview.groups().stream()
.flatMap(group -> group.orders().stream())
.filter(item -> "READY".equals(item.readiness()) || "RESOURCE_PENDING".equals(item.readiness()))
.toList();
for (ContainerInboundPlanPreviewResponse.Order order : convertibleOrders) {
List<String> sourcePalletNos = plans.sourcePalletNos(
order.customerId(), Long.parseLong(order.orderId()), order.orderType());
int targetPalletCount = "RESOURCE_PENDING".equals(order.readiness())
? Objects.requireNonNullElse(order.plannedPalletCount(), 0)
: Objects.requireNonNullElse(order.suggestedPalletCount(), 0);
if (sourcePalletNos.size() > targetPalletCount) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "计划托数不能少于已生成的头程托盘码数量");
}
String fbaCode = trimToNull(order.fbaWarehouseCode());
if (fbaCode != null) {
for (String palletCode : sourcePalletNos) {
PalletCodeParts parts = palletCodeParts(preview.containerNo(), palletCode);
String previousPrefix = prefixByFba.putIfAbsent(fbaCode, parts.prefix());
if (previousPrefix != null && !previousPrefix.equals(parts.prefix())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "同一 FBA 仓的头程托盘码内部编码不一致");
}
nextIndexByFba.merge(fbaCode, parts.index(), Math::max);
if (!usedPalletCodes.add(palletCode)) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "头程托盘码存在重复");
}
}
if (sourcePalletNos.isEmpty()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "散货订单缺少头程托盘码快照");
}
}
InboundPlanService.PlanIdentity existing = "RESOURCE_PENDING".equals(order.readiness())
? plans.existingPlanIdentity(order.customerId(), Long.parseLong(order.orderId())) : null;
long planId = existing == null ? IdWorker.getId() : existing.planId();
String planNo = existing == null
? numbers.next("INBOUND_PLAN", order.destinationWarehouseCode(), planId) : existing.planNo();
if (existing != null && existing.palletCount() != targetPalletCount) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "人工计划托数与柜级预览已不一致");
}
preparedOrders.add(new PreparedOrder(order, planId, planNo, targetPalletCount,
List.copyOf(sourcePalletNos)));
}
List<PreparedItem> items = new ArrayList<>();
int globalSequence = 0;
for (PreparedOrder preparedOrder : preparedOrders) {
ContainerInboundPlanPreviewResponse.Order order = preparedOrder.order();
List<String> sourcePalletNos = new ArrayList<>(preparedOrder.sourcePalletNos());
boolean warehouseGeneratedPallets = trimToNull(order.fbaWarehouseCode()) == null
&& !"ONE_CLICK_DROPSHIP".equals(order.orderType());
if (!warehouseGeneratedPallets && sourcePalletNos.size() != order.suggestedPalletCount()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "头程托盘码数量与柜级预览不一致");
if (!warehouseGeneratedPallets && !"ONE_CLICK_DROPSHIP".equals(order.orderType())) {
String fbaCode = order.fbaWarehouseCode().trim();
String prefix = prefixByFba.get(fbaCode);
while (sourcePalletNos.size() < preparedOrder.targetPalletCount()) {
int next = nextIndexByFba.compute(fbaCode, (ignored, current) -> current == null ? 1 : current + 1);
String palletCode = prefix + "-" + String.format("%03d", next);
if (!usedPalletCodes.add(palletCode)) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "补生成托盘码发生重复");
}
sourcePalletNos.add(palletCode);
}
} else if (!warehouseGeneratedPallets && sourcePalletNos.size() != preparedOrder.targetPalletCount()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "一件代发托盘码数量与柜级预览不一致");
}
for (int localSequence = 1; localSequence <= order.suggestedPalletCount(); localSequence++) {
items.add(new PreparedItem(order.customerId(), Long.parseLong(order.orderId()), planId, planNo,
for (int localSequence = 1; localSequence <= preparedOrder.targetPalletCount(); localSequence++) {
items.add(new PreparedItem(order.customerId(), Long.parseLong(order.orderId()), preparedOrder.planId(),
preparedOrder.planNo(),
localSequence, ++globalSequence, "CIP:" + generationId + ":" + globalSequence,
trimToNull(order.fbaWarehouseCode()), warehouseGeneratedPallets
? null : sourcePalletNos.get(localSequence - 1)));
@@ -379,7 +438,26 @@ public class ContainerInboundPlanConversionCoordinator {
}
public record Preparation(long warehouseId, List<PreparedItem> items) { }
private record PreparedOrder(ContainerInboundPlanPreviewResponse.Order order, long planId, String planNo,
int targetPalletCount, List<String> sourcePalletNos) { }
public record PreparedItem(String customerId, long orderId, long planId, String planNo,
int localSequenceNo, int globalSequenceNo, String businessKey,
String fbaWarehouseCode, String palletCode) { }
private static PalletCodeParts palletCodeParts(String containerNo, String palletCode) {
String stableContainerNo = trimToNull(containerNo);
String stablePalletCode = trimToNull(palletCode);
if (stableContainerNo == null || stablePalletCode == null) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "集装箱号或头程托盘码缺失");
}
Matcher matcher = Pattern.compile("^(" + Pattern.quote(stableContainerNo) + "-.+)-(\\d{3})$")
.matcher(stablePalletCode);
if (!matcher.matches()) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"头程托盘码不符合‘集装箱号-FBA内部编码-下标’规则");
}
return new PalletCodeParts(matcher.group(1), Integer.parseInt(matcher.group(2)));
}
private record PalletCodeParts(String prefix, int index) { }
}
@@ -121,6 +121,14 @@ public class InboundPlanService {
WarehouseResourceGateway.WarehouseReference receivingWarehouse =
requiredReceivingWarehouse(containerWarehouseId);
validateOrders(snapshots);
int minimumPalletCount = suggestedPalletCount(order);
int sourcePalletCount = Objects.requireNonNullElse(
sourcePalletMapper.palletNos(customerId, request.orderId()), List.<String>of()).size();
minimumPalletCount = Math.max(minimumPalletCount, sourcePalletCount);
if (request.palletCount() < minimumPalletCount) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"人工计划托数不能少于柜级预览建议托数 " + minimumPalletCount);
}
InboundPlanResponse response = createPlan(customerId, request.orderId(), snapshots, List.of(), null,
request, receivingWarehouse);
LastlegOrderCommandRecord command = new LastlegOrderCommandRecord();
@@ -186,9 +194,9 @@ public class InboundPlanService {
ContainerInboundPlanPreviewResponse.Order first = items.get(0);
return new ContainerInboundPlanPreviewResponse.Group(first.destinationType(),
first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(),
items.size(), (int) items.stream().filter(item -> "READY".equals(item.readiness())).count(),
items.stream().filter(item -> "READY".equals(item.readiness()))
.map(ContainerInboundPlanPreviewResponse.Order::suggestedPalletCount)
items.size(), (int) items.stream().filter(InboundPlanService::requiresContainerConversion).count(),
items.stream().filter(InboundPlanService::requiresContainerConversion)
.map(InboundPlanService::pendingPalletCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum(),
items.stream().filter(item -> "PLANNED".equals(item.readiness()))
.map(ContainerInboundPlanPreviewResponse.Order::plannedPalletCount)
@@ -199,9 +207,9 @@ public class InboundPlanService {
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum(), List.copyOf(items));
}).toList();
int blocked = (int) orders.stream().filter(item -> "BLOCKED".equals(item.readiness())).count();
int pendingOrders = (int) orders.stream().filter(item -> "READY".equals(item.readiness())).count();
int pendingPallets = orders.stream().filter(item -> "READY".equals(item.readiness()))
.map(ContainerInboundPlanPreviewResponse.Order::suggestedPalletCount)
int pendingOrders = (int) orders.stream().filter(InboundPlanService::requiresContainerConversion).count();
int pendingPallets = orders.stream().filter(InboundPlanService::requiresContainerConversion)
.map(InboundPlanService::pendingPalletCount)
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
int existingPallets = orders.stream().filter(item -> "PLANNED".equals(item.readiness()))
.map(ContainerInboundPlanPreviewResponse.Order::plannedPalletCount)
@@ -246,7 +254,19 @@ public class InboundPlanService {
String fbaWarehouseCode = isFbaDestination(row) ? trimToNull(row.fbaWarehouseCode()) : null;
String destinationCode = fbaWarehouseCode != null
? row.fbaWarehouseCode().trim() : row.destinationWarehouseCode();
String readiness = planned ? "PLANNED" : blockers.isEmpty() ? "READY" : "BLOCKED";
boolean materialized = planned && row.plannedPalletCount() != null
&& row.materializedPalletCount() != null
&& row.materializedPalletCount() >= row.plannedPalletCount();
if (planned && !materialized && row.materializedPalletCount() != null
&& row.materializedPalletCount() > 0) {
blockers.add("PLAN_PALLET_BINDING_INCOMPLETE");
}
if (planned && !materialized && !"PENDING_ASSIGNMENT".equals(row.planStatus())) {
blockers.add("PLAN_ALREADY_STARTED");
}
String readiness = planned ? materialized ? "PLANNED"
: blockers.isEmpty() ? "RESOURCE_PENDING" : "BLOCKED"
: blockers.isEmpty() ? "READY" : "BLOCKED";
return new ContainerInboundPlanPreviewResponse.Order(row.customerId(), row.customerName(),
String.valueOf(row.orderId()), trimToNull(row.orderNo()) == null ? row.sourceOrderNo() : row.orderNo(),
row.inboundCode(), row.orderType(), destinationType, row.destinationWarehouseId(),
@@ -276,6 +296,26 @@ public class InboundPlanService {
}
}
private int suggestedPalletCount(SourceOrderSnapshot order) {
try {
return PalletAllocator.allocate(List.of(new PalletAllocator.LineInput(order.orderId(),
warehouseKey(order), volume(order), order.packageCount(), order.recommendedPalletCount())))
.pallets().size();
} catch (IllegalArgumentException exception) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "订单货量不能计算预览建议托数", exception);
}
}
private static boolean requiresContainerConversion(ContainerInboundPlanPreviewResponse.Order order) {
return "READY".equals(order.readiness()) || "RESOURCE_PENDING".equals(order.readiness());
}
private static Integer pendingPalletCount(ContainerInboundPlanPreviewResponse.Order order) {
return "RESOURCE_PENDING".equals(order.readiness())
? Objects.requireNonNullElse(order.plannedPalletCount(), 0)
: Objects.requireNonNullElse(order.suggestedPalletCount(), 0);
}
private static String destinationType(InboundPlanOrderMapper.ContainerPreviewOrderRow row) {
if ("ONE_CLICK_DROPSHIP".equals(row.orderType())) return "ONE_CLICK_DROPSHIP";
if (isFbaDestination(row)) return "FBA";
@@ -552,9 +592,22 @@ public class InboundPlanService {
Map<Integer, WarehouseResourceGateway.PalletBatchItem> reserved = prepared == null ? Map.of()
: prepared.pallets().stream().collect(Collectors.toMap(PreparedPallet::sequenceNo,
PreparedPallet::reservation));
created.put(refIndex, createPlan(ref.customerId(), ref.orderId(), snapshots.get(pendingIndex), standardPalletNos.get(pendingIndex),
List<String> effectivePalletNos = standardPalletNos.get(pendingIndex);
if (prepared != null && !effectivePalletNos.isEmpty()) {
effectivePalletNos = prepared.pallets().stream()
.sorted(Comparator.comparingInt(PreparedPallet::sequenceNo))
.map(value -> value.reservation().palletCode()).toList();
}
created.put(refIndex, createPlan(ref.customerId(), ref.orderId(), snapshots.get(pendingIndex), effectivePalletNos,
wholePallets.get(pendingIndex), null, null, prepared, reserved));
}
for (int index = 0; index < refs.size(); index++) {
InboundPlan existing = existingPlans.get(index);
PreparedPlan prepared = preparedPlans.get(refs.get(index).orderId());
if (existing != null && prepared != null) {
materializeExistingPlan(existing, refs.get(index), prepared);
}
}
List<InboundPlanResponse> result = new ArrayList<>(refs.size());
for (int index = 0; index < refs.size(); index++) {
InboundPlan existing = existingPlans.get(index);
@@ -568,6 +621,47 @@ public class InboundPlanService {
public record PreparedPallet(int sequenceNo, String businessKey,
WarehouseResourceGateway.PalletBatchItem reservation) { }
public PlanIdentity existingPlanIdentity(String customerId, long orderId) {
InboundPlan plan = planMapper.findBySourceOrder(customerId, orderId);
if (plan == null) return null;
return new PlanIdentity(plan.getId(), plan.getPlanNo(), Objects.requireNonNullElse(plan.getPalletCount(), 0));
}
public record PlanIdentity(long planId, String planNo, int palletCount) { }
private void materializeExistingPlan(InboundPlan plan, InboundPlanOrderMapper.ContainerOrderRef ref,
PreparedPlan prepared) {
if (!Objects.equals(plan.getId(), prepared.planId()) || !Objects.equals(plan.getPlanNo(), prepared.planNo())
|| !Objects.equals(plan.getCustomerId(), prepared.customerId())
|| !Objects.equals(ref.orderId(), prepared.orderId())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "人工计划与柜级托盘预留快照不一致");
}
if (!"PENDING_ASSIGNMENT".equals(plan.getStatus())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "仅未开始作业的已有计划可由整柜生成补齐托盘");
}
List<InboundPlanPallet> pallets = palletMapper.selectList(new LambdaQueryWrapper<InboundPlanPallet>()
.eq(InboundPlanPallet::getCustomerId, plan.getCustomerId())
.eq(InboundPlanPallet::getPlanId, plan.getId())
.orderByAsc(InboundPlanPallet::getSequenceNo).last("FOR UPDATE"));
if (pallets.size() != prepared.pallets().size() || pallets.size() != plan.getPalletCount()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "人工计划托数与柜级托盘预留数量不一致");
}
Map<Integer, PreparedPallet> preparedBySequence = prepared.pallets().stream().collect(Collectors.toMap(
PreparedPallet::sequenceNo, value -> value));
for (InboundPlanPallet pallet : pallets) {
if (pallet.getWarehousePalletId() != null) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "人工计划已存在真实托盘,不能重复补齐");
}
PreparedPallet value = preparedBySequence.get(pallet.getSequenceNo());
if (value == null) throw new ResponseStatusException(HttpStatus.CONFLICT, "人工计划托盘序号不完整");
pallet.setSourcePalletNo(value.reservation().palletCode());
applyReservedPallet(pallet, value.reservation());
palletMapper.updateById(pallet);
}
plan.setUpdatedAt(LocalDateTime.now());
planMapper.updateById(plan);
}
private WholePalletInput wholePallet(DropshipPalletSnapshot pallet) {
return new WholePalletInput(pallet.sourcePalletNo(), Math.toIntExact(pallet.packageCount()));
}