This commit is contained in:
2026-08-21 16:04:19 +08:00
37 changed files with 762 additions and 59 deletions
@@ -21,6 +21,7 @@ import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
@@ -89,6 +90,12 @@ public class GlobalExceptionHandler {
return failure(code, message, request); return failure(code, message, request);
} }
@ExceptionHandler(NoResourceFoundException.class)
public ResponseEntity<ApiResponse<Void>> handleNoResourceFound(
NoResourceFoundException exception, HttpServletRequest request) {
return failure(ResultCode.NOT_FOUND, ResultCode.NOT_FOUND.message(), request);
}
@ExceptionHandler(Exception.class) @ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleUnexpected(Exception exception, HttpServletRequest request) { public ResponseEntity<ApiResponse<Void>> handleUnexpected(Exception exception, HttpServletRequest request) {
log.error("Unhandled request failure requestId={} traceId={}", log.error("Unhandled request failure requestId={} traceId={}",
@@ -12,6 +12,7 @@ import org.springframework.validation.BindException;
import org.springframework.validation.FieldError; import org.springframework.validation.FieldError;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.bind.MissingRequestHeaderException; import org.springframework.web.bind.MissingRequestHeaderException;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
@@ -103,6 +104,18 @@ class GlobalExceptionHandlerTest {
assertThat(response.getBody().requestId()).isEqualTo("stale-version"); 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) { private void paging(long page) {
} }
} }
@@ -7,6 +7,8 @@ spring:
lastleg: lastleg:
dispatch-query-api: dispatch-query-api:
enabled: true enabled: true
smart-dispatch-api:
enabled: true
baiyue: baiyue:
clients: clients:
domestic-warehouse: domestic-warehouse:
@@ -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), (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), (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), (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), (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), (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), (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' 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; 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(); CALL baiyue_firstleg.assert_unified_final_schema();
DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema; DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema;
@@ -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), (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), (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), (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), (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), (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), (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 AND permission.id BETWEEN 5107000 AND 5107299
WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN' 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; 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;
@@ -15,9 +15,14 @@ import static org.assertj.core.api.Assertions.assertThat;
class OverseasPhase4PermissionSqlContractTest { class OverseasPhase4PermissionSqlContractTest {
private static final Pattern CANONICAL = Pattern.compile("platform:overseas-[a-z0-9:-]+"); private static final Pattern CANONICAL = Pattern.compile("platform:overseas-[a-z0-9:-]+");
private static final Set<String> CUSTOM_ADMIN_PERMISSIONS = Set.of(
"platform:overseas-warehouse:inbound-order:fba-appointment",
"platform:overseas-warehouse:inbound-order:smart-dispatch");
private static final List<String> REQUIRED = List.of( private static final List<String> REQUIRED = List.of(
"platform:overseas-warehouse:last-mile-order:list", "platform:overseas-warehouse:last-mile-order:list",
"platform:overseas-warehouse:inbound-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:relabel-order:status",
"platform:overseas-warehouse:pallet:list", "platform:overseas-warehouse:pallet:list",
"platform:overseas-warehouse:inventory:adjust", "platform:overseas-warehouse:inventory:adjust",
@@ -34,9 +39,10 @@ class OverseasPhase4PermissionSqlContractTest {
.getContentAsString(StandardCharsets.UTF_8); .getContentAsString(StandardCharsets.UTF_8);
Set<String> catalog = codes(sql); Set<String> catalog = codes(sql);
assertThat(sql).contains(REQUIRED.toArray(String[]::new)); 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).contains("permission_code LIKE 'platform:lastleg:%'", "status='INACTIVE'", "deleted=1");
assertThat(sql).doesNotContain(":carton:", ":product:", ":outbound:", ":delivery:"); assertThat(sql).doesNotContain(":carton:", ":product:", ":outbound:", ":delivery:");
assertCustomAdminGrant(sql);
} }
@Test @Test
@@ -48,6 +54,7 @@ class OverseasPhase4PermissionSqlContractTest {
.getContentAsString(StandardCharsets.UTF_8)); .getContentAsString(StandardCharsets.UTF_8));
assertThat(codes(sql)).containsAll(moduleCatalog); assertThat(codes(sql)).containsAll(moduleCatalog);
assertThat(sql).contains("baiyue_iam.iam_permission", "baiyue_iam.iam_role_permission"); assertThat(sql).contains("baiyue_iam.iam_permission", "baiyue_iam.iam_role_permission");
assertCustomAdminGrant(sql);
} }
@Test @Test
@@ -69,6 +76,18 @@ class OverseasPhase4PermissionSqlContractTest {
return values; 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 { private static String repositoryFile(String relativePath) throws Exception {
Path path = Path.of(relativePath); Path path = Path.of(relativePath);
if (!Files.exists(path)) path = Path.of("..").resolve(relativePath); if (!Files.exists(path)) path = Path.of("..").resolve(relativePath);
+1 -1
View File
@@ -39,7 +39,7 @@
- 四期在本服务内只进入尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、海外采购和本土卡车;本土散板新增实现也归本服务的独立稳定子域。 - 四期在本服务内只进入尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、海外采购和本土卡车;本土散板新增实现也归本服务的独立稳定子域。
- `dispatch/`、`delivery/`、出库单、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/`,本服务不得复制仓储资源主数据或另建公网认证入口。 - 海外仓库、库位、实物托盘和资源锁归 `warehouse-overseas-service/`;浏览器 API 统一经 `gateway-overseas/`,本服务不得复制仓储资源主数据或另建公网认证入口。
## 数据库表索引与注释约束 ## 数据库表索引与注释约束
@@ -4,7 +4,6 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.dispatch.dto.DispatchRequests; import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
import com.baiyue.lastleg.dispatch.dto.DispatchResponse; 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.DispatchPdaRequests;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses; import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses;
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService; 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.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping; 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.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping; 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.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@RestController @RestController
@ConditionalOnProperty(name = "lastleg.future-outbound-api.enabled", havingValue = "true") @ConditionalOnProperty(name = "lastleg.future-outbound-api.enabled", havingValue = "true")
@RequestMapping("/api/platform/v1/last-leg/dispatches") @RequestMapping("/api/platform/v1/last-leg/dispatches")
@@ -38,28 +34,6 @@ public class DispatchController {
@Resource private DispatchService service; @Resource private DispatchService service;
@Resource private DispatchPdaWorkflowService pdaWorkflow; @Resource private DispatchPdaWorkflowService pdaWorkflow;
@GetMapping("/candidates")
@Operation(summary = "List unlocked planned or received pallets for intelligent pre-dispatch")
public ApiResponse<List<DispatchPalletCandidate>> 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<DispatchResponse> 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}") @PutMapping("/{dispatchNo}")
@Operation(summary = "Edit vehicle, driver, appointment, route and departure data") @Operation(summary = "Edit vehicle, driver, appointment, route and departure data")
public ApiResponse<DispatchResponse> update( public ApiResponse<DispatchResponse> update(
@@ -6,12 +6,14 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.dispatch.dto.DispatchRequests; import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
import com.baiyue.lastleg.dispatch.dto.DispatchResponse; import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
import com.baiyue.lastleg.dispatch.service.DispatchService; 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.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
@@ -27,21 +29,23 @@ public class DispatchQueryController {
@Resource private DispatchService service; @Resource private DispatchService service;
@GetMapping @GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')")
@Operation(summary = "Page dispatch orders") @Operation(summary = "Page dispatch orders")
public ApiResponse<PageResult<DispatchResponse>> page( public ApiResponse<PageResult<DispatchResponse>> page(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@Valid DispatchRequests.Query query) { @Valid DispatchRequests.Query query) {
requirePlatform(user); requirePlatform(user);
return ApiResponse.success(service.page(null, query)); return ApiResponse.success(service.page(service.resolveSingleCustomerScope(), query));
} }
@GetMapping("/{dispatchNo}") @GetMapping("/{dispatchNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')")
@Operation(summary = "Get dispatch details and all pallet destinations") @Operation(summary = "Get dispatch details and all pallet destinations")
public ApiResponse<DispatchResponse> detail( public ApiResponse<DispatchResponse> detail(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo) { @PathVariable String dispatchNo) {
requirePlatform(user); requirePlatform(user);
return ApiResponse.success(service.detail(null, dispatchNo)); return ApiResponse.success(service.detail(service.resolveSingleCustomerScope(), dispatchNo));
} }
private void requirePlatform(AuthenticatedUser user) { private void requirePlatform(AuthenticatedUser user) {
@@ -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<List<DispatchPalletCandidate>> candidates(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam List<Long> 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<DispatchResponse> 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");
}
}
}
@@ -9,6 +9,27 @@ import java.util.List;
@Mapper @Mapper
public interface DispatchCandidateMapper { 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<String> findCustomerScopes();
@Select("<script>" +
"SELECT DISTINCT customer_id FROM ll_inbound_plan WHERE id IN " +
"<foreach collection='planIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"AND customer_id IS NOT NULL AND customer_id != '' ORDER BY customer_id LIMIT 2" +
"</script>")
List<String> findCustomerScopesByPlanIds(@Param("planIds") List<Long> planIds);
@Select("<script>" +
"SELECT DISTINCT ip.customer_id FROM ll_inbound_plan_pallet p " +
"JOIN ll_inbound_plan ip ON ip.id=p.plan_id WHERE p.id IN " +
"<foreach collection='planPalletIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"AND ip.customer_id IS NOT NULL AND ip.customer_id != '' ORDER BY ip.customer_id LIMIT 2" +
"</script>")
List<String> findCustomerScopesByPlanPalletIds(@Param("planPalletIds") List<Long> planPalletIds);
@Select("<script>" + @Select("<script>" +
"SELECT p.plan_id AS planId,p.id AS planPalletId,p.warehouse_pallet_id AS warehousePalletId," + "SELECT p.plan_id AS planId,p.id AS planPalletId,p.warehouse_pallet_id AS warehousePalletId," +
"MIN(l.order_id) AS orderId,MIN(o.overseas_warehouse_id) AS overseasWarehouseId,MIN(o.overseas_warehouse_name) AS overseasWarehouseName,MIN(o.order_no) AS orderNo," + "MIN(l.order_id) AS orderId,MIN(o.overseas_warehouse_id) AS overseasWarehouseId,MIN(o.overseas_warehouse_name) AS overseasWarehouseName,MIN(o.order_no) AS orderNo," +
@@ -25,7 +46,8 @@ public interface DispatchCandidateMapper {
"LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " + "LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " +
"LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id " + "LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id " +
"LEFT JOIN ll_order o ON o.id=l.order_id " + "LEFT JOIN ll_order o ON o.id=l.order_id " +
"WHERE ip.customer_id=#{customerId} " + "WHERE ip.customer_id=#{customerId} AND p.plan_id IN " +
"<foreach collection='planIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"<if test='orderNo != null and orderNo != \"\"'> AND o.order_no LIKE CONCAT('%',#{orderNo},'%')</if> " + "<if test='orderNo != null and orderNo != \"\"'> AND o.order_no LIKE CONCAT('%',#{orderNo},'%')</if> " +
"<if test='fbaWarehouseCode != null and fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code=#{fbaWarehouseCode}</if> " + "<if test='fbaWarehouseCode != null and fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code=#{fbaWarehouseCode}</if> " +
"<if test='sourceStage != null and sourceStage != \"\"'> AND " + "<if test='sourceStage != null and sourceStage != \"\"'> AND " +
@@ -44,6 +66,7 @@ public interface DispatchCandidateMapper {
"ORDER BY p.plan_id,p.sequence_no" + "ORDER BY p.plan_id,p.sequence_no" +
"</script>") "</script>")
List<DispatchPalletCandidate> available(@Param("customerId") String customerId, List<DispatchPalletCandidate> available(@Param("customerId") String customerId,
@Param("planIds") List<Long> planIds,
@Param("orderNo") String orderNo, @Param("orderNo") String orderNo,
@Param("fbaWarehouseCode") String fbaWarehouseCode, @Param("fbaWarehouseCode") String fbaWarehouseCode,
@Param("sourceStage") String sourceStage); @Param("sourceStage") String sourceStage);
@@ -11,9 +11,6 @@ public interface DispatchOrderMapper extends BaseMapper<DispatchOrder> {
@Select("SELECT * FROM ll_dispatch_order WHERE customer_id=#{customerId} AND dispatch_no=#{dispatchNo} LIMIT 1") @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); 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") @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); DispatchOrder lockById(@Param("customerId") String customerId, @Param("id") Long id);
} }
@@ -41,16 +41,43 @@ public class DispatchService {
@Resource private DispatchPalletLockMapper locks; @Resource private DispatchPalletLockMapper locks;
@Resource private DispatchCandidateMapper candidates; @Resource private DispatchCandidateMapper candidates;
public List<DispatchPalletCandidate> candidates(String customerId, String orderNo, public String resolveSingleCustomerScope() {
List<String> 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<Long> planIds) {
return requireSingleCustomerScope(candidates.findCustomerScopesByPlanIds(normalizeIds(planIds, "Plan IDs")),
"Selected inbound plans");
}
public String resolveCustomerScopeForPallets(List<Long> planPalletIds) {
return requireSingleCustomerScope(
candidates.findCustomerScopesByPlanPalletIds(normalizeIds(planPalletIds, "Plan pallet IDs")),
"Selected pallets");
}
public List<DispatchPalletCandidate> candidates(String customerId, List<Long> planIds, String orderNo,
String fbaWarehouseCode, String sourceStage) { String fbaWarehouseCode, String sourceStage) {
if (StringUtils.hasText(sourceStage) && !Set.of("PLAN", "RECEIVED").contains(sourceStage)) { if (StringUtils.hasText(sourceStage) && !Set.of("PLAN", "RECEIVED").contains(sourceStage)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "sourceStage must be PLAN or RECEIVED"); 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<Long> scopedPlanIds = normalizeIds(planIds, "Plan IDs");
return candidates.available(customerId, scopedPlanIds, trimToNull(orderNo), trimToNull(fbaWarehouseCode),
trimToNull(sourceStage));
} }
@Transactional @Transactional
public DispatchResponse preArrange(String customerId, String operatorId, DispatchRequests.PreArrange request) { 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<Long> palletIds = request.planPalletIds().stream().filter(Objects::nonNull).distinct().sorted().toList(); List<Long> palletIds = request.planPalletIds().stream().filter(Objects::nonNull).distinct().sorted().toList();
if (palletIds.isEmpty() || palletIds.size() != request.planPalletIds().size()) { if (palletIds.isEmpty() || palletIds.size() != request.planPalletIds().size()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Plan pallet IDs must be unique and non-empty"); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Plan pallet IDs must be unique and non-empty");
@@ -149,8 +176,11 @@ public class DispatchService {
} }
public PageResult<DispatchResponse> page(String customerId, DispatchRequests.Query query) { public PageResult<DispatchResponse> page(String customerId, DispatchRequests.Query query) {
if (!StringUtils.hasText(customerId)) {
return new PageResult<>(List.of(), query.page(), query.size(), 0, 0);
}
Page<DispatchOrder> page = orders.selectPage(new Page<>(query.page(), query.size()), Page<DispatchOrder> page = orders.selectPage(new Page<>(query.page(), query.size()),
new LambdaQueryWrapper<DispatchOrder>().eq(StringUtils.hasText(customerId), DispatchOrder::getCustomerId, customerId) new LambdaQueryWrapper<DispatchOrder>().eq(DispatchOrder::getCustomerId, customerId)
.like(StringUtils.hasText(query.dispatchNo()), DispatchOrder::getDispatchNo, query.dispatchNo()) .like(StringUtils.hasText(query.dispatchNo()), DispatchOrder::getDispatchNo, query.dispatchNo())
.like(StringUtils.hasText(query.vehiclePlate()), DispatchOrder::getVehiclePlate, query.vehiclePlate()) .like(StringUtils.hasText(query.vehiclePlate()), DispatchOrder::getVehiclePlate, query.vehiclePlate())
.eq(StringUtils.hasText(query.status()), DispatchOrder::getStatus, query.status()) .eq(StringUtils.hasText(query.status()), DispatchOrder::getStatus, query.status())
@@ -216,9 +246,10 @@ public class DispatchService {
} }
private DispatchOrder find(String customerId, String dispatchNo) { private DispatchOrder find(String customerId, String dispatchNo) {
DispatchOrder dispatch = StringUtils.hasText(customerId) if (!StringUtils.hasText(customerId)) {
? orders.findByDispatchNo(customerId, dispatchNo) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
: orders.findAnyByDispatchNo(dispatchNo); }
DispatchOrder dispatch = orders.findByDispatchNo(customerId, dispatchNo);
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
return dispatch; return dispatch;
} }
@@ -260,4 +291,25 @@ public class DispatchService {
private static String trimToNull(String value) { private static String trimToNull(String value) {
return StringUtils.hasText(value) ? value.trim() : null; return StringUtils.hasText(value) ? value.trim() : null;
} }
private static List<Long> normalizeIds(List<Long> 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<Long> 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<String> 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);
}
} }
@@ -17,10 +17,13 @@ public final class LastlegOrderRequests {
@Min(1) long page, @Min(1) long page,
@Min(1) @Max(200) long size, @Min(1) @Max(200) long size,
String orderNo, String orderNo,
String orderType,
String inboundCode, String inboundCode,
String sourceOrderNo, String sourceOrderNo,
String containerNo, String containerNo,
String currentStatus,
String status, String status,
String warehouse,
String transferWarehouseCode, String transferWarehouseCode,
Boolean abnormal, Boolean abnormal,
Boolean detained, Boolean detained,
@@ -33,7 +33,7 @@ public class InboundPlanController {
/** 按入仓计划明细分页查询可预约的入库订单行。 */ /** 按入仓计划明细分页查询可预约的入库订单行。 */
@GetMapping @GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')") @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_CREATE + "')")
@Operation(summary = "分页查询入库订单", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST) @Operation(summary = "分页查询入库订单", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST)
public ApiResponse<PageResult<InboundPlanListResponse>> page( public ApiResponse<PageResult<InboundPlanListResponse>> page(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@@ -43,7 +43,7 @@ public class InboundPlanController {
} }
@PostMapping("/convert") @PostMapping("/convert")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_CREATE + "')") @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')")
/** 将入仓单转换为入仓计划。 */ /** 将入仓单转换为入仓计划。 */
@Operation(summary = "入仓单转入仓计划", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_CREATE) @Operation(summary = "入仓单转入仓计划", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_CREATE)
public ApiResponse<InboundPlanResponse> convert(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<InboundPlanResponse> convert(@AuthenticationPrincipal AuthenticatedUser user,
@@ -72,9 +72,9 @@ public class InboundPlanController {
} }
@PostMapping("/fba-appointments/batch") @PostMapping("/fba-appointments/batch")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')") @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT + "')")
/** 为选中的入仓明细创建 FBA 预约。 */ /** 为选中的入仓明细创建 FBA 预约。 */
@Operation(summary = "创建 FBA 预约", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_EDIT) @Operation(summary = "创建 FBA 预约", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT)
public ApiResponse<FbaAppointmentResponse> batchFbaAppointment( public ApiResponse<FbaAppointmentResponse> batchFbaAppointment(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody BatchFbaAppointmentRequest request) { @Valid @RequestBody BatchFbaAppointmentRequest request) {
@@ -95,8 +95,8 @@ public class InboundPlanController {
} }
@PutMapping("/fba-appointments/{appointmentNo}") @PutMapping("/fba-appointments/{appointmentNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')") @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT + "')")
@Operation(summary = "编辑 FBA 预约", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_EDIT) @Operation(summary = "编辑 FBA 预约", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT)
public ApiResponse<FbaAppointmentResponse> updateFbaAppointment( public ApiResponse<FbaAppointmentResponse> updateFbaAppointment(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String appointmentNo, @PathVariable String appointmentNo,
@@ -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_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_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_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_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_CREATE = "platform:overseas-warehouse:relabel-order:create";
public static final String RELABEL_ORDER_STATUS = "platform:overseas-warehouse:relabel-order:status"; public static final String RELABEL_ORDER_STATUS = "platform:overseas-warehouse:relabel-order:status";
@@ -123,13 +123,20 @@ public class LastlegOrderService {
if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) { if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "createdFrom must not be after 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<LastlegOrder>() return new LambdaQueryWrapper<LastlegOrder>()
.eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId) .eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId)
.like(StringUtils.hasText(query.orderNo()), LastlegOrder::getOrderNo, query.orderNo()) .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.inboundCode()), LastlegOrder::getInboundCode, query.inboundCode())
.like(StringUtils.hasText(query.sourceOrderNo()), LastlegOrder::getSourceOrderNo, query.sourceOrderNo()) .like(StringUtils.hasText(query.sourceOrderNo()), LastlegOrder::getSourceOrderNo, query.sourceOrderNo())
.like(StringUtils.hasText(query.containerNo()), LastlegOrder::getContainerNo, query.containerNo()) .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, .eq(StringUtils.hasText(query.transferWarehouseCode()), LastlegOrder::getTransferWarehouseCode,
query.transferWarehouseCode()) query.transferWarehouseCode())
.eq(query.abnormal() != null, LastlegOrder::getAbnormal, query.abnormal()) .eq(query.abnormal() != null, LastlegOrder::getAbnormal, query.abnormal())
@@ -34,6 +34,12 @@ spring:
password: ${LASTLEG_DB_PASSWORD:123456} password: ${LASTLEG_DB_PASSWORD:123456}
management.endpoints.web.exposure.include: health,info,prometheus 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: baiyue:
clients: clients:
domestic-warehouse: domestic-warehouse:
@@ -3,6 +3,83 @@ info:
title: Lastleg Overseas Business API title: Lastleg Overseas Business API
version: 1.0.0 version: 1.0.0
paths: 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: /api/platform/v1/last-leg/overseas-procurements:
get: get:
summary: 分页查询海外采购订单 summary: 分页查询海外采购订单
@@ -212,6 +289,81 @@ paths:
responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '409': { description: 非法状态迁移或关联尾程单缺失 } } responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '409': { description: 非法状态迁移或关联尾程单缺失 } }
components: components:
schemas: 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] } RelabelStatus: { type: string, enum: [PENDING_PROCESSING, PROCESSING, COMPLETED, REJECTED, CANCELLED] }
RelabelCreate: RelabelCreate:
type: object type: object
@@ -25,7 +25,7 @@ class LastlegOrderControllerTest {
private final LastlegOrderService service = mock(LastlegOrderService.class); private final LastlegOrderService service = mock(LastlegOrderService.class);
private final LastlegOrderController controller = controller(); private final LastlegOrderController controller = controller();
private final LastlegOrderRequests.Query query = new LastlegOrderRequests.Query( 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 @Test
void platformEndpointRejectsMerchantIdentity() { void platformEndpointRejectsMerchantIdentity() {
@@ -18,7 +18,7 @@ class MerchantLastlegOrderControllerTest {
private final LastlegOrderService service = mock(LastlegOrderService.class); private final LastlegOrderService service = mock(LastlegOrderService.class);
private final MerchantLastlegOrderController controller = controller(); private final MerchantLastlegOrderController controller = controller();
private final LastlegOrderRequests.Query query = new LastlegOrderRequests.Query( 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 @Test
void merchantListUsesOnlyAuthenticatedTenant() { void merchantListUsesOnlyAuthenticatedTenant() {
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.controller;
import com.baiyue.lastleg.delivery.controller.DeliveryTransferController; import com.baiyue.lastleg.delivery.controller.DeliveryTransferController;
import com.baiyue.lastleg.dispatch.controller.DispatchController; import com.baiyue.lastleg.dispatch.controller.DispatchController;
import com.baiyue.lastleg.dispatch.controller.DispatchQueryController; 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.dispatch.pda.controller.DispatchPdaController;
import com.baiyue.lastleg.inboundplan.pda.controller.InboundPalletAssignmentController; import com.baiyue.lastleg.inboundplan.pda.controller.InboundPalletAssignmentController;
import com.baiyue.lastleg.inboundplan.pda.controller.InboundPdaController; 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.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import java.util.Arrays; import java.util.Arrays;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; 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.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -53,6 +57,30 @@ class Phase4BrowserApiBoundaryTest {
.contains(annotation.annotationType().getSimpleName()))); .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 @Test
void platformInventoryCommandsDoNotMapOutboundAllocationPickingOrShipping() { void platformInventoryCommandsDoNotMapOutboundAllocationPickingOrShipping() {
Set<String> paths = Arrays.stream(InventoryCommandController.class.getDeclaredMethods()) Set<String> paths = Arrays.stream(InventoryCommandController.class.getDeclaredMethods())
@@ -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:"));
}
}
@@ -4,12 +4,14 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.dispatch.dto.DispatchRequests; import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
import com.baiyue.lastleg.dispatch.service.DispatchService; import com.baiyue.lastleg.dispatch.service.DispatchService;
import com.baiyue.lastleg.security.OverseasPermissions;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.util.List; import java.util.List;
@@ -32,13 +34,14 @@ class DispatchQueryControllerTest {
} }
@Test @Test
void platformPageQueriesAllCustomersWithoutCustomerId() { void platformPageUsesServerResolvedSingleCustomerWithoutCustomerId() {
DispatchRequests.Query query = new DispatchRequests.Query(1, 20, null, null, null, null); 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); var response = controller.page(platform(), query);
verify(service).page(null, query); verify(service).page("100", query);
assertThat(response.code()).isEqualTo("200"); assertThat(response.code()).isEqualTo("200");
assertThat(response.data().records()).isEmpty(); assertThat(response.data().records()).isEmpty();
assertThatThrownBy(() -> controller.page(null, query)) assertThatThrownBy(() -> controller.page(null, query))
@@ -47,6 +50,19 @@ class DispatchQueryControllerTest {
((ResponseStatusException) error).getStatusCode().value()).isEqualTo(401)); ((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() { private static AuthenticatedUser platform() {
return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB", Set.of()); return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB", Set.of());
} }
@@ -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));
}
}
@@ -82,8 +82,50 @@ class DispatchServiceTest {
@Test @Test
void invalidCandidateStageIsRejected() { void invalidCandidateStageIsRejected() {
assertThrows(ResponseStatusException.class, () -> service.candidates("100", null, null, "INVALID")); assertThrows(ResponseStatusException.class,
verify(candidates, never()).available(any(), any(), any(), any()); () -> 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 @Test
@@ -105,14 +147,14 @@ class DispatchServiceTest {
} }
@Test @Test
void platformDetailFindsDispatchGloballyAndLoadsItemsWithRecordCustomer() { void detailUsesResolvedCustomerScopeAndLoadsItems() {
DispatchOrder dispatch = new DispatchOrder(); DispatchOrder dispatch = new DispatchOrder();
dispatch.setId(1L); dispatch.setId(1L);
dispatch.setCustomerId("200"); dispatch.setCustomerId("200");
dispatch.setDispatchNo("PC1"); 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); verify(items).findByDispatchOrder("200", 1L);
} }
@@ -13,6 +13,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
class InboundPlanPermissionContractTest { class InboundPlanPermissionContractTest {
private static final String LIST = "platform:overseas-warehouse:inbound-order:list"; 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 @Test
void listAndDetailUseOnlyCanonicalInboundOrderListAuthority() { void listAndDetailUseOnlyCanonicalInboundOrderListAuthority() {
@@ -25,4 +27,18 @@ class InboundPlanPermissionContractTest {
assertEquals("Required authority: " + LIST, method.getAnnotation(Operation.class).description()); assertEquals("Required authority: " + LIST, method.getAnnotation(Operation.class).description());
} }
} }
@Test
void fbaAppointmentWritesUseDedicatedAuthority() {
Map<String, Method> 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());
}
}
} }
@@ -104,6 +104,23 @@ class LastlegOrderServiceTest {
verify(orders, never()).updateById(any(LastlegOrder.class)); 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<LastlegOrder>(3, 25);
resultPage.setTotal(0);
when(orders.selectPage(any(), any())).thenReturn(resultPage);
service.page(null, query);
ArgumentCaptor<com.baomidou.mybatisplus.extension.plugins.pagination.Page<LastlegOrder>> 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) { private static LastlegOrder order(long id, String customerId, String status, String inboundOrderNo) {
LastlegOrder order = new LastlegOrder(); LastlegOrder order = new LastlegOrder();
order.setId(id); order.setId(id);
@@ -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 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 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<DropshipRequests.Receive> 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 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<DropshipRequests.Receive> 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<LastlegOrderDetail> 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<DropshipRequests.Receive> 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(){ @Test void consumesV2LoadingEventAndKeepsDropshipSnapshot(){
EventEnvelope source=event(); EventEnvelope source=event();
JsonNode node=source.payload().deepCopy(); JsonNode node=source.payload().deepCopy();
@@ -40,4 +59,5 @@ class LoadingCompletedConsumerTest {
verify(orders,never()).insert(any(LastlegOrder.class)); 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 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); }
} }
+6
View File
@@ -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')); '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; 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, INSERT INTO wh_location (id, tenant_id, warehouse_id, zone_code, location_code, location_type,
max_load_kg, status, remark) max_load_kg, status, remark)
SELECT UUID_SHORT(), w.tenant_id, w.id, 'DEFAULT', CONCAT('LOC-', w.warehouse_code, '-DEFAULT'), SELECT UUID_SHORT(), w.tenant_id, w.id, 'DEFAULT', CONCAT('LOC-', w.warehouse_code, '-DEFAULT'),
@@ -99,7 +99,8 @@ public class PlatformWarehouseController {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access required / 需要平台端权限") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access required / 需要平台端权限")
}) })
@GetMapping @GetMapping
@PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.WAREHOUSE_LIST + "')") @PreAuthorize("hasAnyAuthority('" + OverseasWarehousePermissions.WAREHOUSE_LIST + "', '"
+ OverseasWarehousePermissions.PALLET_LIST + "', '" + OverseasWarehousePermissions.LOCATION_LIST + "')")
public ApiResponse<PageResult<WarehouseEntity>> page( public ApiResponse<PageResult<WarehouseEntity>> page(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@Valid WarehouseRequests.QueryRequest request) { @Valid WarehouseRequests.QueryRequest request) {
@@ -22,8 +22,14 @@ public final class PalletRequests {
@NotNull @DecimalMin("0.00") BigDecimal unitPrice, @NotNull @DecimalMin("0.00") BigDecimal unitPrice,
@NotBlank String status, @NotBlank String status,
Long locationId, 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 { public SaveRequest {
if (capacityCbm == null) capacityCbm = DEFAULT_CAPACITY; if (capacityCbm == null) capacityCbm = DEFAULT_CAPACITY;
} }
@@ -24,4 +24,5 @@ public class WarehousePalletEntity extends BaseEntity {
private BigDecimal unitPrice; private BigDecimal unitPrice;
private String status; private String status;
private String remark; private String remark;
private String fbaWarehouseCode;
} }
@@ -276,6 +276,7 @@ public class WarehouseResourceService {
entity.setUnitPrice(request.unitPrice()); entity.setUnitPrice(request.unitPrice());
entity.setStatus(request.status()); entity.setStatus(request.status());
entity.setRemark(request.remark()); entity.setRemark(request.remark());
entity.setFbaWarehouseCode(request.fbaWarehouseCode());
return entity; return entity;
} }
@@ -37,6 +37,7 @@ CREATE TABLE IF NOT EXISTS wh_pallet (
unit_price DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '托盘单价', unit_price DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '托盘单价',
status VARCHAR(20) NOT NULL DEFAULT 'IDLE' COMMENT '托盘状态:IDLE闲置、OCCUPIED占用、SCRAPPED废弃', status VARCHAR(20) NOT NULL DEFAULT 'IDLE' COMMENT '托盘状态:IDLE闲置、OCCUPIED占用、SCRAPPED废弃',
remark VARCHAR(500) DEFAULT NULL COMMENT '备注', remark VARCHAR(500) DEFAULT NULL COMMENT '备注',
fba_warehouse_code VARCHAR(128) DEFAULT NULL COMMENT '所属FBA仓编码',
created_by BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID', created_by BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED DEFAULT NULL COMMENT '最后修改人ID', 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) KEY idx_pallet_location (customer_id, warehouse_id, location_id, deleted)
) ENGINE=InnoDB COMMENT='仓库托盘表:按租户和仓库维护托盘、容量、单价及状态'; ) 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 ( CREATE TABLE IF NOT EXISTS wh_resource_lock (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID', id BIGINT UNSIGNED NOT NULL COMMENT '主键ID',
customer_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 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 status VARCHAR(20) NOT NULL DEFAULT 'IDLE' COMMENT '托盘状态:IDLE闲置、OCCUPIED占用、SCRAPPED废弃',
MODIFY COLUMN remark VARCHAR(500) NULL COMMENT '备注', 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_by BIGINT UNSIGNED NULL COMMENT '创建人ID',
MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID', MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID',
@@ -3,8 +3,10 @@ package com.baiyue.warehouse.controller;
import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.warehouse.dto.WarehouseRequests; import com.baiyue.warehouse.dto.WarehouseRequests;
import com.baiyue.warehouse.security.OverseasWarehousePermissions;
import com.baiyue.warehouse.service.WarehouseApplicationService; import com.baiyue.warehouse.service.WarehouseApplicationService;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import java.util.List; import java.util.List;
@@ -13,8 +15,20 @@ import java.util.Set;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.junit.jupiter.api.Assertions.assertEquals;
class PlatformWarehouseControllerTest { 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 @Test
void platformPageQueriesAllWarehousesWithoutCustomerId() { void platformPageQueriesAllWarehousesWithoutCustomerId() {
WarehouseApplicationService service = mock(WarehouseApplicationService.class); WarehouseApplicationService service = mock(WarehouseApplicationService.class);
@@ -267,6 +267,44 @@ class WarehouseResourceServiceTest {
.hasMessageContaining("cannot be reused"); .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<WarehousePalletEntity> 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() { private static WarehouseEntity warehouse() {
WarehouseEntity warehouse = new WarehouseEntity(); WarehouseEntity warehouse = new WarehouseEntity();
warehouse.setId(WAREHOUSE); warehouse.setId(WAREHOUSE);