From 76c24867a49e30e00fb454d65158d7ce0d2b55e2 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 27 Aug 2026 17:16:33 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 1 + lastleg-service/AGENTS.md | 4 +- .../controller/LastlegOrderController.java | 13 +++ .../lastleg/dto/LastlegOrderRequests.java | 13 +++ .../lastleg/dto/PendingContainerSummary.java | 18 ++++ .../service/InboundPlanService.java | 56 +++++++++--- .../lastleg/mapper/LastlegOrderMapper.java | 34 ++++++++ .../lastleg/service/LastlegOrderService.java | 16 ++++ .../src/main/resources/db/lastleg.sql | 5 +- .../openapi/overseas-business-v1.yaml | 44 ++++++++++ .../LastlegOrderControllerTest.java | 18 ++++ .../controller/InboundPlanControllerTest.java | 18 ++++ .../service/InboundPlanServiceTest.java | 87 +++++++++++++++++++ .../service/LastlegOrderServiceTest.java | 30 +++++++ .../PendingContainerSummaryContractTest.java | 46 ++++++++++ .../container-inbound-plan-batch-contract.mjs | 28 +++--- .../phase4-container-inbound-plan-batch.mjs | 61 +++++-------- ...ainer-inbound-plan-batch-contract.test.mjs | 63 ++++++-------- scripts/agents-index-contract.ps1 | 26 +++++- 19 files changed, 474 insertions(+), 107 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dto/PendingContainerSummary.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/service/PendingContainerSummaryContractTest.java diff --git a/AGENTS.md b/AGENTS.md index cba94bc2d..f83882030 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,6 +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` | | 海外入仓分页、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/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 2e012ad41..df6034517 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` | +| 事件接收、平台订单列表/详情/导出、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;待分配柜级摘要固定为 `/api/platform/v1/last-leg/orders/container-summaries`,按 `sourceContainerBookingId` 跨客户聚合分页,不允许前端本地拼柜 | | 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` | | 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V2,消费端冻结 `destinationType`:FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;历史缺字段事件仅按明确 FBA 单号或目的仓与接柜仓一致性安全推断,歧义直接拒绝 | | 尾程单号生成 | `service/LastlegOrderNoGenerator.java` | @@ -21,7 +21,7 @@ | 独立尾程入仓单快照 | `entity/LastlegInboundOrder.java`、`mapper/LastlegInboundOrderMapper.java`;按客户与来源入仓标识冻结 inboundCode、来源/根订单及目的仓,不以尾程订单行代替入仓单聚合 | | 内部令牌保护 | `security/InternalServiceTokenGuard.java` | | IAM 海外安全投影与实时校验 | 内部入口 `security/controller/LastlegSecurityController.java`,固定 `/internal/lastleg/security/v1/events`、`/validate` 且复用 `InternalServiceTokenGuard`;投影/校验 `security/service/`,DTO `security/dto/`,表固定 `ll_security_event_inbox`、`ll_security_projection`;MySQL 投影缺失、落后或不可用一律 fail-closed,Redis 仅为提交后可重建镜像;业务切片不得直接依赖投影内部类,边界由 `SecurityProjectionArchitectureTest` 与 Node 合同锁定 | -| 入仓计划分页、转换与详情 | `inboundplan/controller/InboundPlanController.java`、`inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java` | +| 入仓计划分页、转换与详情 | `inboundplan/controller/InboundPlanController.java`、`inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java`;整柜转换固定为 `/convert-container`,锁定同一 `sourceContainerBookingId` 后每票独立生成计划,整批原子且幂等 | | 托盘分配算法 | `inboundplan/service/PalletAllocator.java` | | FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` | | PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除 | 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 7fd3375d5..85e6e4df6 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,6 +7,7 @@ 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.security.InternalServiceTokenGuard; import com.baiyue.lastleg.security.OverseasPermissions; import com.baiyue.lastleg.service.LastlegOrderService; @@ -71,6 +72,18 @@ public class LastlegOrderController { return ApiResponse.success(orderService.page(platformScope(user, customerId), query)); } + @GetMapping("/api/platform/v1/last-leg/orders/container-summaries") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')") + @Operation(summary = "分页查询待分配柜级摘要", + description = "按稳定 sourceContainerBookingId 聚合待分配尾程订单;平台跨客户查询。Required authority: " + + OverseasPermissions.LAST_MILE_ORDER_LIST) + public ApiResponse> containerSummaries( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid LastlegOrderRequests.PendingContainerQuery query) { + platformScope(user, null); + return ApiResponse.success(orderService.pendingContainerPage(query)); + } + @GetMapping("/api/platform/v1/last-leg/orders/{id}") @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')") @Operation(summary = "Get one inbound-code-level tail-leg order", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_LIST) 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 99bf49851..d04b41dea 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,6 +36,19 @@ public final class LastlegOrderRequests { } } + public record PendingContainerQuery( + @Min(1) long page, + @Min(1) @Max(200) long size, + String containerNo, + LocalDateTime createdFrom, + LocalDateTime createdTo + ) { + public PendingContainerQuery { + if (page == 0) page = 1; + if (size == 0) size = 20; + } + } + public record TransferDestination( @NotEmpty @Size(max = 200) List orderIds, @NotBlank @Size(max = 128) String warehouseId, 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 new file mode 100644 index 000000000..3b1769216 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/PendingContainerSummary.java @@ -0,0 +1,18 @@ +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/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java index 3ec5eca9f..9181793b8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -31,6 +31,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.ArrayList; import java.util.stream.Collectors; @Service @@ -73,7 +74,16 @@ public class InboundPlanService { return detail(customerId, existing); } validateOrders(orders); + return createPlan(customerId, selectedOrderId, orders); + } + private InboundPlanResponse createPlan(String customerId, Long selectedOrderId, + List orders) { + String containerNo = trimToNull(orders.get(0).containerNo()); + if (containerNo == null) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Inbound order container number is empty"); + } LocalDateTime now = LocalDateTime.now(); InboundPlan plan = new InboundPlan(); plan.setId(IdWorker.getId()); @@ -128,21 +138,47 @@ public class InboundPlanService { if (bookingId == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订 ID 不能为空"); List refs = orderMapper.lockContainerOrders(bookingId); if (refs.isEmpty()) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜没有尾程订单"); - boolean hasEligible = refs.stream().anyMatch(ref -> "PENDING_ASSIGNMENT".equals(ref.currentStatus()) - || "DESTINATION_ASSIGNED".equals(ref.currentStatus())); - if (!hasEligible) { - List replay = refs.stream().map(ref -> { - InboundPlan existing = planMapper.findBySourceOrder(ref.customerId(), ref.orderId()); - if (existing == null) throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜转换状态不一致"); - return detail(ref.customerId(), existing); - }).toList(); - return replay; + List existingPlans = refs.stream() + .map(ref -> planMapper.findBySourceOrder(ref.customerId(), ref.orderId())) + .toList(); + long existingCount = existingPlans.stream().filter(Objects::nonNull).count(); + if (existingCount == refs.size()) { + List replay = new ArrayList<>(refs.size()); + for (int index = 0; index < refs.size(); index++) { + replay.add(detail(refs.get(index).customerId(), existingPlans.get(index))); + } + return List.copyOf(replay); + } + if (existingCount != 0) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜仅部分尾程订单已有入库计划"); } if (refs.stream().anyMatch(ref -> !("PENDING_ASSIGNMENT".equals(ref.currentStatus()) || "DESTINATION_ASSIGNED".equals(ref.currentStatus())))) { throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜存在不能转换的尾程订单"); } - return refs.stream().map(ref -> convert(ref.customerId(), ref.orderId())).toList(); + + List> snapshots = new ArrayList<>(refs.size()); + String containerNo = null; + for (InboundPlanOrderMapper.ContainerOrderRef ref : refs) { + List orders = orderMapper.lockOrder(ref.customerId(), ref.orderId()); + if (orders == null || orders.isEmpty()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜订单在锁定后不存在"); + } + validateOrders(orders); + String currentContainerNo = trimToNull(orders.get(0).containerNo()); + if (containerNo == null) containerNo = currentContainerNo; + if (!Objects.equals(containerNo, currentContainerNo)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜内尾程订单柜号不一致"); + } + snapshots.add(orders); + } + + List created = new ArrayList<>(refs.size()); + for (int index = 0; index < refs.size(); index++) { + InboundPlanOrderMapper.ContainerOrderRef ref = refs.get(index); + created.add(createPlan(ref.customerId(), ref.orderId(), snapshots.get(index))); + } + return List.copyOf(created); } 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 9973bdca2..eb6766c27 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 @@ -2,13 +2,47 @@ 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 org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; +import java.util.List; + @Mapper public interface LastlegOrderMapper extends BaseMapper { + String PENDING_CONTAINER_FILTER = " FROM ll_order o " + + "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 "; + + @Select("") + List pagePendingContainers(@Param("q") LastlegOrderRequests.PendingContainerQuery query, + @Param("offset") long offset, + @Param("limit") long limit); + + @Select("") + long countPendingContainers(@Param("q") LastlegOrderRequests.PendingContainerQuery query); + @Select("SELECT * FROM ll_order WHERE customer_id=#{customerId} AND source_region=#{sourceRegion} " + "AND source_inbound_id=#{sourceInboundId} LIMIT 1") LastlegOrder findSource(@Param("customerId") String customerId, 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 8b88d3694..bb61120ea 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,6 +6,7 @@ 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.entity.LastlegOrder; import com.baiyue.lastleg.entity.LastlegOrderDetail; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; @@ -46,6 +47,21 @@ public class LastlegOrderService { page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); } + @Transactional(readOnly = true) + public PageResult pendingContainerPage( + LastlegOrderRequests.PendingContainerQuery 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 pages = total == 0 ? 0 : (total + query.size() - 1) / query.size(); + return new PageResult<>(records, query.page(), query.size(), total, pages); + } + public LastlegOrderDetailResponse detail(String customerId, long orderId) { LastlegOrder order = findOwned(customerId, orderId); List cargo = detailMapper.selectList(new LambdaQueryWrapper() diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 2a5382912..34be04951 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -100,7 +100,8 @@ CREATE TABLE IF NOT EXISTS ll_order ( UNIQUE KEY uk_ll_order_source (customer_id,source_region,source_inbound_id), 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_list(customer_id,current_status,created_at), + KEY idx_ll_order_pending_container(source_container_booking_id,current_status,created_at) ) ENGINE=InnoDB COMMENT='尾程订单主表'; CREATE TABLE IF NOT EXISTS ll_order_detail ( @@ -198,6 +199,8 @@ 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)')); +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.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')); 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 e493e5f62..478471b24 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -156,6 +156,36 @@ paths: - { name: createdFrom, in: query, schema: { type: string, format: date-time } } - { name: createdTo, in: query, schema: { type: string, format: date-time } } 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 表示柜内状态或柜号不一致,禁止转换。 + security: [{ bearerAuth: [] }] + parameters: + - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } + - { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } } + - { name: containerNo, in: query, schema: { type: string } } + - { name: createdFrom, in: query, schema: { type: string, format: date-time } } + - { name: createdTo, in: query, schema: { type: string, format: date-time } } + responses: + '200': + description: 统一 ApiResponse 包装的柜级摘要分页 + content: + application/json: + schema: + type: object + properties: + code: { type: string, enum: ['200'] } + data: + type: object + properties: + page: { type: integer, format: int64 } + size: { type: integer, format: int64 } + total: { type: integer, format: int64 } + pages: { type: integer, format: int64 } + records: + type: array + items: { $ref: '#/components/schemas/PendingContainerSummary' } /api/platform/v1/last-leg/inbound-plans/convert: post: summary: 平台生成入库计划 @@ -562,6 +592,20 @@ paths: responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '409': { description: 非法状态迁移或关联尾程单缺失 } } components: schemas: + PendingContainerSummary: + type: object + required: [sourceContainerBookingId, orderCount, inboundCodeCount, customerCount, totalPackageCount, totalPalletCount, totalVolumeCbm, eligible] + properties: + sourceContainerBookingId: { type: string } + containerNo: { type: string, nullable: true } + orderCount: { 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 } PdaWarehouseScan: type: object required: [code] 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 abb7991c9..c1b6b222b 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 @@ -50,6 +50,24 @@ class LastlegOrderControllerTest { verify(service).page(eq("200"), eq(query)); } + @Test + void platformOperatorCanQueryCrossCustomerContainerSummaries() { + var summaryQuery = new LastlegOrderRequests.PendingContainerQuery(1, 20, "CONT-", null, null); + + controller.containerSummaries(user(0L, "PLATFORM"), summaryQuery); + + verify(service).pendingContainerPage(summaryQuery); + } + + @Test + void containerSummariesRejectMerchantIdentity() { + var summaryQuery = new LastlegOrderRequests.PendingContainerQuery(1, 20, null, null, null); + + assertThrows(ResponseStatusException.class, + () -> controller.containerSummaries(user(100L, "MERCHANT"), summaryQuery)); + verify(service, never()).pendingContainerPage(summaryQuery); + } + @Test void iamSecurityEventsAreRejectedBeforeLoadingCompletedConsumer() { EventEnvelope event = mock(EventEnvelope.class); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java index 8a72de63b..ab9ad7916 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java @@ -4,6 +4,7 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests; import com.baiyue.lastleg.inboundplan.dto.ConvertInboundPlanRequest; +import com.baiyue.lastleg.inboundplan.dto.ConvertContainerInboundPlanRequest; import com.baiyue.lastleg.inboundplan.dto.UpdateFbaAppointmentRequest; import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; import com.baiyue.lastleg.inboundplan.service.InboundPlanService; @@ -87,6 +88,23 @@ class InboundPlanControllerTest { verify(service).convert("100", 2L); } + @Test + void platformContainerConversionUsesStableBookingIdentity() { + controller.convertContainer(platform(), new ConvertContainerInboundPlanRequest(" BOOKING-1 ")); + + verify(service).convertContainer(" BOOKING-1 "); + } + + @Test + void merchantCannotConvertAWholeContainer() { + AuthenticatedUser customer = new AuthenticatedUser(8L, 100L, 100L, 9L, "customer", "MERCHANT", "WEB", Set.of()); + + assertThatThrownBy(() -> controller.convertContainer(customer, + new ConvertContainerInboundPlanRequest("BOOKING-1"))) + .isInstanceOf(ResponseStatusException.class); + verify(service, never()).convertContainer(org.mockito.ArgumentMatchers.any()); + } + private static AuthenticatedUser platform() { return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB", Set.of("platform:overseas-warehouse:inbound-order:list")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java index 1229d01ce..76e3dcdd3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java @@ -191,6 +191,93 @@ class InboundPlanServiceTest { verify(planMapper, never()).insert(any(InboundPlan.class)); } + @Test + void containerConversionPrevalidatesEveryOrderBeforeWritingIndependentPlans() throws Exception { + assertNotNull(InboundPlanService.class.getMethod("convertContainer", String.class) + .getAnnotation(Transactional.class)); + when(orderMapper.lockContainerOrders("BOOKING-1")).thenReturn(List.of( + new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "PENDING_ASSIGNMENT"), + new InboundPlanOrderMapper.ContainerOrderRef("200", 2L, "DESTINATION_ASSIGNED"))); + when(orderMapper.lockOrder("100", 1L)).thenReturn(List.of(order("100", 1L, "RK-1", "WH-A"))); + when(orderMapper.lockOrder("200", 2L)).thenReturn(List.of(order("200", 2L, "RK-2", "WH-B"))); + when(orderMapper.markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any())) + .thenReturn(1); + + var result = service.convertContainer(" BOOKING-1 "); + + assertEquals(2, result.size()); + assertEquals(1L, result.get(0).sourceOrderId()); + assertEquals(2L, result.get(1).sourceOrderId()); + verify(planMapper, times(2)).insert(any(InboundPlan.class)); + verify(orderMapper).lockContainerOrders("BOOKING-1"); + verify(orderMapper).lockOrder("100", 1L); + verify(orderMapper).lockOrder("200", 2L); + } + + @Test + void invalidLaterOrderCausesNoWritesBecauseWholeContainerIsPrevalidated() { + when(orderMapper.lockContainerOrders("BOOKING-1")).thenReturn(List.of( + new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "PENDING_ASSIGNMENT"), + new InboundPlanOrderMapper.ContainerOrderRef("100", 2L, "PENDING_ASSIGNMENT"))); + when(orderMapper.lockOrder("100", 1L)).thenReturn(List.of(order(1L, "RK-1", "1", "WH-A"))); + when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(new SourceOrderSnapshot(2L, "100", "CONT-1", "RK-2", + "PENDING_ASSIGNMENT", null, BigDecimal.ONE, 1, null, null, null, 1, 0))); + + assertThrows(ResponseStatusException.class, () -> service.convertContainer("BOOKING-1")); + + verify(planMapper, never()).insert(any(InboundPlan.class)); + verify(orderMapper, never()).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any()); + verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any()); + } + + @Test + void completeContainerReplayReturnsStableExistingPlansWithoutWriting() { + var refs = List.of( + new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "INBOUND_PLANNED"), + new InboundPlanOrderMapper.ContainerOrderRef("200", 2L, "INBOUND_COMPLETED")); + when(orderMapper.lockContainerOrders("BOOKING-1")).thenReturn(refs); + when(planMapper.findBySourceOrder("100", 1L)).thenReturn(existingPlan("100", 1L, "LIP-1")); + when(planMapper.findBySourceOrder("200", 2L)).thenReturn(existingPlan("200", 2L, "LIP-2")); + + var result = service.convertContainer("BOOKING-1"); + + assertEquals(List.of("LIP-1", "LIP-2"), result.stream().map(value -> value.planNo()).toList()); + verify(orderMapper, never()).lockOrder(any(), any()); + verify(planMapper, never()).insert(any(InboundPlan.class)); + } + + @Test + void partialExistingPlansAreRejectedBeforeAnyWrite() { + when(orderMapper.lockContainerOrders("BOOKING-1")).thenReturn(List.of( + new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "INBOUND_PLANNED"), + new InboundPlanOrderMapper.ContainerOrderRef("100", 2L, "PENDING_ASSIGNMENT"))); + when(planMapper.findBySourceOrder("100", 1L)).thenReturn(existingPlan("100", 1L, "LIP-1")); + + assertThrows(ResponseStatusException.class, () -> service.convertContainer("BOOKING-1")); + + verify(orderMapper, never()).lockOrder(any(), any()); + verify(planMapper, never()).insert(any(InboundPlan.class)); + } + + private InboundPlan existingPlan(String customerId, long orderId, String planNo) { + InboundPlan plan = new InboundPlan(); + plan.setId(orderId + 100); + plan.setPlanNo(planNo); + plan.setCustomerId(customerId); + plan.setSourceOrderId(orderId); + plan.setContainerNo("CONT-1"); + plan.setStatus("PENDING_ASSIGNMENT"); + plan.setLineCount(1); + plan.setPalletCount(1); + plan.setTotalVolumeCbm(BigDecimal.ONE); + return plan; + } + + private SourceOrderSnapshot order(String customerId, Long id, String inboundCode, String warehouse) { + return new SourceOrderSnapshot(id, customerId, "CONT-1", inboundCode, "PENDING_ASSIGNMENT", null, + BigDecimal.ONE, 10, warehouse, warehouse, warehouse, 1, 0); + } + private SourceOrderSnapshot order(Long id, String inboundCode, String volume, String warehouse) { return new SourceOrderSnapshot(id, "100", "CONT-1", inboundCode, "PENDING_ASSIGNMENT", null, new BigDecimal(volume), new BigDecimal(volume).multiply(BigDecimal.TEN).intValue(), 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 9f7a4f9ab..a45fc5a7a 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,6 +1,7 @@ package com.baiyue.lastleg.service; import com.baiyue.lastleg.dto.LastlegOrderRequests; +import com.baiyue.lastleg.dto.PendingContainerSummary; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.entity.LastlegOrderDetail; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; @@ -19,6 +20,8 @@ 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; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -133,6 +136,33 @@ class LastlegOrderServiceTest { assertEquals(25, page.getValue().getSize()); } + @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)); + + var result = service.pendingContainerPage(query); + + assertEquals(2, result.page()); + assertEquals(10, result.size()); + assertEquals(11, result.total()); + assertEquals(2, result.pages()); + assertEquals(List.of(row), result.records()); + } + + @Test + void pendingContainerPageRejectsReversedDateRangeBeforeQuerying() { + var query = new LastlegOrderRequests.PendingContainerQuery(1, 20, null, + LocalDateTime.of(2026, 8, 20, 0, 0), LocalDateTime.of(2026, 8, 19, 0, 0)); + + assertThrows(ResponseStatusException.class, () -> service.pendingContainerPage(query)); + + verify(orders, never()).countPendingContainers(any()); + } + private static LastlegOrder order(long id, String customerId, String status, String inboundOrderNo) { LastlegOrder order = new LastlegOrder(); order.setId(id); 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 new file mode 100644 index 000000000..4925f6f20 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/PendingContainerSummaryContractTest.java @@ -0,0 +1,46 @@ +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/admin-test-env-modules/container-inbound-plan-batch-contract.mjs b/scripts/admin-test-env-modules/container-inbound-plan-batch-contract.mjs index b3d0cf419..a2520a32c 100644 --- a/scripts/admin-test-env-modules/container-inbound-plan-batch-contract.mjs +++ b/scripts/admin-test-env-modules/container-inbound-plan-batch-contract.mjs @@ -1,4 +1,5 @@ -export const CONTAINER_CONVERT_ENDPOINT = '/api/platform/v1/last-leg/inbound-plans/convert' +export const CONTAINER_SUMMARY_ENDPOINT = '/api/platform/v1/last-leg/orders/container-summaries' +export const CONTAINER_CONVERT_ENDPOINT = '/api/platform/v1/last-leg/inbound-plans/convert-container' export const CONTAINER_CONVERT_EXPECTED_ORDERS = 15 export function groupOrdersByCustomer(orders) { @@ -13,7 +14,7 @@ export function groupOrdersByCustomer(orders) { } export function validContainerBatchResult(result, sourceOrders) { - const plans = Array.isArray(result?.plans) ? result.plans : [] + const plans = Array.isArray(result) ? result : [] if (!Array.isArray(sourceOrders) || !sourceOrders.length || plans.length !== sourceOrders.length) return false const sourceIds = new Set(sourceOrders.map((order) => String(order.orderId))) const resultIds = plans.flatMap((plan) => Array.isArray(plan?.lines) ? plan.lines : []) @@ -32,15 +33,14 @@ export function validContainerBatchResult(result, sourceOrders) { }) } -export function validOrderFact(actual, expected) { - const exact = (field) => (actual?.[field] ?? null) === (expected?.[field] ?? null) - return ['packageCount', 'volumeCbm', 'productQuantity', 'volumeWeightKg', - 'palletCount', 'estimatedArrivalAt', 'actualArrivalAt', 'estimatedWarehouseAt'].every(exact) -} - -export function distinctPhysicalPalletCount(rows) { - if (!Array.isArray(rows)) return 0 - return new Set(rows.map((row) => String(row?.physicalPalletId ?? '').trim()).filter(Boolean)).size +export function validContainerSummaryPage(data, expected) { + if (!Array.isArray(data?.records)) return false + const row = data.records.find((item) => String(item?.sourceContainerBookingId ?? '') + === String(expected?.sourceContainerBookingId ?? '')) + return Number(data.page) >= 1 && Number(data.size) >= 1 && Number(data.total) >= 1 + && Number(data.pages) >= 1 && String(row?.containerNo ?? '') === String(expected?.containerNo ?? '') + && Number(row?.orderCount) === Number(expected?.orderCount) + && Number(row?.inboundCodeCount) === Number(expected?.inboundCodeCount) } export function zeroDrift(before, after) { @@ -50,9 +50,3 @@ export function zeroDrift(before, after) { export function stableReplay(first, replay) { return zeroDrift(first, replay) } - -export function validPdaCandidate(candidate, expected) { - return String(candidate?.containerNo ?? '') === String(expected?.containerNo ?? '') - && Number(candidate?.planCount) === Number(expected?.planCount) - && Number(candidate?.inboundCodeCount) === Number(expected?.inboundCodeCount) -} diff --git a/scripts/admin-test-env-modules/phase4-container-inbound-plan-batch.mjs b/scripts/admin-test-env-modules/phase4-container-inbound-plan-batch.mjs index dbfe33ebe..5784a027d 100644 --- a/scripts/admin-test-env-modules/phase4-container-inbound-plan-batch.mjs +++ b/scripts/admin-test-env-modules/phase4-container-inbound-plan-batch.mjs @@ -1,9 +1,9 @@ import { CONTAINER_CONVERT_ENDPOINT, CONTAINER_CONVERT_EXPECTED_ORDERS, + CONTAINER_SUMMARY_ENDPOINT, validContainerBatchResult, - validOrderFact, - validPdaCandidate, + validContainerSummaryPage, zeroDrift, } from './container-inbound-plan-batch-contract.mjs' @@ -15,15 +15,15 @@ const success = (response) => response?.http === 200 && String(response?.body?.c const trace = (response) => `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` const rejected = (response, codes) => response?.http === 200 && codes.includes(String(response?.body?.code)) -function gap(context, name, reason, priority = 'P0') { - context.addCoverageGap(MODULE, MODULE, name, reason, 'fixture', { deploymentBlocker: priority === 'P0' }) +function gap(context, name, reason, priority = 'P1') { + context.addCoverageGap(MODULE, MODULE, name, reason, 'fixture', { deploymentBlocker: true }) context.addStep(MODULE, name, null, false, `${priority}: ${reason}`, OPTIONS) throw new Error(`${priority} blocking coverage gap: ${name}: ${reason}`) } function fixture(context) { const value = context.createdResources?.phase4ContainerInboundPlanFixture - if (value?.marker !== context.marker || !value?.normal?.containerNo + if (value?.marker !== context.marker || !value?.normal?.containerNo || !value?.normal?.sourceContainerBookingId || !Array.isArray(value.normal.orders) || value.normal.orders.length !== CONTAINER_CONVERT_EXPECTED_ORDERS) { gap(context, '整柜15票新数据前置', '缺少本轮marker的15票同柜正常fixture,禁止借用历史已转换数据') } @@ -32,7 +32,6 @@ function fixture(context) { gap(context, `${name}整柜前置`, `缺少本轮${name}专用fixture,不能验证整批零漂移`, 'P1') } } - if (!value.pdaToken) gap(context, 'PDA候选Token前置', '缺少绑定真实海外仓的WAREHOUSE_PICKER海外Token') return value } @@ -63,32 +62,33 @@ async function snapshot(context, source) { } function convertBody(source) { - const anchor = source.orders[0] - return { orderId: id(anchor.orderId), customerId: id(anchor.customerId) } + return { sourceContainerBookingId: id(source.sourceContainerBookingId) } } async function convert(context, name, source, key) { const response = await context.requestOverseas('POST', CONTAINER_CONVERT_ENDPOINT, - convertBody(source), {}, context.overseasToken, { 'Idempotency-Key': key }) + convertBody(source), { 'Idempotency-Key': key }, context.overseasToken) context.addStep(MODULE, name, response, success(response), trace(response), OPTIONS) return response } export async function run(context) { const data = fixture(context) + const summaryResponse = await context.requestOverseas('GET', + `${CONTAINER_SUMMARY_ENDPOINT}?page=1&size=10&containerNo=${encodeURIComponent(data.normal.containerNo)}`) + context.assert(MODULE, 'P1服务端柜级摘要跨订单分页仍返回15票', summaryResponse, + success(summaryResponse) && validContainerSummaryPage(summaryResponse.body?.data, { + sourceContainerBookingId: data.normal.sourceContainerBookingId, + containerNo: data.normal.containerNo, + orderCount: CONTAINER_CONVERT_EXPECTED_ORDERS, + inboundCodeCount: CONTAINER_CONVERT_EXPECTED_ORDERS, + }), `orderPageSize=10, expectedOrderCount=15; ${trace(summaryResponse)}`, OPTIONS) const normalList = await listOrders(context, data.normal.containerNo) - context.assert(MODULE, 'P0同柜15票列表前置', normalList.response, + context.assert(MODULE, 'P1同柜15票列表前置', normalList.response, success(normalList.response) && normalList.rows.length === CONTAINER_CONVERT_EXPECTED_ORDERS && normalList.rows.every((row) => typeof row.id === 'string' && typeof row.customerId === 'string'), `orders=${normalList.rows.length}; ${trace(normalList.response)}`, OPTIONS) - for (const expected of data.normal.orders) { - const actual = normalList.rows.find((row) => id(row.id) === id(expected.orderId)) - context.assert(MODULE, `P2订单事实不猜算:${id(expected.orderId)}`, normalList.response, - Boolean(actual) && validOrderFact(actual, expected), - `customerId=${id(expected.customerId)}, inboundCode=${expected.inboundCode || 'missing'}`, { ...OPTIONS, deploymentBlocker: false }) - } - const invalidBefore = await snapshot(context, data.invalid) const invalid = await convert(context, 'P1任一非法票整柜转换拒绝', data.invalid, `${context.marker}-invalid-container`) const invalidAfter = await snapshot(context, data.invalid) @@ -103,9 +103,9 @@ export async function run(context) { rejected(partial, ['A0409']) && zeroDrift(partialBefore, partialAfter), trace(partial), OPTIONS) const key = `${context.marker}-container-convert` - const first = await convert(context, 'P0整柜一次转换并逐订单生成独立计划', data.normal, key) + const first = await convert(context, 'P1整柜一次转换并逐订单生成独立计划', data.normal, key) const result = first?.body?.data - context.assert(MODULE, 'P0整柜响应覆盖全部订单和客户计划', first, + context.assert(MODULE, 'P1整柜响应覆盖全部订单和独立计划', first, success(first) && validContainerBatchResult(result, data.normal.orders), trace(first), OPTIONS) const replay = await convert(context, 'P1同键同请求重放', data.normal, key) @@ -122,30 +122,9 @@ export async function run(context) { concurrent.map(trace).join('; '), OPTIONS) const after = await listOrders(context, data.normal.containerNo) - context.assert(MODULE, 'P0全部15票推进INBOUND_PLANNED', after.response, + context.assert(MODULE, 'P1全部15票推进INBOUND_PLANNED', after.response, success(after.response) && after.rows.length === CONTAINER_CONVERT_EXPECTED_ORDERS && after.rows.every((row) => row.currentStatus === 'INBOUND_PLANNED' && row.inboundOrderNo), `planned=${after.rows.filter((row) => row.currentStatus === 'INBOUND_PLANNED').length}; ${trace(after.response)}`, OPTIONS) - const candidateResponse = await context.requestOverseas('GET', - '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, data.pdaToken) - const candidate = Array.isArray(candidateResponse?.body?.data) - ? candidateResponse.body.data.find((row) => row?.containerNo === data.normal.containerNo) : null - context.assert(MODULE, 'P0同柜PDA候选计划数与入仓码数一致', candidateResponse, - success(candidateResponse) && validPdaCandidate(candidate, { - containerNo: data.normal.containerNo, - planCount: data.normal.orders.length, - inboundCodeCount: data.normal.orders.length, - }), trace(candidateResponse), OPTIONS) - - const carton = data.normal.orders.flatMap((order) => order.cartonMarks || []) - .map((cartonMark) => ({ cartonMark })).at(0) - if (!carton) gap(context, 'P3箱码命中fixture', '正常整柜没有可用于resolve的冻结箱码', 'P3') - const prepared = data.preparedSession - if (!prepared) gap(context, 'P3箱码命中会话', '需先由PDA合同完成prepare+三照片+sign,再执行resolve', 'P3') - const resolved = await context.requestOverseas('POST', - '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', carton, {}, data.pdaToken) - context.assert(MODULE, 'P3箱码服务端命中当前柜唯一入仓码', resolved, - success(resolved) && data.normal.orders.some((order) => order.inboundCode === resolved?.body?.data?.inboundCode), - trace(resolved), { ...OPTIONS, deploymentBlocker: false }) } diff --git a/scripts/admin-test-env-modules/test-container-inbound-plan-batch-contract.test.mjs b/scripts/admin-test-env-modules/test-container-inbound-plan-batch-contract.test.mjs index 783bdbab3..eb6c1a80c 100644 --- a/scripts/admin-test-env-modules/test-container-inbound-plan-batch-contract.test.mjs +++ b/scripts/admin-test-env-modules/test-container-inbound-plan-batch-contract.test.mjs @@ -5,12 +5,11 @@ import test from 'node:test' import { CONTAINER_CONVERT_ENDPOINT, CONTAINER_CONVERT_EXPECTED_ORDERS, - distinctPhysicalPalletCount, + CONTAINER_SUMMARY_ENDPOINT, groupOrdersByCustomer, stableReplay, validContainerBatchResult, - validOrderFact, - validPdaCandidate, + validContainerSummaryPage, zeroDrift, } from './container-inbound-plan-batch-contract.mjs' @@ -19,7 +18,6 @@ const scenario = read('phase4-container-inbound-plan-batch.mjs') const controller = read('../../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java') const service = read('../../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java') const openapi = read('../../lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml') -const pda = read('../../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java') const adminApi = read('../../../apps/admin-web/src/modules/overseas-warehouse/api/lastMileOrders.ts') const adminView = read('../../../apps/admin-web/src/modules/overseas-warehouse/pages/LastMileOrdersView.vue') @@ -36,35 +34,32 @@ test('semantic contract creates one independent plan per order even for the same const source = orders() assert.equal(CONTAINER_CONVERT_EXPECTED_ORDERS, 15) assert.equal(groupOrdersByCustomer(source).size, 2) - const result = { plans: source.map((order, index) => ({ + const result = source.map((order, index) => ({ customerId: order.customerId, planNo: `LIP-${index + 1}`, status: 'PENDING_ASSIGNMENT', lines: [{ orderId: order.orderId }], pallets: [{}], allocations: [{}], - })) } + })) assert.equal(validContainerBatchResult(result, source), true) }) -test('facts preserve source values and physical pallets are not counted once per SKU', () => { - const expected = orders()[0] - assert.equal(validOrderFact({ ...expected }, expected), true) - assert.equal(validOrderFact({ ...expected, actualArrivalAt: '2026-09-02T08:00:00' }, expected), false) - assert.equal(distinctPhysicalPalletCount([ - { physicalPalletId: 'P1', sku: 'A' }, { physicalPalletId: 'P1', sku: 'B' }, { physicalPalletId: 'P2', sku: 'A' }, - ]), 2) +test('server container summary remains complete when the order page would contain only ten rows', () => { + assert.equal(CONTAINER_SUMMARY_ENDPOINT, '/api/platform/v1/last-leg/orders/container-summaries') + assert.equal(validContainerSummaryPage({ + records: [{ sourceContainerBookingId: 'CB-1', containerNo: 'C1', orderCount: '15', inboundCodeCount: '15' }], + page: '1', size: '10', total: '1', pages: '1', + }, { sourceContainerBookingId: 'CB-1', containerNo: 'C1', orderCount: 15, inboundCodeCount: 15 }), true) }) -test('atomic failure, replay and PDA count helpers reject drift', () => { +test('atomic failure and replay helpers reject drift', () => { const snapshot = { orders: [{ id: '1', status: 'PENDING_ASSIGNMENT' }], plans: [] } assert.equal(zeroDrift(snapshot, structuredClone(snapshot)), true) assert.equal(stableReplay({ plans: [{ planNo: 'LIP-1' }] }, { plans: [{ planNo: 'LIP-1' }] }), true) - assert.equal(validPdaCandidate({ containerNo: 'C1', planCount: '2', inboundCodeCount: '15' }, - { containerNo: 'C1', planCount: 2, inboundCodeCount: 15 }), true) }) test('HTTP scenario freezes normal, invalid, replay, concurrency, partial and PDA evidence', () => { - assert.equal(CONTAINER_CONVERT_ENDPOINT, '/api/platform/v1/last-leg/inbound-plans/convert') - for (const marker of ['P0整柜一次转换并逐订单生成独立计划', 'P1任一非法票整柜转换拒绝', + assert.equal(CONTAINER_CONVERT_ENDPOINT, '/api/platform/v1/last-leg/inbound-plans/convert-container') + for (const marker of ['P1整柜一次转换并逐订单生成独立计划', 'P1任一非法票整柜转换拒绝', 'P1同键同请求重放', 'P1并发双击A', 'P1部分已转换整柜冲突', - 'P0同柜PDA候选计划数与入仓码数一致', 'P3箱码服务端命中当前柜唯一入仓码']) { + 'P1服务端柜级摘要跨订单分页仍返回15票']) { assert.match(scenario, new RegExp(marker)) } assert.match(scenario, /Promise\.all/) @@ -73,17 +68,14 @@ test('HTTP scenario freezes normal, invalid, replay, concurrency, partial and PD assert.match(scenario, /traceId/) }) -test('platform API keeps two long string identifiers and Admin uses the selected row customer', () => { +test('container API uses one stable long string identity', () => { const endpoint = openapi.slice(openapi.indexOf(` ${CONTAINER_CONVERT_ENDPOINT}:`), openapi.indexOf(' /api/platform/v1/last-leg/dispatches:', openapi.indexOf(` ${CONTAINER_CONVERT_ENDPOINT}:`))) - assert.match(endpoint, /required: \[customerId, orderId\]/) - assert.match(endpoint, /customerId:\s*\{ type: string/) - assert.match(endpoint, /orderId:\s*\{ type: string/) - assert.match(controller, /convertScope\(user, request\.customerId\(\)\)/) - assert.match(adminApi, /orderId: string[\s\S]{0,80}customerId: string/) - assert.match(adminView, /selectedRows\.value\.length !== 1/) - assert.match(adminView, /customerId = typeof selected\.customerId === 'string' \? selected\.customerId\.trim\(\)/) - assert.match(adminView, /orderId: String\(selected\.id\), customerId/) + assert.match(endpoint, /required: \[sourceContainerBookingId\]/) + assert.match(endpoint, /sourceContainerBookingId:\s*\{ type: string/) + assert.match(controller, /convertContainer/) + assert.match(adminApi, /sourceContainerBookingId: string/) + assert.match(adminView, /sourceContainerBookingId: row\.sourceContainerBookingId/) }) test('backend batch conversion is one transaction and never merges same-customer orders', () => { @@ -92,13 +84,14 @@ test('backend batch conversion is one transaction and never merges same-customer assert.match(service, /(?:List|Container.*Response|Batch.*Response)/) assert.match(service, /(?:for \([^)]*(?:order|source)|\.map\([^)]*(?:order|source))/) assert.doesNotMatch(service, /groupingBy\([^)]*(?:customerId|getCustomerId)/) - assert.match(service, /(?:partial|部分已转换|already belongs)[\s\S]{0,180}(?:CONFLICT|HttpStatus\.CONFLICT)/i) + assert.match(service, /来源柜存在不能转换的尾程订单/) + assert.match(service, /HttpStatus\.CONFLICT/) }) -test('PDA candidate and resolve contracts remain server-owned', () => { - assert.match(pda, /grouped\.computeIfAbsent\(plan\.getContainerNo\(\)/) - assert.match(pda, /entry\.getValue\(\)\.size\(\)/) - assert.match(pda, /totals\.inboundCount\(\)/) - assert.match(pda, /requireReceivingAvailable\(customerId, containerNo\)/) - assert.match(pda, /requireCartonRef|requireUnmatchedFbaCartonPrefix/) +test('Admin consumes a server-paged container summary and never derives 15 from the current order page', () => { + assert.match(adminApi, /(?:listLastMileContainerSummaries|listContainerSummaries)/) + assert.match(adminApi, /container-summaries/) + assert.match(adminView, /(?:listLastMileContainerSummaries|listContainerSummaries)\(/) + assert.match(adminView, /containerRows\.value\s*=\s*result\.records/) + assert.doesNotMatch(adminView, /(?:rows|result\.records)\.value?\.(?:reduce|filter|forEach)[\s\S]{0,300}containerNo/) }) diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index eeb4bcdaa..00819578d 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -232,7 +232,9 @@ Assert-Contains $navigationRecovery 'if (!isStaleRouteAssetError(error))' 'Admin $domesticPdaRuntime = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/config/runtime.js') $domesticPdaVite = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/vite.config.js') $domesticPdaSession = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/stores/session.js') -Assert-Contains $domesticPdaRuntime "VITE_H5_API_BASE_URL || '/api'" '国内 PDA H5 同源 API' +Assert-Contains $domesticPdaRuntime "VITE_H5_API_BASE_URL || '/gateway-api'" '国内 PDA H5 同源 API' +Assert-Contains $domesticPdaVite "'/gateway-api'" '国内 PDA H5 独立代理前缀' +Assert-Contains $domesticPdaVite 'path.replace(/^\/gateway-api/, ''/api'')' '国内 PDA H5 Gateway 重写' Assert-Contains $domesticPdaVite 'API_PROXY_TARGET' '国内 PDA H5 代理上游' Assert-Contains $domesticPdaSession 'session.apiBase = PLATFORM_API_ROOT' '国内 PDA 当前构建 API' Assert-NotContains $domesticPdaSession 'apiBase: session.apiBase' '国内 PDA 会话持久化' @@ -303,6 +305,21 @@ 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') +$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 '/inbound-plans/convert-container' 'Admin 整柜转换目标入口' +Assert-Contains $adminLastMileApi '/orders/container-summaries' 'Admin 柜级摘要真实调用' +Assert-NotContains $adminLastMileApi '/orders/pending-containers' 'Admin 旧柜级摘要调用' + +# 国内 PDA 部分拣货必须展示真实可用库位事实,并在无库位时保持页面整洁。 +Assert-Contains $pdaAgents 'tests/partial-layout.test.mjs' '国内 PDA 部分拣货布局合同' +Assert-Contains $pdaAgents 'tests/picking-location-display.test.mjs' '国内 PDA 库位展示合同' + # Maven 聚合声明的全部历史/当前模块必须在后端根指引中可定位。 [xml]$pom = Get-Content -LiteralPath (Join-Path $backendRoot 'pom.xml') -Raw foreach ($module in $pom.project.modules.module) { @@ -312,6 +329,8 @@ 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/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', 'apps/admin-web/src/modules/overseas-warehouse/api/pallets.ts', @@ -351,6 +370,8 @@ $criticalPaths = @( 'apps/pda-uniapp/vite.config.js', 'apps/pda-uniapp/utils/app-update.js', 'apps/pda-uniapp/tests/app-update.test.mjs', + 'apps/pda-uniapp/tests/partial-layout.test.mjs', + 'apps/pda-uniapp/tests/picking-location-display.test.mjs', 'apps/pda-uniapp/utils/evidence-upload-queue.js', 'apps/pda-overseas-uniapp/package.json', 'apps/pda-overseas-uniapp/src/pages.json', @@ -423,6 +444,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/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', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/controller/PdaStocktakeController.java',