From 26c37deb55f1873509130bc90faf373d5eaa327d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 14 Sep 2026 16:38:50 +0800 Subject: [PATCH] fix: enforce dropship pallet count for inbound plans --- lastleg-service/AGENTS.md | 2 +- .../mapper/InboundPlanOrderMapper.java | 4 ++ .../service/InboundPlanService.java | 25 +++++++++++-- .../ContainerInboundPlanPreviewTest.java | 19 ++++++++++ .../service/InboundPlanServiceTest.java | 37 +++++++++++++++++++ 5 files changed, 82 insertions(+), 5 deletions(-) diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index e215e0bd1..806724be0 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -27,7 +27,7 @@ |---|---| | 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md`、`scripts/analyze-lastleg-dependencies.mjs`、`scripts/analyze-lastleg-dependencies.test.mjs`、`src/test/resources/architecture/lastleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 | | 事件接收、平台订单列表/详情/导出、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码、有效计划及托盘资源异常并分页返回全部稳定来源柜,已完成计划转换的柜仍保留用于接柜仓查询与修改;单票手工计划保存后,整柜最后一票齐全时自动生成并绑定全柜托盘码,一票柜立即生成,`resourcePendingPlanCount` 只作为失败重试事实;`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 | -| 尾程订单完整计划、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`,已拣货、已发运或已有出库单时禁止新增暂扣;暂扣期间不得进入排车或出库。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 | +| 尾程订单完整计划、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;一件代发人工计划托数必须严格等于不可变头程封托托盘数,禁止保存可增减托数但无法绑定真实资源的半成品计划;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`,已拣货、已发运或已有出库单时禁止新增暂扣;暂扣期间不得进入排车或出库。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 | | 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` | | 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V2,消费端冻结 `destinationType`:FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;历史缺字段事件仅按明确 FBA 单号或目的仓与接柜仓一致性安全推断,歧义直接拒绝 | | 尾程单号生成 | `service/LastlegOrderNoGenerator.java` | diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java index 35c6d97ed..5b4536665 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java @@ -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," + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java index 930b74881..c913ef7fa 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -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.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.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; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanPreviewTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanPreviewTest.java index 409c5f89b..a0e37a1fa 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanPreviewTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanPreviewTest.java @@ -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( diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java index 523c4e2d0..89c063e8a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java @@ -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");