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/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 7d4038556..f5309a87d 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -15964,6 +15964,8 @@ VALUES (5107010,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:inbound-order:list','海外入仓单','/overseas-warehouse/inbound-orders','GET',710,'ACTIVE',1,0), (5107011,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:create','创建海外入仓计划',NULL,'POST',711,'ACTIVE',1,0), (5107012,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:edit','编辑海外入仓计划',NULL,'POST',712,'ACTIVE',1,0), + (5107013,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:fba-appointment','FBA预约',NULL,'POST',713,'ACTIVE',1,0), + (5107014,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:smart-dispatch','智能排车',NULL,'POST',714,'ACTIVE',1,0), (5107020,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:relabel-order:list','海外换标单','/overseas-warehouse/relabel-orders','GET',720,'ACTIVE',1,0), (5107021,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:create','创建海外换标单',NULL,'POST',721,'ACTIVE',1,0), (5107022,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:status','变更海外换标单状态',NULL,'PUT',722,'ACTIVE',1,0), @@ -16020,6 +16022,29 @@ JOIN baiyue_iam.iam_permission permission ON permission.tenant_id=0 AND permissi WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN' AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; +INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT 0, role.id, permission.id, NOW(3) +FROM baiyue_iam.iam_role role +JOIN baiyue_iam.iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.permission_code IN ( + 'platform:overseas-warehouse:inbound-order:fba-appointment', + 'platform:overseas-warehouse:inbound-order:smart-dispatch' + ) +WHERE role.tenant_id=0 AND role.app_code='PLATFORM' + AND UPPER(role.role_code)='PLATFORM_CUSTOM_ADMIN' + AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; + +INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT 0, role.id, permission.id, NOW(3) +FROM baiyue_iam.iam_role role +JOIN baiyue_iam.iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.permission_code IN ( + 'platform:overseas-warehouse:last-mile-order:edit', + 'platform:overseas-warehouse:inbound-order:create' + ) +WHERE role.tenant_id=0 AND role.app_code='PLATFORM' + AND UPPER(role.role_code) IN ('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN') + AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; CALL baiyue_firstleg.assert_unified_final_schema(); DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema; diff --git a/iam/src/main/resources/db/lastleg-permissions.sql b/iam/src/main/resources/db/lastleg-permissions.sql index 12b0a7672..5bd6ff291 100644 --- a/iam/src/main/resources/db/lastleg-permissions.sql +++ b/iam/src/main/resources/db/lastleg-permissions.sql @@ -17,6 +17,8 @@ VALUES (5107010,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:inbound-order:list','海外入仓单','/overseas-warehouse/inbound-orders','GET',710,'ACTIVE',1,0), (5107011,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:create','创建海外入仓计划',NULL,'POST',711,'ACTIVE',1,0), (5107012,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:edit','编辑海外入仓计划',NULL,'POST',712,'ACTIVE',1,0), + (5107013,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:fba-appointment','FBA预约',NULL,'POST',713,'ACTIVE',1,0), + (5107014,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:smart-dispatch','智能排车',NULL,'POST',714,'ACTIVE',1,0), (5107020,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:relabel-order:list','海外换标单','/overseas-warehouse/relabel-orders','GET',720,'ACTIVE',1,0), (5107021,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:create','创建海外换标单',NULL,'POST',721,'ACTIVE',1,0), (5107022,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:status','变更海外换标单状态',NULL,'PUT',722,'ACTIVE',1,0), @@ -72,3 +74,28 @@ JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code AND permission.id BETWEEN 5107000 AND 5107299 WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN' AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; + +INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT 0, role.id, permission.id, NOW(3) +FROM iam_role role +JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.permission_code IN ( + 'platform:overseas-warehouse:inbound-order:fba-appointment', + 'platform:overseas-warehouse:inbound-order:smart-dispatch' + ) +WHERE role.tenant_id=0 AND role.app_code='PLATFORM' + AND UPPER(role.role_code)='PLATFORM_CUSTOM_ADMIN' + AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; + +-- Tail-leg order actions are available to the current platform administrator role. +INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT 0, role.id, permission.id, NOW(3) +FROM iam_role role +JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' + AND permission.permission_code IN ( + 'platform:overseas-warehouse:last-mile-order:edit', + 'platform:overseas-warehouse:inbound-order:create' + ) +WHERE role.tenant_id=0 AND role.app_code='PLATFORM' + AND UPPER(role.role_code) IN ('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN') + AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; diff --git a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java index 6267c9cb1..c333f01e1 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java @@ -15,9 +15,14 @@ import static org.assertj.core.api.Assertions.assertThat; class OverseasPhase4PermissionSqlContractTest { private static final Pattern CANONICAL = Pattern.compile("platform:overseas-[a-z0-9:-]+"); + private static final Set CUSTOM_ADMIN_PERMISSIONS = Set.of( + "platform:overseas-warehouse:inbound-order:fba-appointment", + "platform:overseas-warehouse:inbound-order:smart-dispatch"); private static final List REQUIRED = List.of( "platform:overseas-warehouse:last-mile-order:list", "platform:overseas-warehouse:inbound-order:list", + "platform:overseas-warehouse:inbound-order:fba-appointment", + "platform:overseas-warehouse:inbound-order:smart-dispatch", "platform:overseas-warehouse:relabel-order:status", "platform:overseas-warehouse:pallet:list", "platform:overseas-warehouse:inventory:adjust", @@ -34,9 +39,10 @@ class OverseasPhase4PermissionSqlContractTest { .getContentAsString(StandardCharsets.UTF_8); Set catalog = codes(sql); assertThat(sql).contains(REQUIRED.toArray(String[]::new)); - assertThat(catalog).hasSize(51).noneMatch(code -> code.endsWith(":view")); + assertThat(catalog).hasSize(53).noneMatch(code -> code.endsWith(":view")); assertThat(sql).contains("permission_code LIKE 'platform:lastleg:%'", "status='INACTIVE'", "deleted=1"); assertThat(sql).doesNotContain(":carton:", ":product:", ":outbound:", ":delivery:"); + assertCustomAdminGrant(sql); } @Test @@ -48,6 +54,7 @@ class OverseasPhase4PermissionSqlContractTest { .getContentAsString(StandardCharsets.UTF_8)); assertThat(codes(sql)).containsAll(moduleCatalog); assertThat(sql).contains("baiyue_iam.iam_permission", "baiyue_iam.iam_role_permission"); + assertCustomAdminGrant(sql); } @Test @@ -69,6 +76,18 @@ class OverseasPhase4PermissionSqlContractTest { return values; } + private static void assertCustomAdminGrant(String sql) { + String roleMatch = "UPPER(role.role_code)='PLATFORM_CUSTOM_ADMIN'"; + int roleIndex = sql.indexOf(roleMatch); + assertThat(roleIndex).isGreaterThanOrEqualTo(0); + int grantStart = sql.lastIndexOf("INSERT IGNORE INTO", roleIndex); + int grantEnd = sql.indexOf(';', roleIndex); + assertThat(grantStart).isGreaterThanOrEqualTo(0); + assertThat(grantEnd).isGreaterThan(roleIndex); + assertThat(codes(sql.substring(grantStart, grantEnd))) + .containsExactlyInAnyOrderElementsOf(CUSTOM_ADMIN_PERMISSIONS); + } + private static String repositoryFile(String relativePath) throws Exception { Path path = Path.of(relativePath); if (!Files.exists(path)) path = Path.of("..").resolve(relativePath); diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 9b4cab290..a4e5e1f4b 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`:排车列表/详情从既有排车数据解析范围,智能排车候选根据已选入库计划 ID、预排创建根据所选托盘 ID 在服务端解析并校验唯一客户范围;预排只能创建待出库排车并锁定托盘,不得借此开放编辑、取消、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..fe30d351a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java @@ -0,0 +1,71 @@ +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 List planIds, + @RequestParam(required = false) String orderNo, + @RequestParam(required = false) String fbaWarehouseCode, + @RequestParam(required = false) String sourceStage) { + requirePlatform(user); + return ApiResponse.success(service.candidates(service.resolveCustomerScopeForPlans(planIds), planIds, orderNo, + fbaWarehouseCode, sourceStage)); + } + + @PostMapping("/pre-arrange") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')") + @Operation(summary = "Create a pending-departure dispatch and lock all selected pallets", + description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH) + public ApiResponse preArrange( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody DispatchRequests.PreArrange request) { + requirePlatform(user); + String scopedCustomer = service.resolveCustomerScopeForPallets(request.planPalletIds()); + 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..e25b11ff0 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,27 @@ 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/mapper/DispatchOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchOrderMapper.java index e5354e3c7..2a46cf219 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchOrderMapper.java @@ -11,9 +11,6 @@ public interface DispatchOrderMapper extends BaseMapper { @Select("SELECT * FROM ll_dispatch_order WHERE customer_id=#{customerId} AND dispatch_no=#{dispatchNo} LIMIT 1") DispatchOrder findByDispatchNo(@Param("customerId") String customerId, @Param("dispatchNo") String dispatchNo); - @Select("SELECT * FROM ll_dispatch_order WHERE dispatch_no=#{dispatchNo} LIMIT 1") - DispatchOrder findAnyByDispatchNo(@Param("dispatchNo") String dispatchNo); - @Select("SELECT * FROM ll_dispatch_order WHERE customer_id=#{customerId} AND id=#{id} LIMIT 1 FOR UPDATE") DispatchOrder lockById(@Param("customerId") String customerId, @Param("id") Long id); } 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 c82b1af0f..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 @@ -41,16 +41,43 @@ public class DispatchService { @Resource private DispatchPalletLockMapper locks; @Resource private DispatchCandidateMapper candidates; - public List candidates(String customerId, String orderNo, + public String resolveSingleCustomerScope() { + List customerIds = candidates.findCustomerScopes(); + if (customerIds.size() > 1) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Dispatch APIs require exactly one customer in the current deployment"); + } + return customerIds.isEmpty() ? null : customerIds.get(0); + } + + 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"); } - return candidates.available(customerId, trimToNull(orderNo), trimToNull(fbaWarehouseCode), trimToNull(sourceStage)); + if (!StringUtils.hasText(customerId)) return List.of(); + List scopedPlanIds = normalizeIds(planIds, "Plan IDs"); + return candidates.available(customerId, scopedPlanIds, trimToNull(orderNo), trimToNull(fbaWarehouseCode), + trimToNull(sourceStage)); } @Transactional public DispatchResponse preArrange(String customerId, String operatorId, DispatchRequests.PreArrange request) { + if (!StringUtils.hasText(customerId)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "No customer business data is available for dispatch"); + } List palletIds = request.planPalletIds().stream().filter(Objects::nonNull).distinct().sorted().toList(); if (palletIds.isEmpty() || palletIds.size() != request.planPalletIds().size()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Plan pallet IDs must be unique and non-empty"); @@ -149,8 +176,11 @@ public class DispatchService { } public PageResult page(String customerId, DispatchRequests.Query query) { + if (!StringUtils.hasText(customerId)) { + return new PageResult<>(List.of(), query.page(), query.size(), 0, 0); + } Page page = orders.selectPage(new Page<>(query.page(), query.size()), - new LambdaQueryWrapper().eq(StringUtils.hasText(customerId), DispatchOrder::getCustomerId, customerId) + new LambdaQueryWrapper().eq(DispatchOrder::getCustomerId, customerId) .like(StringUtils.hasText(query.dispatchNo()), DispatchOrder::getDispatchNo, query.dispatchNo()) .like(StringUtils.hasText(query.vehiclePlate()), DispatchOrder::getVehiclePlate, query.vehiclePlate()) .eq(StringUtils.hasText(query.status()), DispatchOrder::getStatus, query.status()) @@ -216,9 +246,10 @@ public class DispatchService { } private DispatchOrder find(String customerId, String dispatchNo) { - DispatchOrder dispatch = StringUtils.hasText(customerId) - ? orders.findByDispatchNo(customerId, dispatchNo) - : orders.findAnyByDispatchNo(dispatchNo); + if (!StringUtils.hasText(customerId)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); + } + DispatchOrder dispatch = orders.findByDispatchNo(customerId, dispatchNo); if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); return dispatch; } @@ -260,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/application.yml b/lastleg-service/src/main/resources/application.yml index 1ea1a0cb0..d9e11b65c 100644 --- a/lastleg-service/src/main/resources/application.yml +++ b/lastleg-service/src/main/resources/application.yml @@ -34,6 +34,12 @@ spring: password: ${LASTLEG_DB_PASSWORD:123456} management.endpoints.web.exposure.include: health,info,prometheus +lastleg: + dispatch-query-api: + enabled: ${LASTLEG_DISPATCH_QUERY_API_ENABLED:true} + smart-dispatch-api: + enabled: ${LASTLEG_SMART_DISPATCH_API_ENABLED:true} + baiyue: clients: domestic-warehouse: 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 67ea74c0e..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,83 @@ 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: 分页查询排车单 + description: 权限 platform:overseas-warehouse:inbound-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: dispatchNo, in: query, schema: { type: string } } + - { name: vehiclePlate, in: query, schema: { type: string } } + - { name: status, in: query, schema: { type: string } } + - { name: fbaAppointmentNo, in: query, schema: { type: string } } + responses: + '200': { description: 统一 ApiResponse 包装的排车单分页,records 元素为 DispatchResponse } + '409': { description: 当前部署检测到多个客户范围,拒绝跨客户查询 } + /api/platform/v1/last-leg/dispatches/{dispatchNo}: + get: + summary: 查询排车单详情 + description: 权限 platform:overseas-warehouse:inbound-order:list;服务端解析唯一客户范围,前端不传 customerId。 + security: [{ bearerAuth: [] }] + parameters: + - { name: dispatchNo, in: path, required: true, schema: { type: string } } + responses: + '200': { description: 统一 ApiResponse 包装的 DispatchResponse } + '404': { description: 排车单不存在 } + '409': { description: 当前部署检测到多个客户范围 } + /api/platform/v1/last-leg/dispatches/candidates: + get: + summary: 查询智能排车候选托盘 + 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] } } + responses: + '200': + description: 统一 ApiResponse 包装的候选托盘数组 + content: { application/json: { schema: { type: array, items: { $ref: '#/components/schemas/SmartDispatchCandidate' } } } } + /api/platform/v1/last-leg/dispatches/pre-arrange: + post: + summary: 创建智能预排车单 + description: 权限 platform:overseas-warehouse:inbound-order:smart-dispatch;服务端根据所选托盘解析并校验唯一客户范围,锁定同一海外仓的托盘并创建 PENDING_DEPARTURE 排车单,前端不传 customerId;不开放编辑、取消、depart、PDA 出库或派送。 + security: [{ bearerAuth: [] }] + requestBody: + required: true + content: { application/json: { schema: { $ref: '#/components/schemas/SmartDispatchPreArrange' } } } + responses: + '200': { description: 统一 ApiResponse 包装的预排车详情, content: { application/json: { schema: { $ref: '#/components/schemas/DispatchResponse' } } } } /api/platform/v1/last-leg/overseas-procurements: get: summary: 分页查询海外采购订单 @@ -212,6 +289,81 @@ paths: responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '409': { description: 非法状态迁移或关联尾程单缺失 } } components: schemas: + SmartDispatchCandidate: + type: object + properties: + planId: { type: string } + planPalletId: { type: string } + warehousePalletId: { type: string, nullable: true } + orderId: { type: string } + overseasWarehouseId: { type: string } + overseasWarehouseName: { type: string } + orderNo: { type: string } + orderRefs: { type: string } + inboundCode: { type: string } + palletNo: { type: string } + destinationWarehouseId: { type: string } + destinationWarehouseCode: { type: string } + destinationWarehouseName: { type: string } + fbaWarehouseCode: { type: string } + packageCount: { type: integer } + volumeCbm: { type: number } + sourceStage: { type: string, enum: [PLAN, RECEIVED] } + SmartDispatchPreArrange: + type: object + required: [planPalletIds] + properties: + planPalletIds: { type: array, minItems: 1, maxItems: 500, uniqueItems: true, items: { type: string } } + route: { type: string, nullable: true, maxLength: 255 } + fbaAppointmentNo: { type: string, nullable: true, maxLength: 64 } + fbaAppointmentStatus: { type: string, nullable: true, maxLength: 32, default: PENDING } + vehicleAppointmentStatus: { type: string, nullable: true, maxLength: 32, default: PENDING } + DispatchResponse: + type: object + properties: + id: { type: string } + dispatchNo: { type: string } + vehiclePlate: { type: string, nullable: true } + driverName: { type: string, nullable: true } + driverPhone: { type: string, nullable: true } + carrierName: { type: string, nullable: true } + orderNos: { type: string } + route: { type: string, nullable: true } + overseasWarehouseId: { type: string } + overseasWarehouseName: { type: string } + departAt: { type: string, format: date-time, nullable: true } + fbaAppointmentNo: { type: string, nullable: true } + fbaAppointmentStatus: { type: string } + vehicleAppointmentStatus: { type: string } + estimatedArrivalAt: { type: string, format: date-time, nullable: true } + status: { type: string, description: 预排新建固定为 PENDING_DEPARTURE;查询可返回历史排车状态 } + itemCount: { type: integer } + palletCount: { type: integer } + packageCount: { type: integer } + pickedPackageCount: { type: integer } + shippedPackageCount: { type: integer } + remainingPackageCount: { type: integer } + partialCompletionReason: { type: string, nullable: true } + createdAt: { type: string, format: date-time } + items: { type: array, items: { $ref: '#/components/schemas/DispatchItem' } } + DispatchItem: + type: object + properties: + id: { type: string } + planPalletId: { type: string } + warehousePalletId: { type: string, nullable: true } + palletNo: { type: string } + overseasWarehouseId: { type: string } + overseasWarehouseName: { type: string } + orderNo: { type: string } + orderRefs: { type: string } + inboundCode: { type: string } + destinationWarehouseCode: { type: string } + destinationWarehouseName: { type: string } + fbaWarehouseCode: { type: string } + packageCount: { type: integer } + volumeCbm: { type: number } + sourceStage: { type: string, enum: [PLAN, RECEIVED] } RelabelStatus: { type: string, enum: [PENDING_PROCESSING, PROCESSING, COMPLETED, REJECTED, CANCELLED] } RelabelCreate: type: object 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/controller/Phase4BrowserApiBoundaryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java index 0fee6eeda..b15d336f9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java @@ -3,6 +3,7 @@ package com.baiyue.lastleg.controller; import com.baiyue.lastleg.delivery.controller.DeliveryTransferController; import com.baiyue.lastleg.dispatch.controller.DispatchController; import com.baiyue.lastleg.dispatch.controller.DispatchQueryController; +import com.baiyue.lastleg.dispatch.controller.SmartDispatchController; import com.baiyue.lastleg.dispatch.pda.controller.DispatchPdaController; import com.baiyue.lastleg.inboundplan.pda.controller.InboundPalletAssignmentController; import com.baiyue.lastleg.inboundplan.pda.controller.InboundPdaController; @@ -11,10 +12,13 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; import java.util.Arrays; import java.util.Set; import java.util.stream.Collectors; +import java.nio.file.Files; +import java.nio.file.Path; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -53,6 +57,30 @@ class Phase4BrowserApiBoundaryTest { .contains(annotation.annotationType().getSimpleName()))); } + @Test + void dispatchQueryIsEnabledByDefaultWithoutEnablingOutboundCommands() throws Exception { + String yaml = Files.readString(Path.of("src/main/resources/application.yml")); + assertTrue(yaml.contains("enabled: ${LASTLEG_DISPATCH_QUERY_API_ENABLED:true}")); + assertTrue(yaml.contains("enabled: ${LASTLEG_SMART_DISPATCH_API_ENABLED:true}")); + assertFalse(yaml.contains("future-outbound-api:")); + } + + @Test + void smartDispatchIsIndependentlyEnabledWithOnlyCandidateAndPreArrangeEndpoints() { + ConditionalOnProperty condition = SmartDispatchController.class.getAnnotation(ConditionalOnProperty.class); + assertTrue(condition != null); + assertTrue(Arrays.asList(condition.name()).contains("lastleg.smart-dispatch-api.enabled")); + assertFalse(condition.matchIfMissing()); + assertTrue(Arrays.stream(SmartDispatchController.class.getDeclaredMethods()) + .filter(method -> method.isAnnotationPresent(GetMapping.class)).count() == 1); + assertTrue(Arrays.stream(SmartDispatchController.class.getDeclaredMethods()) + .filter(method -> method.isAnnotationPresent(PostMapping.class)).count() == 1); + assertFalse(Arrays.stream(SmartDispatchController.class.getDeclaredMethods()) + .flatMap(method -> Arrays.stream(method.getAnnotations())) + .anyMatch(annotation -> Set.of("PutMapping", "DeleteMapping", "PatchMapping") + .contains(annotation.annotationType().getSimpleName()))); + } + @Test void platformInventoryCommandsDoNotMapOutboundAllocationPickingOrShipping() { Set paths = Arrays.stream(InventoryCommandController.class.getDeclaredMethods()) 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 new file mode 100644 index 000000000..2d88558b0 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.dispatch; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SmartDispatchOpenApiContractTest { + @Test + void contractDocumentsOnlyCandidateAndPreArrangeSmartDispatchWrites() throws Exception { + String yaml; + try (var input = getClass().getResourceAsStream("/openapi/overseas-business-v1.yaml")) { + yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + 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: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")); + assertFalse(yaml.contains("/api/platform/v1/last-leg/dispatches/{dispatchNo}/depart:")); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryControllerTest.java index 405cee35c..dae4fc5e2 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryControllerTest.java @@ -4,12 +4,14 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.dispatch.dto.DispatchRequests; import com.baiyue.lastleg.dispatch.service.DispatchService; +import com.baiyue.lastleg.security.OverseasPermissions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.server.ResponseStatusException; import java.util.List; @@ -32,13 +34,14 @@ class DispatchQueryControllerTest { } @Test - void platformPageQueriesAllCustomersWithoutCustomerId() { + void platformPageUsesServerResolvedSingleCustomerWithoutCustomerId() { DispatchRequests.Query query = new DispatchRequests.Query(1, 20, null, null, null, null); - when(service.page(null, query)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); + when(service.resolveSingleCustomerScope()).thenReturn("100"); + when(service.page("100", query)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); var response = controller.page(platform(), query); - verify(service).page(null, query); + verify(service).page("100", query); assertThat(response.code()).isEqualTo("200"); assertThat(response.data().records()).isEmpty(); assertThatThrownBy(() -> controller.page(null, query)) @@ -47,6 +50,19 @@ class DispatchQueryControllerTest { ((ResponseStatusException) error).getStatusCode().value()).isEqualTo(401)); } + @Test + void listAndDetailReuseInboundOrderListAuthority() throws Exception { + PreAuthorize page = DispatchQueryController.class + .getMethod("page", AuthenticatedUser.class, DispatchRequests.Query.class) + .getAnnotation(PreAuthorize.class); + PreAuthorize detail = DispatchQueryController.class + .getMethod("detail", AuthenticatedUser.class, String.class) + .getAnnotation(PreAuthorize.class); + + assertThat(page.value()).contains(OverseasPermissions.INBOUND_ORDER_LIST); + assertThat(detail.value()).contains(OverseasPermissions.INBOUND_ORDER_LIST); + } + private static AuthenticatedUser platform() { return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB", Set.of()); } 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 new file mode 100644 index 000000000..3b198b269 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java @@ -0,0 +1,71 @@ +package com.baiyue.lastleg.dispatch.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.dispatch.dto.DispatchRequests; +import com.baiyue.lastleg.dispatch.service.DispatchService; +import com.baiyue.lastleg.security.OverseasPermissions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SmartDispatchControllerTest { + @Mock DispatchService service; + private SmartDispatchController controller; + + @BeforeEach + void setUp() { + controller = new SmartDispatchController(); + ReflectionTestUtils.setField(controller, "service", service); + } + + @Test + 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(), List.of(1L, 2L), "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.resolveCustomerScopeForPallets(List.of(10L, 11L))).thenReturn("100"); + + controller.preArrange(platform(), request); + + verify(service).preArrange("100", "7", request); + } + + @Test + void endpointsUseInboundOrderListAndDedicatedSmartDispatchAuthorities() throws Exception { + PreAuthorize candidates = SmartDispatchController.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_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_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 2829c9eac..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,50 @@ 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 + void resolvesOnlyCustomerScopeAndRejectsMultipleCustomers() { + when(candidates.findCustomerScopes()).thenReturn(List.of("100")); + assertEquals("100", service.resolveSingleCustomerScope()); + + when(candidates.findCustomerScopes()).thenReturn(List.of("100", "200")); + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.resolveSingleCustomerScope()); + assertEquals(409, exception.getStatusCode().value()); + } + + @Test + void emptyCustomerScopeProducesEmptyCandidatesAndRejectsPreArrange() { + 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", + new DispatchRequests.PreArrange(List.of(10L), null, null, null, null))); + assertEquals(409, exception.getStatusCode().value()); } @Test @@ -105,14 +147,14 @@ class DispatchServiceTest { } @Test - void platformDetailFindsDispatchGloballyAndLoadsItemsWithRecordCustomer() { + void detailUsesResolvedCustomerScopeAndLoadsItems() { DispatchOrder dispatch = new DispatchOrder(); dispatch.setId(1L); dispatch.setCustomerId("200"); dispatch.setDispatchNo("PC1"); - when(orders.findAnyByDispatchNo("PC1")).thenReturn(dispatch); + when(orders.findByDispatchNo("200", "PC1")).thenReturn(dispatch); - service.detail(null, "PC1"); + service.detail("200", "PC1"); verify(items).findByDispatchOrder("200", 1L); } 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); } } diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index 74143d495..2011ee141 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -759,6 +759,12 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab 'ALTER TABLE wh_pallet MODIFY COLUMN pallet_code VARCHAR(128) NOT NULL COMMENT ''Warehouse pallet code''', 'SELECT 1')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='wh_pallet' AND column_name='fba_warehouse_code'), + 'ALTER TABLE wh_pallet MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''所属FBA仓编码''', + 'ALTER TABLE wh_pallet ADD COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''所属FBA仓编码'' AFTER remark')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + INSERT INTO wh_location (id, tenant_id, warehouse_id, zone_code, location_code, location_type, max_load_kg, status, remark) SELECT UUID_SHORT(), w.tenant_id, w.id, 'DEFAULT', CONCAT('LOC-', w.warehouse_code, '-DEFAULT'), diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java index aff808313..9467901d7 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java @@ -99,7 +99,8 @@ public class PlatformWarehouseController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access required / 需要平台端权限") }) @GetMapping - @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.WAREHOUSE_LIST + "')") + @PreAuthorize("hasAnyAuthority('" + OverseasWarehousePermissions.WAREHOUSE_LIST + "', '" + + OverseasWarehousePermissions.PALLET_LIST + "', '" + OverseasWarehousePermissions.LOCATION_LIST + "')") public ApiResponse> page( @AuthenticationPrincipal AuthenticatedUser user, @Valid WarehouseRequests.QueryRequest request) { diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java index f5238b4ef..b5c6d8491 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/PalletRequests.java @@ -22,8 +22,14 @@ public final class PalletRequests { @NotNull @DecimalMin("0.00") BigDecimal unitPrice, @NotBlank String status, Long locationId, - @Size(max = 500) String remark + @Size(max = 500) String remark, + @Size(max = 128) String fbaWarehouseCode ) { + public SaveRequest(String palletCode, String palletType, BigDecimal capacityCbm, + BigDecimal unitPrice, String status, Long locationId, String remark) { + this(palletCode, palletType, capacityCbm, unitPrice, status, locationId, remark, null); + } + public SaveRequest { if (capacityCbm == null) capacityCbm = DEFAULT_CAPACITY; } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletEntity.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletEntity.java index d6d356979..91872198c 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletEntity.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletEntity.java @@ -24,4 +24,5 @@ public class WarehousePalletEntity extends BaseEntity { private BigDecimal unitPrice; private String status; private String remark; + private String fbaWarehouseCode; } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java index 00695b535..68c90694c 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java @@ -276,6 +276,7 @@ public class WarehouseResourceService { entity.setUnitPrice(request.unitPrice()); entity.setStatus(request.status()); entity.setRemark(request.remark()); + entity.setFbaWarehouseCode(request.fbaWarehouseCode()); return entity; } diff --git a/warehouse-overseas-service/src/main/resources/db/warehouse.sql b/warehouse-overseas-service/src/main/resources/db/warehouse.sql index 7654de69b..9eb4d8dcd 100644 --- a/warehouse-overseas-service/src/main/resources/db/warehouse.sql +++ b/warehouse-overseas-service/src/main/resources/db/warehouse.sql @@ -37,6 +37,7 @@ CREATE TABLE IF NOT EXISTS wh_pallet ( unit_price DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '托盘单价', status VARCHAR(20) NOT NULL DEFAULT 'IDLE' COMMENT '托盘状态:IDLE闲置、OCCUPIED占用、SCRAPPED废弃', remark VARCHAR(500) DEFAULT NULL COMMENT '备注', + fba_warehouse_code VARCHAR(128) DEFAULT NULL COMMENT '所属FBA仓编码', created_by BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_by BIGINT UNSIGNED DEFAULT NULL COMMENT '最后修改人ID', @@ -49,6 +50,14 @@ CREATE TABLE IF NOT EXISTS wh_pallet ( KEY idx_pallet_location (customer_id, warehouse_id, location_id, deleted) ) ENGINE=InnoDB COMMENT='仓库托盘表:按租户和仓库维护托盘、容量、单价及状态'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='wh_pallet' AND column_name='fba_warehouse_code'), + 'SELECT 1', + 'ALTER TABLE wh_pallet ADD COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''所属FBA仓编码'' AFTER remark')); +PREPARE pallet_fba_warehouse_stmt FROM @ddl; +EXECUTE pallet_fba_warehouse_stmt; +DEALLOCATE PREPARE pallet_fba_warehouse_stmt; + CREATE TABLE IF NOT EXISTS wh_resource_lock ( id BIGINT UNSIGNED NOT NULL COMMENT '主键ID', customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户租户ID', @@ -193,6 +202,7 @@ ALTER TABLE wh_pallet MODIFY COLUMN unit_price DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '托盘单价', MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'IDLE' COMMENT '托盘状态:IDLE闲置、OCCUPIED占用、SCRAPPED废弃', MODIFY COLUMN remark VARCHAR(500) NULL COMMENT '备注', + MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT '所属FBA仓编码', MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID', MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseControllerTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseControllerTest.java index 20c30c7a3..1206986b8 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseControllerTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseControllerTest.java @@ -3,8 +3,10 @@ package com.baiyue.warehouse.controller; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.warehouse.dto.WarehouseRequests; +import com.baiyue.warehouse.security.OverseasWarehousePermissions; import com.baiyue.warehouse.service.WarehouseApplicationService; import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.test.util.ReflectionTestUtils; import java.util.List; @@ -13,8 +15,20 @@ import java.util.Set; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.junit.jupiter.api.Assertions.assertEquals; class PlatformWarehouseControllerTest { + @Test + void platformPageAllowsWarehousePalletAndLocationListAuthorities() throws Exception { + PreAuthorize preAuthorize = PlatformWarehouseController.class + .getMethod("page", AuthenticatedUser.class, WarehouseRequests.QueryRequest.class) + .getAnnotation(PreAuthorize.class); + + assertEquals("hasAnyAuthority('" + OverseasWarehousePermissions.WAREHOUSE_LIST + "', '" + + OverseasWarehousePermissions.PALLET_LIST + "', '" + OverseasWarehousePermissions.LOCATION_LIST + "')", + preAuthorize.value()); + } + @Test void platformPageQueriesAllWarehousesWithoutCustomerId() { WarehouseApplicationService service = mock(WarehouseApplicationService.class); diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java index e52b8fc79..005815ef1 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java @@ -267,6 +267,44 @@ class WarehouseResourceServiceTest { .hasMessageContaining("cannot be reused"); } + @Test + void updatePalletChangesCodeAndFbaWarehouseCode() { + when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true); + WarehousePalletEntity existing = new WarehousePalletEntity(); + existing.setId(40003L); + existing.setPalletCode("PLT-OLD"); + existing.setStatus("IDLE"); + when(repository.findPallet(TENANT, WAREHOUSE, 40003L)).thenReturn(existing); + + service.updatePallet(TENANT, WAREHOUSE, 40003L, + new PalletRequests.SaveRequest("PLT-NEW", "WOOD", new BigDecimal("2.5000"), + new BigDecimal("25.00"), "IDLE", null, "updated", "ONT8")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(WarehousePalletEntity.class); + verify(repository).palletCodeExists(TENANT, WAREHOUSE, "PLT-NEW", 40003L); + verify(repository).updatePallet(captor.capture()); + assertThat(captor.getValue().getPalletCode()).isEqualTo("PLT-NEW"); + assertThat(captor.getValue().getFbaWarehouseCode()).isEqualTo("ONT8"); + } + + @Test + void updatePalletRejectsDuplicateCodeInSameWarehouse() { + when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true); + WarehousePalletEntity existing = new WarehousePalletEntity(); + existing.setId(40004L); + existing.setPalletCode("PLT-OLD"); + existing.setStatus("IDLE"); + when(repository.findPallet(TENANT, WAREHOUSE, 40004L)).thenReturn(existing); + when(repository.palletCodeExists(TENANT, WAREHOUSE, "PLT-DUP", 40004L)).thenReturn(true); + + assertThatThrownBy(() -> service.updatePallet(TENANT, WAREHOUSE, 40004L, + new PalletRequests.SaveRequest("PLT-DUP", "WOOD", BigDecimal.ONE, + BigDecimal.ONE, "IDLE", null, null, "ONT8"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("托盘编码已存在"); + verify(repository, never()).updatePallet(org.mockito.ArgumentMatchers.any()); + } + private static WarehouseEntity warehouse() { WarehouseEntity warehouse = new WarehouseEntity(); warehouse.setId(WAREHOUSE);