diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index c57fb819a..402460f9d 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -14,7 +14,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` 服务端聚合全柜订单、入仓码和有效计划并分页返回全部稳定来源柜,已完成计划转换的柜仍保留用于接柜仓查询与修改,生成资格只由 `allPlansReady/pendingPlanCount` 表达;`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 | +| 事件接收、平台订单列表/详情/导出、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码、有效计划及待补真实托盘计划并分页返回全部稳定来源柜,已完成计划转换的柜仍保留用于接柜仓查询与修改,生成资格由 `pendingPlanCount/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/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` | | 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V2,消费端冻结 `destinationType`:FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;历史缺字段事件仅按明确 FBA 单号或目的仓与接柜仓一致性安全推断,歧义直接拒绝 | diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerPlanProgressSummary.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerPlanProgressSummary.java index ebd55205b..5debb2aab 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerPlanProgressSummary.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerPlanProgressSummary.java @@ -18,6 +18,7 @@ public record ContainerPlanProgressSummary( long planCount, long plannedInboundCodeCount, long pendingPlanCount, + long resourcePendingPlanCount, LocalDateTime plannedArrivalAt, boolean allPlansReady, String blockingReason @@ -30,6 +31,6 @@ public record ContainerPlanProgressSummary( String blockingReason) { this(sourceContainerBookingId, containerNo, null, null, null, -1, false, null, totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, - plannedInboundCodeCount, pendingPlanCount, plannedArrivalAt, allPlansReady, blockingReason); + plannedInboundCodeCount, pendingPlanCount, 0, plannedArrivalAt, allPlansReady, blockingReason); } } 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 09b5dad93..3f5f23bd4 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 @@ -261,7 +261,8 @@ public class InboundPlanService { && row.materializedPalletCount() > 0) { blockers.add("PLAN_PALLET_BINDING_INCOMPLETE"); } - if (planned && !materialized && !"PENDING_ASSIGNMENT".equals(row.planStatus())) { + if (planned && !materialized && !("PENDING_ASSIGNMENT".equals(row.planStatus()) + || "SIGNED".equals(row.planStatus()))) { blockers.add("PLAN_ALREADY_STARTED"); } String readiness = planned ? materialized ? "PLANNED" @@ -636,8 +637,8 @@ public class InboundPlanService { || !Objects.equals(ref.orderId(), prepared.orderId())) { throw new ResponseStatusException(HttpStatus.CONFLICT, "人工计划与柜级托盘预留快照不一致"); } - if (!"PENDING_ASSIGNMENT".equals(plan.getStatus())) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "仅未开始作业的已有计划可由整柜生成补齐托盘"); + if (!("PENDING_ASSIGNMENT".equals(plan.getStatus()) || "SIGNED".equals(plan.getStatus()))) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "仅待入库或已签收但未实收的已有计划可由整柜生成补齐托盘"); } List pallets = palletMapper.selectList(new LambdaQueryWrapper() .eq(InboundPlanPallet::getCustomerId, plan.getCustomerId()) 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 cae686970..fea64fe0b 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 @@ -42,6 +42,10 @@ public interface LastlegOrderMapper extends BaseMapper { "AND NULLIF(TRIM(p.status),'') IS NOT NULL THEN NULLIF(TRIM(o.inbound_code),'') END) AS plannedInboundCodeCount," + "SUM(CASE WHEN p.id IS NULL OR NULLIF(TRIM(p.plan_no),'') IS NULL OR NULLIF(TRIM(p.status),'') IS NULL " + "THEN 1 ELSE 0 END) AS pendingPlanCount," + + "SUM(CASE WHEN p.id IS NOT NULL AND COALESCE(p.pallet_count,0)>0 AND " + + "(SELECT COUNT(*) FROM ll_inbound_plan_pallet pp WHERE pp.customer_id=p.customer_id " + + "AND pp.plan_id=p.id AND pp.warehouse_pallet_id IS NOT NULL)<COALESCE(p.pallet_count,0) " + + "THEN 1 ELSE 0 END) AS resourcePendingPlanCount," + "CASE WHEN COUNT(o.estimated_warehouse_at)=COUNT(*) " + "AND COUNT(DISTINCT o.estimated_warehouse_at)=1 THEN MAX(o.estimated_warehouse_at) ELSE NULL END AS plannedArrivalAt," + "CASE WHEN SUM(CASE WHEN NULLIF(TRIM(o.inbound_code),'') IS NULL THEN 1 ELSE 0 END)=0 " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java index 4595fca5c..bde438fbd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java @@ -176,7 +176,7 @@ public class LastlegOrderService { summary.assignmentVersion(), invalidContainer ? false : summary.canChangeReceivingWarehouse(), invalidContainer ? issue : summary.receivingWarehouseLockReason(), summary.totalOrderCount(), summary.inboundCodeCount(), summary.missingInboundCodeCount(), summary.planCount(), - summary.plannedInboundCodeCount(), summary.pendingPlanCount(), summary.plannedArrivalAt(), + summary.plannedInboundCodeCount(), summary.pendingPlanCount(), summary.resourcePendingPlanCount(), summary.plannedArrivalAt(), false, issue); } 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 28f6d20d8..08bb82b09 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -1289,7 +1289,7 @@ components: items: { type: string, pattern: '^[1-9][0-9]*$' } ContainerPlanProgressSummary: type: object - required: [sourceContainerBookingId, containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, assignmentVersion, canChangeReceivingWarehouse, totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount, pendingPlanCount, allPlansReady] + required: [sourceContainerBookingId, containerNo, overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, assignmentVersion, canChangeReceivingWarehouse, totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount, pendingPlanCount, resourcePendingPlanCount, allPlansReady] properties: sourceContainerBookingId: { type: string } containerNo: { type: string, pattern: '^[A-Z]{3}[UJZ][0-9]{7}$', description: 装柜完成事件非空且通过 ISO 6346 校验位验证的规范化真实集装箱号;不使用 cabinetNo、订单号或内部 ID 回退 } @@ -1305,6 +1305,7 @@ components: planCount: { type: integer, format: int64 } plannedInboundCodeCount: { type: integer, format: int64 } pendingPlanCount: { type: integer, format: int64, description: 尚未生成有效计划的尾程订单数,即未操作数 } + resourcePendingPlanCount: { type: integer, format: int64, description: 已有有效计划但尚未完整绑定海外仓真实托盘的订单数 } plannedArrivalAt: { type: string, format: date-time, nullable: true, description: 仅来源于装柜完成事件 estimatedWarehouseArrivalAt 冻结的 ll_order.estimated_warehouse_at } allPlansReady: { type: boolean, description: 全柜每票均有入仓码且均存在有效独立计划时为 true } blockingReason: { type: string, nullable: true } 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 f8f54c7ec..e308c5588 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 @@ -103,9 +103,33 @@ class ContainerInboundPlanPreviewTest { .map(ContainerInboundPlanPreviewResponse.Group::destinationType).toList()); } + @Test + void signedManualPlanWithoutWarehousePalletsRemainsResourcePending() { + when(orderMapper.containerPreviewOrders("BOOKING-SIGNED")).thenReturn(List.of( + row(1L, "LM-SIGNED", "FIRST_LEG", "FBA", "ACY2", "Amazon ACY2", 3, + "IP-SIGNED", 3, 1, "SIGNED", 0) + )); + + var result = service.previewContainer("BOOKING-SIGNED"); + + assertEquals(true, result.ready()); + assertEquals(1, result.pendingOrderCount()); + assertEquals(3, result.pendingPalletCount()); + assertEquals("RESOURCE_PENDING", result.groups().get(0).orders().get(0).readiness()); + } + private InboundPlanOrderMapper.ContainerPreviewOrderRow row( long id, String orderNo, String orderType, String destinationType, String destinationCode, String destinationName, int recommendedPallets, String planNo, Integer plannedPallets, int version) { + return row(id, orderNo, orderType, destinationType, destinationCode, destinationName, + recommendedPallets, planNo, plannedPallets, version, + planNo == null ? null : "PENDING_ASSIGNMENT", plannedPallets); + } + + private InboundPlanOrderMapper.ContainerPreviewOrderRow row( + long id, String orderNo, String orderType, String destinationType, String destinationCode, String destinationName, + int recommendedPallets, String planNo, Integer plannedPallets, int version, String planStatus, + Integer materializedPallets) { return new InboundPlanOrderMapper.ContainerPreviewOrderRow("100", id, orderNo, "FO-" + id, "测试客户", orderType, destinationType, "IN-" + id, "PENDING_ASSIGNMENT", planNo, "MSCU6639870", recommendedPallets, 10, new BigDecimal("1.0000"), @@ -113,6 +137,6 @@ class ContainerInboundPlanPreviewTest { "FBA".equals(destinationType) || destinationCode == null ? null : "WH-" + id, "FBA".equals(destinationType) ? null : destinationCode, destinationName, "FBA".equals(destinationType) || destinationCode == null ? 0 : 1, false, version, planNo, - planNo == null ? null : "PENDING_ASSIGNMENT", plannedPallets, plannedPallets); + planStatus, plannedPallets, materializedPallets); } } 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 72441b51d..667f5658f 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 @@ -181,10 +181,11 @@ class InboundPlanServiceTest { } @Test - void containerConversionBindsRealWarehousePalletsBackToExistingManualPlan() { + void containerConversionBindsRealWarehousePalletsBackToSignedManualPlanBeforeReceiving() { InboundPlan plan = existingPlan("100", 2L, "IP-MANUAL"); plan.setCreationMode("MANUAL"); plan.setPalletCount(2); + plan.setStatus("SIGNED"); InboundPlanPallet first = new InboundPlanPallet(); first.setId(301L); first.setCustomerId("100"); first.setPlanId(plan.getId()); first.setSequenceNo(1); InboundPlanPallet second = new InboundPlanPallet(); 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 74eb585ae..6afad5ec2 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 @@ -27,7 +27,7 @@ class ContainerPlanProgressContractTest { assertEquals(List.of("sourceContainerBookingId", "containerNo", "overseasWarehouseId", "overseasWarehouseCode", "overseasWarehouseName", "assignmentVersion", "canChangeReceivingWarehouse", "receivingWarehouseLockReason", "totalOrderCount", "inboundCodeCount", - "missingInboundCodeCount", "planCount", "plannedInboundCodeCount", "pendingPlanCount", + "missingInboundCodeCount", "planCount", "plannedInboundCodeCount", "pendingPlanCount", "resourcePendingPlanCount", "plannedArrivalAt", "allPlansReady", "blockingReason"), Arrays.stream(ContainerPlanProgressSummary.class.getRecordComponents()) .map(value -> value.getName()).toList()); @@ -40,6 +40,7 @@ class ContainerPlanProgressContractTest { LastlegOrderRequests.ContainerProgressQuery.class, long.class, long.class).getAnnotation(Select.class); String sql = String.join("", rows.value()).toLowerCase(); assertTrue(sql.contains("left join ll_inbound_plan")); + assertTrue(sql.contains("ll_inbound_plan_pallet")); assertTrue(sql.contains("o.estimated_warehouse_at")); assertTrue(sql.contains("group by o.source_container_booking_id")); assertFalse(sql.contains("having ")); 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 bbf7da8c5..f720cfcd9 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 @@ -97,7 +97,7 @@ class LastlegOrderServiceTest { } var updated = new ContainerPlanProgressSummary("BOOKING-1", "MSCU6639870", "900", "US-NYC-01", "纽约仓", 1, true, null, 2, 2, 0, 2, 2, 0, - null, true, null); + 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")); @@ -382,7 +382,7 @@ class LastlegOrderServiceTest { var healthy = summary(false, 3, "存在3票尚未生成有效入库计划"); var missingWarehouse = new ContainerPlanProgressSummary("BOOKING-2", "MSCU6639870", null, null, null, 0, true, null, 2, 2, 0, 0, 0, 2, - LocalDateTime.of(2026, 10, 4, 9, 0), false, "存在2票尚未生成有效入库计划"); + 0, LocalDateTime.of(2026, 10, 4, 9, 0), false, "存在2票尚未生成有效入库计划"); when(orders.countContainerProgress(query)).thenReturn(2L); when(orders.pageContainerProgress(query, 0, 10)).thenReturn(List.of(healthy, missingWarehouse)); @@ -402,7 +402,7 @@ class LastlegOrderServiceTest { 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); + 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)); @@ -419,7 +419,7 @@ class LastlegOrderServiceTest { 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, - 15 - pending, 15 - pending, pending, LocalDateTime.of(2026, 10, 3, 9, 0), ready, reason); + 15 - pending, 15 - pending, pending, 0, LocalDateTime.of(2026, 10, 3, 9, 0), ready, reason); } @Test