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("