fix: enforce dropship pallet count for inbound plans

This commit is contained in:
2026-09-14 16:38:50 +08:00
parent 99f2c730e0
commit 26c37deb55
5 changed files with 82 additions and 5 deletions
@@ -75,6 +75,10 @@ public interface InboundPlanOrderMapper {
String findSourceContainerBookingId(@Param("customerId") String customerId,
@Param("orderId") Long orderId);
@Select("SELECT order_type FROM ll_order WHERE customer_id=#{customerId} AND id=#{orderId} LIMIT 1")
String findOrderType(@Param("customerId") String customerId,
@Param("orderId") Long orderId);
@Select("SELECT o.id AS orderId,o.customer_id AS customerId,o.container_no AS containerNo," +
"o.inbound_code AS inboundCode,o.current_status AS currentStatus,o.inbound_order_no AS inboundOrderNo," +
"COALESCE(o.volume_cbm,0) AS volumeCbm,COALESCE(o.package_count,0) AS packageCount," +
@@ -130,10 +130,23 @@ 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);
String orderType = trimToNull(orderMapper.findOrderType(customerId, request.orderId()));
int sourcePalletCount = "ONE_CLICK_DROPSHIP".equals(orderType)
? dropshipOrders.palletSnapshots(customerId, request.orderId()).size()
: Objects.requireNonNullElse(
sourcePalletMapper.palletNos(customerId, request.orderId()), List.<String>of()).size();
if ("ONE_CLICK_DROPSHIP".equals(orderType)) {
if (sourcePalletCount <= 0) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"一件代发订单缺少不可变来源托盘快照,不能生成入库计划");
}
if (request.palletCount() != sourcePalletCount) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"一件代发人工计划托数必须与头程封托托盘数一致:" + sourcePalletCount);
}
}
int minimumPalletCount = "ONE_CLICK_DROPSHIP".equals(orderType)
? sourcePalletCount : Math.max(suggestedPalletCount(order), sourcePalletCount);
if (request.palletCount() < minimumPalletCount) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"人工计划托数不能少于柜级预览建议托数 " + minimumPalletCount);
@@ -272,6 +285,10 @@ public class InboundPlanService {
int targetPalletCount = Objects.requireNonNullElse(row.plannedPalletCount(),
Objects.requireNonNullElse(suggested, 0));
int supplementPalletCount = Math.max(0, targetPalletCount - sourcePalletCount);
if (planned && "ONE_CLICK_DROPSHIP".equals(row.orderType())
&& targetPalletCount != sourcePalletCount) {
blockers.add("DROPSHIP_PALLET_COUNT_MISMATCH");
}
if (!planned && (suggested == null || suggested <= 0)) blockers.add("PALLET_COUNT_NOT_READY");
String destinationType = destinationType(row);
String fbaWarehouseCode = isFbaDestination(row) ? trimToNull(row.fbaWarehouseCode()) : null;
@@ -120,6 +120,25 @@ class ContainerInboundPlanPreviewTest {
assertEquals("RESOURCE_PENDING", result.groups().get(0).orders().get(0).readiness());
}
@Test
void blocksExistingDropshipPlanWhosePalletCountDiffersFromFrozenSourcePallets() {
when(orderMapper.containerPreviewOrders("BOOKING-DROPSHIP-MISMATCH")).thenReturn(List.of(
row(1L, "LM-DROPSHIP", "ONE_CLICK_DROPSHIP", "OVERSEAS", "US-LAX-01", "洛杉矶仓", 2,
"IP-DROPSHIP", 2, 1, "PENDING_ASSIGNMENT", 0)
));
when(dropshipOrders.palletSnapshots("100", 1L)).thenReturn(List.of(
new DropshipPalletSnapshot("DP-260914-0001", 10, List.of())));
var result = service.previewContainer("BOOKING-DROPSHIP-MISMATCH");
var order = result.groups().get(0).orders().get(0);
assertFalse(result.ready());
assertEquals("BLOCKED", order.readiness());
assertEquals(List.of("DROPSHIP_PALLET_COUNT_MISMATCH"), order.blockers());
assertEquals(1, order.sourcePalletCount());
assertEquals(2, order.targetPalletCount());
}
@Test
void exposesHeadLegExistingSupplementAndFinalPalletCountsWithoutBlocking() {
when(orderMapper.containerPreviewOrders("BOOKING-SUPPLEMENT")).thenReturn(List.of(
@@ -183,6 +183,43 @@ class InboundPlanServiceTest {
verify(commandMapper, never()).insert(any(com.baiyue.lastleg.entity.LastlegOrderCommandRecord.class));
}
@Test
void oneClickDropshipManualPlanMustKeepTheFrozenSourcePalletCount() {
SourceOrderSnapshot source = order(2L, "RK-2", "3.3650", "WH-A");
when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(source));
when(orderMapper.findOrderType("100", 2L)).thenReturn("ONE_CLICK_DROPSHIP");
when(dropshipOrders.palletSnapshots("100", 2L)).thenReturn(List.of(
new DropshipPalletSnapshot("DP-260914-0001", 10L, List.of())));
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.convertManual(
new ManualInboundPlanRequest("100", 2L, 0, LocalDate.of(2026, 9, 15),
2, "WOOD", false, false, false, null), "dropship-pallet-mismatch", "900"));
assertEquals(400, error.getStatusCode().value());
assertEquals("一件代发人工计划托数必须与头程封托托盘数一致:1", error.getReason());
verify(planMapper, never()).insert(any(InboundPlan.class));
verify(commandMapper, never()).insert(any(com.baiyue.lastleg.entity.LastlegOrderCommandRecord.class));
}
@Test
void oneClickDropshipUsesFrozenSourcePalletCountInsteadOfVolumeSuggestion() {
SourceOrderSnapshot source = order(2L, "RK-2", "3.3650", "WH-A");
when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(source));
when(orderMapper.findOrderType("100", 2L)).thenReturn("ONE_CLICK_DROPSHIP");
when(dropshipOrders.palletSnapshots("100", 2L)).thenReturn(List.of(
new DropshipPalletSnapshot("DP-260914-0001", 10L, List.of())));
when(orderMapper.markPlannedManual(eq("100"), eq(2L), any(), eq(1), eq(false), eq(0), any()))
.thenReturn(1);
var result = service.convertManual(new ManualInboundPlanRequest("100", 2L, 0,
LocalDate.of(2026, 9, 15), 1, "WOOD", false, false, false, null),
"dropship-exact-pallet-count", "900");
assertEquals(1, result.palletCount());
verify(planMapper).insert(any(InboundPlan.class));
verify(palletMapper).insert(any(InboundPlanPallet.class));
}
@Test
void containerConversionBindsRealWarehousePalletsBackToSignedManualPlanBeforeReceiving() {
InboundPlan plan = existingPlan("100", 2L, "IP-MANUAL");