From 5fd00d3151b3b3caad5778486ec0c9bf9064f4a7 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Fri, 21 Aug 2026 11:43:03 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/web/GlobalExceptionHandler.java | 7 + .../web/GlobalExceptionHandlerTest.java | 13 ++ config/nacos/overseas/lastleg-service.yml | 2 + lastleg-service/AGENTS.md | 2 +- .../controller/DispatchController.java | 26 ---- .../controller/DispatchQueryController.java | 8 +- .../controller/SmartDispatchController.java | 70 ++++++++++ .../mapper/DispatchCandidateMapper.java | 6 + .../dispatch/mapper/DispatchOrderMapper.java | 3 - .../dispatch/service/DispatchService.java | 26 +++- .../src/main/resources/application.yml | 6 + .../openapi/overseas-business-v1.yaml | 124 ++++++++++++++++++ .../Phase4BrowserApiBoundaryTest.java | 28 ++++ .../SmartDispatchOpenApiContractTest.java | 28 ++++ .../DispatchQueryControllerTest.java | 22 +++- .../SmartDispatchControllerTest.java | 71 ++++++++++ .../dispatch/service/DispatchServiceTest.java | 28 +++- scripts/db/incremental.sql | 6 + .../PlatformWarehouseController.java | 3 +- .../baiyue/warehouse/dto/PalletRequests.java | 8 +- .../entity/WarehousePalletEntity.java | 1 + .../service/WarehouseResourceService.java | 1 + .../src/main/resources/db/warehouse.sql | 10 ++ .../PlatformWarehouseControllerTest.java | 14 ++ .../service/WarehouseResourceServiceTest.java | 38 ++++++ 25 files changed, 507 insertions(+), 44 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java diff --git a/common-web/src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java b/common-web/src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java index 3cc9626c3..652028ab7 100644 --- a/common-web/src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java +++ b/common-web/src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java @@ -21,6 +21,7 @@ import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.resource.NoResourceFoundException; import org.springframework.web.server.ResponseStatusException; import java.util.LinkedHashMap; @@ -89,6 +90,12 @@ public class GlobalExceptionHandler { return failure(code, message, request); } + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity> handleNoResourceFound( + NoResourceFoundException exception, HttpServletRequest request) { + return failure(ResultCode.NOT_FOUND, ResultCode.NOT_FOUND.message(), request); + } + @ExceptionHandler(Exception.class) public ResponseEntity> handleUnexpected(Exception exception, HttpServletRequest request) { log.error("Unhandled request failure requestId={} traceId={}", diff --git a/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java b/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java index febb9da67..c932c7d96 100644 --- a/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java +++ b/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java @@ -12,6 +12,7 @@ import org.springframework.validation.BindException; import org.springframework.validation.FieldError; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.bind.MissingRequestHeaderException; +import org.springframework.web.servlet.resource.NoResourceFoundException; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.http.HttpStatus; import org.springframework.web.server.ResponseStatusException; @@ -103,6 +104,18 @@ class GlobalExceptionHandlerTest { assertThat(response.getBody().requestId()).isEqualTo("stale-version"); } + @Test + void missingControllerPathMapsToNotFoundInsteadOfInternalError() { + MockHttpServletRequest request = new MockHttpServletRequest(); + + var response = handler.handleNoResourceFound( + new NoResourceFoundException(org.springframework.http.HttpMethod.GET, + "api/platform/v1/last-leg/dispatches"), request); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(response.getBody().code()).isEqualTo(ResultCode.NOT_FOUND.code()); + } + private void paging(long page) { } } diff --git a/config/nacos/overseas/lastleg-service.yml b/config/nacos/overseas/lastleg-service.yml index 161993e09..b4e34c36d 100644 --- a/config/nacos/overseas/lastleg-service.yml +++ b/config/nacos/overseas/lastleg-service.yml @@ -7,6 +7,8 @@ spring: lastleg: dispatch-query-api: enabled: true + smart-dispatch-api: + enabled: true baiyue: clients: domestic-warehouse: diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 9b4cab290..c0de5dd13 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -39,7 +39,7 @@ - 四期在本服务内只进入尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、海外采购和本土卡车;本土散板新增实现也归本服务的独立稳定子域。 - `dispatch/`、`delivery/`、出库单、PDA 出库拣货、派送和其他库存离仓能力虽然已存在于服务代码或表结构中,但不属于当前四期开发与验收范围,不得因代码已存在而自动纳入。 -- 历史 `dispatch/` 写操作、PDA 出库和 `delivery/` Controller 默认不注册;只有后续阶段明确启用 `lastleg.future-outbound-api.enabled=true` 才可恢复。平台排车列表与详情是独立只读入口,仅由海外配置 `lastleg.dispatch-query-api.enabled=true` 启用,不得借此开放预排、编辑、取消、PDA 出库或派送。四期平台库存 Controller 不映射 allocate、unallocate、pick、ship。 +- 历史排车编辑/取消/出库、PDA 出库和 `delivery/` Controller 默认不注册;只有后续阶段明确启用 `lastleg.future-outbound-api.enabled=true` 才可恢复。平台排车列表与详情由 `lastleg.dispatch-query-api.enabled=true` 独立启用;入库单智能排车候选和预排创建由 `lastleg.smart-dispatch-api.enabled=true` 独立启用。四个排车平台入口均不接收 `customerId`,服务端从尾程库排车/入仓数据解析唯一客户范围,检测到多客户时拒绝请求;预排只能创建待出库排车并锁定托盘,不得借此开放编辑、取消、depart、PDA 出库或派送。四期平台库存 Controller 不映射 allocate、unallocate、pick、ship。 - 海外仓库、库位、实物托盘和资源锁归 `warehouse-overseas-service/`;浏览器 API 统一经 `gateway-overseas/`,本服务不得复制仓储资源主数据或另建公网认证入口。 ## 数据库表索引与注释约束 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java index c5edf4f4d..d6de4b587 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java @@ -4,7 +4,6 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.dispatch.dto.DispatchRequests; import com.baiyue.lastleg.dispatch.dto.DispatchResponse; -import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests; import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses; import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService; @@ -18,7 +17,6 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; @@ -28,8 +26,6 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; -import java.util.List; - @RestController @ConditionalOnProperty(name = "lastleg.future-outbound-api.enabled", havingValue = "true") @RequestMapping("/api/platform/v1/last-leg/dispatches") @@ -38,28 +34,6 @@ public class DispatchController { @Resource private DispatchService service; @Resource private DispatchPdaWorkflowService pdaWorkflow; - @GetMapping("/candidates") - @Operation(summary = "List unlocked planned or received pallets for intelligent pre-dispatch") - public ApiResponse> candidates( - @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam(required = false) String customerId, - @RequestParam(required = false) String orderNo, - @RequestParam(required = false) String fbaWarehouseCode, - @RequestParam(required = false) String sourceStage) { - return ApiResponse.success(service.candidates(scope(user, customerId), orderNo, fbaWarehouseCode, sourceStage)); - } - - @PostMapping("/pre-arrange") - @Operation(summary = "Create a pending-departure dispatch and lock all selected pallets") - public ApiResponse preArrange( - @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam(required = false) String customerId, - @Valid @RequestBody DispatchRequests.PreArrange request) { - String scopedTenant = scope(user, customerId); - String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId()); - return ApiResponse.success(service.preArrange(scopedTenant, operatorId, request)); - } - @PutMapping("/{dispatchNo}") @Operation(summary = "Edit vehicle, driver, appointment, route and departure data") public ApiResponse update( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java index aca77fa56..cc8677d2b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java @@ -6,12 +6,14 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.dispatch.dto.DispatchRequests; import com.baiyue.lastleg.dispatch.dto.DispatchResponse; import com.baiyue.lastleg.dispatch.service.DispatchService; +import com.baiyue.lastleg.security.OverseasPermissions; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -27,21 +29,23 @@ public class DispatchQueryController { @Resource private DispatchService service; @GetMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')") @Operation(summary = "Page dispatch orders") public ApiResponse> page( @AuthenticationPrincipal AuthenticatedUser user, @Valid DispatchRequests.Query query) { requirePlatform(user); - return ApiResponse.success(service.page(null, query)); + return ApiResponse.success(service.page(service.resolveSingleCustomerScope(), query)); } @GetMapping("/{dispatchNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')") @Operation(summary = "Get dispatch details and all pallet destinations") public ApiResponse detail( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo) { requirePlatform(user); - return ApiResponse.success(service.detail(null, dispatchNo)); + return ApiResponse.success(service.detail(service.resolveSingleCustomerScope(), dispatchNo)); } private void requirePlatform(AuthenticatedUser user) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java new file mode 100644 index 000000000..651bce163 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java @@ -0,0 +1,70 @@ +package com.baiyue.lastleg.dispatch.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.dispatch.dto.DispatchRequests; +import com.baiyue.lastleg.dispatch.dto.DispatchResponse; +import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; +import com.baiyue.lastleg.dispatch.service.DispatchService; +import com.baiyue.lastleg.security.OverseasPermissions; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +@RestController +@ConditionalOnProperty(name = "lastleg.smart-dispatch-api.enabled", havingValue = "true") +@RequestMapping("/api/platform/v1/last-leg/dispatches") +@Tag(name = "Tail-leg smart dispatch") +public class SmartDispatchController { + @Resource private DispatchService service; + + @GetMapping("/candidates") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')") + @Operation(summary = "List unlocked planned or received pallets for intelligent pre-dispatch", + description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST) + public ApiResponse> candidates( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) String orderNo, + @RequestParam(required = false) String fbaWarehouseCode, + @RequestParam(required = false) String sourceStage) { + requirePlatform(user); + return ApiResponse.success(service.candidates(service.resolveSingleCustomerScope(), orderNo, + fbaWarehouseCode, sourceStage)); + } + + @PostMapping("/pre-arrange") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')") + @Operation(summary = "Create a pending-departure dispatch and lock all selected pallets", + description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_EDIT) + public ApiResponse preArrange( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody DispatchRequests.PreArrange request) { + requirePlatform(user); + String scopedCustomer = service.resolveSingleCustomerScope(); + String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId()); + return ApiResponse.success(service.preArrange(scopedCustomer, operatorId, request)); + } + + private void requirePlatform(AuthenticatedUser user) { + if (user == null || user.tenantId() == null) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + } + if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform application required"); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java index 2d0b37abc..2c4d9b5e2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java @@ -9,6 +9,12 @@ import java.util.List; @Mapper public interface DispatchCandidateMapper { + @Select("SELECT customer_id FROM (" + + "SELECT customer_id FROM ll_inbound_plan UNION SELECT customer_id FROM ll_dispatch_order" + + ") dispatch_customers WHERE customer_id IS NOT NULL AND customer_id != '' " + + "ORDER BY customer_id LIMIT 2") + List findCustomerScopes(); + @Select("") + List findCustomerScopesByPlanIds(@Param("planIds") List planIds); + + @Select("") + List findCustomerScopesByPlanPalletIds(@Param("planPalletIds") List planPalletIds); + @Select("") List available(@Param("customerId") String customerId, + @Param("planIds") List planIds, @Param("orderNo") String orderNo, @Param("fbaWarehouseCode") String fbaWarehouseCode, @Param("sourceStage") String sourceStage); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java index bade8c38a..461008164 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -50,13 +50,26 @@ public class DispatchService { return customerIds.isEmpty() ? null : customerIds.get(0); } - public List candidates(String customerId, String orderNo, + public String resolveCustomerScopeForPlans(List planIds) { + return requireSingleCustomerScope(candidates.findCustomerScopesByPlanIds(normalizeIds(planIds, "Plan IDs")), + "Selected inbound plans"); + } + + public String resolveCustomerScopeForPallets(List planPalletIds) { + return requireSingleCustomerScope( + candidates.findCustomerScopesByPlanPalletIds(normalizeIds(planPalletIds, "Plan pallet IDs")), + "Selected pallets"); + } + + public List candidates(String customerId, List planIds, String orderNo, String fbaWarehouseCode, String sourceStage) { if (StringUtils.hasText(sourceStage) && !Set.of("PLAN", "RECEIVED").contains(sourceStage)) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "sourceStage must be PLAN or RECEIVED"); } if (!StringUtils.hasText(customerId)) return List.of(); - return candidates.available(customerId, trimToNull(orderNo), trimToNull(fbaWarehouseCode), trimToNull(sourceStage)); + List scopedPlanIds = normalizeIds(planIds, "Plan IDs"); + return candidates.available(customerId, scopedPlanIds, trimToNull(orderNo), trimToNull(fbaWarehouseCode), + trimToNull(sourceStage)); } @Transactional @@ -278,4 +291,25 @@ public class DispatchService { private static String trimToNull(String value) { return StringUtils.hasText(value) ? value.trim() : null; } + + private static List normalizeIds(List ids, String label) { + if (ids == null || ids.isEmpty() || ids.stream().anyMatch(Objects::isNull)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, label + " must be non-empty"); + } + List normalized = ids.stream().distinct().sorted().toList(); + if (normalized.size() > 500) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, label + " cannot exceed 500 items"); + } + return normalized; + } + + private static String requireSingleCustomerScope(List customerIds, String source) { + if (customerIds == null || customerIds.isEmpty()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, source + " were not found"); + } + if (customerIds.size() > 1) { + throw new ResponseStatusException(HttpStatus.CONFLICT, source + " must belong to exactly one customer"); + } + return customerIds.get(0); + } } 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 712d2b829..99bf49851 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 @@ -17,10 +17,13 @@ public final class LastlegOrderRequests { @Min(1) long page, @Min(1) @Max(200) long size, String orderNo, + String orderType, String inboundCode, String sourceOrderNo, String containerNo, + String currentStatus, String status, + String warehouse, String transferWarehouseCode, Boolean abnormal, Boolean detained, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java index 0a8719a4c..f294b6fc7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java @@ -33,7 +33,7 @@ public class InboundPlanController { /** 按入仓计划明细分页查询可预约的入库订单行。 */ @GetMapping - @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_CREATE + "')") @Operation(summary = "分页查询入库订单", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST) public ApiResponse> page( @AuthenticationPrincipal AuthenticatedUser user, @@ -43,7 +43,7 @@ public class InboundPlanController { } @PostMapping("/convert") - @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_CREATE + "')") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')") /** 将入仓单转换为入仓计划。 */ @Operation(summary = "入仓单转入仓计划", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_CREATE) public ApiResponse convert(@AuthenticationPrincipal AuthenticatedUser user, @@ -72,9 +72,9 @@ public class InboundPlanController { } @PostMapping("/fba-appointments/batch") - @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT + "')") /** 为选中的入仓明细创建 FBA 预约。 */ - @Operation(summary = "创建 FBA 预约", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_EDIT) + @Operation(summary = "创建 FBA 预约", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT) public ApiResponse batchFbaAppointment( @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody BatchFbaAppointmentRequest request) { @@ -95,8 +95,8 @@ public class InboundPlanController { } @PutMapping("/fba-appointments/{appointmentNo}") - @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')") - @Operation(summary = "编辑 FBA 预约", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_EDIT) + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT + "')") + @Operation(summary = "编辑 FBA 预约", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT) public ApiResponse updateFbaAppointment( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String appointmentNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java b/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java index 910f94a98..5ac51600c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java @@ -8,6 +8,10 @@ public final class OverseasPermissions { public static final String INBOUND_ORDER_LIST = "platform:overseas-warehouse:inbound-order:list"; public static final String INBOUND_ORDER_CREATE = "platform:overseas-warehouse:inbound-order:create"; public static final String INBOUND_ORDER_EDIT = "platform:overseas-warehouse:inbound-order:edit"; + public static final String INBOUND_ORDER_FBA_APPOINTMENT = + "platform:overseas-warehouse:inbound-order:fba-appointment"; + public static final String INBOUND_ORDER_SMART_DISPATCH = + "platform:overseas-warehouse:inbound-order:smart-dispatch"; public static final String RELABEL_ORDER_LIST = "platform:overseas-warehouse:relabel-order:list"; public static final String RELABEL_ORDER_CREATE = "platform:overseas-warehouse:relabel-order:create"; public static final String RELABEL_ORDER_STATUS = "platform:overseas-warehouse:relabel-order:status"; 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 9be1c0f3d..3ca6411e6 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 @@ -123,13 +123,20 @@ public class LastlegOrderService { if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "createdFrom must not be after createdTo"); } + String currentStatus = StringUtils.hasText(query.currentStatus()) ? query.currentStatus() : query.status(); return new LambdaQueryWrapper() .eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId) .like(StringUtils.hasText(query.orderNo()), LastlegOrder::getOrderNo, query.orderNo()) + .eq(StringUtils.hasText(query.orderType()), LastlegOrder::getOrderType, query.orderType()) .like(StringUtils.hasText(query.inboundCode()), LastlegOrder::getInboundCode, query.inboundCode()) .like(StringUtils.hasText(query.sourceOrderNo()), LastlegOrder::getSourceOrderNo, query.sourceOrderNo()) .like(StringUtils.hasText(query.containerNo()), LastlegOrder::getContainerNo, query.containerNo()) - .eq(StringUtils.hasText(query.status()), LastlegOrder::getCurrentStatus, query.status()) + .eq(StringUtils.hasText(currentStatus), LastlegOrder::getCurrentStatus, currentStatus) + .and(StringUtils.hasText(query.warehouse()), wrapper -> wrapper + .like(LastlegOrder::getOverseasWarehouseId, query.warehouse()) + .or().like(LastlegOrder::getOverseasWarehouseName, query.warehouse()) + .or().like(LastlegOrder::getTransferWarehouseCode, query.warehouse()) + .or().like(LastlegOrder::getTransferWarehouseName, query.warehouse())) .eq(StringUtils.hasText(query.transferWarehouseCode()), LastlegOrder::getTransferWarehouseCode, query.transferWarehouseCode()) .eq(query.abnormal() != null, LastlegOrder::getAbnormal, query.abnormal()) 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 f6cea8506..bd48a2959 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -3,6 +3,27 @@ info: title: Lastleg Overseas Business API version: 1.0.0 paths: + /api/platform/v1/last-leg/orders: + get: + summary: 分页查询尾程订单 + description: 权限 platform:overseas-warehouse:order:list;平台管理员省略 customerId 时查询全部客户。 + 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: orderNo, in: query, schema: { type: string } } + - { name: orderType, in: query, schema: { type: string, enum: [FIRST_LEG, RELABEL_APPLICATION, WAREHOUSE_TRANSFER, LOCAL_LOOSEBOARD, RETURN_RELABEL, ONE_CLICK_DROPSHIP] } } + - { name: currentStatus, in: query, schema: { type: string } } + - { name: inboundCode, in: query, schema: { type: string } } + - { name: sourceOrderNo, in: query, schema: { type: string } } + - { name: containerNo, in: query, schema: { type: string } } + - { name: warehouse, in: query, schema: { type: string } } + - { name: transferWarehouseCode, in: query, schema: { type: string } } + - { name: abnormal, in: query, schema: { type: boolean } } + - { name: detained, in: query, schema: { type: boolean } } + - { name: createdFrom, in: query, schema: { type: string, format: date-time } } + - { name: createdTo, in: query, schema: { type: string, format: date-time } } + responses: { '200': { description: 统一 ApiResponse 包装的尾程订单分页 } } /api/platform/v1/last-leg/dispatches: get: summary: 分页查询排车单 @@ -35,6 +56,13 @@ paths: description: 权限 platform:overseas-warehouse:inbound-order:list;服务端解析唯一客户范围,仅返回尚未被有效排车锁定的计划或已实收托盘,前端不传 customerId。 security: [{ bearerAuth: [] }] parameters: + - name: planIds + in: query + required: true + description: 已选入库计划 ID,逗号分隔;服务端据此解析并校验唯一客户范围。 + schema: { type: array, minItems: 1, maxItems: 500, items: { type: integer, format: int64 } } + style: form + explode: false - { name: orderNo, in: query, schema: { type: string } } - { name: fbaWarehouseCode, in: query, schema: { type: string } } - { name: sourceStage, in: query, schema: { type: string, enum: [PLAN, RECEIVED] } } @@ -45,7 +73,7 @@ paths: /api/platform/v1/last-leg/dispatches/pre-arrange: post: summary: 创建智能预排车单 - description: 权限 platform:overseas-warehouse:inbound-order:edit;服务端解析唯一客户范围,锁定同一海外仓的所选托盘并创建 PENDING_DEPARTURE 排车单,前端不传 customerId;不开放编辑、取消、depart、PDA 出库或派送。 + description: 权限 platform:overseas-warehouse:inbound-order:smart-dispatch;服务端根据所选托盘解析并校验唯一客户范围,锁定同一海外仓的托盘并创建 PENDING_DEPARTURE 排车单,前端不传 customerId;不开放编辑、取消、depart、PDA 出库或派送。 security: [{ bearerAuth: [] }] requestBody: required: true 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 ced14630f..e2975697a 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 @@ -25,7 +25,7 @@ class LastlegOrderControllerTest { private final LastlegOrderService service = mock(LastlegOrderService.class); private final LastlegOrderController controller = controller(); private final LastlegOrderRequests.Query query = new LastlegOrderRequests.Query( - 1, 20, null, null, null, null, null, null, null, null, null, null); + 1, 20, null, null, null, null, null, null, null, null, null, null, null, null, null); @Test void platformEndpointRejectsMerchantIdentity() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/MerchantLastlegOrderControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/MerchantLastlegOrderControllerTest.java index 0e1a2b7aa..c21728b09 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/MerchantLastlegOrderControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/MerchantLastlegOrderControllerTest.java @@ -18,7 +18,7 @@ class MerchantLastlegOrderControllerTest { private final LastlegOrderService service = mock(LastlegOrderService.class); private final MerchantLastlegOrderController controller = controller(); private final LastlegOrderRequests.Query query = new LastlegOrderRequests.Query( - 1, 20, null, null, null, null, null, null, null, null, null, null); + 1, 20, null, null, null, null, null, null, null, null, null, null, null, null, null); @Test void merchantListUsesOnlyAuthenticatedTenant() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java index 660f94f4c..2d88558b0 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java @@ -17,9 +17,11 @@ class SmartDispatchOpenApiContractTest { assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/candidates:")); assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/pre-arrange:")); assertTrue(yaml.contains("platform:overseas-warehouse:inbound-order:list")); - assertTrue(yaml.contains("platform:overseas-warehouse:inbound-order:edit")); + assertTrue(yaml.contains("platform:overseas-warehouse:inbound-order:smart-dispatch")); assertTrue(yaml.contains("SmartDispatchPreArrange")); assertTrue(yaml.contains("SmartDispatchCandidate")); + assertTrue(yaml.contains("name: planIds")); + assertTrue(yaml.contains("required: true")); String dispatchContract = yaml.substring(0, yaml.indexOf("/api/platform/v1/last-leg/overseas-procurements:")); assertFalse(dispatchContract.contains("name: customerId")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java index 32cd2bc6f..3b198b269 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java @@ -31,20 +31,20 @@ class SmartDispatchControllerTest { } @Test - void candidatesResolveSingleCustomerScopeWithoutRequestParameter() { - when(service.resolveSingleCustomerScope()).thenReturn("100"); - when(service.candidates("100", "LMO-1", "ONT8", "PLAN")).thenReturn(List.of()); + void candidatesResolveCustomerScopeFromSelectedPlans() { + when(service.resolveCustomerScopeForPlans(List.of(1L, 2L))).thenReturn("100"); + when(service.candidates("100", List.of(1L, 2L), "LMO-1", "ONT8", "PLAN")).thenReturn(List.of()); - controller.candidates(platform(), "LMO-1", "ONT8", "PLAN"); + controller.candidates(platform(), List.of(1L, 2L), "LMO-1", "ONT8", "PLAN"); - verify(service).candidates("100", "LMO-1", "ONT8", "PLAN"); + verify(service).candidates("100", List.of(1L, 2L), "LMO-1", "ONT8", "PLAN"); } @Test void preArrangeResolvesSingleCustomerAndAuthenticatedOperator() { DispatchRequests.PreArrange request = new DispatchRequests.PreArrange( List.of(10L, 11L), "ONT8 -> LAX9", "APT-1", "SUCCESS", "PENDING"); - when(service.resolveSingleCustomerScope()).thenReturn("100"); + when(service.resolveCustomerScopeForPallets(List.of(10L, 11L))).thenReturn("100"); controller.preArrange(platform(), request); @@ -52,20 +52,20 @@ class SmartDispatchControllerTest { } @Test - void endpointsReuseInboundOrderListAndEditAuthorities() throws Exception { + void endpointsUseInboundOrderListAndDedicatedSmartDispatchAuthorities() throws Exception { PreAuthorize candidates = SmartDispatchController.class - .getMethod("candidates", AuthenticatedUser.class, String.class, String.class, String.class) + .getMethod("candidates", AuthenticatedUser.class, List.class, String.class, String.class, String.class) .getAnnotation(PreAuthorize.class); PreAuthorize preArrange = SmartDispatchController.class .getMethod("preArrange", AuthenticatedUser.class, DispatchRequests.PreArrange.class) .getAnnotation(PreAuthorize.class); assertThat(candidates.value()).contains(OverseasPermissions.INBOUND_ORDER_LIST); - assertThat(preArrange.value()).contains(OverseasPermissions.INBOUND_ORDER_EDIT); + assertThat(preArrange.value()).contains(OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH); } private static AuthenticatedUser platform() { return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB", - Set.of(OverseasPermissions.INBOUND_ORDER_LIST, OverseasPermissions.INBOUND_ORDER_EDIT)); + Set.of(OverseasPermissions.INBOUND_ORDER_LIST, OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH)); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java index e40a65ff4..ff778910f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java @@ -82,8 +82,28 @@ class DispatchServiceTest { @Test void invalidCandidateStageIsRejected() { - assertThrows(ResponseStatusException.class, () -> service.candidates("100", null, null, "INVALID")); - verify(candidates, never()).available(any(), any(), any(), any()); + assertThrows(ResponseStatusException.class, + () -> service.candidates("100", List.of(1L), null, null, "INVALID")); + verify(candidates, never()).available(any(), any(), any(), any(), any()); + } + + @Test + void resolvesCustomerFromSelectedPlansAndPalletsInMultiCustomerDeployment() { + when(candidates.findCustomerScopesByPlanIds(List.of(1L, 2L))).thenReturn(List.of("100")); + when(candidates.findCustomerScopesByPlanPalletIds(List.of(10L, 11L))).thenReturn(List.of("100")); + + assertEquals("100", service.resolveCustomerScopeForPlans(List.of(2L, 1L, 1L))); + assertEquals("100", service.resolveCustomerScopeForPallets(List.of(11L, 10L))); + } + + @Test + void rejectsSelectedPlansThatSpanCustomers() { + when(candidates.findCustomerScopesByPlanIds(List.of(1L, 2L))).thenReturn(List.of("100", "200")); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.resolveCustomerScopeForPlans(List.of(1L, 2L))); + + assertEquals(409, exception.getStatusCode().value()); } @Test @@ -99,8 +119,8 @@ class DispatchServiceTest { @Test void emptyCustomerScopeProducesEmptyCandidatesAndRejectsPreArrange() { - assertEquals(List.of(), service.candidates(null, null, null, null)); - verify(candidates, never()).available(any(), any(), any(), any()); + assertEquals(List.of(), service.candidates(null, null, null, null, null)); + verify(candidates, never()).available(any(), any(), any(), any(), any()); ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> service.preArrange(null, "7", diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java index 8d3f3b085..d73111a9b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java @@ -13,6 +13,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals; class InboundPlanPermissionContractTest { private static final String LIST = "platform:overseas-warehouse:inbound-order:list"; + private static final String FBA_APPOINTMENT = + "platform:overseas-warehouse:inbound-order:fba-appointment"; @Test void listAndDetailUseOnlyCanonicalInboundOrderListAuthority() { @@ -25,4 +27,18 @@ class InboundPlanPermissionContractTest { assertEquals("Required authority: " + LIST, method.getAnnotation(Operation.class).description()); } } + + @Test + void fbaAppointmentWritesUseDedicatedAuthority() { + Map methods = Arrays.stream(InboundPlanController.class.getDeclaredMethods()) + .collect(Collectors.toMap(Method::getName, method -> method)); + + for (String name : new String[] {"batchFbaAppointment", "updateFbaAppointment"}) { + Method method = methods.get(name); + assertEquals("hasAuthority('" + FBA_APPOINTMENT + "')", + method.getAnnotation(PreAuthorize.class).value()); + assertEquals("Required authority: " + FBA_APPOINTMENT, + method.getAnnotation(Operation.class).description()); + } + } } 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 1d36bbf6d..6d691750a 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 @@ -104,6 +104,23 @@ class LastlegOrderServiceTest { verify(orders, never()).updateById(any(LastlegOrder.class)); } + @Test + void pageUsesPaginationAndPlatformFiltersWithoutCustomerScope() { + var query = new LastlegOrderRequests.Query(3, 25, "LL-", "FIRST_LEG", "IN-", null, + "", "PENDING_ASSIGNMENT", null, "LAX", null, null, null, null, null); + var resultPage = new com.baomidou.mybatisplus.extension.plugins.pagination.Page(3, 25); + resultPage.setTotal(0); + when(orders.selectPage(any(), any())).thenReturn(resultPage); + + service.page(null, query); + + ArgumentCaptor> page = + ArgumentCaptor.forClass(com.baomidou.mybatisplus.extension.plugins.pagination.Page.class); + verify(orders).selectPage(page.capture(), any()); + assertEquals(3, page.getValue().getCurrent()); + assertEquals(25, page.getValue().getSize()); + } + 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/LoadingCompletedConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java index 3ee2c40f6..c482c97b4 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 @@ -9,6 +9,25 @@ class LoadingCompletedConsumerTest { @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)); } @Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); } @Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","ONE_CLICK_DROPSHIP",List.of(pallet)); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","CONT-D","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); assertEquals("100",captor.getValue().customerId()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); } + @Test void oneDropshipInboundWithMultipleSkusCreatesOneOrderAndMultipleSkuDetails(){ + var itemA=new FirstLegLoadingPlanCompletedV1.Item("d-a","SKU-A","产品A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-A","MARK-A",null,List.of("BOX-A1","BOX-A2")); + var itemB=new FirstLegLoadingPlanCompletedV1.Item("d-b","SKU-B","产品B","Product B",3,15,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-B","MARK-B",null,List.of("BOX-B1","BOX-B2","BOX-B3")); + var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of( + new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","产品A",2,20,List.of("BOX-A1","BOX-A2")), + new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-B","产品B",3,15,List.of("BOX-B1","BOX-B2","BOX-B3")))); + var order=new FirstLegLoadingPlanCompletedV1.Order("in-multi","root-multi","ord-multi","FL-MULTI","root-ord-multi","RK-MULTI","wh-1","ONT8","ONT8",5,35,1,BigDecimal.valueOf(2),BigDecimal.valueOf(2),BigDecimal.valueOf(2),List.of(itemA,itemB),"100","ONE_CLICK_DROPSHIP",List.of(pallet)); + EventEnvelope event=eventForPayload(new FirstLegLoadingPlanCompletedV1("plan-multi","LP-MULTI","book-multi","CONT-MULTI","CAB-MULTI","92","Warehouse",List.of(order)),2); + + assertEquals(1,service.consume(event)); + verify(orders,times(1)).insert(any(LastlegOrder.class)); + ArgumentCaptor detailCaptor=ArgumentCaptor.forClass(LastlegOrderDetail.class); + verify(details,times(2)).insert(detailCaptor.capture()); + assertEquals(Map.of("SKU-A",2,"SKU-B",3),detailCaptor.getAllValues().stream().collect(java.util.stream.Collectors.toMap(LastlegOrderDetail::getSkuCode,LastlegOrderDetail::getPackageCount))); + ArgumentCaptor receiveCaptor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); + verify(dropshipOrders).receive(receiveCaptor.capture()); + assertEquals(2,receiveCaptor.getValue().pallets().get(0).skuPackages().size()); + assertEquals(Map.of("SKU-A",2L,"SKU-B",3L),receiveCaptor.getValue().pallets().get(0).skuPackages().stream().collect(java.util.stream.Collectors.toMap(DropshipRequests.SkuPackages::skuCode,DropshipRequests.SkuPackages::packageCount))); + } @Test void consumesV2LoadingEventAndKeepsDropshipSnapshot(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); @@ -40,4 +59,5 @@ class LoadingCompletedConsumerTest { verify(orders,never()).insert(any(LastlegOrder.class)); } private EventEnvelope event(){ var item1=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-1","产品","Product",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("12"),new BigDecimal("1.2"),"wh-1","ONT8","FBA ONT8","FBA-1","SHIP-1","MARK-1"); var item2=new FirstLegLoadingPlanCompletedV1.Item("d-2","SKU-2","产品2","Product2",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("9"),new BigDecimal("0.8"),"wh-2","LAX9","FBA LAX9","FBA-2","SHIP-2","MARK-2"); var p=new FirstLegLoadingPlanCompletedV1("plan-1","LP1","book-1","MSCU1","CAB1","ow-1","洛杉矶海外仓",List.of(new FirstLegLoadingPlanCompletedV1.Order("in-1","root-1","ord-1","FL1","root-o1","RK1","wh-1","ONT8","FBA ONT8",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("1.2"),List.of(item1)),new FirstLegLoadingPlanCompletedV1.Order("in-2","root-2","ord-2","FL2","root-o2","RK2","wh-2","LAX9","FBA LAX9",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("0.8"),List.of(item2)))); JsonNode node=json.valueToTree(p); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-1",2,1L,"123e4567-e89b-12d3-a456-426614174001","FIRSTLEG_LOADING_COMPLETED:plan-1:100:1",null,node,hash); } + private EventEnvelope eventForPayload(FirstLegLoadingPlanCompletedV1 payload, long version){ JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174099",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN",payload.loadingPlanId(),2,version,"123e4567-e89b-12d3-a456-426614174098","FIRSTLEG_LOADING_COMPLETED:"+payload.loadingPlanId()+":100:1",null,node,hash); } }