diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 90b6f8879..4a7c20eff 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -76,7 +76,7 @@ - 海外仓全流程连续覆盖尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、转运、废弃、采购、本土卡车、本土散板、排车查询/智能预排、排车编辑/取消/发车、PDA 拣货与出库、一件代发、转派送、客户派送单、异常退回和客户回访。 - 服务端正式入口包括 `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`delivery/controller/OutboundPdaController.java`、`delivery/controller/MerchantDeliveryController.java`、`exceptionorder/` 与 `customervisit/`;设备页面是否可用以海外 PDA 正式路由和能力开关为准,未由 PDA 承载的操作由 Admin/Client 完成。 -- 平台一件代发出库候选由 `/outbound-orders/one-click-dropship/candidates` 统一返回已完成入库且仍有可用箱唛、SKU 汇总和库存仓位绑定的托盘明细;查询不强制先传 `customerId`,未传时跨客户展示供页面分页筛选,创建时必须从所选候选解析为唯一客户和唯一海外仓,禁止一张出库单跨客户或跨仓。 +- 平台一件代发出库候选由 `/outbound-orders/one-click-dropship/candidates` 统一返回已完成入库且仍有可用箱唛、SKU 汇总和库存仓位绑定的托盘明细;查询不强制先传 `customerId`,未传时跨客户展示供页面分页筛选,创建时必须从所选候选解析为唯一客户和唯一海外仓,禁止一张出库单跨客户或跨仓。创建后出库单按 `WAIT_PICK → PICKING → PICKED → OUTBOUNDED` 推进;海外 PDA 扫描托盘码时通过整托命令原子拣取该托盘在当前任务内全部剩余箱件和 SKU,任一库存明细失败须整托回滚,逐箱拣货入口继续保留。 - 排车转派送允许在 `PENDING_DEPARTURE`、`PICKING`、`READY_TO_DEPART` 阶段幂等生成一张 `WAIT_OUTBOUND` 出库单,并按目的FBA仓生成多张派送单,但必须早于发车;BOL和签收资料均绑定拆分后的单仓派送单,派送费用仍只发布一次。PDA 排车详情由 `DispatchPdaWorkflowService` 返回 `canDepart`、`departBlockReason`,只有车辆信息完整、拣货任务完成且箱数核平、排车为 `READY_TO_DEPART`、关联出库单仍为 `WAIT_OUTBOUND` 时才允许发车,PDA 不得自行推导该门禁。 - 排车执行与 `delivery/` Controller 和其他尾程业务接口一同注册,不受功能开关控制。平台排车列表与详情仍由 `lastleg.dispatch-query-api.enabled=true` 独立启用;入库单智能排车候选、草稿锁定/解锁/释放和预排创建仍由 `lastleg.smart-dispatch-api.enabled=true` 独立启用。这些排车平台入口均不接收 `customerId`:排车列表跨客户分页,详情按全局排车单号定位后使用记录自身客户范围加载明细;智能排车候选未传计划 ID 时跨客户查询,传入时根据计划 ID 解析范围;托盘移入草稿时立即以操作人和随机草稿标识锁定,移除或关闭时释放,草稿锁超时失效;预排创建根据所选托盘 ID 在服务端解析并校验唯一客户范围,并将当前操作人的草稿锁原子转换为正式排车锁。 - 仓库 ID、类型、状态、地址和能力唯一权威归国内 `warehouse-service`;海外库位、实物托盘和资源锁归 `warehouse-overseas-service/` 且只引用同一国内 `warehouseId`。Lastleg PDA 通过 Warehouse Overseas 内部资源入口校验该国内共享收货仓,不得要求海外数据库重复建仓;浏览器 API 统一经 `gateway-overseas/`。 diff --git a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml index bf8f99a8c..bb904ef34 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: Lastleg Overseas PDA Warehouse Operations API - version: 1.0.0 + version: 1.1.0 description: 海外仓PDA排车拣货发车、一件代发出库、盘点、同仓整托移库、托盘与换标作业;全部范围从PDA Token解析,派送不进入PDA。 security: - bearerAuth: [] diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java index 87d248ed6..e7e1e64ef 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java @@ -735,6 +735,69 @@ class DeliveryTransferServiceTest { assertEquals("SKU-1", detail.items().get(0).positions().get(0).skuCode()); } + @Test + void dropshipPalletScanAtomicallyPicksEveryRemainingCartonOnThePallet() { + var commands = org.mockito.Mockito.mock(com.baiyue.lastleg.inventory.command.service.InventoryCommandService.class); + ReflectionTestUtils.setField(service, "inventoryCommands", commands); + OutboundOrder outbound = outbound(301L, null, null, "OD-WH-1", "whole-pallet"); + outbound.setOverseasWarehouseId("WH-1"); outbound.setOutboundType("ONE_CLICK_DROPSHIP"); outbound.setStatus("WAIT_PICK"); + OutboundPdaTask task = new OutboundPdaTask(); task.setId(701L); task.setCustomerId("TENANT-A"); + task.setOutboundOrderId(301L); task.setTaskNo("PDA-701"); task.setStatus("PENDING"); + OutboundPalletItem item = pallet(501L, "DP-1", "ONT8", "Ontario", "IN-A", 2); + item.setSkuCode("SKU-1"); item.setCartonMarks("BOX-A,BOX-B"); item.setStatus("WAIT_PICK"); item.setPickedPackageCount(0); + OutboundPalletItem secondItem = pallet(501L, "DP-1", "ONT8", "Ontario", "IN-A", 1); + secondItem.setId(1502L); secondItem.setSkuCode("SKU-2"); secondItem.setCartonMarks("BOX-C"); + secondItem.setStatus("WAIT_PICK"); secondItem.setPickedPackageCount(0); + InventoryPosition position = new InventoryPosition(); position.setId(901L); position.setCustomerId("TENANT-A"); + position.setBalanceId(801L); position.setPalletId(501L); position.setPalletNo("DP-1"); + position.setOverseasWarehouseId("WH-1"); position.setAvailablePackages(2L); + InventoryPosition secondPosition = new InventoryPosition(); secondPosition.setId(902L); secondPosition.setCustomerId("TENANT-A"); + secondPosition.setBalanceId(802L); secondPosition.setPalletId(501L); secondPosition.setPalletNo("DP-1"); + secondPosition.setOverseasWarehouseId("WH-1"); secondPosition.setAvailablePackages(1L); + InventoryBalance balance = new InventoryBalance(); balance.setCustomerId("TENANT-A"); balance.setInboundCode("IN-A"); balance.setSkuCode("SKU-1"); + InventoryBalance secondBalance = new InventoryBalance(); secondBalance.setCustomerId("TENANT-A"); secondBalance.setInboundCode("IN-A"); secondBalance.setSkuCode("SKU-2"); + DropshipOrderProfile profile = new DropshipOrderProfile(); profile.setId(1001L); + DropshipOrderProfile secondProfile = new DropshipOrderProfile(); secondProfile.setId(1002L); + DropshipCarton first = new DropshipCarton(); first.setId(1101L); first.setProductQuantity(7L); + DropshipCarton second = new DropshipCarton(); second.setId(1102L); second.setProductQuantity(13L); + first.setStatus("AVAILABLE"); first.setSkuCode("SKU-1"); first.setCartonMark("BOX-A"); + second.setStatus("AVAILABLE"); second.setSkuCode("SKU-1"); second.setCartonMark("BOX-B"); + DropshipCarton third = new DropshipCarton(); third.setId(1103L); third.setProductQuantity(5L); + third.setStatus("AVAILABLE"); third.setSkuCode("SKU-2"); third.setCartonMark("BOX-C"); + when(pdaTasks.lockByNoAndWarehouse("PDA-701", "WH-1")).thenReturn(task); + when(outboundOrders.selectById(301L)).thenReturn(outbound); + when(outboundOrders.lockByNo("TENANT-A", "OD-WH-1")).thenReturn(outbound); + when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item, secondItem)); + when(outboundPallets.selectById(item.getId())).thenReturn(item); + when(outboundPallets.selectById(secondItem.getId())).thenReturn(secondItem); + when(inventoryPositions.listPallet("TENANT-A", 501L)).thenReturn(List.of(position, secondPosition)); + when(inventoryPositions.selectById(901L)).thenReturn(position); + when(inventoryPositions.selectById(902L)).thenReturn(secondPosition); + when(inventoryBalances.selectById(801L)).thenReturn(balance); + when(inventoryBalances.selectById(802L)).thenReturn(secondBalance); + when(dropshipProfiles.findPosition("TENANT-A", 901L)).thenReturn(profile); + when(dropshipProfiles.findPosition("TENANT-A", 902L)).thenReturn(secondProfile); + when(dropshipCartons.listProfile("TENANT-A", 1001L)).thenReturn(List.of(first, second)); + when(dropshipCartons.listProfile("TENANT-A", 1002L)).thenReturn(List.of(third)); + when(dropshipCartons.findByProfileAndMark("TENANT-A", 1001L, "BOX-A")).thenReturn(first); + when(dropshipCartons.findByProfileAndMark("TENANT-A", 1001L, "BOX-B")).thenReturn(second); + when(dropshipCartons.findByProfileAndMark("TENANT-A", 1002L, "BOX-C")).thenReturn(third); + when(dropshipSkuInventory.deduct("TENANT-A", 1001L, "SKU-1", 2L, 20L)).thenReturn(1); + when(dropshipSkuInventory.deduct("TENANT-A", 1002L, "SKU-2", 1L, 5L)).thenReturn(1); + + DeliveryResponses.PdaTask result = service.pdaPickPallet("WH-1", "U-1", "PDA-701", "DP-1", + new DeliveryRequests.PdaPalletPick("whole-pallet-1")); + + assertEquals("IN_PROGRESS", result.status()); + assertEquals(2, item.getPickedPackageCount()); assertEquals("PICKED", item.getStatus()); + assertEquals(1, secondItem.getPickedPackageCount()); assertEquals("PICKED", secondItem.getStatus()); + assertEquals("SHIPPED", first.getStatus()); assertEquals("SHIPPED", second.getStatus()); + assertEquals("SHIPPED", third.getStatus()); + assertEquals("PICKED", outbound.getStatus()); + verify(dropshipSkuInventory).deduct("TENANT-A", 1001L, "SKU-1", 2L, 20L); + verify(dropshipSkuInventory).deduct("TENANT-A", 1002L, "SKU-2", 1L, 5L); + } + @Test void dropshipPartialPicksDeductExactCartonProductQuantities() { var commands = org.mockito.Mockito.mock(com.baiyue.lastleg.inventory.command.service.InventoryCommandService.class); @@ -769,6 +832,7 @@ class DeliveryTransferServiceTest { new DeliveryRequests.PdaPick(item.getId(), 901L, 1L, "wrong-mark", List.of("WRONG-BOX")))); service.pdaPick("WH-1", "U-1", "PDA-701", new DeliveryRequests.PdaPick(item.getId(), 901L, 1L, "pick-7", List.of("BOX-A"))); assertEquals(1, item.getPickedPackageCount()); assertEquals("AVAILABLE", second.getStatus()); + assertEquals("PICKING", outbound.getStatus()); assertThrows(ResponseStatusException.class, () -> service.pdaPick("WH-1", "U-1", "PDA-701", new DeliveryRequests.PdaPick(item.getId(), 901L, 1L, "repeat-box-new-key", List.of("BOX-A")))); service.pdaPick("WH-1", "U-1", "PDA-701", new DeliveryRequests.PdaPick(item.getId(), 901L, 1L, "pick-13", List.of("BOX-B")));