From a01598bd21d770853d5e407f5f9ab66d744eac69 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 4 Nov 2026 13:35:23 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=81=E8=AE=B8=E7=A7=81=E4=BA=BA=E5=9C=B0?= =?UTF-8?q?=E5=9D=80=E6=8C=89=E7=8B=AC=E7=AB=8B=E6=89=98=E7=9B=98=E7=94=9F?= =?UTF-8?q?=E6=88=90=E6=B5=B7=E5=A4=96=E5=85=A5=E5=BA=93=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lastleg-service/docs/business-index.md | 2 +- .../mapper/InboundPlanOrderMapper.java | 4 +- .../service/InboundPlanService.java | 11 +++++- .../openapi/overseas-business-v1.yaml | 2 +- .../InboundPlanOrderMapperContractTest.java | 10 +++++ .../ContainerInboundPlanPreviewTest.java | 27 +++++++++++++ .../service/InboundPlanServiceTest.java | 38 +++++++++++++++++++ 7 files changed, 88 insertions(+), 6 deletions(-) diff --git a/lastleg-service/docs/business-index.md b/lastleg-service/docs/business-index.md index 0f2288008..04df2a5bc 100644 --- a/lastleg-service/docs/business-index.md +++ b/lastleg-service/docs/business-index.md @@ -32,7 +32,7 @@ | 独立尾程入仓单快照 | `entity/LastlegInboundOrder.java`、`mapper/LastlegInboundOrderMapper.java`;按客户与来源入仓标识冻结 inboundCode、来源/根订单及目的仓,不以尾程订单行代替入仓单聚合 | | 内部令牌保护 | `security/InternalServiceTokenGuard.java` | | 统一认证与撤销 | 复用 common-security 的Redis会话及Auth/IAM权威指纹校验,故障拒绝访问;Lastleg不保存独立授权投影 | -| 入仓计划分页、柜级预览、转换与详情 | 平台 `inboundplan/controller/InboundPlanController.java`,客户只读 `inboundplan/controller/MerchantInboundPlanController.java`;共用 `inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`ContainerInboundPlanPreviewResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java`;客户入口固定 `/api/merchant/v1/last-leg/inbound-plans`,范围只取 Token。`/container-preview` 按全柜返回 FBA/一件代发/私人地址分组、托数、就绪、阻断项及 `snapshotVersion/snapshotHash`;`ContainerInboundPlanConversionCoordinator.java` 用唯一柜级 Saga 锁柜重算,先经 `WarehouseResourceGateway` 批量预留真实托盘,再本地原子生成剩余独立计划并确认资源,本地失败只补偿未确认资源,远端确认失败的重放只重试确认,同柜不同幂等键也不得产生第二批资源 | +| 入仓计划分页、柜级预览、转换与详情 | 平台 `inboundplan/controller/InboundPlanController.java`,客户只读 `inboundplan/controller/MerchantInboundPlanController.java`;共用 `inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`ContainerInboundPlanPreviewResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java`;客户入口固定 `/api/merchant/v1/last-leg/inbound-plans`,范围只取 Token。`/container-preview` 按全柜返回 FBA/一件代发/私人地址分组、托数、就绪、阻断项及 `snapshotVersion/snapshotHash`;私人地址可无目的仓 ID/编码,票内地址名称须非空且一致,托盘分配按来源订单独立归属,接柜仓仍须有效绑定;`ContainerInboundPlanConversionCoordinator.java` 用唯一柜级 Saga 锁柜重算,先经 `WarehouseResourceGateway` 批量预留真实托盘,再本地原子生成剩余独立计划并确认资源,本地失败只补偿未确认资源,远端确认失败的重放只重试确认,同柜不同幂等键也不得产生第二批资源 | | 托盘分配算法 | `inboundplan/service/PalletAllocator.java` | | FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` | | PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除。多计划同柜返回计划级 `assigned/mergeAllowed/urgent/mergeHint`;普通 FBA/OVERSEAS 分别使用 bulk/carton 收货,一件代发只允许 `/current/receipts/pallet` 按国内冻结来源托盘整托收货。普通 OVERSEAS 允许在本票已预留托盘间按现场实际分配箱数,逐托建议箱数不作为收货上限,仍校验本票总箱数、单托66箱、目的仓及客户归属;库存按已扫箱码的SKU和真实托盘归属入账。合托只作现场可选提示;全部计划完整绑定真实 pallet/location/lock 前不得查看、准备或签收该柜,托盘校验和提交均只确认本票已预留绑定。Admin 入库单详情可按行打印已预留真实托盘标签,复用海外托盘列表权限和标签PDF接口,不生成第二个托盘码 | 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 f714bed2a..d1882fa11 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 @@ -44,7 +44,7 @@ public interface InboundPlanOrderMapper { "COALESCE(NULLIF(o.transfer_warehouse_code,''),(SELECT MIN(NULLIF(d.destination_warehouse_code,'')) FROM ll_order_detail d WHERE d.order_id=o.id)) AS destinationWarehouseCode," + "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," + + "(SELECT COUNT(DISTINCT COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),NULLIF(TRIM(d.destination_warehouse_name),''),'#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," + "(SELECT COUNT(*) FROM ll_inbound_plan_pallet pp WHERE pp.customer_id=p.customer_id " + @@ -93,7 +93,7 @@ public interface InboundPlanOrderMapper { "COALESCE(NULLIF(o.transfer_warehouse_code,''),MIN(NULLIF(d.destination_warehouse_code,''))) AS destinationWarehouseCode," + "COALESCE(NULLIF(o.transfer_warehouse_name,''),MIN(NULLIF(d.destination_warehouse_name,''))) AS destinationWarehouseName," + "CASE WHEN COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) IS NOT NULL THEN 1 " + - "ELSE COUNT(DISTINCT COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#')) END AS destinationCount," + + "ELSE COUNT(DISTINCT COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),NULLIF(TRIM(d.destination_warehouse_name),''),'#EMPTY#')) END AS destinationCount," + "0 AS destinationMismatchCount,o.detained AS detained,o.version AS version " + "FROM ll_order o LEFT JOIN ll_order_detail d ON d.order_id=o.id " + "WHERE o.customer_id=#{customerId} AND o.id=#{orderId} " + 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 ec602dff8..830b925f6 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 @@ -291,7 +291,8 @@ public class InboundPlanService { boolean fba = isFbaDestination(row); if (fba ? trimToNull(row.fbaWarehouseCode()) == null : row.destinationCount() != 1 || trimToNull(row.destinationWarehouseId()) == null - && trimToNull(row.destinationWarehouseCode()) == null) blockers.add("DESTINATION_NOT_READY"); + && trimToNull(row.destinationWarehouseCode()) == null + && trimToNull(row.destinationWarehouseName()) == null) blockers.add("DESTINATION_NOT_READY"); if (row.packageCount() < 0 || row.volumeCbm() == null || row.volumeCbm().signum() < 0 || row.packageCount() > 0 && row.volumeCbm().signum() == 0) blockers.add("CARGO_FACT_INVALID"); } @@ -1181,7 +1182,13 @@ public class InboundPlanService { private String warehouseKey(SourceOrderSnapshot order) { String id = trimToNull(order.destinationWarehouseId()); String code = trimToNull(order.destinationWarehouseCode()); - return id != null ? "ID:" + id : code == null ? null : "CODE:" + code; + if (id != null) return "ID:" + id; + if (code != null) return "CODE:" + code; + if (trimToNull(order.fbaWarehouseCode()) == null + && trimToNull(order.destinationWarehouseName()) != null) { + return "PRIVATE:" + order.orderId(); + } + return null; } private static String trimToNull(String value) { diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index b07c621c1..6041ea8de 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -773,7 +773,7 @@ paths: /api/platform/v1/last-leg/inbound-plans/container-preview: get: summary: 预览按柜生成入库计划 - description: 权限 platform:overseas-warehouse:inbound-order:list;按稳定 sourceContainerBookingId 返回不受订单分页影响的全柜事实。分组固定为 FBA、一件代发、私人地址,FBA 按目的仓编码/名称/ID 稳定排序;托数及 readiness/blockers 只由服务端计算。确认时必须原样回传 snapshotVersion 与 snapshotHash,服务端仍会锁柜重算。 + description: 权限 platform:overseas-warehouse:inbound-order:list;按稳定 sourceContainerBookingId 返回不受订单分页影响的全柜事实。分组固定为 FBA、一件代发、私人地址,FBA 按目的仓编码/名称/ID 稳定排序;私人地址允许目的仓 ID 和编码为空,仍须具备票内一致的非空地址名称和有效接柜仓绑定。托数及 readiness/blockers 只由服务端计算。确认时必须原样回传 snapshotVersion 与 snapshotHash,服务端仍会锁柜重算。 security: [{ bearerAuth: [] }] parameters: - { name: sourceContainerBookingId, in: query, required: true, schema: { type: string, minLength: 1, maxLength: 128 } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapperContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapperContractTest.java index 1d3bf75e9..89d03483b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapperContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapperContractTest.java @@ -48,6 +48,15 @@ class InboundPlanOrderMapperContractTest { row = jdbc.queryForMap(sql, "C1", 1L); assertEquals("ABE3", row.get("fbaWarehouseCode")); assertEquals(row.get("destinationWarehouseCode"), row.get("fbaWarehouseCode")); + + jdbc.update("INSERT INTO ll_order (id,customer_id) VALUES (2,'C1')"); + jdbc.update("INSERT INTO ll_order_detail VALUES (2,NULL,NULL,'私人地址 A')"); + row = jdbc.queryForMap(sql, "C1", 2L); + assertEquals(1L, ((Number) row.get("destinationCount")).longValue()); + assertEquals("私人地址 A", row.get("destinationWarehouseName")); + jdbc.update("INSERT INTO ll_order_detail VALUES (2,NULL,NULL,'私人地址 B')"); + row = jdbc.queryForMap(sql, "C1", 2L); + assertEquals(2L, ((Number) row.get("destinationCount")).longValue()); } @Test @@ -57,6 +66,7 @@ class InboundPlanOrderMapperContractTest { String sql = String.join(" ", method.getAnnotation(Select.class).value()); assertTrue(sql.contains("COALESCE(NULLIF(o.transfer_warehouse_code,''),NULLIF(o.fba_warehouse_code,'')) AS fbaWarehouseCode"), method.getName()); + assertTrue(sql.contains("NULLIF(TRIM(d.destination_warehouse_name),'')"), method.getName()); } } 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 49d5f1540..96246ce4c 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 @@ -70,6 +70,33 @@ class ContainerInboundPlanPreviewTest { assertEquals(List.of("DESTINATION_NOT_READY"), result.groups().get(4).orders().get(0).blockers()); } + @Test + void privateAddressReadinessRequiresOneNamedDestinationWithoutMasterWarehouseIdentity() { + when(orderMapper.containerPreviewOrders("BOOKING-PRIVATE")).thenReturn(List.of( + privateAddressRow(1L, "私人地址 A", 1), + privateAddressRow(2L, " ", 1), + privateAddressRow(3L, "私人地址 B", 2))); + + var result = service.previewContainer("BOOKING-PRIVATE"); + var orders = result.groups().stream().flatMap(group -> group.orders().stream()).toList(); + var ready = orders.stream().filter(order -> order.orderId().equals("1")).findFirst().orElseThrow(); + assertEquals("READY", ready.readiness()); + assertEquals("PRIVATE_ADDRESS", ready.destinationType()); + assertEquals(List.of(), ready.blockers()); + assertEquals(1, ready.targetPalletCount()); + assertEquals(2, result.blockedOrderCount()); + orders.stream().filter(order -> !order.orderId().equals("1")).forEach(order -> + assertEquals(List.of("DESTINATION_NOT_READY"), order.blockers())); + } + + private InboundPlanOrderMapper.ContainerPreviewOrderRow privateAddressRow(long id, String name, int destinations) { + return new InboundPlanOrderMapper.ContainerPreviewOrderRow("100", id, "LM-" + id, "FO-" + id, + "测试客户", "FIRST_LEG", "OVERSEAS", "IN-" + id, "PENDING_ASSIGNMENT", null, + "MSCU6639870", 1, 4, BigDecimal.ONE, "193000000000200003", "US-LAX-01", + "洛杉矶仓", null, null, null, name, destinations, false, 1, + null, null, null, null, 0); + } + @Test void hashesBlockedPreviewEvenWhenContainerAndWarehouseFactsAreNull() { when(orderMapper.containerPreviewOrders("BOOKING-NULL")).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 c58703e0c..b48683b74 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 @@ -37,6 +37,7 @@ import java.time.LocalDate; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; @@ -101,6 +102,43 @@ class InboundPlanServiceTest { .thenReturn(java.time.LocalDate.of(2026, 9, 15)); } + @Test + void convertsPrivateAddressWithoutCreatingADestinationWarehouseIdentity() { + SourceOrderSnapshot source = new SourceOrderSnapshot(2L, "100", "MSCU6639870", "RK-PRIVATE", + "PENDING_ASSIGNMENT", null, BigDecimal.ONE, 4, 1, + "193000000000200003", "US-LAX-01", "洛杉矶仓", null, null, null, "私人地址 A", 1, 0); + when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(source)); + when(orderMapper.markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any())) + .thenReturn(1); + + var result = service.convert("100", 2L); + + assertEquals(1, result.palletCount()); + ArgumentCaptor line = ArgumentCaptor.forClass(InboundPlanLine.class); + verify(lineMapper).insert(line.capture()); + assertNull(line.getValue().getDestinationWarehouseId()); + assertNull(line.getValue().getDestinationWarehouseCode()); + assertEquals("私人地址 A", line.getValue().getDestinationWarehouseName()); + assertEquals(4, line.getValue().getRequiredPackageCount()); + ArgumentCaptor pallet = ArgumentCaptor.forClass(InboundPlanPallet.class); + verify(palletMapper).insert(pallet.capture()); + assertEquals("私人地址 A", pallet.getValue().getDestinationWarehouseName()); + } + + @Test + void rejectsPrivateAddressWithoutANamedDestination() { + SourceOrderSnapshot source = new SourceOrderSnapshot(2L, "100", "MSCU6639870", "RK-PRIVATE", + "PENDING_ASSIGNMENT", null, BigDecimal.ONE, 4, 1, + "193000000000200003", "US-LAX-01", "洛杉矶仓", null, null, null, " ", 1, 0); + when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(source)); + + ResponseStatusException failure = assertThrows(ResponseStatusException.class, + () -> service.convert("100", 2L)); + + assertEquals(400, failure.getStatusCode().value()); + verify(planMapper, never()).insert(any(InboundPlan.class)); + } + @Test void convertsOnlyTheSelectedInboundOrderToItsOwnPlan() throws Exception { assertNotNull(InboundPlanService.class.getMethod("convert", String.class, Long.class)