diff --git a/AGENTS.md b/AGENTS.md index f83882030..d7c89d668 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,7 +40,7 @@ | 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` | | 海外 IAM 状态与权限版本投影 | `lastleg-service/security/` | `lastleg-service/AGENTS.md`;内部入口 `/internal/lastleg/security/v1/events`、`/validate`;消费三类 IAM 安全事件并为海外 Gateway 提供 fail-closed 校验,投影表归 `baiyue_lastleg` | | 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 | -| 待分配柜级摘要与整柜入仓计划转换 | `lastleg-service/` | `orders/container-summaries` 按 `sourceContainerBookingId` 跨客户聚合分页;`inbound-plans/convert-container` 锁定同柜订单,每票生成独立计划,整批原子且幂等;详见 `lastleg-service/AGENTS.md` | +| 柜级入仓计划进度与整柜转换 | `lastleg-service/` | `orders/container-summaries` 按 `sourceContainerBookingId` 跨客户聚合全柜进度与 `allPlansReady`,详情返回柜内全部订单/入仓码/计划且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结事实;`inbound-plans/convert-container` 锁定同柜订单,每票生成独立计划,整批原子且幂等 | | 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | 平台入口 `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`;PDA 公网入口 `inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,旧 planNo/allocationId Controller 已移除 | | 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 | | 客户海外仓贴标、转运、废弃与退货换标 | `lastleg-service/` | `relabel/`、`transfer/`、`discard/` 的 Merchant Controller/Service;Client 消费端 `../apps/client-web/src/modules/overseas-warehouse/`,统一经 `gateway-overseas/` | diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 5e7faa8cf..5f9812428 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -491,6 +491,24 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab 'ALTER TABLE ll_order MODIFY COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID''', 'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id')); PREPARE phase4_event_stmt FROM @ddl; EXECUTE phase4_event_stmt; DEALLOCATE PREPARE phase4_event_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='idx_ll_order_pending_container'), + 'ALTER TABLE ll_order DROP INDEX idx_ll_order_pending_container','SELECT 1')); +PREPARE phase4_container_progress_idx_stmt FROM @ddl; EXECUTE phase4_container_progress_idx_stmt; DEALLOCATE PREPARE phase4_container_progress_idx_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='idx_ll_order_container_progress'), + 'SELECT 1','ALTER TABLE ll_order ADD INDEX idx_ll_order_container_progress(source_container_booking_id,estimated_warehouse_at,created_at,id)')); +PREPARE phase4_container_progress_idx_stmt FROM @ddl; EXECUTE phase4_container_progress_idx_stmt; DEALLOCATE PREPARE phase4_container_progress_idx_stmt; +DROP PROCEDURE IF EXISTS assert_container_progress_index_ready; +DELIMITER $$ +CREATE PROCEDURE assert_container_progress_index_ready() +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() + AND table_name='ll_order' AND index_name='idx_ll_order_container_progress') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外区域初始化失败:柜级入库计划进度索引缺失'; + END IF; +END$$ +DELIMITER ; +CALL assert_container_progress_index_ready(); +DROP PROCEDURE assert_container_progress_index_ready; -- 头程 V2 事件冻结目的类型;只补结构,不由部署脚本自动执行历史数据回填。 SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='destination_type'), @@ -585,7 +603,8 @@ CALL assert_pending_orders_do_not_use_source_warehouse(); DROP PROCEDURE assert_pending_orders_do_not_use_source_warehouse; CREATE TABLE IF NOT EXISTS ll_receiving_container_session ( - id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', + id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识', + source_container_booking_id VARCHAR(128) NOT NULL COMMENT '稳定来源柜预订ID', container_fact_hash VARCHAR(64) NOT NULL COMMENT '领取时整柜订单与计划事实摘要', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', overseas_warehouse_id BIGINT NOT NULL COMMENT '操作员Token绑定并冻结的海外实际收货共享物理仓ID', overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外实际收货共享物理仓名称快照', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键', active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED', @@ -596,6 +615,13 @@ CREATE TABLE IF NOT EXISTS ll_receiving_container_session ( KEY idx_ll_receiving_container(customer_id,container_no,status), KEY idx_ll_receiving_operator(customer_id,operator_key,created_at) ) ENGINE=InnoDB COMMENT='海外PDA柜号签收收货会话'; +SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='source_container_booking_id'), + 'SELECT 1','ALTER TABLE ll_receiving_container_session ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''稳定来源柜预订ID'' AFTER customer_id')); +PREPARE phase4_receiving_fact_stmt FROM @ddl; EXECUTE phase4_receiving_fact_stmt; DEALLOCATE PREPARE phase4_receiving_fact_stmt; +SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='container_fact_hash'), + 'SELECT 1','ALTER TABLE ll_receiving_container_session ADD COLUMN container_fact_hash VARCHAR(64) NULL COMMENT ''领取时整柜订单与计划事实摘要'' AFTER source_container_booking_id')); +PREPARE phase4_receiving_fact_stmt FROM @ddl; EXECUTE phase4_receiving_fact_stmt; DEALLOCATE PREPARE phase4_receiving_fact_stmt; + SET @ddl = (SELECT IF(EXISTS(SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_receiving_container_session' AND column_name='overseas_warehouse_id'), 'SELECT 1','ALTER TABLE ll_receiving_container_session ADD COLUMN overseas_warehouse_id BIGINT NULL COMMENT ''操作员Token绑定并冻结的海外实际收货共享物理仓ID'' AFTER container_no')); PREPARE phase4_session_receiving_warehouse_stmt FROM @ddl; EXECUTE phase4_session_receiving_warehouse_stmt; DEALLOCATE PREPARE phase4_session_receiving_warehouse_stmt; diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 4cdc99bc5..87e7dc59c 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -11,7 +11,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` 跨客户聚合分页,不允许前端本地拼柜 | +| 事件接收、平台订单列表/详情/导出、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码、计划数、未操作数和 `allPlansReady`,`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 | | 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` | | 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V2,消费端冻结 `destinationType`:FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;历史缺字段事件仅按明确 FBA 单号或目的仓与接柜仓一致性安全推断,歧义直接拒绝 | | 尾程单号生成 | `service/LastlegOrderNoGenerator.java` | diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClient.java new file mode 100644 index 000000000..2f3b48a3c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClient.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.client; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; + +import java.util.List; + +/** 国内 Warehouse Service 平台公共仓库只读目录。 */ +@FeignClient(name = "warehouse-service", contextId = "domesticWarehouseDirectoryClient", + url = "${baiyue.clients.domestic-warehouse.url:}") +public interface DomesticWarehouseDirectoryClient { + @GetMapping("/internal/warehouses/0/{warehouseId}/active") + ApiResponse activeWarehouse( + @PathVariable("warehouseId") long warehouseId, + @RequestHeader("X-Internal-Service-Token") String internalToken); + + record Detail(Warehouse warehouse, List
addresses) { } + + record Warehouse(Long id, Long tenantId, String warehouseCode, String warehouseName, + String warehouseType, String countryCode, String status) { } + + record Address(String stateProvince) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java index 85e6e4df6..a47943906 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java @@ -7,7 +7,8 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.dto.LastlegOrderDetailResponse; import com.baiyue.lastleg.dto.LastlegOrderRequests; import com.baiyue.lastleg.dto.LastlegOrderSummary; -import com.baiyue.lastleg.dto.PendingContainerSummary; +import com.baiyue.lastleg.dto.ContainerPlanProgressDetail; +import com.baiyue.lastleg.dto.ContainerPlanProgressSummary; import com.baiyue.lastleg.security.InternalServiceTokenGuard; import com.baiyue.lastleg.security.OverseasPermissions; import com.baiyue.lastleg.service.LastlegOrderService; @@ -77,11 +78,23 @@ public class LastlegOrderController { @Operation(summary = "分页查询待分配柜级摘要", description = "按稳定 sourceContainerBookingId 聚合待分配尾程订单;平台跨客户查询。Required authority: " + OverseasPermissions.LAST_MILE_ORDER_LIST) - public ApiResponse> containerSummaries( + public ApiResponse> containerSummaries( @AuthenticationPrincipal AuthenticatedUser user, - @Valid LastlegOrderRequests.PendingContainerQuery query) { + @Valid LastlegOrderRequests.ContainerProgressQuery query) { platformScope(user, null); - return ApiResponse.success(orderService.pendingContainerPage(query)); + return ApiResponse.success(orderService.containerProgressPage(query)); + } + + @GetMapping("/api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')") + @Operation(summary = "查询柜级入库计划进度详情", + description = "按稳定 sourceContainerBookingId 返回柜内全部尾程订单、入仓码及各自计划状态,不受订单分页影响。Required authority: " + + OverseasPermissions.LAST_MILE_ORDER_LIST) + public ApiResponse containerSummaryDetail( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String sourceContainerBookingId) { + platformScope(user, null); + return ApiResponse.success(orderService.containerProgressDetail(sourceContainerBookingId)); } @GetMapping("/api/platform/v1/last-leg/orders/{id}") @@ -110,7 +123,9 @@ public class LastlegOrderController { @PutMapping("/api/platform/v1/last-leg/orders/{id}/destination") @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") - @Operation(summary = "Transfer one order to a destination warehouse", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT) + @Operation(summary = "将单票尾程订单转入 FBA 目的仓", + description = "目标仓必须来自国内 Warehouse Service 的平台公共 ACTIVE FBA 主档,ID/编码/名称必须一致。Required authority: " + + OverseasPermissions.LAST_MILE_ORDER_EDIT) public ApiResponse transferOne( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @@ -121,7 +136,9 @@ public class LastlegOrderController { @PutMapping("/api/platform/v1/last-leg/orders/destination") @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") - @Operation(summary = "Transfer multiple orders to a destination warehouse", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT) + @Operation(summary = "批量将尾程订单转入 FBA 目的仓", + description = "目标仓必须来自国内 Warehouse Service 的平台公共 ACTIVE FBA 主档,ID/编码/名称必须一致。Required authority: " + + OverseasPermissions.LAST_MILE_ORDER_EDIT) public ApiResponse transferBatch( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerPlanProgressDetail.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerPlanProgressDetail.java new file mode 100644 index 000000000..4c97daede --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerPlanProgressDetail.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.dto; + +import java.time.LocalDateTime; +import java.util.List; + +/** 柜级摘要及柜内全部尾程订单、入仓码和计划状态。 */ +public record ContainerPlanProgressDetail( + ContainerPlanProgressSummary summary, + List orders +) { + public ContainerPlanProgressDetail { + orders = orders == null ? List.of() : List.copyOf(orders); + } + + public record Item( + Long orderId, + String customerId, + String orderNo, + String inboundCode, + String orderStatus, + Long planId, + String planNo, + String planStatus, + boolean planValid, + LocalDateTime plannedArrivalAt + ) { + } +} 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 new file mode 100644 index 000000000..226f4bb40 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerPlanProgressSummary.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.dto; + +import java.time.LocalDateTime; + +/** 平台按稳定来源柜查询的入库计划完整度事实。 */ +public record ContainerPlanProgressSummary( + String sourceContainerBookingId, + String containerNo, + long totalOrderCount, + long inboundCodeCount, + long missingInboundCodeCount, + long planCount, + long plannedInboundCodeCount, + long pendingPlanCount, + LocalDateTime plannedArrivalAt, + boolean allPlansReady, + String blockingReason +) { +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java index d04b41dea..c5472f7e7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java @@ -36,14 +36,14 @@ public final class LastlegOrderRequests { } } - public record PendingContainerQuery( + public record ContainerProgressQuery( @Min(1) long page, @Min(1) @Max(200) long size, String containerNo, LocalDateTime createdFrom, LocalDateTime createdTo ) { - public PendingContainerQuery { + public ContainerProgressQuery { if (page == 0) page = 1; if (size == 0) size = 20; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/PendingContainerSummary.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/PendingContainerSummary.java deleted file mode 100644 index 3b1769216..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/PendingContainerSummary.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.baiyue.lastleg.dto; - -import java.math.BigDecimal; - -/** 平台待分配尾程订单按来源货柜聚合的只读摘要。 */ -public record PendingContainerSummary( - String sourceContainerBookingId, - String containerNo, - long orderCount, - long inboundCodeCount, - long customerCount, - long totalPackageCount, - long totalPalletCount, - BigDecimal totalVolumeCbm, - boolean eligible, - String ineligibleReason -) { -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java index 6afe67cba..ea5d5ccda 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java @@ -6,9 +6,13 @@ import java.util.List; public final class ReceivingContainerResponses { private ReceivingContainerResponses() { } - public record Candidate(String containerNo, String status, int planCount, int inboundCodeCount, + public record Candidate(String sourceContainerBookingId, String containerNo, String status, + int totalOrderCount, int inboundCodeCount, int missingInboundCodeCount, + int planCount, int plannedInboundCodeCount, int pendingPlanCount, List destinationCodes, int plannedPalletCount, - int totalPackages, int receivedPackages, boolean currentAccount) { } + int totalPackages, int receivedPackages, + boolean operable, boolean allPlansReady, String blockReason, + LocalDateTime plannedArrivalAt, boolean currentAccount) { } public record WarehouseProfile(String warehouseId, String warehouseCode, String warehouseName) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java index 388bcf420..c38b9b291 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java @@ -16,6 +16,8 @@ public class ReceivingContainerSession { @TableId(type = IdType.INPUT) private Long id; private String customerId; + private String sourceContainerBookingId; + private String containerFactHash; private String containerNo; private Long overseasWarehouseId; private String overseasWarehouseName; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index e777c24ff..154db1285 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -96,28 +96,24 @@ public class ReceivingContainerService { public List candidates(String operatorKey, long overseasWarehouseId) { ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey)); requireSessionWarehouse(current, overseasWarehouseId); - Map> grouped = new LinkedHashMap<>(); - plans.selectList(new LambdaQueryWrapper() - .ne(InboundPlan::getStatus, "COMPLETED").orderByAsc(InboundPlan::getCreatedAt)).forEach(plan -> { - if (plan.getContainerNo() != null && !plan.getContainerNo().isBlank()) { - grouped.computeIfAbsent(plan.getContainerNo(), ignored -> new ArrayList<>()).add(plan); - } - }); + List orderFacts = orders.selectList(new LambdaQueryWrapper() + .isNotNull(LastlegOrder::getSourceContainerBookingId) + .isNotNull(LastlegOrder::getContainerNo) + .orderByAsc(LastlegOrder::getSourceContainerBookingId, LastlegOrder::getId)); + List planFacts = plans.selectList(new LambdaQueryWrapper().orderByAsc(InboundPlan::getId)); + List lineFacts = lines.selectList(new LambdaQueryWrapper().orderByAsc(InboundPlanLine::getId)); + Map> grouped = orderFacts.stream() + .filter(order -> trim(order.getSourceContainerBookingId()) != null && trim(order.getContainerNo()) != null) + .collect(Collectors.groupingBy(LastlegOrder::getSourceContainerBookingId, LinkedHashMap::new, Collectors.toList())); String operatorLockKey = activeOperatorKey(operatorKey); Set reservedByOthers = sessions.findActiveContainers().stream() .filter(session -> !operatorLockKey.equals(session.getActiveOperatorKey())) .map(ReceivingContainerSession::getActiveContainerKey).filter(Objects::nonNull).collect(Collectors.toSet()); return grouped.entrySet().stream() - .filter(entry -> eligible(entry.getValue(), overseasWarehouseId)) - .filter(entry -> !reservedByOthers.contains(activeContainerKey(entry.getKey()))) - .map(entry -> { - requireSingleCustomer(entry.getValue(), entry.getKey()); - Totals totals = totals(entry.getValue()); - boolean active = current != null && entry.getKey().equals(current.getContainerNo()); - return new ReceivingContainerResponses.Candidate(entry.getKey(), active ? current.getStatus() : "WAIT_SIGN", - entry.getValue().size(), totals.inboundCount(), totals.destinationCodes(), totals.plannedPalletCount(), - totals.total(), totals.received(), active); - }).toList(); + .map(entry -> readiness(entry.getKey(), entry.getValue(), planFacts, lineFacts, overseasWarehouseId)) + .filter(Objects::nonNull) + .filter(value -> !reservedByOthers.contains(activeContainerKey(value.containerNo()))) + .map(value -> candidate(value, current)).toList(); } public ReceivingContainerResponses.Session detail(String operatorKey, long overseasWarehouseId, String containerNo) { @@ -144,6 +140,7 @@ public class ReceivingContainerService { public ReceivingContainerResponses.Session prepare(String operatorKey, String operatorName, long overseasWarehouseId, String containerNo, ReceivingContainerRequests.Action request) { PrepareCommand command = new PrepareCommand(containerNo, request.idempotencyKey()); + ContainerReadiness readiness = requireReadyContainer(containerNo, overseasWarehouseId, true); ReceivingContainerOperation replay = replay(operatorKey, "PREPARE", request.idempotencyKey(), command); if (replay != null) { ReceivingContainerResponses.Session result = read(replay, ReceivingContainerResponses.Session.class); @@ -176,6 +173,8 @@ public class ReceivingContainerService { LocalDateTime now = LocalDateTime.now(); ReceivingContainerSession session = new ReceivingContainerSession(); session.setId(IdWorker.getId()); session.setCustomerId(customerId); session.setContainerNo(containerNo); + session.setSourceContainerBookingId(readiness.sourceContainerBookingId()); + session.setContainerFactHash(readiness.factHash()); session.setOverseasWarehouseId(overseasWarehouseId); session.setOverseasWarehouseName(receivingWarehouse.warehouseName()); session.setOperatorKey(operatorKey); session.setOperatorName(operatorName); @@ -381,9 +380,117 @@ public class ReceivingContainerService { ReceivingContainerSession session = lock ? sessions.lockActiveByOperator(key) : sessions.findActiveByOperator(key); if (session == null) throw notFound("Current receiving container session was not found"); + requireStableSessionFacts(session, lock); return session; } + private ContainerReadiness requireReadyContainer(String containerNo, long overseasWarehouseId, boolean lock) { + LambdaQueryWrapper query = new LambdaQueryWrapper() + .eq(LastlegOrder::getContainerNo, containerNo).orderByAsc(LastlegOrder::getId); + if (lock) query.last("FOR UPDATE"); + List orderFacts = orders.selectList(query); + Set bookingIds = orderFacts.stream().map(LastlegOrder::getSourceContainerBookingId) + .map(ReceivingContainerService::trim).filter(Objects::nonNull).collect(Collectors.toSet()); + if (orderFacts.isEmpty() || bookingIds.size() != 1) { + throw conflict("Container must resolve exactly one sourceContainerBookingId"); + } + List planFacts = plans.selectList(new LambdaQueryWrapper() + .eq(InboundPlan::getContainerNo, containerNo).orderByAsc(InboundPlan::getId)); + List lineFacts = lines.selectList(new LambdaQueryWrapper() + .in(InboundPlanLine::getOrderId, orderFacts.stream().map(LastlegOrder::getId).toList()) + .orderByAsc(InboundPlanLine::getId)); + ContainerReadiness value = readiness(bookingIds.iterator().next(), orderFacts, planFacts, lineFacts, + overseasWarehouseId); + if (value == null || !value.operable()) { + throw conflict(value == null ? "Container is assigned to another overseas warehouse" : value.blockReason()); + } + return value; + } + + private void requireStableSessionFacts(ReceivingContainerSession session, boolean lock) { + if (trim(session.getContainerFactHash()) == null) return; + ContainerReadiness current = requireReadyContainer(session.getContainerNo(), session.getOverseasWarehouseId(), lock); + if (!Objects.equals(session.getSourceContainerBookingId(), current.sourceContainerBookingId()) + || !Objects.equals(session.getContainerFactHash(), current.factHash())) { + throw conflict("Container orders or inbound plans changed after prepare; restart the receiving session"); + } + } + + private ContainerReadiness readiness(String sourceContainerBookingId, List orderFacts, + List planFacts, List lineFacts, + long overseasWarehouseId) { + Set orderIds = orderFacts.stream().map(LastlegOrder::getId).collect(Collectors.toSet()); + Map> linesByOrder = lineFacts.stream() + .filter(line -> orderIds.contains(line.getOrderId())) + .collect(Collectors.groupingBy(InboundPlanLine::getOrderId)); + Map plansById = planFacts.stream().collect(Collectors.toMap(InboundPlan::getId, + value -> value, (left, right) -> left)); + List matchedPlans = linesByOrder.values().stream().flatMap(List::stream) + .map(line -> plansById.get(line.getPlanId())).filter(Objects::nonNull).distinct().toList(); + Set assignedWarehouses = matchedPlans.stream().map(InboundPlan::getOverseasWarehouseId) + .filter(Objects::nonNull).collect(Collectors.toSet()); + orderFacts.stream().map(LastlegOrder::getOverseasWarehouseId).map(ReceivingContainerService::trim) + .filter(Objects::nonNull).forEach(value -> { + try { assignedWarehouses.add(Long.valueOf(value)); } + catch (NumberFormatException ignored) { assignedWarehouses.add(Long.MIN_VALUE); } + }); + if (assignedWarehouses.size() > 1) throw conflict("Container plans have inconsistent receiving warehouse assignments"); + if (!assignedWarehouses.isEmpty() && !assignedWarehouses.contains(overseasWarehouseId)) return null; + Set customers = orderFacts.stream().map(LastlegOrder::getCustomerId).collect(Collectors.toSet()); + String containerNo = orderFacts.stream().map(LastlegOrder::getContainerNo).filter(Objects::nonNull).findFirst().orElse(null); + int totalOrders = orderFacts.size(); + int inboundCodes = (int) orderFacts.stream().map(LastlegOrder::getInboundCode).map(ReceivingContainerService::trim) + .filter(Objects::nonNull).distinct().count(); + int plannedOrders = (int) orderFacts.stream().filter(order -> { + List values = linesByOrder.getOrDefault(order.getId(), List.of()); + return values.size() == 1 && plansById.containsKey(values.get(0).getPlanId()); + }).count(); + int planCount = matchedPlans.size(); + boolean allPlansReady = totalOrders > 0 && customers.size() == 1 && inboundCodes == totalOrders + && plannedOrders == totalOrders && planCount == totalOrders; + String customerId = customers.size() == 1 ? customers.iterator().next() : null; + boolean shippingReady = customerId != null && shippingGates.receivingAvailable(customerId, containerNo) == 1; + boolean operable = allPlansReady && shippingReady; + int pending = Math.max(0, totalOrders - plannedOrders); + String blockReason = operable ? null : (!allPlansReady + ? plannedOrders + "个入仓码已生成计划," + pending + "个订单未操作/缺入仓码" + : "柜号尚未达到海外收货可操作节点"); + LocalDateTime arrival = orderFacts.stream().map(LastlegOrder::getEstimatedWarehouseAt) + .filter(Objects::nonNull).min(LocalDateTime::compareTo).orElse(null); + String factHash = hash(orderFacts.stream().sorted(Comparator.comparing(LastlegOrder::getId)).map(order -> { + List values = linesByOrder.getOrDefault(order.getId(), List.of()); + return order.getId() + ":" + trim(order.getInboundCode()) + ":" + values.stream() + .map(InboundPlanLine::getPlanId).sorted().toList(); + }).toList()); + Map ordersById = orderFacts.stream().collect(Collectors.toMap(LastlegOrder::getId, + value -> value, (left, right) -> left)); + List matchedRefs = lineFacts.stream().filter(line -> orderIds.contains(line.getOrderId())) + .map(line -> new InboundRef(plansById.get(line.getPlanId()), line, ordersById.get(line.getOrderId()))) + .filter(ref -> ref.plan() != null && ref.order() != null).toList(); + List inboundValues = matchedRefs.stream().map(this::inbound).toList(); + List destinationCodes = inboundValues.stream().map(this::destinationCode) + .filter(Objects::nonNull).distinct().sorted().toList(); + Totals totals = new Totals(inboundValues.size(), destinationCodes, + inboundValues.stream().mapToInt(ReceivingContainerResponses.Inbound::plannedPalletCount).sum(), + inboundValues.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum(), + inboundValues.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum()); + return new ContainerReadiness(sourceContainerBookingId, containerNo, totalOrders, inboundCodes, + totalOrders - inboundCodes, planCount, plannedOrders, pending, allPlansReady, operable, + blockReason, arrival, factHash, totals); + } + + private ReceivingContainerResponses.Candidate candidate(ContainerReadiness value, + ReceivingContainerSession current) { + boolean active = current != null && value.containerNo().equals(current.getContainerNo()); + Totals totals = value.totals(); + return new ReceivingContainerResponses.Candidate(value.sourceContainerBookingId(), value.containerNo(), + active ? current.getStatus() : "WAIT_SIGN", value.totalOrderCount(), value.inboundCodeCount(), + value.missingInboundCodeCount(), value.planCount(), value.plannedInboundCodeCount(), + value.pendingPlanCount(), totals.destinationCodes(), totals.plannedPalletCount(), totals.total(), + totals.received(), value.operable(), value.allPlansReady(), value.blockReason(), + value.plannedArrivalAt(), active); + } + private ReceivingContainerSession requireSigned(String operatorKey) { ReceivingContainerSession session = requireCurrent(operatorKey, true); if (!List.of("SIGNED", "RECEIVING").contains(session.getStatus())) { @@ -858,5 +965,10 @@ public class ReceivingContainerService { private record FbaSkuAllocation(InboundRef ref, String skuCode, String productName, int packages) { } private record Totals(int inboundCount, List destinationCodes, int plannedPalletCount, int total, int received) { } + private record ContainerReadiness(String sourceContainerBookingId, String containerNo, + int totalOrderCount, int inboundCodeCount, int missingInboundCodeCount, + int planCount, int plannedInboundCodeCount, int pendingPlanCount, + boolean allPlansReady, boolean operable, String blockReason, + LocalDateTime plannedArrivalAt, String factHash, Totals totals) { } private record PrepareCommand(String containerNo, String idempotencyKey) { } } 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 eb6766c27..9a7915a2e 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 @@ -3,7 +3,8 @@ package com.baiyue.lastleg.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.dto.LastlegOrderRequests; -import com.baiyue.lastleg.dto.PendingContainerSummary; +import com.baiyue.lastleg.dto.ContainerPlanProgressDetail; +import com.baiyue.lastleg.dto.ContainerPlanProgressSummary; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; @@ -13,35 +14,69 @@ import java.util.List; @Mapper public interface LastlegOrderMapper extends BaseMapper { - String PENDING_CONTAINER_FILTER = " FROM ll_order o " + + String CONTAINER_PROGRESS_FILTER = " FROM ll_order o LEFT JOIN ll_inbound_plan p " + + "ON p.customer_id=o.customer_id AND p.source_order_id=o.id " + "WHERE o.source_container_booking_id IS NOT NULL AND o.source_container_booking_id<>'' " + "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 SUM(CASE WHEN o.current_status IN ('PENDING_ASSIGNMENT','DESTINATION_ASSIGNED') THEN 1 ELSE 0 END)>0 "; + "GROUP BY o.source_container_booking_id "; - @Select("") - List pagePendingContainers(@Param("q") LastlegOrderRequests.PendingContainerQuery query, - @Param("offset") long offset, - @Param("limit") long limit); + "WHEN SUM(CASE WHEN NULLIF(TRIM(o.inbound_code),'') IS NULL THEN 1 ELSE 0 END)>0 " + + "THEN CONCAT('存在',SUM(CASE WHEN NULLIF(TRIM(o.inbound_code),'') IS NULL THEN 1 ELSE 0 END),'票缺少入仓码') " + + "WHEN 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)>0 THEN CONCAT('存在',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),'票尚未生成有效入库计划') " + + "WHEN COUNT(DISTINCT p.id)<>COUNT(*) THEN '入库计划与尾程订单关联不一致' ELSE NULL END AS blockingReason "; + + @Select("") + List pageContainerProgress( + @Param("q") LastlegOrderRequests.ContainerProgressQuery query, + @Param("offset") long offset, + @Param("limit") long limit); @Select("") - long countPendingContainers(@Param("q") LastlegOrderRequests.PendingContainerQuery query); + CONTAINER_PROGRESS_FILTER + ") container_progress") + long countContainerProgress(@Param("q") LastlegOrderRequests.ContainerProgressQuery query); + + @Select("") + ContainerPlanProgressSummary findContainerProgress( + @Param("sourceContainerBookingId") String sourceContainerBookingId); + + @Select("SELECT o.id AS orderId,o.customer_id AS customerId,o.order_no AS orderNo,o.inbound_code AS inboundCode," + + "o.current_status AS orderStatus,p.id AS planId,p.plan_no AS planNo,p.status AS planStatus," + + "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 TRUE ELSE FALSE END AS planValid," + + "o.estimated_warehouse_at AS plannedArrivalAt FROM ll_order o LEFT JOIN ll_inbound_plan p " + + "ON p.customer_id=o.customer_id AND p.source_order_id=o.id " + + "WHERE o.source_container_booking_id=#{sourceContainerBookingId} " + + "ORDER BY CASE WHEN o.estimated_warehouse_at IS NULL THEN 1 ELSE 0 END," + + "o.estimated_warehouse_at ASC,o.customer_id ASC,o.id ASC") + List findContainerProgressItems( + @Param("sourceContainerBookingId") String sourceContainerBookingId); @Select("SELECT * FROM ll_order WHERE customer_id=#{customerId} AND source_region=#{sourceRegion} " + "AND source_inbound_id=#{sourceInboundId} LIMIT 1") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/FbaWarehouseValidator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/FbaWarehouseValidator.java new file mode 100644 index 000000000..5dd39f930 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/FbaWarehouseValidator.java @@ -0,0 +1,93 @@ +package com.baiyue.lastleg.service; + +import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient; +import feign.FeignException; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; +import java.util.Objects; + +/** 通过国内 Warehouse Service 权威主档校验尾程转仓 FBA 目的仓。 */ +@Component +public class FbaWarehouseValidator { + private static final String INVALID_MESSAGE = "所选FBA仓不存在或已停用"; + + @Resource private DomesticWarehouseDirectoryClient client; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + + public VerifiedFbaWarehouse required(String requestedId, String requestedCode, String requestedName) { + long warehouseId = parseId(requestedId); + var response = lookup(warehouseId); + if (response == null || !response.successful() || response.data() == null + || response.data().warehouse() == null) { + throw invalid(); + } + DomesticWarehouseDirectoryClient.Warehouse warehouse = response.data().warehouse(); + if (!Objects.equals(warehouse.id(), warehouseId) + || !Objects.equals(warehouse.tenantId(), 0L) + || !"FBA".equals(warehouse.warehouseType()) + || !"ACTIVE".equals(warehouse.status()) + || !same(requestedCode, warehouse.warehouseCode()) + || !same(requestedName, warehouse.warehouseName())) { + throw invalid(); + } + String region = firstRegion(response.data().addresses()); + return new VerifiedFbaWarehouse(warehouse.id(), warehouse.warehouseCode().trim(), + warehouse.warehouseName().trim(), trimToNull(warehouse.countryCode()), region); + } + + private com.baiyue.common.core.api.ApiResponse lookup(long warehouseId) { + try { + return client.activeWarehouse(warehouseId, internalToken); + } catch (FeignException exception) { + if (exception.status() >= 400 && exception.status() < 500) throw invalid(); + throw unavailable(exception); + } catch (ResponseStatusException exception) { + throw exception; + } catch (RuntimeException exception) { + throw unavailable(exception); + } + } + + private long parseId(String value) { + try { + long id = Long.parseLong(value == null ? "" : value.trim()); + if (id <= 0) throw invalid(); + return id; + } catch (NumberFormatException exception) { + throw invalid(); + } + } + + private boolean same(String requested, String actual) { + return StringUtils.hasText(requested) && StringUtils.hasText(actual) + && requested.trim().equals(actual.trim()); + } + + private String firstRegion(List addresses) { + if (addresses == null) return null; + return addresses.stream().filter(Objects::nonNull) + .map(DomesticWarehouseDirectoryClient.Address::stateProvince) + .map(FbaWarehouseValidator::trimToNull).filter(Objects::nonNull).findFirst().orElse(null); + } + + private ResponseStatusException invalid() { + return new ResponseStatusException(HttpStatus.BAD_REQUEST, INVALID_MESSAGE); + } + + private ResponseStatusException unavailable(RuntimeException cause) { + return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "国内FBA仓主数据暂不可用", cause); + } + + private static String trimToNull(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + + public record VerifiedFbaWarehouse(long id, String code, String name, String countryCode, String region) { } +} 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 bb61120ea..5a4a91e64 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 @@ -6,7 +6,8 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.lastleg.dto.LastlegOrderDetailResponse; import com.baiyue.lastleg.dto.LastlegOrderRequests; import com.baiyue.lastleg.dto.LastlegOrderSummary; -import com.baiyue.lastleg.dto.PendingContainerSummary; +import com.baiyue.lastleg.dto.ContainerPlanProgressDetail; +import com.baiyue.lastleg.dto.ContainerPlanProgressSummary; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.entity.LastlegOrderDetail; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; @@ -39,6 +40,7 @@ public class LastlegOrderService { @Resource private LastlegOrderDetailMapper detailMapper; @Resource private LastlegOrderStatusHistoryMapper historyMapper; @Resource private InboundPlanCartonMapper cartonMapper; + @Resource private FbaWarehouseValidator fbaWarehouseValidator; public PageResult page(String customerId, LastlegOrderRequests.Query query) { Page page = orderMapper.selectPage( @@ -48,20 +50,33 @@ public class LastlegOrderService { } @Transactional(readOnly = true) - public PageResult pendingContainerPage( - LastlegOrderRequests.PendingContainerQuery query) { + public PageResult containerProgressPage( + LastlegOrderRequests.ContainerProgressQuery query) { if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "createdFrom must not be after createdTo"); } - long total = orderMapper.countPendingContainers(query); - List records = total == 0 ? List.of() - : orderMapper.pagePendingContainers(query, (query.page() - 1) * query.size(), query.size()); + long total = orderMapper.countContainerProgress(query); + List records = total == 0 ? List.of() + : orderMapper.pageContainerProgress(query, (query.page() - 1) * query.size(), query.size()); long pages = total == 0 ? 0 : (total + query.size() - 1) / query.size(); return new PageResult<>(records, query.page(), query.size(), total, pages); } + @Transactional(readOnly = true) + public ContainerPlanProgressDetail containerProgressDetail(String sourceContainerBookingId) { + String bookingId = sourceContainerBookingId == null ? null : sourceContainerBookingId.trim(); + if (!StringUtils.hasText(bookingId)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订ID不能为空"); + } + ContainerPlanProgressSummary summary = orderMapper.findContainerProgress(bookingId); + if (summary == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜不存在"); + } + return new ContainerPlanProgressDetail(summary, orderMapper.findContainerProgressItems(bookingId)); + } + public LastlegOrderDetailResponse detail(String customerId, long orderId) { LastlegOrder order = findOwned(customerId, orderId); List cargo = detailMapper.selectList(new LambdaQueryWrapper() @@ -83,6 +98,9 @@ public class LastlegOrderService { if (ids.isEmpty()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "At least one order is required"); } + FbaWarehouseValidator.VerifiedFbaWarehouse target = fbaWarehouseValidator.required( + request.warehouseId(), request.warehouseCode(), request.warehouseName()); + String warehouseId = Long.toString(target.id()); List orders = orderMapper.selectList(new LambdaQueryWrapper() .eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId) .in(LastlegOrder::getId, ids).last("FOR UPDATE")); @@ -95,25 +113,25 @@ public class LastlegOrderService { LocalDateTime now = LocalDateTime.now(); for (LastlegOrder order : orders) { String fromStatus = order.getCurrentStatus(); - order.setTransferWarehouseId(request.warehouseId()); - order.setTransferWarehouseCode(request.warehouseCode()); - order.setTransferWarehouseName(request.warehouseName()); + order.setTransferWarehouseId(warehouseId); + order.setTransferWarehouseCode(target.code()); + order.setTransferWarehouseName(target.name()); order.setCurrentStatus(DESTINATION_ASSIGNED); order.setUpdatedAt(now); orderMapper.updateById(order); LastlegOrderDetail detailUpdate = new LastlegOrderDetail(); - detailUpdate.setDestinationWarehouseId(request.warehouseId()); - detailUpdate.setDestinationWarehouseCode(request.warehouseCode()); - detailUpdate.setDestinationWarehouseName(request.warehouseName()); + detailUpdate.setDestinationWarehouseId(warehouseId); + detailUpdate.setDestinationWarehouseCode(target.code()); + detailUpdate.setDestinationWarehouseName(target.name()); detailMapper.update(detailUpdate, new LambdaQueryWrapper() .eq(LastlegOrderDetail::getCustomerId, order.getCustomerId()) .eq(LastlegOrderDetail::getOrderId, order.getId())); InboundPlanCarton cartonUpdate = new InboundPlanCarton(); - cartonUpdate.setDestinationWarehouseId(request.warehouseId()); - cartonUpdate.setDestinationWarehouseCode(request.warehouseCode()); - cartonUpdate.setDestinationWarehouseName(request.warehouseName()); + cartonUpdate.setDestinationWarehouseId(warehouseId); + cartonUpdate.setDestinationWarehouseCode(target.code()); + cartonUpdate.setDestinationWarehouseName(target.name()); cartonMapper.update(cartonUpdate, new LambdaQueryWrapper() .eq(InboundPlanCarton::getCustomerId, order.getCustomerId()) .eq(InboundPlanCarton::getOrderId, order.getId())); @@ -124,7 +142,7 @@ public class LastlegOrderService { history.setFromStatus(fromStatus); history.setToStatus(DESTINATION_ASSIGNED); history.setRemark(StringUtils.hasText(request.remark()) - ? request.remark() : "Destination warehouse assigned: " + request.warehouseCode()); + ? request.remark() : "Destination warehouse assigned: " + target.code()); history.setCreatedAt(now); historyMapper.insert(history); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java index 8cdd78592..ffb17be0a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -190,6 +190,7 @@ public class LoadingCompletedConsumer { order.setContainerStatus("待转柜"); order.setAbnormal(false); order.setDetained(false); order.setPackageCount(source.packageCount()); order.setPalletCount(dropshipPallets.size()); order.setProductQuantity(source.productQuantity()); order.setVolumeCbm(source.volumeCbm()); + order.setEstimatedWarehouseAt(parsePlannedArrival(payload.estimatedWarehouseArrivalAt())); order.setVolumeWeightKg(source.items().stream().map(FirstLegLoadingPlanCompletedV1.Item::dimensionalWeightKg) .filter(Objects::nonNull).reduce(BigDecimal.ZERO,BigDecimal::add)); order.setCurrentStatus("PENDING_ASSIGNMENT"); order.setSourceEventId(event.eventId()); @@ -237,6 +238,15 @@ public class LoadingCompletedConsumer { } } + private LocalDateTime parsePlannedArrival(String value) { + if (value == null || value.isBlank()) return null; + try { + return LocalDateTime.parse(value.trim()); + } catch (java.time.format.DateTimeParseException exception) { + throw new IllegalArgumentException("事件计划到仓时间 estimatedWarehouseArrivalAt 无效", exception); + } + } + private void createInboundOrderSnapshot(EventEnvelope event, FirstLegLoadingPlanCompletedV1 payload, FirstLegLoadingPlanCompletedV1.Order source, LastlegOrder order, String orderHash) { diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 5204fe1fb..2d1bf4d6b 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -101,7 +101,7 @@ CREATE TABLE IF NOT EXISTS ll_order ( KEY idx_ll_order_group(customer_id,order_no), KEY idx_ll_order_container(customer_id,container_no), KEY idx_ll_order_list(customer_id,current_status,created_at), - KEY idx_ll_order_pending_container(source_container_booking_id,current_status,created_at) + KEY idx_ll_order_container_progress(source_container_booking_id,estimated_warehouse_at,created_at,id) ) ENGINE=InnoDB COMMENT='尾程订单主表'; CREATE TABLE IF NOT EXISTS ll_order_detail ( @@ -199,8 +199,10 @@ ALTER TABLE ll_loading_event_inbox COMMENT='头程装柜完成事件接收幂等 ALTER TABLE ll_order COMMENT='尾程订单主表'; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_container_booking_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN source_container_booking_id VARCHAR(128) NULL COMMENT ''来源头程货柜预订ID'' AFTER source_loading_plan_id')); PREPARE lastleg_order_container_booking_stmt FROM @ddl; EXECUTE lastleg_order_container_booking_stmt; DEALLOCATE PREPARE lastleg_order_container_booking_stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='idx_ll_order_pending_container'), 'SELECT 1', 'ALTER TABLE ll_order ADD INDEX idx_ll_order_pending_container(source_container_booking_id,current_status,created_at)')); +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='idx_ll_order_pending_container'), 'ALTER TABLE ll_order DROP INDEX idx_ll_order_pending_container', 'SELECT 1')); PREPARE lastleg_order_pending_container_idx_stmt FROM @ddl; EXECUTE lastleg_order_pending_container_idx_stmt; DEALLOCATE PREPARE lastleg_order_pending_container_idx_stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_order' AND index_name='idx_ll_order_container_progress'), 'SELECT 1', 'ALTER TABLE ll_order ADD INDEX idx_ll_order_container_progress(source_container_booking_id,estimated_warehouse_at,created_at,id)')); +PREPARE lastleg_order_container_progress_idx_stmt FROM @ddl; EXECUTE lastleg_order_container_progress_idx_stmt; DEALLOCATE PREPARE lastleg_order_container_progress_idx_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER order_type')); PREPARE lastleg_order_destination_type_stmt FROM @ddl; EXECUTE lastleg_order_destination_type_stmt; DEALLOCATE PREPARE lastleg_order_destination_type_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_inbound_order' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_inbound_order ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA按箱数接收、OVERSEAS逐箱扫码'' AFTER destination_warehouse_name')); @@ -708,6 +710,7 @@ WHERE (l.fba_warehouse_code IS NULL AND o.fba_warehouse_code IS NOT NULL) CREATE TABLE IF NOT EXISTS ll_receiving_container_session ( id BIGINT NOT NULL COMMENT '柜号签收会话ID', customer_id VARCHAR(128) NOT NULL COMMENT '所选柜解析出的客户标识', + source_container_booking_id VARCHAR(128) NOT NULL COMMENT '稳定来源柜预订ID', container_fact_hash VARCHAR(64) NOT NULL COMMENT '领取时整柜订单与计划事实摘要', container_no VARCHAR(64) NOT NULL COMMENT '集装箱号', overseas_warehouse_id BIGINT NOT NULL COMMENT '操作员Token绑定并冻结的海外实际收货共享物理仓ID', overseas_warehouse_name VARCHAR(255) NOT NULL COMMENT '仓库服务校验后的海外实际收货共享物理仓名称快照', operator_key VARCHAR(128) NOT NULL COMMENT '操作账号唯一标识', operator_name VARCHAR(255) NULL COMMENT '操作账号名称', active_operator_key VARCHAR(255) NULL COMMENT '未完成会话平台操作账号全局唯一锁键', active_container_key VARCHAR(255) NULL COMMENT '未完成会话柜号全局唯一锁键', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED', 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 0247d96a1..3eba290c0 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -158,8 +158,8 @@ paths: responses: { '200': { description: 统一 ApiResponse 包装的尾程订单分页;列表行 destinationType 为 FBA 或 OVERSEAS } } /api/platform/v1/last-leg/orders/container-summaries: get: - summary: 分页查询待分配柜级摘要 - description: 权限 platform:overseas-warehouse:order:list;平台跨客户按稳定 sourceContainerBookingId 聚合。仅返回至少有一票待分配的柜;eligible=false 表示柜内状态或柜号不一致,禁止转换。 + summary: 分页查询柜级入库计划进度 + description: 权限 platform:overseas-warehouse:order:list;平台跨客户按稳定 sourceContainerBookingId 服务端聚合,不受订单分页影响。plannedArrivalAt 仅来自装柜完成事件 estimatedWarehouseArrivalAt 冻结的 ll_order.estimated_warehouse_at;按该时间升序、null 最后、稳定柜 ID 升序。 security: [{ bearerAuth: [] }] parameters: - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } @@ -185,7 +185,58 @@ paths: pages: { type: integer, format: int64 } records: type: array - items: { $ref: '#/components/schemas/PendingContainerSummary' } + items: { $ref: '#/components/schemas/ContainerPlanProgressSummary' } + /api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}: + get: + summary: 查询柜级入库计划进度详情 + description: 权限 platform:overseas-warehouse:order:list;按稳定柜 ID 返回柜内全部尾程订单、入仓码及各自计划状态,不受订单分页截断。 + security: [{ bearerAuth: [] }] + parameters: + - { name: sourceContainerBookingId, in: path, required: true, schema: { type: string, minLength: 1, maxLength: 128 } } + responses: + '200': + description: 统一 ApiResponse 包装的柜级进度详情 + content: + application/json: + schema: + type: object + properties: + code: { type: string, enum: ['200'] } + data: { $ref: '#/components/schemas/ContainerPlanProgressDetail' } + '404': { description: 稳定柜 ID 不存在 } + /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仓不存在或已停用”。 + security: [{ bearerAuth: [] }] + parameters: + - { name: id, in: path, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + - { name: customerId, in: query, required: false, schema: { type: string, pattern: '^[1-9][0-9]*$' } } + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/FbaTransferDestination' } + responses: + '200': { description: 统一 ApiResponse 包装的更新订单数 } + '400': { description: 所选FBA仓不存在、已停用、类型错误或字段不一致 } + '503': { description: 国内FBA仓权威主数据暂不可用,写入失败关闭 } + /api/platform/v1/last-leg/orders/destination: + put: + summary: 批量将尾程订单转入 FBA 目的仓 + description: 权限 platform:overseas-warehouse: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]*$' } } + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/FbaBatchTransferDestination' } + responses: + '200': { description: 统一 ApiResponse 包装的更新订单数 } + '400': { description: 所选FBA仓不存在、已停用、类型错误或字段不一致 } + '503': { description: 国内FBA仓权威主数据暂不可用,写入失败关闭 } /api/platform/v1/last-leg/inbound-plans/convert: post: summary: 平台生成入库计划 @@ -592,20 +643,63 @@ paths: responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '409': { description: 非法状态迁移或关联尾程单缺失 } } components: schemas: - PendingContainerSummary: + FbaTransferDestination: type: object - required: [sourceContainerBookingId, orderCount, inboundCodeCount, customerCount, totalPackageCount, totalPalletCount, totalVolumeCbm, eligible] + required: [warehouseId, warehouseCode, warehouseName] + properties: + warehouseId: { type: string, pattern: '^[1-9][0-9]*$', description: 国内 Warehouse Service 平台公共 FBA 仓 ID } + warehouseCode: { type: string, minLength: 1, maxLength: 128 } + warehouseName: { type: string, minLength: 1, maxLength: 255 } + remark: { type: string, nullable: true, maxLength: 500 } + FbaBatchTransferDestination: + allOf: + - { $ref: '#/components/schemas/FbaTransferDestination' } + - type: object + required: [orderIds] + properties: + orderIds: + type: array + minItems: 1 + maxItems: 200 + uniqueItems: true + items: { type: string, pattern: '^[1-9][0-9]*$' } + ContainerPlanProgressSummary: + type: object + required: [sourceContainerBookingId, totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount, pendingPlanCount, allPlansReady] properties: sourceContainerBookingId: { type: string } containerNo: { type: string, nullable: true } - orderCount: { type: integer, format: int64 } + totalOrderCount: { type: integer, format: int64 } inboundCodeCount: { type: integer, format: int64 } - customerCount: { type: integer, format: int64 } - totalPackageCount: { type: integer, format: int64 } - totalPalletCount: { type: integer, format: int64 } - totalVolumeCbm: { type: number } - eligible: { type: boolean } - ineligibleReason: { type: string, nullable: true } + missingInboundCodeCount: { type: integer, format: int64 } + planCount: { type: integer, format: int64 } + plannedInboundCodeCount: { type: integer, format: int64 } + pendingPlanCount: { 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 } + ContainerPlanProgressDetail: + type: object + required: [summary, orders] + properties: + summary: { $ref: '#/components/schemas/ContainerPlanProgressSummary' } + orders: + type: array + items: { $ref: '#/components/schemas/ContainerPlanProgressItem' } + ContainerPlanProgressItem: + type: object + required: [orderId, customerId, orderStatus, planValid] + properties: + orderId: { type: string, pattern: '^[1-9][0-9]*$' } + customerId: { type: string } + orderNo: { type: string, nullable: true } + inboundCode: { type: string, nullable: true } + orderStatus: { type: string } + planId: { type: string, nullable: true, pattern: '^[1-9][0-9]*$' } + planNo: { type: string, nullable: true } + planStatus: { type: string, nullable: true } + planValid: { type: boolean } + plannedArrivalAt: { type: string, format: date-time, nullable: true, description: ll_order.estimated_warehouse_at } PdaWarehouseScan: type: object required: [code] @@ -773,16 +867,25 @@ components: warehouseName: { type: string, description: 国内Warehouse Service主档维护的当前中文名称 } ReceivingCandidate: type: object - required: [containerNo, status, planCount, inboundCodeCount, destinationCodes, plannedPalletCount, totalPackages, receivedPackages, currentAccount] + required: [sourceContainerBookingId, containerNo, status, totalOrderCount, inboundCodeCount, missingInboundCodeCount, planCount, plannedInboundCodeCount, pendingPlanCount, destinationCodes, plannedPalletCount, totalPackages, receivedPackages, operable, allPlansReady, currentAccount] properties: + sourceContainerBookingId: { type: string, description: 头程与尾程共享的稳定柜身份 } containerNo: { type: string } status: { type: string, enum: [WAIT_SIGN, SIGNED, RECEIVING, COMPLETED] } + totalOrderCount: { type: integer, minimum: 1 } + missingInboundCodeCount: { type: integer, minimum: 0 } planCount: { type: integer } inboundCodeCount: { type: integer } + plannedInboundCodeCount: { type: integer, minimum: 0 } + pendingPlanCount: { type: integer, minimum: 0 } destinationCodes: { type: array, uniqueItems: true, items: { type: string } } plannedPalletCount: { type: integer } totalPackages: { type: integer } receivedPackages: { type: integer } + operable: { type: boolean } + allPlansReady: { type: boolean } + blockReason: { type: string, nullable: true } + plannedArrivalAt: { type: string, format: date-time, nullable: true } currentAccount: { type: boolean } ReceivingSession: type: object diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClientContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClientContractTest.java new file mode 100644 index 000000000..cf96c781e --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClientContractTest.java @@ -0,0 +1,38 @@ +package com.baiyue.lastleg.client; + +import org.junit.jupiter.api.Test; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; + +import java.lang.reflect.Method; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +class DomesticWarehouseDirectoryClientContractTest { + @Test + void freezesActivePlatformWarehouseLookupAndDto() throws Exception { + FeignClient feign = DomesticWarehouseDirectoryClient.class.getAnnotation(FeignClient.class); + assertThat(feign.name()).isEqualTo("warehouse-service"); + assertThat(feign.contextId()).isEqualTo("domesticWarehouseDirectoryClient"); + assertThat(feign.url()).isEqualTo("${baiyue.clients.domestic-warehouse.url:}"); + + Method method = DomesticWarehouseDirectoryClient.class.getDeclaredMethod( + "activeWarehouse", long.class, String.class); + assertThat(method.getAnnotation(GetMapping.class).value()) + .containsExactly("/internal/warehouses/0/{warehouseId}/active"); + assertThat(method.getParameters()[0].getAnnotation(PathVariable.class).value()) + .isEqualTo("warehouseId"); + assertThat(method.getParameters()[1].getAnnotation(RequestHeader.class).value()) + .isEqualTo("X-Internal-Service-Token"); + assertThat(Arrays.stream(DomesticWarehouseDirectoryClient.Warehouse.class.getRecordComponents()) + .map(value -> value.getName() + ":" + value.getType().getSimpleName())) + .containsExactly("id:Long", "tenantId:Long", "warehouseCode:String", "warehouseName:String", + "warehouseType:String", "countryCode:String", "status:String"); + assertThat(Arrays.stream(DomesticWarehouseDirectoryClient.Address.class.getRecordComponents()) + .map(value -> value.getName() + ":" + value.getType().getSimpleName())) + .containsExactly("stateProvince:String"); + } +} 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 c1b6b222b..47d14efe8 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 @@ -27,6 +27,7 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.when; class LastlegOrderControllerTest { + // Controller contract tests intentionally instantiate the controller without a Spring context here. private final LastlegOrderService service = mock(LastlegOrderService.class); private final LastlegOrderController controller = controller(); private final LastlegOrderRequests.Query query = new LastlegOrderRequests.Query( @@ -52,20 +53,27 @@ class LastlegOrderControllerTest { @Test void platformOperatorCanQueryCrossCustomerContainerSummaries() { - var summaryQuery = new LastlegOrderRequests.PendingContainerQuery(1, 20, "CONT-", null, null); + var summaryQuery = new LastlegOrderRequests.ContainerProgressQuery(1, 20, "CONT-", null, null); controller.containerSummaries(user(0L, "PLATFORM"), summaryQuery); - verify(service).pendingContainerPage(summaryQuery); + verify(service).containerProgressPage(summaryQuery); } @Test void containerSummariesRejectMerchantIdentity() { - var summaryQuery = new LastlegOrderRequests.PendingContainerQuery(1, 20, null, null, null); + var summaryQuery = new LastlegOrderRequests.ContainerProgressQuery(1, 20, null, null, null); assertThrows(ResponseStatusException.class, () -> controller.containerSummaries(user(100L, "MERCHANT"), summaryQuery)); - verify(service, never()).pendingContainerPage(summaryQuery); + verify(service, never()).containerProgressPage(summaryQuery); + } + + @Test + void platformOperatorCanQueryUnpagedContainerDetailByStableId() { + controller.containerSummaryDetail(user(0L, "PLATFORM"), "BOOKING-1"); + + verify(service).containerProgressDetail("BOOKING-1"); } @Test diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java index 2183d612e..5b430f545 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java @@ -39,7 +39,8 @@ class ReceivingContainerControllerTest { @Test void candidatesDerivesOperatorFromPlatformWarehousePickerIdentity() { when(service.candidates("42", 900L)).thenReturn(List.of(new ReceivingContainerResponses.Candidate( - "CONT-1", "WAIT_SIGN", 2, 2, List.of("ONT8"), 4, 20, 0, false))); + "BOOKING-1", "CONT-1", "WAIT_SIGN", 2, 2, 0, 2, 2, 0, + List.of("ONT8"), 4, 20, 0, true, true, null, null, false))); controller.candidates(picker()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java index 7270ef952..930923ecb 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java @@ -87,11 +87,18 @@ class ReceivingContainerServiceBehaviorTest { } @Test - void containerIsHiddenAndPrepareRejectedBeforePickupReady() { + void containerRemainsVisibleButPrepareIsRejectedBeforePickupReady() { when(plans.selectList(any())).thenReturn(List.of(plan("CONT-A"))); + when(lines.selectList(any())).thenReturn(List.of(line("FBA"))); + LastlegOrder order = containerOrder(1L, "100", "CONT-A", "BOOKING-A", "IN-1"); + when(orders.selectList(any())).thenReturn(List.of(order)); when(shippingGates.receivingAvailable("100", "CONT-A")).thenReturn(0); - assertThat(service.candidates("7", 100L)).isEmpty(); + assertThat(service.candidates("7", 100L)).singleElement().satisfies(candidate -> { + assertThat(candidate.operable()).isFalse(); + assertThat(candidate.allPlansReady()).isTrue(); + assertThat(candidate.blockReason()).contains("尚未达到"); + }); assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-A", new ReceivingContainerRequests.Action("not-ready"))) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); @@ -101,7 +108,10 @@ class ReceivingContainerServiceBehaviorTest { @Test void prepareIdempotencyHashIncludesContainerPath() { when(plans.selectList(any())).thenReturn(List.of(plan("CONT-A"))); - when(lines.selectList(any())).thenReturn(List.of()); + when(lines.selectList(any())).thenReturn(List.of(line("FBA"))); + LastlegOrder order = containerOrder(1L, "100", "CONT-A", "BOOKING-A", "IN-1"); + when(orders.selectList(any())).thenReturn(List.of(order)); + when(orders.selectById(1L)).thenReturn(order); when(sessions.insert(any(ReceivingContainerSession.class))).thenReturn(1); when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1); when(warehouse.requireActiveSharedWarehouse(100L)).thenReturn(warehouseReference()); @@ -132,14 +142,23 @@ class ReceivingContainerServiceBehaviorTest { InboundPlan second = plan("CONT-B"); second.setId(3L); second.setCustomerId("200"); when(plans.selectList(any())).thenReturn(List.of(first, second), List.of(first), List.of(second)); when(lines.selectList(any())).thenReturn(List.of()); + when(orders.selectList(any())).thenReturn(List.of( + containerOrder(1L, "100", "CONT-A", "BOOKING-A", "IN-1"), + containerOrder(2L, "200", "CONT-B", "BOOKING-B", "IN-2"))); assertThat(service.candidates("7", 100L)).extracting("containerNo").containsExactly("CONT-A", "CONT-B"); InboundPlan ambiguous = plan("CONT-A"); ambiguous.setId(4L); ambiguous.setCustomerId("200"); org.mockito.Mockito.reset(plans, sessions); when(plans.selectList(any())).thenReturn(List.of(first, ambiguous)); - assertThatThrownBy(() -> service.candidates("7", 100L)).isInstanceOf(ResponseStatusException.class) - .hasMessageContaining("exactly one customer"); + when(lines.selectList(any())).thenReturn(List.of()); + when(orders.selectList(any())).thenReturn(List.of( + containerOrder(1L, "100", "CONT-A", "BOOKING-A", "IN-1"), + containerOrder(2L, "200", "CONT-A", "BOOKING-A", "IN-2"))); + assertThat(service.candidates("7", 100L)).singleElement().satisfies(candidate -> { + assertThat(candidate.operable()).isFalse(); + assertThat(candidate.blockReason()).contains("未操作/缺入仓码"); + }); } @Test @@ -150,6 +169,12 @@ class ReceivingContainerServiceBehaviorTest { when(plans.selectList(any())).thenReturn(List.of(unassigned, same, other), List.of(unassigned), List.of(same)); when(lines.selectList(any())).thenReturn(List.of()); + LastlegOrder orderA = containerOrder(1L, "100", "CONT-A", "BOOKING-A", "IN-1"); + LastlegOrder orderB = containerOrder(2L, "100", "CONT-B", "BOOKING-B", "IN-2"); + orderB.setOverseasWarehouseId("100"); + LastlegOrder orderC = containerOrder(3L, "100", "CONT-C", "BOOKING-C", "IN-3"); + orderC.setOverseasWarehouseId("200"); + when(orders.selectList(any())).thenReturn(List.of(orderA, orderB, orderC)); assertThat(service.candidates("7", 100L)).extracting("containerNo") .containsExactly("CONT-A", "CONT-B"); @@ -164,11 +189,36 @@ class ReceivingContainerServiceBehaviorTest { other.setActiveContainerKey("CONTAINER:CONT-B"); when(plans.selectList(any())).thenReturn(List.of(available, reserved), List.of(available)); when(lines.selectList(any())).thenReturn(List.of()); + when(orders.selectList(any())).thenReturn(List.of( + containerOrder(1L, "100", "CONT-A", "BOOKING-A", "IN-1"), + containerOrder(2L, "100", "CONT-B", "BOOKING-B", "IN-2"))); when(sessions.findActiveContainers()).thenReturn(List.of(other)); assertThat(service.candidates("7", 100L)).extracting("containerNo").containsExactly("CONT-A"); } + @Test + void partialContainerPlansRemainVisibleAndPrepareIsHardRejected() { + InboundPlan firstPlan = plan("CONT-A"); + when(plans.selectList(any())).thenReturn(List.of(firstPlan)); + when(lines.selectList(any())).thenReturn(List.of(line("FBA"))); + when(orders.selectList(any())).thenReturn(List.of( + containerOrder(1L, "100", "CONT-A", "BOOKING-A", "IN-1"), + containerOrder(2L, "100", "CONT-A", "BOOKING-A", "IN-2"))); + + var candidate = service.candidates("7", 100L).get(0); + + assertThat(candidate.totalOrderCount()).isEqualTo(2); + assertThat(candidate.plannedInboundCodeCount()).isEqualTo(1); + assertThat(candidate.pendingPlanCount()).isEqualTo(1); + assertThat(candidate.operable()).isFalse(); + assertThat(candidate.blockReason()).isEqualTo("1个入仓码已生成计划,1个订单未操作/缺入仓码"); + assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-A", + new ReceivingContainerRequests.Action("partial"))) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); + verify(sessions, never()).insert(any(ReceivingContainerSession.class)); + } + @Test void detailRejectsContainerReservedByAnotherOperator() { when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B"))); @@ -193,6 +243,9 @@ class ReceivingContainerServiceBehaviorTest { @Test void prepareEnforcesBothActiveAccountAndActiveContainerUniqueness() { when(plans.selectList(any())).thenReturn(List.of(plan("CONT-B"))); + when(lines.selectList(any())).thenReturn(List.of(line("FBA"))); + LastlegOrder order = containerOrder(1L, "100", "CONT-B", "BOOKING-B", "IN-1"); + when(orders.selectList(any())).thenReturn(List.of(order)); when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-B", new ReceivingContainerRequests.Action("account-lock"))).isInstanceOf(ResponseStatusException.class); @@ -575,6 +628,14 @@ class ReceivingContainerServiceBehaviorTest { return value; } + private static LastlegOrder containerOrder(long id, String customerId, String containerNo, + String bookingId, String inboundCode) { + LastlegOrder value = order("FBA"); + value.setId(id); value.setCustomerId(customerId); value.setContainerNo(containerNo); + value.setSourceContainerBookingId(bookingId); value.setInboundCode(inboundCode); + return value; + } + private static LastlegOrderDetail detail(String skuCode, int packages) { LastlegOrderDetail value = new LastlegOrderDetail(); value.setId(11L); value.setCustomerId("100"); value.setOrderId(1L); 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 new file mode 100644 index 000000000..760806c8e --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/ContainerPlanProgressContractTest.java @@ -0,0 +1,77 @@ +package com.baiyue.lastleg.service; + +import com.baiyue.lastleg.dto.ContainerPlanProgressDetail; +import com.baiyue.lastleg.dto.ContainerPlanProgressSummary; +import com.baiyue.lastleg.dto.LastlegOrderRequests; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.mapping.SqlSource; +import org.apache.ibatis.scripting.xmltags.XMLLanguageDriver; +import org.apache.ibatis.session.Configuration; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ContainerPlanProgressContractTest { + @Test + void responseFieldsAndServerAggregateStayFrozen() throws Exception { + assertEquals(List.of("sourceContainerBookingId", "containerNo", "totalOrderCount", "inboundCodeCount", + "missingInboundCodeCount", "planCount", "plannedInboundCodeCount", "pendingPlanCount", + "plannedArrivalAt", "allPlansReady", "blockingReason"), + Arrays.stream(ContainerPlanProgressSummary.class.getRecordComponents()) + .map(value -> value.getName()).toList()); + assertEquals(List.of("orderId", "customerId", "orderNo", "inboundCode", "orderStatus", "planId", + "planNo", "planStatus", "planValid", "plannedArrivalAt"), + Arrays.stream(ContainerPlanProgressDetail.Item.class.getRecordComponents()) + .map(value -> value.getName()).toList()); + + Select rows = LastlegOrderMapper.class.getMethod("pageContainerProgress", + 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("o.estimated_warehouse_at")); + assertTrue(sql.contains("progress.plannedarrivalat asc")); + assertTrue(sql.contains("progress.sourcecontainerbookingid asc")); + assertTrue(sql.contains("limit #{limit} offset #{offset}")); + } + + @Test + void openApiAndSchemaExposeReadinessAndUnpagedDetail() throws Exception { + String openApi = Files.readString(Path.of("src/main/resources/openapi/overseas-business-v1.yaml"), + StandardCharsets.UTF_8); + String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8); + String deployment = Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"), + StandardCharsets.UTF_8); + + assertTrue(openApi.contains("/api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}:")); + assertTrue(openApi.contains("ContainerPlanProgressSummary:")); + assertTrue(openApi.contains("allPlansReady:")); + 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)")); + assertTrue(deployment.contains("assert_container_progress_index_ready")); + } + + @Test + void mybatisRendersExecutableProgressSqlWithoutXmlEntities() throws Exception { + Select select = LastlegOrderMapper.class.getMethod("pageContainerProgress", + LastlegOrderRequests.ContainerProgressQuery.class, long.class, long.class).getAnnotation(Select.class); + SqlSource source = new XMLLanguageDriver().createSqlSource(new Configuration(), + String.join("", select.value()), Map.class); + String sql = source.getBoundSql(Map.of( + "q", new LastlegOrderRequests.ContainerProgressQuery(1, 20, null, null, null), + "limit", 20L, "offset", 0L)).getSql().replaceAll("\\s+", " ").trim(); + + assertTrue(sql.contains("source_container_booking_id<>''")); + assertTrue(sql.contains("LEFT JOIN ll_inbound_plan")); + assertTrue(!sql.contains("<")); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/FbaWarehouseValidatorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/FbaWarehouseValidatorTest.java new file mode 100644 index 000000000..ed50bf734 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/FbaWarehouseValidatorTest.java @@ -0,0 +1,101 @@ +package com.baiyue.lastleg.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class FbaWarehouseValidatorTest { + @Mock private DomesticWarehouseDirectoryClient client; + private FbaWarehouseValidator validator; + + @BeforeEach + void setUp() { + validator = new FbaWarehouseValidator(); + ReflectionTestUtils.setField(validator, "client", client); + ReflectionTestUtils.setField(validator, "internalToken", "internal-token"); + } + + @Test + void acceptsOnlyMatchingActivePlatformFbaAndReturnsRegionSnapshot() { + when(client.activeWarehouse(193000000000000001L, "internal-token")) + .thenReturn(ApiResponse.success(detail("FBA", "ACTIVE", "ONT8", "安大略 ONT8"))); + + var result = validator.required("193000000000000001", "ONT8", "安大略 ONT8"); + + assertThat(result.id()).isEqualTo(193000000000000001L); + assertThat(result.code()).isEqualTo("ONT8"); + assertThat(result.name()).isEqualTo("安大略 ONT8"); + assertThat(result.countryCode()).isEqualTo("US"); + assertThat(result.region()).isEqualTo("CA"); + } + + @Test + void rejectsAnActualOverseasReceivingWarehouseAsFbaDestination() { + when(client.activeWarehouse(193000000000000001L, "internal-token")) + .thenReturn(ApiResponse.success(detail("OVERSEAS", "ACTIVE", "US-LAX", "洛杉矶海外收货仓"))); + + assertInvalid(() -> validator.required("193000000000000001", "US-LAX", "洛杉矶海外收货仓")); + } + + @Test + void rejectsForgedCodeOrNameEvenWhenIdPointsToAnActiveFba() { + when(client.activeWarehouse(193000000000000001L, "internal-token")) + .thenReturn(ApiResponse.success(detail("FBA", "ACTIVE", "ONT8", "安大略 ONT8"))); + + assertInvalid(() -> validator.required("193000000000000001", "FAKE", "安大略 ONT8")); + assertInvalid(() -> validator.required("193000000000000001", "ONT8", "伪造名称")); + } + + @Test + void rejectsInactiveOrMissingAuthorityFactWithFrozenMessage() { + when(client.activeWarehouse(193000000000000001L, "internal-token")) + .thenReturn(ApiResponse.failure(ResultCode.NOT_FOUND, "仓库已停用", null, null)); + + assertInvalid(() -> validator.required("193000000000000001", "ONT8", "安大略 ONT8")); + assertInvalid(() -> validator.required("not-an-id", "ONT8", "安大略 ONT8")); + } + + @Test + void openApiFreezesFbaAuthorityValidationAndExistingAdminCandidateContract() throws Exception { + String lastlegOpenApi = Files.readString(Path.of("src/main/resources/openapi/overseas-business-v1.yaml"), + StandardCharsets.UTF_8); + String warehouseController = Files.readString(Path.of( + "../warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java"), + StandardCharsets.UTF_8); + + assertThat(lastlegOpenApi).contains("/api/platform/v1/last-leg/orders/{id}/destination:") + .contains("FbaTransferDestination:") + .contains("所选FBA仓不存在或已停用"); + assertThat(warehouseController).contains("@RequestMapping(\"/api/platform/v1/warehouse-admin\")") + .contains("@PreAuthorize(\"hasAuthority('platform:warehouse:list')\")"); + } + + private DomesticWarehouseDirectoryClient.Detail detail(String type, String status, String code, String name) { + return new DomesticWarehouseDirectoryClient.Detail( + new DomesticWarehouseDirectoryClient.Warehouse(193000000000000001L, 0L, code, name, + type, "US", status), + List.of(new DomesticWarehouseDirectoryClient.Address("CA"))); + } + + private void assertInvalid(org.assertj.core.api.ThrowableAssert.ThrowingCallable action) { + assertThatThrownBy(action).isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("所选FBA仓不存在或已停用"); + } +} 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 a45fc5a7a..29d95a056 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 @@ -1,7 +1,8 @@ package com.baiyue.lastleg.service; import com.baiyue.lastleg.dto.LastlegOrderRequests; -import com.baiyue.lastleg.dto.PendingContainerSummary; +import com.baiyue.lastleg.dto.ContainerPlanProgressDetail; +import com.baiyue.lastleg.dto.ContainerPlanProgressSummary; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.entity.LastlegOrderDetail; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; @@ -20,7 +21,6 @@ import org.springframework.web.server.ResponseStatusException; import org.springframework.test.util.ReflectionTestUtils; import java.util.List; -import java.math.BigDecimal; import java.time.LocalDateTime; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -30,13 +30,16 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.lenient; @ExtendWith(MockitoExtension.class) class LastlegOrderServiceTest { + // Service tests keep remote master-data validation and persistence independently observable here. @Mock LastlegOrderMapper orders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; + @Mock FbaWarehouseValidator fbaWarehouses; LastlegOrderService service; @BeforeEach @@ -46,6 +49,11 @@ class LastlegOrderServiceTest { ReflectionTestUtils.setField(service, "detailMapper", details); ReflectionTestUtils.setField(service, "historyMapper", histories); ReflectionTestUtils.setField(service, "cartonMapper", cartons); + ReflectionTestUtils.setField(service, "fbaWarehouseValidator", fbaWarehouses); + lenient().when(fbaWarehouses.required(any(), any(), any())).thenAnswer(invocation -> + new FbaWarehouseValidator.VerifiedFbaWarehouse( + Long.parseLong(invocation.getArgument(0)), invocation.getArgument(1), invocation.getArgument(2), + "US", "CA")); } @Test @@ -73,11 +81,11 @@ class LastlegOrderServiceTest { LastlegOrder order = order(11L, "100", "PENDING_ASSIGNMENT", null); when(orders.selectList(any())).thenReturn(List.of(order)); var request = new LastlegOrderRequests.TransferDestination( - List.of(11L), "wh-2", "LAX-02", "Los Angeles 02", null); + List.of(11L), "193000000000000002", "LAX-02", "Los Angeles 02", null); assertEquals(1, service.transferDestination("100", request)); - assertEquals("wh-2", order.getTransferWarehouseId()); + assertEquals("193000000000000002", order.getTransferWarehouseId()); assertEquals("LAX-02", order.getTransferWarehouseCode()); assertEquals("DESTINATION_ASSIGNED", order.getCurrentStatus()); ArgumentCaptor cargo = ArgumentCaptor.forClass(LastlegOrderDetail.class); @@ -85,7 +93,7 @@ class LastlegOrderServiceTest { assertEquals("LAX-02", cargo.getValue().getDestinationWarehouseCode()); ArgumentCaptor carton = ArgumentCaptor.forClass(InboundPlanCarton.class); verify(cartons).update(carton.capture(), any()); - assertEquals("wh-2", carton.getValue().getDestinationWarehouseId()); + assertEquals("193000000000000002", carton.getValue().getDestinationWarehouseId()); assertEquals("LAX-02", carton.getValue().getDestinationWarehouseCode()); ArgumentCaptor history = ArgumentCaptor.forClass(LastlegOrderStatusHistory.class); verify(histories).insert(history.capture()); @@ -99,7 +107,7 @@ class LastlegOrderServiceTest { LastlegOrder order = order(11L, "100", "DESTINATION_ASSIGNED", "IB-1"); when(orders.selectList(any())).thenReturn(List.of(order)); var request = new LastlegOrderRequests.TransferDestination( - List.of(11L), "wh-2", "LAX-02", "Los Angeles 02", null); + List.of(11L), "193000000000000002", "LAX-02", "Los Angeles 02", null); assertThrows(ResponseStatusException.class, () -> service.transferDestination("100", request)); verify(orders, never()).updateById(any(LastlegOrder.class)); @@ -111,7 +119,7 @@ class LastlegOrderServiceTest { void batchRejectsMissingOrCrossCustomerIdsBeforeWriting() { when(orders.selectList(any())).thenReturn(List.of(order(11L, "100", "PENDING_ASSIGNMENT", null))); var request = new LastlegOrderRequests.TransferDestination( - List.of(11L, 22L), "wh-2", "LAX-02", "Los Angeles 02", null); + List.of(11L, 22L), "193000000000000002", "LAX-02", "Los Angeles 02", null); ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.transferDestination("100", request)); @@ -119,6 +127,22 @@ class LastlegOrderServiceTest { verify(orders, never()).updateById(any(LastlegOrder.class)); } + @Test + void forgedFbaSelectionIsRejectedBeforeLockingOrWritingOrders() { + var request = new LastlegOrderRequests.TransferDestination( + List.of(11L), "193000000000000002", "FAKE", "伪造仓库", null); + when(fbaWarehouses.required("193000000000000002", "FAKE", "伪造仓库")) + .thenThrow(new ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST, + "所选FBA仓不存在或已停用")); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, + () -> service.transferDestination("100", request)); + + assertTrue(error.getMessage().contains("所选FBA仓不存在或已停用")); + verify(orders, never()).selectList(any()); + verify(orders, never()).updateById(any(LastlegOrder.class)); + } + @Test void pageUsesPaginationAndPlatformFiltersWithoutCustomerScope() { var query = new LastlegOrderRequests.Query(3, 25, "LL-", "FIRST_LEG", "IN-", null, @@ -137,14 +161,13 @@ class LastlegOrderServiceTest { } @Test - void pendingContainerPageUsesServerSideAggregatePagination() { - var query = new LastlegOrderRequests.PendingContainerQuery(2, 10, "CONT-", null, null); - var row = new PendingContainerSummary("BOOKING-1", "CONT-1", 15, 15, 3, - 150, 0, new BigDecimal("12.5000"), true, null); - when(orders.countPendingContainers(query)).thenReturn(11L); - when(orders.pagePendingContainers(query, 10, 10)).thenReturn(List.of(row)); + void containerProgressPageUsesServerSideAggregatePagination() { + var query = new LastlegOrderRequests.ContainerProgressQuery(2, 10, "CONT-", null, null); + var row = summary(false, 3, "存在3票尚未生成有效入库计划"); + when(orders.countContainerProgress(query)).thenReturn(11L); + when(orders.pageContainerProgress(query, 10, 10)).thenReturn(List.of(row)); - var result = service.pendingContainerPage(query); + var result = service.containerProgressPage(query); assertEquals(2, result.page()); assertEquals(10, result.size()); @@ -154,13 +177,36 @@ class LastlegOrderServiceTest { } @Test - void pendingContainerPageRejectsReversedDateRangeBeforeQuerying() { - var query = new LastlegOrderRequests.PendingContainerQuery(1, 20, null, + void containerProgressPageRejectsReversedDateRangeBeforeQuerying() { + var query = new LastlegOrderRequests.ContainerProgressQuery(1, 20, null, LocalDateTime.of(2026, 8, 20, 0, 0), LocalDateTime.of(2026, 8, 19, 0, 0)); - assertThrows(ResponseStatusException.class, () -> service.pendingContainerPage(query)); + assertThrows(ResponseStatusException.class, () -> service.containerProgressPage(query)); - verify(orders, never()).countPendingContainers(any()); + verify(orders, never()).countContainerProgress(any()); + } + + @Test + void containerProgressDetailReturnsEveryOrderWithoutPagination() { + var summary = summary(true, 0, null); + var items = List.of( + new ContainerPlanProgressDetail.Item(1L, "100", "LMO-1", "RK-1", "INBOUND_PLANNED", + 101L, "LIP-1", "PENDING_ASSIGNMENT", true, LocalDateTime.of(2026, 10, 3, 9, 0)), + new ContainerPlanProgressDetail.Item(2L, "200", "LMO-1", "RK-2", "INBOUND_PLANNED", + 102L, "LIP-2", "PDA_PENDING", true, LocalDateTime.of(2026, 10, 3, 9, 0))); + when(orders.findContainerProgress("BOOKING-1")).thenReturn(summary); + when(orders.findContainerProgressItems("BOOKING-1")).thenReturn(items); + + var result = service.containerProgressDetail(" BOOKING-1 "); + + assertEquals(summary, result.summary()); + assertEquals(items, result.orders()); + verify(orders).findContainerProgressItems("BOOKING-1"); + } + + private static ContainerPlanProgressSummary summary(boolean ready, long pending, String reason) { + return new ContainerPlanProgressSummary("BOOKING-1", "CONT-1", 15, 15, 0, + 15 - pending, 15 - pending, pending, LocalDateTime.of(2026, 10, 3, 9, 0), ready, reason); } private static LastlegOrder order(long id, String customerId, String status, String inboundOrderNo) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java index 9fe37253e..3d1d0feda 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java @@ -5,6 +5,7 @@ class LoadingCompletedConsumerTest { @Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; @Mock LastlegOrderNoDailySequenceMapper sequences; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service; @BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(); ReflectionTestUtils.setField(generator,"sequenceMapper",sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().when(sequences.currentValue()).thenReturn(1L); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); } @Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&o.getOverseasWarehouseId()==null)); ArgumentCaptor snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("LMO-[0-9]{6}-0001")); } + @Test void freezesFormalPlannedWarehouseArrivalFromLoadingEvent(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("estimatedWarehouseArrivalAt","2026-10-03T09:00"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); service.consume(changed); ArgumentCaptor captor=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(captor.capture()); assertTrue(captor.getAllValues().stream().allMatch(order->java.time.LocalDateTime.of(2026,10,3,9,0).equals(order.getEstimatedWarehouseAt()))); } @Test void loadingCreatesAwaitingPickupReadyGateWithoutPollutingShippingVersion(){ service.consume(event()); verify(shippingGates).initialize("book-1","100","","MSCU1",0L,1L); } @Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU1")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU1"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); } @Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/PendingContainerSummaryContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/PendingContainerSummaryContractTest.java deleted file mode 100644 index 4925f6f20..000000000 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/PendingContainerSummaryContractTest.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.baiyue.lastleg.service; - -import com.baiyue.lastleg.dto.PendingContainerSummary; -import com.baiyue.lastleg.mapper.LastlegOrderMapper; -import org.apache.ibatis.annotations.Select; -import org.junit.jupiter.api.Test; - -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Arrays; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -class PendingContainerSummaryContractTest { - @Test - void responseFieldsAndServerAggregateSqlStayFrozen() throws Exception { - assertEquals(List.of("sourceContainerBookingId", "containerNo", "orderCount", "inboundCodeCount", - "customerCount", "totalPackageCount", "totalPalletCount", "totalVolumeCbm", "eligible", - "ineligibleReason"), - Arrays.stream(PendingContainerSummary.class.getRecordComponents()).map(value -> value.getName()).toList()); - - Select rows = LastlegOrderMapper.class.getMethod("pagePendingContainers", - com.baiyue.lastleg.dto.LastlegOrderRequests.PendingContainerQuery.class, long.class, long.class) - .getAnnotation(Select.class); - String sql = String.join("", rows.value()).toLowerCase(); - assertTrue(sql.contains("group by o.source_container_booking_id")); - assertTrue(sql.contains("pending_assignment")); - assertTrue(sql.contains("destination_assigned")); - assertTrue(sql.contains("limit #{limit} offset #{offset}")); - } - - @Test - void openApiAndSchemaExposeStableContainerIdentity() throws Exception { - String openApi = Files.readString(Path.of("src/main/resources/openapi/overseas-business-v1.yaml"), - StandardCharsets.UTF_8); - String schema = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8); - - assertTrue(openApi.contains("/api/platform/v1/last-leg/orders/container-summaries:")); - assertTrue(openApi.contains("PendingContainerSummary:")); - assertTrue(openApi.contains("sourceContainerBookingId:")); - assertTrue(schema.contains("idx_ll_order_pending_container(source_container_booking_id,current_status,created_at)")); - } -} diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index 00819578d..9f5945766 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -138,12 +138,13 @@ foreach ($pdaApplication in $pdaApplications) { } } -# 海外 PDA 当前只开放真实拆柜收货;其余后端虽已存在,前端接入前必须保持禁用。 +# 海外 PDA 当前开放真实拆柜收货与托盘管理;其余后端虽已存在,前端接入前必须保持禁用。 $overseasWorkbenchPath = Join-Path $workspaceRoot 'apps/pda-overseas-uniapp/src/data/overseas-workbench.js' $overseasWorkbench = Read-RequiredText $overseasWorkbenchPath Assert-Matches $overseasWorkbench "(?s)name:\s*'拆柜收货'.{0,300}?available:\s*true" 'Overseas PDA 拆柜收货开关' +Assert-Matches $overseasWorkbench "(?s)name:\s*'托盘管理'.{0,300}?available:\s*true.{0,200}?route:\s*'/pages/pallet/pallet'" 'Overseas PDA 托盘管理开关' foreach ($disabledOperation in @( - '库存盘点', '商品移库', '托盘管理', + '库存盘点', '商品移库', '换标收货', '换标执行', '换标复核', '库存查询', '库位查询', '托盘查询' )) { @@ -280,6 +281,7 @@ $overseasWarehouseSql = Read-RequiredText (Join-Path $backendRoot 'warehouse-ove $adminIamApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/iam/api/platformIam.ts') Assert-Contains $backendAgents '全部仓库 ID、类型、状态、地址和能力主档归国内 `warehouse`' '后端仓库主档权威' Assert-Contains $warehouseAgents '/internal/warehouses/shared-overseas' '国内 Warehouse 共享海外目录' +Assert-Contains $warehouseAgents '/internal/warehouses/0/{warehouseId}/active' '国内 Warehouse FBA 内部校验入口' Assert-Contains $overseasWarehouseAgents '只保存国内仓库 ID 引用' '海外 Warehouse 引用边界' Assert-Contains $domesticWarehouseController '@GetMapping("/shared-overseas")' '共享海外仓列表入口' Assert-Contains $domesticWarehouseService 'hasReceivingCapability' '共享海外收货能力筛选' @@ -305,16 +307,24 @@ Assert-Contains $productImageController "hasAuthority('platform:order:list')" ' Assert-Contains $receivingController '/current/unload-photos/files/{fileId}/download' '海外 PDA 签收照片预览' Assert-Contains $receivingController 'CacheControl.noStore()' '海外 PDA 签收照片缓存边界' -# 待分配尾程订单按来源柜由服务端聚合,整柜转换每票独立计划且整批原子。 +# 尾程订单按来源柜展示服务端计划进度,整柜转换每票独立计划且整批原子。 $lastlegBusinessOpenApi = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml') +$lastlegAgents = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/AGENTS.md') $adminLastMileApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/api/lastMileOrders.ts') Assert-Contains $lastlegBusinessOpenApi '/api/platform/v1/last-leg/orders/container-summaries:' 'Lastleg 柜级摘要契约' Assert-Contains $lastlegBusinessOpenApi '/api/platform/v1/last-leg/inbound-plans/convert-container:' 'Lastleg 整柜计划转换契约' Assert-Contains $lastlegBusinessOpenApi 'sourceContainerBookingId' 'Lastleg 稳定来源柜身份' Assert-Contains $adminAgents '/orders/container-summaries' 'Admin 柜级摘要目标入口' +Assert-Contains $adminAgents 'allPlansReady' 'Admin 柜级计划就绪事实' +Assert-Contains $adminAgents 'ACTIVE + FBA' 'Admin FBA 权威仓候选' Assert-Contains $adminAgents '/inbound-plans/convert-container' 'Admin 整柜转换目标入口' Assert-Contains $adminLastMileApi '/orders/container-summaries' 'Admin 柜级摘要真实调用' Assert-NotContains $adminLastMileApi '/orders/pending-containers' 'Admin 旧柜级摘要调用' +Assert-Contains $lastlegBusinessOpenApi 'ContainerPlanProgressSummary' 'Lastleg 柜级计划进度摘要模型' +Assert-Contains $lastlegBusinessOpenApi 'ContainerPlanProgressDetail' 'Lastleg 柜级计划进度详情模型' +Assert-Contains $lastlegBusinessOpenApi 'allPlansReady' 'Lastleg 全柜计划就绪事实' +Assert-Contains $lastlegBusinessOpenApi 'plannedArrivalAt' 'Lastleg 计划到仓冻结事实' +Assert-Contains $lastlegAgents 'service/FbaWarehouseValidator.java' 'Lastleg FBA 权威仓校验入口' # 国内 PDA 部分拣货必须展示真实可用库位事实,并在无库位时保持页面整洁。 Assert-Contains $pdaAgents 'tests/partial-layout.test.mjs' '国内 PDA 部分拣货布局合同' @@ -330,6 +340,9 @@ foreach ($module in $pom.project.modules.module) { $criticalPaths = @( 'apps/admin-web/src/modules/overseas-warehouse/api/inboundOrders.ts', 'apps/admin-web/src/modules/overseas-warehouse/api/lastMileOrders.ts', + 'apps/pda-overseas-uniapp/src/api/pallet.js', + 'apps/pda-overseas-uniapp/src/pages/pallet/pallet.vue', + 'apps/pda-overseas-uniapp/tests/pallet-operations.test.mjs', 'apps/admin-web/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue', 'apps/admin-web/src/modules/overseas-warehouse/api/inboundPlans.ts', 'apps/admin-web/src/modules/overseas-warehouse/api/inventory.ts', @@ -444,8 +457,9 @@ $criticalPaths = @( 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java', - 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dto/PendingContainerSummary.java', - 'baiyue-firstleg/lastleg-service/src/test/java/com/baiyue/lastleg/service/PendingContainerSummaryContractTest.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerPlanProgressSummary.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerPlanProgressDetail.java', + 'baiyue-firstleg/lastleg-service/src/test/java/com/baiyue/lastleg/service/ContainerPlanProgressContractTest.java', 'baiyue-firstleg/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/UnloadPhotoFileGateway.java', diff --git a/warehouse/AGENTS.md b/warehouse/AGENTS.md index a0991e123..61c70fcbe 100644 --- a/warehouse/AGENTS.md +++ b/warehouse/AGENTS.md @@ -12,6 +12,8 @@ | 目标 | 入口 | |---|---| | 仓库/地址/能力 | `controller/PlatformWarehouseController.java`、`MerchantWarehouseController.java`、`service/WarehouseApplicationService.java` | +| FBA 目的仓公共主数据与 Admin 候选 | 权威表 `wh_warehouse` 的 `tenant_id=0 + warehouse_type=FBA` 记录;Admin 直接调用 `GET /api/platform/v1/warehouse-admin?warehouseType=FBA&status=ACTIVE&page=1&size=200`,权限 `platform:warehouse:list`,DTO `WarehouseSummaryResponse` 的 `id/warehouseCode/warehouseName/status/countryCode/primaryAddress.stateProvince` 分别作为 ID/编码/名称/状态/国家/区域 | +| FBA 目的仓内部校验 | `InternalWarehouseController.java` 的 `GET /internal/warehouses/0/{warehouseId}/active`,使用内部服务令牌返回 `WarehouseDetailResponse`;调用方仍须校验 `warehouseType=FBA` 及 ID/编码/名称一致,不得把 `OVERSEAS` 实际收货仓或 `DOMESTIC` 中转仓当作 FBA 目的仓 | | 公共海外收货仓内部目录 | `controller/InternalWarehouseController.java` 的 `/internal/warehouses/shared-overseas`、`/shared-overseas/{warehouseId}`,响应 `dto/SharedOverseasWarehouseResponse.java`;只返回平台公共 `OVERSEAS + ACTIVE + RECEIVING` 仓,供海外资源服务按 warehouseId 引用 | | 四期海外仓合作仓类型 | 沿用仓库/地址/能力入口;同步核对仓库 DTO、OpenAPI、`src/main/resources/db/`、统一 SQL 及 Admin `modules/warehouses/`,并与普通海外仓语义明确区分 | | 往来单位/快递 | `controller/PlatformPartnerController.java`、`PlatformExpressCompanyController.java`、`service/PlatformPartnerQueryService.java`、`PartnerImportService.java` |