From 847df6a85f479e7bf530aaf5f296ae655aac7f81 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 11:51:04 +0800 Subject: [PATCH] fix(lastleg): keep completed containers editable --- .../lastleg/mapper/LastlegOrderMapper.java | 5 +--- .../openapi/overseas-business-v1.yaml | 12 ++++---- .../LastlegOrderControllerTest.java | 19 ++++++++++++ .../ContainerPlanProgressContractTest.java | 16 ++++++---- .../service/LastlegOrderServiceTest.java | 29 +++++++++++++++++-- 5 files changed, 63 insertions(+), 18 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java index 1c629aedb..b44ff99e3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java @@ -20,10 +20,7 @@ public interface LastlegOrderMapper extends BaseMapper { "AND o.container_no LIKE CONCAT('%',#{q.containerNo},'%') " + "AND o.created_at >=#{q.createdFrom} " + "AND o.created_at <=#{q.createdTo} " + - "GROUP BY o.source_container_booking_id " + - "HAVING COUNT(DISTINCT CASE WHEN p.id IS NOT NULL AND NULLIF(TRIM(p.plan_no),'') IS NOT NULL " + - "AND NULLIF(TRIM(p.status),'') IS NOT NULL THEN NULLIF(TRIM(o.inbound_code),'') END) " + - "< COUNT(DISTINCT NULLIF(TRIM(o.inbound_code),'')) "; + "GROUP BY o.source_container_booking_id "; String CONTAINER_PROGRESS_COLUMNS = "SELECT o.source_container_booking_id AS sourceContainerBookingId," + "CASE WHEN COUNT(NULLIF(o.container_no,''))=COUNT(*) AND COUNT(DISTINCT NULLIF(o.container_no,''))=1 " + 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 d6ef2bc94..f51b51f67 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -149,7 +149,7 @@ paths: /api/platform/v1/last-leg/orders: get: summary: 分页查询尾程订单 - description: 权限 platform:overseas-warehouse:order:list;平台管理员省略 customerId 时查询全部客户。 + description: 权限 platform:overseas-warehouse:last-mile-order:list;平台管理员省略 customerId 时查询全部客户。 security: [{ bearerAuth: [] }] parameters: - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } @@ -274,7 +274,7 @@ paths: /api/platform/v1/last-leg/orders/container-summaries: get: summary: 分页查询柜级入库计划进度 - description: 权限 platform:overseas-warehouse:order:list;平台跨客户按稳定 sourceContainerBookingId 服务端聚合,不受订单分页影响,仅返回仍有入仓码待生成有效独立计划的柜,全部入仓码均已生成有效独立计划的柜在分页前排除。containerNo 只返回装柜完成事件携带并规范化冻结的真实集装箱号,不从 cabinetNo、订单号或内部 ID 推导。plannedArrivalAt 仅来自装柜完成事件 estimatedWarehouseArrivalAt 冻结的 ll_order.estimated_warehouse_at;按该时间升序、null 最后、稳定柜 ID 升序。 + description: 权限 platform:overseas-warehouse:last-mile-order:list;平台跨客户按稳定 sourceContainerBookingId 服务端聚合,不受订单分页影响,返回全部稳定来源柜;全部入仓码均已生成有效独立计划的柜仍保留,供柜级接柜仓查询与修改,生成计划动作由 allPlansReady/pendingPlanCount 控制。containerNo 只返回装柜完成事件携带并规范化冻结的真实集装箱号,不从 cabinetNo、订单号或内部 ID 推导。plannedArrivalAt 仅来自装柜完成事件 estimatedWarehouseArrivalAt 冻结的 ll_order.estimated_warehouse_at;按该时间升序、null 最后、稳定柜 ID 升序。 security: [{ bearerAuth: [] }] parameters: - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } @@ -304,7 +304,7 @@ paths: /api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}: get: summary: 查询柜级入库计划进度详情 - description: 权限 platform:overseas-warehouse:order:list;按稳定柜 ID 返回柜内全部尾程订单、入仓码及各自计划状态,不受订单分页截断。 + description: 权限 platform:overseas-warehouse:last-mile-order:list;按稳定柜 ID 返回柜内全部尾程订单、入仓码及各自计划状态,不受订单分页截断。 security: [{ bearerAuth: [] }] parameters: - { name: sourceContainerBookingId, in: path, required: true, schema: { type: string, minLength: 1, maxLength: 128 } } @@ -322,7 +322,7 @@ paths: /api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}/receiving-warehouse: put: summary: 修改整柜海外接柜仓 - description: 权限 platform:overseas-warehouse:order:edit;目标必须为国内 Warehouse Service 有效 OVERSEAS 仓。使用柜级版本和 Idempotency-Key,事务内原子同步柜内全部尾程订单及已有入库计划;海外PDA一旦建立接柜会话即永久锁定。 + description: 权限 platform:overseas-warehouse:last-mile-order:edit;目标必须为国内 Warehouse Service 有效 OVERSEAS 仓。使用柜级版本和 Idempotency-Key,事务内原子同步柜内全部尾程订单及已有入库计划;海外PDA一旦建立接柜会话即永久锁定。 security: [{ bearerAuth: [] }] parameters: - { name: sourceContainerBookingId, in: path, required: true, schema: { type: string, minLength: 1, maxLength: 128 } } @@ -339,7 +339,7 @@ paths: /api/platform/v1/last-leg/orders/{id}/destination: put: summary: 将单票尾程订单转入 FBA 目的仓 - description: 权限 platform:overseas-warehouse:order:edit;目标必须来自国内 Warehouse Service 平台公共 ACTIVE FBA 主档,warehouseId/warehouseCode/warehouseName 必须与权威记录一致。实际海外收货仓、普通国内中转仓、停用仓或伪造字段均返回 A0400“所选FBA仓不存在或已停用”。 + description: 权限 platform:overseas-warehouse:last-mile-order:edit;目标必须来自国内 Warehouse Service 平台公共 ACTIVE FBA 主档,warehouseId/warehouseCode/warehouseName 必须与权威记录一致。实际海外收货仓、普通国内中转仓、停用仓或伪造字段均返回 A0400“所选FBA仓不存在或已停用”。 security: [{ bearerAuth: [] }] parameters: - { name: id, in: path, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } @@ -356,7 +356,7 @@ paths: /api/platform/v1/last-leg/orders/destination: put: summary: 批量将尾程订单转入 FBA 目的仓 - description: 权限 platform:overseas-warehouse:order:edit;一次校验国内 Warehouse Service 权威 FBA 主档后,在 Lastleg 事务内更新所选订单及冻结明细。目标仓必须为平台公共 ACTIVE FBA 且 ID/编码/名称完全一致。 + description: 权限 platform:overseas-warehouse:last-mile-order:edit;一次校验国内 Warehouse Service 权威 FBA 主档后,在 Lastleg 事务内更新所选订单及冻结明细。目标仓必须为平台公共 ACTIVE FBA 且 ID/编码/名称完全一致。 security: [{ bearerAuth: [] }] parameters: - { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java index f3ca58fc2..9f311cfbb 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java @@ -16,6 +16,7 @@ import com.baiyue.lastleg.shipping.service.ContainerShippingNodeConsumer; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.beans.BeanUtils; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.server.ResponseStatusException; import java.util.Set; @@ -88,6 +89,24 @@ class LastlegOrderControllerTest { verify(service).containerProgressPage(summaryQuery); } + @Test + void containerEndpointsUseCanonicalLastMileOrderAuthorities() throws Exception { + var list = LastlegOrderController.class.getMethod("containerSummaries", + AuthenticatedUser.class, LastlegOrderRequests.ContainerProgressQuery.class); + var detail = LastlegOrderController.class.getMethod("containerSummaryDetail", + AuthenticatedUser.class, String.class); + var change = LastlegOrderController.class.getMethod("changeReceivingWarehouse", + AuthenticatedUser.class, String.class, String.class, + LastlegOrderRequests.ChangeReceivingWarehouse.class); + + assertEquals("hasAuthority('platform:overseas-warehouse:last-mile-order:list')", + list.getAnnotation(PreAuthorize.class).value()); + assertEquals("hasAuthority('platform:overseas-warehouse:last-mile-order:list')", + detail.getAnnotation(PreAuthorize.class).value()); + assertEquals("hasAuthority('platform:overseas-warehouse:last-mile-order:edit')", + change.getAnnotation(PreAuthorize.class).value()); + } + @Test void containerSummariesRejectMerchantIdentity() { var summaryQuery = new LastlegOrderRequests.ContainerProgressQuery(1, 20, null, null, null); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/ContainerPlanProgressContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/ContainerPlanProgressContractTest.java index bc544e078..74eb585ae 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/ContainerPlanProgressContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/ContainerPlanProgressContractTest.java @@ -18,6 +18,7 @@ import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; class ContainerPlanProgressContractTest { @@ -40,8 +41,8 @@ class ContainerPlanProgressContractTest { String sql = String.join("", rows.value()).toLowerCase(); assertTrue(sql.contains("left join ll_inbound_plan")); assertTrue(sql.contains("o.estimated_warehouse_at")); - assertTrue(sql.contains("having count(distinct case when p.id is not null")); - assertTrue(sql.contains("< count(distinct nullif(trim(o.inbound_code),''))")); + assertTrue(sql.contains("group by o.source_container_booking_id")); + assertFalse(sql.contains("having ")); assertTrue(sql.contains("progress.plannedarrivalat asc")); assertTrue(sql.contains("progress.sourcecontainerbookingid asc")); assertTrue(sql.contains("limit #{limit} offset #{offset}")); @@ -58,7 +59,12 @@ class ContainerPlanProgressContractTest { assertTrue(openApi.contains("/api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}:")); assertTrue(openApi.contains("ContainerPlanProgressSummary:")); assertTrue(openApi.contains("allPlansReady:")); - assertTrue(openApi.contains("仅返回仍有入仓码待生成有效独立计划的柜")); + assertTrue(openApi.contains("返回全部稳定来源柜")); + assertTrue(openApi.contains("全部入仓码均已生成有效独立计划的柜仍保留")); + assertTrue(openApi.contains("权限 platform:overseas-warehouse:last-mile-order:list")); + assertTrue(openApi.contains("权限 platform:overseas-warehouse:last-mile-order:edit")); + assertFalse(openApi.contains("platform:overseas-warehouse:order:list")); + assertFalse(openApi.contains("platform:overseas-warehouse:order:edit")); assertTrue(openApi.contains("ll_order.estimated_warehouse_at")); assertTrue(schema.contains("idx_ll_order_container_progress(source_container_booking_id,estimated_warehouse_at,created_at,id)")); assertTrue(deployment.contains("idx_ll_order_container_progress(source_container_booking_id,estimated_warehouse_at,created_at,id)")); @@ -77,8 +83,8 @@ class ContainerPlanProgressContractTest { assertTrue(sql.contains("source_container_booking_id<>''")); assertTrue(sql.contains("LEFT JOIN ll_inbound_plan")); - assertTrue(sql.contains("HAVING COUNT(DISTINCT CASE WHEN p.id IS NOT NULL")); - assertTrue(sql.contains("< COUNT(DISTINCT NULLIF(TRIM(o.inbound_code),''))")); + assertTrue(sql.contains("GROUP BY o.source_container_booking_id")); + assertFalse(sql.contains("HAVING ")); assertTrue(!sql.contains("<")); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java index a47ffcc73..0acce48a0 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java @@ -86,7 +86,7 @@ class LastlegOrderServiceTest { } @Test - void changesReceivingWarehouseForWholeContainerBeforePdaSession() { + void changesReceivingWarehouseForAllPlansReadyContainerBeforePdaSession() { LastlegOrder first = order(1L, "100", "PENDING_ASSIGNMENT", null); LastlegOrder second = order(2L, "200", "PENDING_ASSIGNMENT", null); for (LastlegOrder value : List.of(first, second)) { @@ -94,8 +94,8 @@ class LastlegOrderServiceTest { value.setOverseasWarehouseCode("US-LAX-01"); value.setOverseasWarehouseName("洛杉矶仓"); } var updated = new ContainerPlanProgressSummary("BOOKING-1", "MSCU6639870", "900", - "US-NYC-01", "纽约仓", 1, true, null, 2, 2, 0, 0, 0, 2, - null, false, "存在2票尚未生成有效入库计划"); + "US-NYC-01", "纽约仓", 1, true, null, 2, 2, 0, 2, 2, 0, + null, true, null); when(orders.lockContainerOrders("BOOKING-1")).thenReturn(List.of(first, second)); when(warehouseResourceGateway.requireActiveSharedWarehouse(900L)).thenReturn( new WarehouseResourceGateway.WarehouseReference(900L, "US-NYC-01", "纽约仓", "ACTIVE")); @@ -107,6 +107,8 @@ class LastlegOrderServiceTest { new LastlegOrderRequests.ChangeReceivingWarehouse(900L, 0, "改派纽约"), "admin"); assertEquals("US-NYC-01", result.overseasWarehouseCode()); + assertTrue(result.allPlansReady()); + assertTrue(result.canChangeReceivingWarehouse()); verify(orders).updateContainerPlanReceivingWarehouse("BOOKING-1", 900L); verify(receivingWarehouseOperations).insert(any(com.baiyue.lastleg.entity.ReceivingWarehouseOperation.class)); } @@ -360,6 +362,27 @@ class LastlegOrderServiceTest { assertTrue(result.records().get(1).canChangeReceivingWarehouse()); } + @Test + void containerProgressPageKeepsReadyAndPdaLockedContainersVisible() { + var query = new LastlegOrderRequests.ContainerProgressQuery(1, 10, null, null, null); + var ready = summary(true, 0, null); + var locked = new ContainerPlanProgressSummary("BOOKING-2", "TSTU0000061", + "193000000000200003", "US-LAX-01", "洛杉矶仓", 4, false, + "海外PDA已建立接柜会话,接柜仓已永久锁定", 1, 1, 0, 1, 1, 0, + LocalDateTime.of(2026, 10, 4, 9, 0), true, null); + when(orders.countContainerProgress(query)).thenReturn(2L); + when(orders.pageContainerProgress(query, 0, 10)).thenReturn(List.of(ready, locked)); + + var result = service.containerProgressPage(query); + + assertEquals(List.of(ready, locked), result.records()); + assertTrue(result.records().get(0).allPlansReady()); + assertTrue(result.records().get(0).canChangeReceivingWarehouse()); + assertFalse(result.records().get(1).canChangeReceivingWarehouse()); + assertEquals("海外PDA已建立接柜会话,接柜仓已永久锁定", + result.records().get(1).receivingWarehouseLockReason()); + } + private static ContainerPlanProgressSummary summary(boolean ready, long pending, String reason) { return new ContainerPlanProgressSummary("BOOKING-1", "MSCU6639870", "193000000000200003", "US-LAX-01", "洛杉矶仓", 0, true, null, 15, 15, 0,