fix: materialize container pallet plans
This commit is contained in:
+6
-2
@@ -25,7 +25,8 @@ public interface InboundPlanOrderMapper {
|
|||||||
java.math.BigDecimal volumeCbm, String overseasWarehouseId, String overseasWarehouseCode,
|
java.math.BigDecimal volumeCbm, String overseasWarehouseId, String overseasWarehouseCode,
|
||||||
String overseasWarehouseName, String fbaWarehouseCode, String destinationWarehouseId,
|
String overseasWarehouseName, String fbaWarehouseCode, String destinationWarehouseId,
|
||||||
String destinationWarehouseCode, String destinationWarehouseName, int destinationCount,
|
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," +
|
@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," +
|
"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 " +
|
"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," +
|
"(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 " +
|
"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")
|
"WHERE o.source_container_booking_id=#{sourceContainerBookingId} ORDER BY o.customer_id,o.id")
|
||||||
List<ContainerPreviewOrderRow> containerPreviewOrders(
|
List<ContainerPreviewOrderRow> containerPreviewOrders(
|
||||||
|
|||||||
+89
-11
@@ -30,6 +30,10 @@ import java.util.LinkedHashMap;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
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;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -116,7 +120,7 @@ public class ContainerInboundPlanConversionCoordinator {
|
|||||||
WarehouseResourceGateway.PalletBatchEnsure ensure = new WarehouseResourceGateway.PalletBatchEnsure(
|
WarehouseResourceGateway.PalletBatchEnsure ensure = new WarehouseResourceGateway.PalletBatchEnsure(
|
||||||
preparation.warehouseId(), command.getBatchKey(), preparation.items().stream().map(item ->
|
preparation.warehouseId(), command.getBatchKey(), preparation.items().stream().map(item ->
|
||||||
new WarehouseResourceGateway.PalletBatchItemRequest(Long.parseLong(item.customerId()),
|
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());
|
item.fbaWarehouseCode(), item.palletCode())).toList());
|
||||||
WarehouseResourceGateway.PalletBatchResponse reservation;
|
WarehouseResourceGateway.PalletBatchResponse reservation;
|
||||||
try {
|
try {
|
||||||
@@ -201,21 +205,76 @@ public class ContainerInboundPlanConversionCoordinator {
|
|||||||
catch (RuntimeException exception) {
|
catch (RuntimeException exception) {
|
||||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "柜级接柜仓 ID 无效", exception);
|
throw new ResponseStatusException(HttpStatus.CONFLICT, "柜级接柜仓 ID 无效", exception);
|
||||||
}
|
}
|
||||||
List<PreparedItem> items = new ArrayList<>();
|
List<PreparedOrder> preparedOrders = new ArrayList<>();
|
||||||
int globalSequence = 0;
|
Map<String, String> prefixByFba = new LinkedHashMap<>();
|
||||||
for (ContainerInboundPlanPreviewResponse.Order order : preview.groups().stream()
|
Map<String, Integer> nextIndexByFba = new LinkedHashMap<>();
|
||||||
.flatMap(group -> group.orders().stream()).filter(item -> "READY".equals(item.readiness())).toList()) {
|
Set<String> usedPalletCodes = new HashSet<>();
|
||||||
long planId = IdWorker.getId();
|
List<ContainerInboundPlanPreviewResponse.Order> convertibleOrders = preview.groups().stream()
|
||||||
String planNo = numbers.next("INBOUND_PLAN", order.destinationWarehouseCode(), planId);
|
.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(
|
List<String> sourcePalletNos = plans.sourcePalletNos(
|
||||||
order.customerId(), Long.parseLong(order.orderId()), order.orderType());
|
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
|
boolean warehouseGeneratedPallets = trimToNull(order.fbaWarehouseCode()) == null
|
||||||
&& !"ONE_CLICK_DROPSHIP".equals(order.orderType());
|
&& !"ONE_CLICK_DROPSHIP".equals(order.orderType());
|
||||||
if (!warehouseGeneratedPallets && sourcePalletNos.size() != order.suggestedPalletCount()) {
|
if (!warehouseGeneratedPallets && !"ONE_CLICK_DROPSHIP".equals(order.orderType())) {
|
||||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "头程托盘码数量与柜级预览不一致");
|
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++) {
|
for (int localSequence = 1; localSequence <= preparedOrder.targetPalletCount(); localSequence++) {
|
||||||
items.add(new PreparedItem(order.customerId(), Long.parseLong(order.orderId()), planId, planNo,
|
items.add(new PreparedItem(order.customerId(), Long.parseLong(order.orderId()), preparedOrder.planId(),
|
||||||
|
preparedOrder.planNo(),
|
||||||
localSequence, ++globalSequence, "CIP:" + generationId + ":" + globalSequence,
|
localSequence, ++globalSequence, "CIP:" + generationId + ":" + globalSequence,
|
||||||
trimToNull(order.fbaWarehouseCode()), warehouseGeneratedPallets
|
trimToNull(order.fbaWarehouseCode()), warehouseGeneratedPallets
|
||||||
? null : sourcePalletNos.get(localSequence - 1)));
|
? null : sourcePalletNos.get(localSequence - 1)));
|
||||||
@@ -379,7 +438,26 @@ public class ContainerInboundPlanConversionCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public record Preparation(long warehouseId, List<PreparedItem> items) { }
|
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,
|
public record PreparedItem(String customerId, long orderId, long planId, String planNo,
|
||||||
int localSequenceNo, int globalSequenceNo, String businessKey,
|
int localSequenceNo, int globalSequenceNo, String businessKey,
|
||||||
String fbaWarehouseCode, String palletCode) { }
|
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) { }
|
||||||
}
|
}
|
||||||
|
|||||||
+102
-8
@@ -121,6 +121,14 @@ public class InboundPlanService {
|
|||||||
WarehouseResourceGateway.WarehouseReference receivingWarehouse =
|
WarehouseResourceGateway.WarehouseReference receivingWarehouse =
|
||||||
requiredReceivingWarehouse(containerWarehouseId);
|
requiredReceivingWarehouse(containerWarehouseId);
|
||||||
validateOrders(snapshots);
|
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,
|
InboundPlanResponse response = createPlan(customerId, request.orderId(), snapshots, List.of(), null,
|
||||||
request, receivingWarehouse);
|
request, receivingWarehouse);
|
||||||
LastlegOrderCommandRecord command = new LastlegOrderCommandRecord();
|
LastlegOrderCommandRecord command = new LastlegOrderCommandRecord();
|
||||||
@@ -186,9 +194,9 @@ public class InboundPlanService {
|
|||||||
ContainerInboundPlanPreviewResponse.Order first = items.get(0);
|
ContainerInboundPlanPreviewResponse.Order first = items.get(0);
|
||||||
return new ContainerInboundPlanPreviewResponse.Group(first.destinationType(),
|
return new ContainerInboundPlanPreviewResponse.Group(first.destinationType(),
|
||||||
first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(),
|
first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(),
|
||||||
items.size(), (int) items.stream().filter(item -> "READY".equals(item.readiness())).count(),
|
items.size(), (int) items.stream().filter(InboundPlanService::requiresContainerConversion).count(),
|
||||||
items.stream().filter(item -> "READY".equals(item.readiness()))
|
items.stream().filter(InboundPlanService::requiresContainerConversion)
|
||||||
.map(ContainerInboundPlanPreviewResponse.Order::suggestedPalletCount)
|
.map(InboundPlanService::pendingPalletCount)
|
||||||
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum(),
|
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum(),
|
||||||
items.stream().filter(item -> "PLANNED".equals(item.readiness()))
|
items.stream().filter(item -> "PLANNED".equals(item.readiness()))
|
||||||
.map(ContainerInboundPlanPreviewResponse.Order::plannedPalletCount)
|
.map(ContainerInboundPlanPreviewResponse.Order::plannedPalletCount)
|
||||||
@@ -199,9 +207,9 @@ public class InboundPlanService {
|
|||||||
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum(), List.copyOf(items));
|
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum(), List.copyOf(items));
|
||||||
}).toList();
|
}).toList();
|
||||||
int blocked = (int) orders.stream().filter(item -> "BLOCKED".equals(item.readiness())).count();
|
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 pendingOrders = (int) orders.stream().filter(InboundPlanService::requiresContainerConversion).count();
|
||||||
int pendingPallets = orders.stream().filter(item -> "READY".equals(item.readiness()))
|
int pendingPallets = orders.stream().filter(InboundPlanService::requiresContainerConversion)
|
||||||
.map(ContainerInboundPlanPreviewResponse.Order::suggestedPalletCount)
|
.map(InboundPlanService::pendingPalletCount)
|
||||||
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
|
.filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
|
||||||
int existingPallets = orders.stream().filter(item -> "PLANNED".equals(item.readiness()))
|
int existingPallets = orders.stream().filter(item -> "PLANNED".equals(item.readiness()))
|
||||||
.map(ContainerInboundPlanPreviewResponse.Order::plannedPalletCount)
|
.map(ContainerInboundPlanPreviewResponse.Order::plannedPalletCount)
|
||||||
@@ -246,7 +254,19 @@ public class InboundPlanService {
|
|||||||
String fbaWarehouseCode = isFbaDestination(row) ? trimToNull(row.fbaWarehouseCode()) : null;
|
String fbaWarehouseCode = isFbaDestination(row) ? trimToNull(row.fbaWarehouseCode()) : null;
|
||||||
String destinationCode = fbaWarehouseCode != null
|
String destinationCode = fbaWarehouseCode != null
|
||||||
? row.fbaWarehouseCode().trim() : row.destinationWarehouseCode();
|
? 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(),
|
return new ContainerInboundPlanPreviewResponse.Order(row.customerId(), row.customerName(),
|
||||||
String.valueOf(row.orderId()), trimToNull(row.orderNo()) == null ? row.sourceOrderNo() : row.orderNo(),
|
String.valueOf(row.orderId()), trimToNull(row.orderNo()) == null ? row.sourceOrderNo() : row.orderNo(),
|
||||||
row.inboundCode(), row.orderType(), destinationType, row.destinationWarehouseId(),
|
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) {
|
private static String destinationType(InboundPlanOrderMapper.ContainerPreviewOrderRow row) {
|
||||||
if ("ONE_CLICK_DROPSHIP".equals(row.orderType())) return "ONE_CLICK_DROPSHIP";
|
if ("ONE_CLICK_DROPSHIP".equals(row.orderType())) return "ONE_CLICK_DROPSHIP";
|
||||||
if (isFbaDestination(row)) return "FBA";
|
if (isFbaDestination(row)) return "FBA";
|
||||||
@@ -552,9 +592,22 @@ public class InboundPlanService {
|
|||||||
Map<Integer, WarehouseResourceGateway.PalletBatchItem> reserved = prepared == null ? Map.of()
|
Map<Integer, WarehouseResourceGateway.PalletBatchItem> reserved = prepared == null ? Map.of()
|
||||||
: prepared.pallets().stream().collect(Collectors.toMap(PreparedPallet::sequenceNo,
|
: prepared.pallets().stream().collect(Collectors.toMap(PreparedPallet::sequenceNo,
|
||||||
PreparedPallet::reservation));
|
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));
|
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());
|
List<InboundPlanResponse> result = new ArrayList<>(refs.size());
|
||||||
for (int index = 0; index < refs.size(); index++) {
|
for (int index = 0; index < refs.size(); index++) {
|
||||||
InboundPlan existing = existingPlans.get(index);
|
InboundPlan existing = existingPlans.get(index);
|
||||||
@@ -568,6 +621,47 @@ public class InboundPlanService {
|
|||||||
public record PreparedPallet(int sequenceNo, String businessKey,
|
public record PreparedPallet(int sequenceNo, String businessKey,
|
||||||
WarehouseResourceGateway.PalletBatchItem reservation) { }
|
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) {
|
private WholePalletInput wholePallet(DropshipPalletSnapshot pallet) {
|
||||||
return new WholePalletInput(pallet.sourcePalletNo(), Math.toIntExact(pallet.packageCount()));
|
return new WholePalletInput(pallet.sourcePalletNo(), Math.toIntExact(pallet.packageCount()));
|
||||||
}
|
}
|
||||||
|
|||||||
+66
@@ -290,6 +290,72 @@ class ContainerInboundPlanConversionCoordinatorTest {
|
|||||||
.map(ContainerInboundPlanConversionCoordinator.PreparedItem::palletCode).toList());
|
.map(ContainerInboundPlanConversionCoordinator.PreparedItem::palletCode).toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void standardFbaPreviewMaySupplementButNeverReduceHeadLegPalletCodes() {
|
||||||
|
ConvertContainerInboundPlanRequest request = request();
|
||||||
|
var order = new ContainerInboundPlanPreviewResponse.Order(
|
||||||
|
"100", "Customer 100", "11", "LM-11", "RK-FBA", "FIRST_LEG", "FBA",
|
||||||
|
null, "ACY2", "Amazon ACY2", "ACY2", 20, new BigDecimal("5.8000"), 3,
|
||||||
|
null, null, null, 0, "READY", List.of());
|
||||||
|
var preview = new ContainerInboundPlanPreviewResponse(BOOKING_ID, "MSCU6639870", "31", "US-LAX-01",
|
||||||
|
"Los Angeles", 7L, SNAPSHOT_HASH, 1, 1, 0, 1, 3, 0, 3, 0, true,
|
||||||
|
List.of(new ContainerInboundPlanPreviewResponse.Group("FBA", null, "ACY2", "Amazon ACY2",
|
||||||
|
1, 1, 3, 0, 3, 0, List.of(order))));
|
||||||
|
when(plans.previewContainer(BOOKING_ID)).thenReturn(preview);
|
||||||
|
when(numbers.next(eq("INBOUND_PLAN"), any(String.class), any(Long.class))).thenReturn("IP-ACY2");
|
||||||
|
when(plans.sourcePalletNos("100", 11L, "FIRST_LEG")).thenReturn(List.of(
|
||||||
|
"MSCU6639870-101-001", "MSCU6639870-101-002"));
|
||||||
|
|
||||||
|
ContainerPlanConversionCommand command = ReflectionTestUtils.invokeMethod(coordinator, "prepareCommand",
|
||||||
|
1L, 2L, BOOKING_ID, "FIRST-KEY", request,
|
||||||
|
ReflectionTestUtils.invokeMethod(coordinator, "hash",
|
||||||
|
List.of(BOOKING_ID, request.snapshotVersion(), request.snapshotHash())));
|
||||||
|
ContainerInboundPlanConversionCoordinator.Preparation preparation;
|
||||||
|
try {
|
||||||
|
preparation = objectMapper.readValue(command.getPreparationJson(),
|
||||||
|
ContainerInboundPlanConversionCoordinator.Preparation.class);
|
||||||
|
} catch (Exception exception) {
|
||||||
|
throw new AssertionError(exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(List.of("MSCU6639870-101-001", "MSCU6639870-101-002", "MSCU6639870-101-003"),
|
||||||
|
preparation.items().stream().map(ContainerInboundPlanConversionCoordinator.PreparedItem::palletCode).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void existingManualPlanIsPreparedForWarehouseMaterialization() {
|
||||||
|
ConvertContainerInboundPlanRequest request = request();
|
||||||
|
var order = new ContainerInboundPlanPreviewResponse.Order(
|
||||||
|
"100", "Customer 100", "11", "LM-11", "RK-FBA", "FIRST_LEG", "FBA",
|
||||||
|
null, "ACY2", "Amazon ACY2", "ACY2", 20, new BigDecimal("5.8000"), 3,
|
||||||
|
3, "IP-MANUAL", "PENDING_ASSIGNMENT", 1, "RESOURCE_PENDING", List.of());
|
||||||
|
var preview = new ContainerInboundPlanPreviewResponse(BOOKING_ID, "MSCU6639870", "31", "US-LAX-01",
|
||||||
|
"Los Angeles", 7L, SNAPSHOT_HASH, 1, 1, 0, 1, 3, 3, 3, 3, true,
|
||||||
|
List.of(new ContainerInboundPlanPreviewResponse.Group("FBA", null, "ACY2", "Amazon ACY2",
|
||||||
|
1, 1, 3, 3, 3, 3, List.of(order))));
|
||||||
|
when(plans.previewContainer(BOOKING_ID)).thenReturn(preview);
|
||||||
|
when(plans.sourcePalletNos("100", 11L, "FIRST_LEG")).thenReturn(List.of(
|
||||||
|
"MSCU6639870-101-001", "MSCU6639870-101-002"));
|
||||||
|
when(plans.existingPlanIdentity("100", 11L))
|
||||||
|
.thenReturn(new InboundPlanService.PlanIdentity(21L, "IP-MANUAL", 3));
|
||||||
|
|
||||||
|
ContainerPlanConversionCommand command = ReflectionTestUtils.invokeMethod(coordinator, "prepareCommand",
|
||||||
|
1L, 2L, BOOKING_ID, "FIRST-KEY", request,
|
||||||
|
ReflectionTestUtils.invokeMethod(coordinator, "hash",
|
||||||
|
List.of(BOOKING_ID, request.snapshotVersion(), request.snapshotHash())));
|
||||||
|
ContainerInboundPlanConversionCoordinator.Preparation preparation;
|
||||||
|
try {
|
||||||
|
preparation = objectMapper.readValue(command.getPreparationJson(),
|
||||||
|
ContainerInboundPlanConversionCoordinator.Preparation.class);
|
||||||
|
} catch (Exception exception) {
|
||||||
|
throw new AssertionError(exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(21L, preparation.items().get(0).planId());
|
||||||
|
assertEquals("IP-MANUAL", preparation.items().get(0).planNo());
|
||||||
|
verify(numbers, never()).next(any(String.class), any(String.class), any(Long.class));
|
||||||
|
}
|
||||||
|
|
||||||
private ContainerInboundPlanPreviewResponse.Order previewOrder(String customerId, String orderId,
|
private ContainerInboundPlanPreviewResponse.Order previewOrder(String customerId, String orderId,
|
||||||
String inboundCode, String destinationCode,
|
String inboundCode, String destinationCode,
|
||||||
String fbaWarehouseCode) {
|
String fbaWarehouseCode) {
|
||||||
|
|||||||
+2
-2
@@ -74,7 +74,7 @@ class ContainerInboundPlanPreviewTest {
|
|||||||
new InboundPlanOrderMapper.ContainerPreviewOrderRow("100", 1L, "LM-1", "FO-1", "测试客户",
|
new InboundPlanOrderMapper.ContainerPreviewOrderRow("100", 1L, "LM-1", "FO-1", "测试客户",
|
||||||
"FIRST_LEG", "OVERSEAS", "IN-1", "PENDING_ASSIGNMENT", null, null, 0, 0,
|
"FIRST_LEG", "OVERSEAS", "IN-1", "PENDING_ASSIGNMENT", null, null, 0, 0,
|
||||||
BigDecimal.ZERO, null, null, null, null, null, null, null, 0, false, 1,
|
BigDecimal.ZERO, null, null, null, null, null, null, null, 0, false, 1,
|
||||||
null, null, null)));
|
null, null, null, null)));
|
||||||
|
|
||||||
var result = service.previewContainer("BOOKING-NULL");
|
var result = service.previewContainer("BOOKING-NULL");
|
||||||
|
|
||||||
@@ -113,6 +113,6 @@ class ContainerInboundPlanPreviewTest {
|
|||||||
"FBA".equals(destinationType) || destinationCode == null ? null : "WH-" + id,
|
"FBA".equals(destinationType) || destinationCode == null ? null : "WH-" + id,
|
||||||
"FBA".equals(destinationType) ? null : destinationCode, destinationName,
|
"FBA".equals(destinationType) ? null : destinationCode, destinationName,
|
||||||
"FBA".equals(destinationType) || destinationCode == null ? 0 : 1, false, version, planNo,
|
"FBA".equals(destinationType) || destinationCode == null ? 0 : 1, false, version, planNo,
|
||||||
planNo == null ? null : "PENDING_ASSIGNMENT", plannedPallets);
|
planNo == null ? null : "PENDING_ASSIGNMENT", plannedPallets, plannedPallets);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+47
@@ -164,6 +164,53 @@ class InboundPlanServiceTest {
|
|||||||
verify(orderMapper).lockContainerWarehouseFacts("100", 2L);
|
verify(orderMapper).lockContainerWarehouseFacts("100", 2L);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void manualPlanMayExceedButCannotUndercutPreviewPalletCount() {
|
||||||
|
SourceOrderSnapshot source = new SourceOrderSnapshot(2L, "100", "MSCU6639870", "RK-2",
|
||||||
|
"PENDING_ASSIGNMENT", null, new BigDecimal("5.8000"), 58, 2,
|
||||||
|
"193000000000200003", "US-LAX-01", "洛杉矶仓", "ACY2",
|
||||||
|
null, null, "Amazon ACY2", 0, 0, false, 0);
|
||||||
|
when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(source));
|
||||||
|
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.convertManual(
|
||||||
|
new ManualInboundPlanRequest("100", 2L, 0, LocalDate.of(2026, 9, 15),
|
||||||
|
2, "WOOD", false, false, false, null), "manual-below-preview", "900"));
|
||||||
|
|
||||||
|
assertEquals(400, error.getStatusCode().value());
|
||||||
|
verify(planMapper, never()).insert(any(InboundPlan.class));
|
||||||
|
verify(commandMapper, never()).insert(any(com.baiyue.lastleg.entity.LastlegOrderCommandRecord.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void containerConversionBindsRealWarehousePalletsBackToExistingManualPlan() {
|
||||||
|
InboundPlan plan = existingPlan("100", 2L, "IP-MANUAL");
|
||||||
|
plan.setCreationMode("MANUAL");
|
||||||
|
plan.setPalletCount(2);
|
||||||
|
InboundPlanPallet first = new InboundPlanPallet();
|
||||||
|
first.setId(301L); first.setCustomerId("100"); first.setPlanId(plan.getId()); first.setSequenceNo(1);
|
||||||
|
InboundPlanPallet second = new InboundPlanPallet();
|
||||||
|
second.setId(302L); second.setCustomerId("100"); second.setPlanId(plan.getId()); second.setSequenceNo(2);
|
||||||
|
when(palletMapper.selectList(any())).thenReturn(List.of(first, second));
|
||||||
|
var firstReservation = new WarehouseResourceGateway.PalletBatchItem(100L, "CIP:1:1", "IP-MANUAL",
|
||||||
|
1, 401L, "MSCU6639870-101-001", 501L, 601L, "RESERVED");
|
||||||
|
var secondReservation = new WarehouseResourceGateway.PalletBatchItem(100L, "CIP:1:2", "IP-MANUAL",
|
||||||
|
2, 402L, "MSCU6639870-101-002", 502L, 602L, "RESERVED");
|
||||||
|
var prepared = new InboundPlanService.PreparedPlan(plan.getId(), plan.getPlanNo(), "100", 2L, List.of(
|
||||||
|
new InboundPlanService.PreparedPallet(1, "CIP:1:1", firstReservation),
|
||||||
|
new InboundPlanService.PreparedPallet(2, "CIP:1:2", secondReservation)));
|
||||||
|
|
||||||
|
ReflectionTestUtils.invokeMethod(service, "materializeExistingPlan", plan,
|
||||||
|
new InboundPlanOrderMapper.ContainerOrderRef("100", 2L, "INBOUND_PLANNED", "MSCU6639870", "FIRST_LEG"),
|
||||||
|
prepared);
|
||||||
|
|
||||||
|
assertEquals("MSCU6639870-101-001", first.getPalletNo());
|
||||||
|
assertEquals("MSCU6639870-101-001", first.getSourcePalletNo());
|
||||||
|
assertEquals(401L, first.getWarehousePalletId());
|
||||||
|
assertEquals("RESERVED", first.getAssignmentStatus());
|
||||||
|
assertEquals(402L, second.getWarehousePalletId());
|
||||||
|
verify(palletMapper, times(2)).updateById(any(InboundPlanPallet.class));
|
||||||
|
verify(planMapper).updateById(plan);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void manuallyCreatesPlanWhenStableWarehouseIdExistsWithoutDisplaySnapshots() {
|
void manuallyCreatesPlanWhenStableWarehouseIdExistsWithoutDisplaySnapshots() {
|
||||||
SourceOrderSnapshot source = new SourceOrderSnapshot(2L, "100", "MSCU6639870", "RK-2",
|
SourceOrderSnapshot source = new SourceOrderSnapshot(2L, "100", "MSCU6639870", "RK-2",
|
||||||
|
|||||||
+5
-2
@@ -38,6 +38,9 @@ import java.util.Set;
|
|||||||
@Service
|
@Service
|
||||||
public class WarehousePalletBatchService {
|
public class WarehousePalletBatchService {
|
||||||
private static final String BUSINESS_TYPE = "INBOUND_PLAN_AUTO";
|
private static final String BUSINESS_TYPE = "INBOUND_PLAN_AUTO";
|
||||||
|
private static final BigDecimal AUTO_PALLET_CAPACITY_CBM = new BigDecimal("2.0000");
|
||||||
|
private static final BigDecimal AUTO_PALLET_UNIT_PRICE_USD = new BigDecimal("5.00");
|
||||||
|
private static final String AUTO_PALLET_REMARK = "自动生成";
|
||||||
|
|
||||||
@Resource private WarehousePalletBatchMapper batches;
|
@Resource private WarehousePalletBatchMapper batches;
|
||||||
@Resource private WarehousePalletBatchItemMapper items;
|
@Resource private WarehousePalletBatchItemMapper items;
|
||||||
@@ -80,8 +83,8 @@ public class WarehousePalletBatchService {
|
|||||||
List<WarehousePalletBatchItemEntity> created = new ArrayList<>();
|
List<WarehousePalletBatchItemEntity> created = new ArrayList<>();
|
||||||
for (WarehousePalletBatchRequests.Item requested : normalized.items()) {
|
for (WarehousePalletBatchRequests.Item requested : normalized.items()) {
|
||||||
Long palletId = resources.createPallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE,
|
Long palletId = resources.createPallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE,
|
||||||
normalized.warehouseId(), new PalletRequests.SaveRequest(requested.palletCode(), requested.palletType(), null,
|
normalized.warehouseId(), new PalletRequests.SaveRequest(requested.palletCode(), "WOOD",
|
||||||
BigDecimal.ZERO, "IDLE", null, "Auto-created for inbound plan " + requested.inboundPlanNo(),
|
AUTO_PALLET_CAPACITY_CBM, AUTO_PALLET_UNIT_PRICE_USD, "IDLE", null, AUTO_PALLET_REMARK,
|
||||||
requested.fbaWarehouseCode()));
|
requested.fbaWarehouseCode()));
|
||||||
WarehousePalletEntity pallet = resources.pallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE,
|
WarehousePalletEntity pallet = resources.pallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE,
|
||||||
normalized.warehouseId(), palletId);
|
normalized.warehouseId(), palletId);
|
||||||
|
|||||||
+4
@@ -105,6 +105,10 @@ class WarehousePalletBatchServiceTest {
|
|||||||
verify(resources).createPallet(org.mockito.ArgumentMatchers.eq(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE),
|
verify(resources).createPallet(org.mockito.ArgumentMatchers.eq(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE),
|
||||||
org.mockito.ArgumentMatchers.eq(WAREHOUSE), saves.capture());
|
org.mockito.ArgumentMatchers.eq(WAREHOUSE), saves.capture());
|
||||||
assertThat(saves.getValue().palletCode()).isNull();
|
assertThat(saves.getValue().palletCode()).isNull();
|
||||||
|
assertThat(saves.getValue().palletType()).isEqualTo("WOOD");
|
||||||
|
assertThat(saves.getValue().capacityCbm()).isEqualByComparingTo("2.0000");
|
||||||
|
assertThat(saves.getValue().unitPrice()).isEqualByComparingTo("5.00");
|
||||||
|
assertThat(saves.getValue().remark()).isEqualTo("自动生成");
|
||||||
assertThat(response.items()).extracting(value -> value.palletCode()).containsExactly("PAL-301");
|
assertThat(response.items()).extracting(value -> value.palletCode()).containsExactly("PAL-301");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user