diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index c7fd0b2a2..8903d7e61 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` 服务端聚合全柜订单、入仓码、有效计划及待补真实托盘计划并分页返回全部稳定来源柜,已完成计划转换的柜仍保留用于接柜仓查询与修改,生成资格由 `pendingPlanCount/resourcePendingPlanCount` 表达;`/{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` 服务端聚合全柜订单、入仓码、有效计划及托盘资源异常并分页返回全部稳定来源柜,已完成计划转换的柜仍保留用于接柜仓查询与修改;单票手工计划保存后,整柜最后一票齐全时自动生成并绑定全柜托盘码,一票柜立即生成,`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/inboundplan/controller/InboundPlanController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java index 7930f219a..76699da30 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java @@ -49,7 +49,7 @@ public class InboundPlanController { @PostMapping("/manual") @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_CREATE + "')") - @Operation(summary = "手工填写并创建单票入仓计划", description = "要求订单版本和 Idempotency-Key;仓库主档、入仓码及客户归属均由服务端校验。Required authority: " + @Operation(summary = "手工填写并创建单票入仓计划", description = "要求订单版本和 Idempotency-Key;仓库主档、入仓码及客户归属均由服务端校验。整柜最后一票计划保存后,服务端自动生成并绑定全柜托盘码。Required authority: " + OverseasPermissions.INBOUND_ORDER_CREATE) public ApiResponse manual(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String idempotencyKey, @@ -59,7 +59,8 @@ public class InboundPlanController { } convertScope(user, request.customerId()); String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId()); - return ApiResponse.success(service.convertManual(request, idempotencyKey, operatorId)); + return ApiResponse.success(containerConversion.convertManualAndAutoMaterialize( + request, idempotencyKey, operatorId)); } @PostMapping("/convert-container") 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 7ad10ccf2..a4e84580b 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 @@ -67,6 +67,11 @@ public interface InboundPlanOrderMapper { @Select("SELECT container_no FROM ll_order WHERE customer_id=#{customerId} AND id=#{orderId} LIMIT 1") String findContainerNo(@Param("customerId") String customerId, @Param("orderId") Long orderId); + @Select("SELECT source_container_booking_id FROM ll_order " + + "WHERE customer_id=#{customerId} AND id=#{orderId} LIMIT 1") + String findSourceContainerBookingId(@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/ContainerInboundPlanConversionCoordinator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java index ca824ad84..255e764bf 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java @@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baiyue.lastleg.inboundplan.dto.ContainerInboundPlanPreviewResponse; import com.baiyue.lastleg.inboundplan.dto.ConvertContainerInboundPlanRequest; import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; +import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest; import com.baiyue.lastleg.inboundplan.entity.ContainerPlanConversionCommand; import com.baiyue.lastleg.inboundplan.mapper.ContainerPlanConversionCommandMapper; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; @@ -46,6 +47,35 @@ public class ContainerInboundPlanConversionCoordinator { @Resource private ObjectMapper objectMapper; @Resource private PlatformTransactionManager transactionManager; + /** + * Saves one manually adjusted plan, then materializes every plan in the container as soon as + * the last logical plan is complete. The manual endpoint is therefore never a separate pallet + * creation workflow: for a one-order container this happens immediately; for a multi-order + * container the final manual save closes the whole container. + */ + public InboundPlanResponse convertManualAndAutoMaterialize(ManualInboundPlanRequest request, + String idempotencyKey, + String operatorId) { + InboundPlanResponse manual = plans.convertManual(request, idempotencyKey, operatorId); + String bookingId = plans.sourceContainerBookingId(manual.customerId(), manual.sourceOrderId()); + ContainerInboundPlanPreviewResponse preview = plans.previewContainer(bookingId); + List orders = preview.groups().stream() + .flatMap(group -> group.orders().stream()).toList(); + boolean allLogicalPlansComplete = !orders.isEmpty() && orders.stream().allMatch(order -> + "PLANNED".equals(order.readiness()) || "RESOURCE_PENDING".equals(order.readiness())); + boolean needsMaterialization = orders.stream() + .anyMatch(order -> "RESOURCE_PENDING".equals(order.readiness())); + if (!preview.ready() || !allLogicalPlansComplete || !needsMaterialization) return manual; + + List materialized = confirm(new ConvertContainerInboundPlanRequest( + bookingId, preview.snapshotVersion(), preview.snapshotHash()), + "AUTO-MANUAL:" + hash(List.of(bookingId, idempotencyKey))); + return materialized.stream() + .filter(plan -> Objects.equals(plan.customerId(), manual.customerId()) + && Objects.equals(plan.sourceOrderId(), manual.sourceOrderId())) + .findFirst().orElse(manual); + } + public List confirm(ConvertContainerInboundPlanRequest request, String idempotencyKey) { String bookingId = required(request.sourceContainerBookingId(), 128, "来源柜预订 ID 不能为空"); String key = required(idempotencyKey, 128, "Idempotency-Key 必须为1至128个字符"); 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 3f5f23bd4..b54b94c02 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 @@ -76,6 +76,15 @@ public class InboundPlanService { : sourcePalletMapper.palletNos(customerId, orderId); } + @Transactional(readOnly = true) + public String sourceContainerBookingId(String customerId, long orderId) { + String bookingId = trimToNull(orderMapper.findSourceContainerBookingId(customerId, orderId)); + if (bookingId == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "尾程订单不存在或未绑定来源柜"); + } + return bookingId; + } + @Transactional public InboundPlanResponse convertManual(ManualInboundPlanRequest request, String idempotencyKey, String operatorId) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java index d29022f19..6ec92d391 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java @@ -68,7 +68,7 @@ class InboundPlanControllerTest { void platformManualPlanPassesVersionedIdempotentCommand() { ManualInboundPlanRequest request = manualRequest(); controller.manual(platform(), "manual-key", request); - verify(service).convertManual(request, "manual-key", "7"); + verify(containerConversion).convertManualAndAutoMaterialize(request, "manual-key", "7"); } @Test @@ -76,7 +76,7 @@ class InboundPlanControllerTest { AuthenticatedUser customer = new AuthenticatedUser(8L, 100L, 100L, 9L, "customer", "MERCHANT", "WEB", Set.of()); assertThatThrownBy(() -> controller.manual(customer, "manual-key", manualRequest())) .isInstanceOf(ResponseStatusException.class); - verify(service, never()).convertManual(org.mockito.ArgumentMatchers.any(), + verify(containerConversion, never()).convertManualAndAutoMaterialize(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any()); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java index b2621bbd9..b064e4b11 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java @@ -2,6 +2,8 @@ package com.baiyue.lastleg.inboundplan.service; import com.baiyue.lastleg.inboundplan.dto.ConvertContainerInboundPlanRequest; import com.baiyue.lastleg.inboundplan.dto.ContainerInboundPlanPreviewResponse; +import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; +import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest; import com.baiyue.lastleg.inboundplan.entity.ContainerPlanConversionCommand; import com.baiyue.lastleg.inboundplan.mapper.ContainerPlanConversionCommandMapper; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; @@ -18,6 +20,7 @@ import org.springframework.transaction.support.SimpleTransactionStatus; import org.springframework.web.server.ResponseStatusException; import java.time.LocalDateTime; +import java.time.LocalDate; import java.math.BigDecimal; import java.util.List; @@ -28,6 +31,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.mockito.Mockito.any; import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.doReturn; @ExtendWith(MockitoExtension.class) class ContainerInboundPlanConversionCoordinatorTest { @@ -57,6 +61,43 @@ class ContainerInboundPlanConversionCoordinatorTest { .thenReturn(new SimpleTransactionStatus()); } + @Test + void finalManualPlanAutomaticallyMaterializesSingleOrderContainer() { + ManualInboundPlanRequest request = manualRequest(); + InboundPlanResponse logical = plan("IP-MANUAL", List.of()); + InboundPlanResponse physical = plan("IP-MANUAL", List.of(new InboundPlanResponse.Pallet( + 31L, "MSCU6639870-101-001", null, "ACY2", "Amazon ACY2", BigDecimal.ONE, + BigDecimal.ONE, 1, 51L, 61L, 71L, "WOOD", "LOCKED", LocalDateTime.now(), null, 0))); + ContainerInboundPlanConversionCoordinator spy = org.mockito.Mockito.spy(coordinator); + when(plans.convertManual(request, "manual-key", "7")).thenReturn(logical); + when(plans.sourceContainerBookingId("100", 11L)).thenReturn(BOOKING_ID); + when(plans.previewContainer(BOOKING_ID)).thenReturn(manualPreview(List.of(resourcePendingOrder()))); + doReturn(List.of(physical)).when(spy).confirm(any(ConvertContainerInboundPlanRequest.class), any(String.class)); + + assertEquals(physical, spy.convertManualAndAutoMaterialize(request, "manual-key", "7")); + + verify(spy).confirm(eq(new ConvertContainerInboundPlanRequest(BOOKING_ID, 7L, SNAPSHOT_HASH)), + any(String.class)); + } + + @Test + void earlierManualPlanWaitsUntilEveryOrderInContainerHasAPlan() { + ManualInboundPlanRequest request = manualRequest(); + InboundPlanResponse logical = plan("IP-MANUAL", List.of()); + var ready = new ContainerInboundPlanPreviewResponse.Order( + "200", "Customer 200", "12", "LM-12", "RK-2", "FIRST_LEG", "FBA", + null, "ONT8", "Amazon ONT8", "ONT8", 10, BigDecimal.ONE, 1, + null, null, null, 0, "READY", List.of()); + when(plans.convertManual(request, "manual-key", "7")).thenReturn(logical); + when(plans.sourceContainerBookingId("100", 11L)).thenReturn(BOOKING_ID); + when(plans.previewContainer(BOOKING_ID)).thenReturn(manualPreview(List.of(resourcePendingOrder(), ready))); + + assertEquals(logical, coordinator.convertManualAndAutoMaterialize(request, "manual-key", "7")); + + verify(commands, never()).findByBooking(any(String.class)); + verify(warehouse, never()).ensurePalletBatch(any()); + } + @Test void anotherIdempotencyKeyForCompletedCabinetReplaysWithoutCreatingAnotherBatch() throws Exception { ConvertContainerInboundPlanRequest request = request(); @@ -366,6 +407,36 @@ class ContainerInboundPlanConversionCoordinatorTest { fbaWarehouseCode, 10, BigDecimal.ONE, 1, null, null, null, 0, "READY", List.of()); } + private ManualInboundPlanRequest manualRequest() { + return new ManualInboundPlanRequest("100", 11L, 0, LocalDate.of(2026, 9, 10), + 1, "WOOD", false, false, false, "自动生成"); + } + + private ContainerInboundPlanPreviewResponse.Order resourcePendingOrder() { + return new ContainerInboundPlanPreviewResponse.Order( + "100", "Customer 100", "11", "LM-11", "RK-1", "FIRST_LEG", "FBA", + null, "ACY2", "Amazon ACY2", "ACY2", 10, BigDecimal.ONE, 1, + 1, "IP-MANUAL", "PENDING_ASSIGNMENT", 1, "RESOURCE_PENDING", List.of()); + } + + private ContainerInboundPlanPreviewResponse manualPreview( + List orders) { + int pending = (int) orders.stream().filter(order -> "RESOURCE_PENDING".equals(order.readiness()) + || "READY".equals(order.readiness())).count(); + return new ContainerInboundPlanPreviewResponse(BOOKING_ID, "MSCU6639870", "31", "US-LAX-01", + "Los Angeles", 7L, SNAPSHOT_HASH, orders.size(), orders.size(), 0, pending, pending, + 0, orders.size(), 1, true, + List.of(new ContainerInboundPlanPreviewResponse.Group("FBA", null, "ACY2", "Amazon ACY2", + orders.size(), pending, pending, 0, orders.size(), 1, orders))); + } + + private InboundPlanResponse plan(String planNo, List pallets) { + return new InboundPlanResponse(21L, planNo, "100", 11L, "MSCU6639870", "US-LAX-01", + "Los Angeles", "PENDING_ASSIGNMENT", 1, 1, BigDecimal.ONE, 10, 0, + List.of(), pallets, List.of(), 1, LocalDate.of(2026, 9, 10), "WOOD", false, + false, false, "MANUAL", "自动生成"); + } + private ConvertContainerInboundPlanRequest request() { return new ConvertContainerInboundPlanRequest(BOOKING_ID, 7L, SNAPSHOT_HASH); }