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()));
}
@@ -290,6 +290,72 @@ class ContainerInboundPlanConversionCoordinatorTest {
.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,
String inboundCode, String destinationCode,
String fbaWarehouseCode) {
@@ -74,7 +74,7 @@ class ContainerInboundPlanPreviewTest {
new InboundPlanOrderMapper.ContainerPreviewOrderRow("100", 1L, "LM-1", "FO-1", "测试客户",
"FIRST_LEG", "OVERSEAS", "IN-1", "PENDING_ASSIGNMENT", null, null, 0, 0,
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");
@@ -113,6 +113,6 @@ class ContainerInboundPlanPreviewTest {
"FBA".equals(destinationType) || destinationCode == null ? null : "WH-" + id,
"FBA".equals(destinationType) ? null : destinationCode, destinationName,
"FBA".equals(destinationType) || destinationCode == null ? 0 : 1, false, version, planNo,
planNo == null ? null : "PENDING_ASSIGNMENT", plannedPallets);
planNo == null ? null : "PENDING_ASSIGNMENT", plannedPallets, plannedPallets);
}
}
@@ -164,6 +164,53 @@ class InboundPlanServiceTest {
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
void manuallyCreatesPlanWhenStableWarehouseIdExistsWithoutDisplaySnapshots() {
SourceOrderSnapshot source = new SourceOrderSnapshot(2L, "100", "MSCU6639870", "RK-2",
@@ -38,6 +38,9 @@ import java.util.Set;
@Service
public class WarehousePalletBatchService {
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 WarehousePalletBatchItemMapper items;
@@ -80,8 +83,8 @@ public class WarehousePalletBatchService {
List<WarehousePalletBatchItemEntity> created = new ArrayList<>();
for (WarehousePalletBatchRequests.Item requested : normalized.items()) {
Long palletId = resources.createPallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE,
normalized.warehouseId(), new PalletRequests.SaveRequest(requested.palletCode(), requested.palletType(), null,
BigDecimal.ZERO, "IDLE", null, "Auto-created for inbound plan " + requested.inboundPlanNo(),
normalized.warehouseId(), new PalletRequests.SaveRequest(requested.palletCode(), "WOOD",
AUTO_PALLET_CAPACITY_CBM, AUTO_PALLET_UNIT_PRICE_USD, "IDLE", null, AUTO_PALLET_REMARK,
requested.fbaWarehouseCode()));
WarehousePalletEntity pallet = resources.pallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE,
normalized.warehouseId(), palletId);
@@ -105,6 +105,10 @@ class WarehousePalletBatchServiceTest {
verify(resources).createPallet(org.mockito.ArgumentMatchers.eq(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE),
org.mockito.ArgumentMatchers.eq(WAREHOUSE), saves.capture());
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");
}