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.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import org.springframework.web.server.ResponseStatusException;
import java.util.LinkedHashMap;
@@ -89,6 +90,12 @@ public class GlobalExceptionHandler {
return failure(code, message, request);
}
@ExceptionHandler(NoResourceFoundException.class)
public ResponseEntity<ApiResponse<Void>> handleNoResourceFound(
NoResourceFoundException exception, HttpServletRequest request) {
return failure(ResultCode.NOT_FOUND, ResultCode.NOT_FOUND.message(), request);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleUnexpected(Exception exception, HttpServletRequest request) {
log.error("Unhandled request failure requestId={} traceId={}",
@@ -12,6 +12,7 @@ import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.bind.MissingRequestHeaderException;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
@@ -103,6 +104,18 @@ class GlobalExceptionHandlerTest {
assertThat(response.getBody().requestId()).isEqualTo("stale-version");
}
@Test
void missingControllerPathMapsToNotFoundInsteadOfInternalError() {
MockHttpServletRequest request = new MockHttpServletRequest();
var response = handler.handleNoResourceFound(
new NoResourceFoundException(org.springframework.http.HttpMethod.GET,
"api/platform/v1/last-leg/dispatches"), request);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody().code()).isEqualTo(ResultCode.NOT_FOUND.code());
}
private void paging(long page) {
}
}
@@ -7,6 +7,8 @@ spring:
lastleg:
dispatch-query-api:
enabled: true
smart-dispatch-api:
enabled: true
baiyue:
clients:
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),
(5107011,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:create','创建海外入仓计划',NULL,'POST',711,'ACTIVE',1,0),
(5107012,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:edit','编辑海外入仓计划',NULL,'POST',712,'ACTIVE',1,0),
(5107013,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:fba-appointment','FBA预约',NULL,'POST',713,'ACTIVE',1,0),
(5107014,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:smart-dispatch','智能排车',NULL,'POST',714,'ACTIVE',1,0),
(5107020,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:relabel-order:list','海外换标单','/overseas-warehouse/relabel-orders','GET',720,'ACTIVE',1,0),
(5107021,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:create','创建海外换标单',NULL,'POST',721,'ACTIVE',1,0),
(5107022,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:status','变更海外换标单状态',NULL,'PUT',722,'ACTIVE',1,0),
@@ -16020,6 +16022,29 @@ JOIN baiyue_iam.iam_permission permission ON permission.tenant_id=0 AND permissi
WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN'
AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0;
INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id, role_id, permission_id, created_at)
SELECT 0, role.id, permission.id, NOW(3)
FROM baiyue_iam.iam_role role
JOIN baiyue_iam.iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
AND permission.permission_code IN (
'platform:overseas-warehouse:inbound-order:fba-appointment',
'platform:overseas-warehouse:inbound-order:smart-dispatch'
)
WHERE role.tenant_id=0 AND role.app_code='PLATFORM'
AND UPPER(role.role_code)='PLATFORM_CUSTOM_ADMIN'
AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0;
INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id, role_id, permission_id, created_at)
SELECT 0, role.id, permission.id, NOW(3)
FROM baiyue_iam.iam_role role
JOIN baiyue_iam.iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
AND permission.permission_code IN (
'platform:overseas-warehouse:last-mile-order:edit',
'platform:overseas-warehouse:inbound-order:create'
)
WHERE role.tenant_id=0 AND role.app_code='PLATFORM'
AND UPPER(role.role_code) IN ('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN')
AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0;
CALL baiyue_firstleg.assert_unified_final_schema();
DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema;
@@ -17,6 +17,8 @@ VALUES
(5107010,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:inbound-order:list','海外入仓单','/overseas-warehouse/inbound-orders','GET',710,'ACTIVE',1,0),
(5107011,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:create','创建海外入仓计划',NULL,'POST',711,'ACTIVE',1,0),
(5107012,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:edit','编辑海外入仓计划',NULL,'POST',712,'ACTIVE',1,0),
(5107013,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:fba-appointment','FBA预约',NULL,'POST',713,'ACTIVE',1,0),
(5107014,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:smart-dispatch','智能排车',NULL,'POST',714,'ACTIVE',1,0),
(5107020,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:relabel-order:list','海外换标单','/overseas-warehouse/relabel-orders','GET',720,'ACTIVE',1,0),
(5107021,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:create','创建海外换标单',NULL,'POST',721,'ACTIVE',1,0),
(5107022,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:status','变更海外换标单状态',NULL,'PUT',722,'ACTIVE',1,0),
@@ -72,3 +74,28 @@ JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code
AND permission.id BETWEEN 5107000 AND 5107299
WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN'
AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0;
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
SELECT 0, role.id, permission.id, NOW(3)
FROM iam_role role
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
AND permission.permission_code IN (
'platform:overseas-warehouse:inbound-order:fba-appointment',
'platform:overseas-warehouse:inbound-order:smart-dispatch'
)
WHERE role.tenant_id=0 AND role.app_code='PLATFORM'
AND UPPER(role.role_code)='PLATFORM_CUSTOM_ADMIN'
AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0;
-- Tail-leg order actions are available to the current platform administrator role.
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
SELECT 0, role.id, permission.id, NOW(3)
FROM iam_role role
JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM'
AND permission.permission_code IN (
'platform:overseas-warehouse:last-mile-order:edit',
'platform:overseas-warehouse:inbound-order:create'
)
WHERE role.tenant_id=0 AND role.app_code='PLATFORM'
AND UPPER(role.role_code) IN ('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN')
AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0;
@@ -15,9 +15,14 @@ import static org.assertj.core.api.Assertions.assertThat;
class OverseasPhase4PermissionSqlContractTest {
private static final Pattern CANONICAL = Pattern.compile("platform:overseas-[a-z0-9:-]+");
private static final Set<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(
"platform:overseas-warehouse:last-mile-order:list",
"platform:overseas-warehouse:inbound-order:list",
"platform:overseas-warehouse:inbound-order:fba-appointment",
"platform:overseas-warehouse:inbound-order:smart-dispatch",
"platform:overseas-warehouse:relabel-order:status",
"platform:overseas-warehouse:pallet:list",
"platform:overseas-warehouse:inventory:adjust",
@@ -34,9 +39,10 @@ class OverseasPhase4PermissionSqlContractTest {
.getContentAsString(StandardCharsets.UTF_8);
Set<String> catalog = codes(sql);
assertThat(sql).contains(REQUIRED.toArray(String[]::new));
assertThat(catalog).hasSize(51).noneMatch(code -> code.endsWith(":view"));
assertThat(catalog).hasSize(53).noneMatch(code -> code.endsWith(":view"));
assertThat(sql).contains("permission_code LIKE 'platform:lastleg:%'", "status='INACTIVE'", "deleted=1");
assertThat(sql).doesNotContain(":carton:", ":product:", ":outbound:", ":delivery:");
assertCustomAdminGrant(sql);
}
@Test
@@ -48,6 +54,7 @@ class OverseasPhase4PermissionSqlContractTest {
.getContentAsString(StandardCharsets.UTF_8));
assertThat(codes(sql)).containsAll(moduleCatalog);
assertThat(sql).contains("baiyue_iam.iam_permission", "baiyue_iam.iam_role_permission");
assertCustomAdminGrant(sql);
}
@Test
@@ -69,6 +76,18 @@ class OverseasPhase4PermissionSqlContractTest {
return values;
}
private static void assertCustomAdminGrant(String sql) {
String roleMatch = "UPPER(role.role_code)='PLATFORM_CUSTOM_ADMIN'";
int roleIndex = sql.indexOf(roleMatch);
assertThat(roleIndex).isGreaterThanOrEqualTo(0);
int grantStart = sql.lastIndexOf("INSERT IGNORE INTO", roleIndex);
int grantEnd = sql.indexOf(';', roleIndex);
assertThat(grantStart).isGreaterThanOrEqualTo(0);
assertThat(grantEnd).isGreaterThan(roleIndex);
assertThat(codes(sql.substring(grantStart, grantEnd)))
.containsExactlyInAnyOrderElementsOf(CUSTOM_ADMIN_PERMISSIONS);
}
private static String repositoryFile(String relativePath) throws Exception {
Path path = Path.of(relativePath);
if (!Files.exists(path)) path = Path.of("..").resolve(relativePath);
+1 -1
View File
@@ -39,7 +39,7 @@
- 四期在本服务内只进入尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、海外采购和本土卡车;本土散板新增实现也归本服务的独立稳定子域。
- `dispatch/`、`delivery/`、出库单、PDA 出库拣货、派送和其他库存离仓能力虽然已存在于服务代码或表结构中,但不属于当前四期开发与验收范围,不得因代码已存在而自动纳入。
- 历史 `dispatch/` 写操作、PDA 出库和 `delivery/` Controller 默认不注册;只有后续阶段明确启用 `lastleg.future-outbound-api.enabled=true` 才可恢复。平台排车列表与详情是独立只读入口,仅由海外配置 `lastleg.dispatch-query-api.enabled=true` 启用,不得借此开放预排、编辑、取消、PDA 出库或派送。四期平台库存 Controller 不映射 allocate、unallocate、pick、ship。
- 历史排车编辑/取消/出库、PDA 出库和 `delivery/` Controller 默认不注册;只有后续阶段明确启用 `lastleg.future-outbound-api.enabled=true` 才可恢复。平台排车列表与详情由 `lastleg.dispatch-query-api.enabled=true` 独立启用;入库单智能排车候选和预排创建由 `lastleg.smart-dispatch-api.enabled=true` 独立启用。四个排车平台入口均不接收 `customerId`:排车列表/详情从既有排车数据解析范围,智能排车候选根据已选入库计划 ID、预排创建根据所选托盘 ID 在服务端解析并校验唯一客户范围;预排只能创建待出库排车并锁定托盘,不得借此开放编辑、取消、depart、PDA 出库或派送。四期平台库存 Controller 不映射 allocate、unallocate、pick、ship。
- 海外仓库、库位、实物托盘和资源锁归 `warehouse-overseas-service/`;浏览器 API 统一经 `gateway-overseas/`,本服务不得复制仓储资源主数据或另建公网认证入口。
## 数据库表索引与注释约束
@@ -4,7 +4,6 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.dispatch.dto.DispatchRequests;
import com.baiyue.lastleg.dispatch.dto.DispatchResponse;
import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests;
import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses;
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
@@ -18,7 +17,6 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
@@ -28,8 +26,6 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@RestController
@ConditionalOnProperty(name = "lastleg.future-outbound-api.enabled", havingValue = "true")
@RequestMapping("/api/platform/v1/last-leg/dispatches")
@@ -38,28 +34,6 @@ public class DispatchController {
@Resource private DispatchService service;
@Resource private DispatchPdaWorkflowService pdaWorkflow;
@GetMapping("/candidates")
@Operation(summary = "List unlocked planned or received pallets for intelligent pre-dispatch")
public ApiResponse<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}")
@Operation(summary = "Edit vehicle, driver, appointment, route and departure data")
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.DispatchResponse;
import com.baiyue.lastleg.dispatch.service.DispatchService;
import com.baiyue.lastleg.security.OverseasPermissions;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -27,21 +29,23 @@ public class DispatchQueryController {
@Resource private DispatchService service;
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')")
@Operation(summary = "Page dispatch orders")
public ApiResponse<PageResult<DispatchResponse>> page(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid DispatchRequests.Query query) {
requirePlatform(user);
return ApiResponse.success(service.page(null, query));
return ApiResponse.success(service.page(service.resolveSingleCustomerScope(), query));
}
@GetMapping("/{dispatchNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')")
@Operation(summary = "Get dispatch details and all pallet destinations")
public ApiResponse<DispatchResponse> detail(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo) {
requirePlatform(user);
return ApiResponse.success(service.detail(null, dispatchNo));
return ApiResponse.success(service.detail(service.resolveSingleCustomerScope(), dispatchNo));
}
private void requirePlatform(AuthenticatedUser user) {
@@ -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
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 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," +
@@ -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_line l ON l.id=a.line_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='fbaWarehouseCode != null and fbaWarehouseCode != \"\"'> AND l.fba_warehouse_code=#{fbaWarehouseCode}</if> " +
"<if test='sourceStage != null and sourceStage != \"\"'> AND " +
@@ -44,6 +66,7 @@ public interface DispatchCandidateMapper {
"ORDER BY p.plan_id,p.sequence_no" +
"</script>")
List<DispatchPalletCandidate> available(@Param("customerId") String customerId,
@Param("planIds") List<Long> planIds,
@Param("orderNo") String orderNo,
@Param("fbaWarehouseCode") String fbaWarehouseCode,
@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")
DispatchOrder findByDispatchNo(@Param("customerId") String customerId, @Param("dispatchNo") String dispatchNo);
@Select("SELECT * FROM ll_dispatch_order WHERE dispatch_no=#{dispatchNo} LIMIT 1")
DispatchOrder findAnyByDispatchNo(@Param("dispatchNo") String dispatchNo);
@Select("SELECT * FROM ll_dispatch_order WHERE customer_id=#{customerId} AND id=#{id} LIMIT 1 FOR UPDATE")
DispatchOrder lockById(@Param("customerId") String customerId, @Param("id") Long id);
}
@@ -41,16 +41,43 @@ public class DispatchService {
@Resource private DispatchPalletLockMapper locks;
@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) {
if (StringUtils.hasText(sourceStage) && !Set.of("PLAN", "RECEIVED").contains(sourceStage)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "sourceStage must be PLAN or RECEIVED");
}
return candidates.available(customerId, trimToNull(orderNo), trimToNull(fbaWarehouseCode), trimToNull(sourceStage));
if (!StringUtils.hasText(customerId)) return List.of();
List<Long> scopedPlanIds = normalizeIds(planIds, "Plan IDs");
return candidates.available(customerId, scopedPlanIds, trimToNull(orderNo), trimToNull(fbaWarehouseCode),
trimToNull(sourceStage));
}
@Transactional
public DispatchResponse preArrange(String customerId, String operatorId, DispatchRequests.PreArrange request) {
if (!StringUtils.hasText(customerId)) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"No customer business data is available for dispatch");
}
List<Long> palletIds = request.planPalletIds().stream().filter(Objects::nonNull).distinct().sorted().toList();
if (palletIds.isEmpty() || palletIds.size() != request.planPalletIds().size()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Plan pallet IDs must be unique and non-empty");
@@ -149,8 +176,11 @@ public class DispatchService {
}
public PageResult<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()),
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.vehiclePlate()), DispatchOrder::getVehiclePlate, query.vehiclePlate())
.eq(StringUtils.hasText(query.status()), DispatchOrder::getStatus, query.status())
@@ -216,9 +246,10 @@ public class DispatchService {
}
private DispatchOrder find(String customerId, String dispatchNo) {
DispatchOrder dispatch = StringUtils.hasText(customerId)
? orders.findByDispatchNo(customerId, dispatchNo)
: orders.findAnyByDispatchNo(dispatchNo);
if (!StringUtils.hasText(customerId)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
}
DispatchOrder dispatch = orders.findByDispatchNo(customerId, dispatchNo);
if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found");
return dispatch;
}
@@ -260,4 +291,25 @@ public class DispatchService {
private static String trimToNull(String value) {
return StringUtils.hasText(value) ? value.trim() : null;
}
private static List<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) @Max(200) long size,
String orderNo,
String orderType,
String inboundCode,
String sourceOrderNo,
String containerNo,
String currentStatus,
String status,
String warehouse,
String transferWarehouseCode,
Boolean abnormal,
Boolean detained,
@@ -33,7 +33,7 @@ public class InboundPlanController {
/** 按入仓计划明细分页查询可预约的入库订单行。 */
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_CREATE + "')")
@Operation(summary = "分页查询入库订单", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST)
public ApiResponse<PageResult<InboundPlanListResponse>> page(
@AuthenticationPrincipal AuthenticatedUser user,
@@ -43,7 +43,7 @@ public class InboundPlanController {
}
@PostMapping("/convert")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_CREATE + "')")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')")
/** 将入仓单转换为入仓计划。 */
@Operation(summary = "入仓单转入仓计划", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_CREATE)
public ApiResponse<InboundPlanResponse> convert(@AuthenticationPrincipal AuthenticatedUser user,
@@ -72,9 +72,9 @@ public class InboundPlanController {
}
@PostMapping("/fba-appointments/batch")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT + "')")
/** 为选中的入仓明细创建 FBA 预约。 */
@Operation(summary = "创建 FBA 预约", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_EDIT)
@Operation(summary = "创建 FBA 预约", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT)
public ApiResponse<FbaAppointmentResponse> batchFbaAppointment(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody BatchFbaAppointmentRequest request) {
@@ -95,8 +95,8 @@ public class InboundPlanController {
}
@PutMapping("/fba-appointments/{appointmentNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')")
@Operation(summary = "编辑 FBA 预约", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_EDIT)
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT + "')")
@Operation(summary = "编辑 FBA 预约", description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_FBA_APPOINTMENT)
public ApiResponse<FbaAppointmentResponse> updateFbaAppointment(
@AuthenticationPrincipal AuthenticatedUser user,
@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_CREATE = "platform:overseas-warehouse:inbound-order:create";
public static final String INBOUND_ORDER_EDIT = "platform:overseas-warehouse:inbound-order:edit";
public static final String INBOUND_ORDER_FBA_APPOINTMENT =
"platform:overseas-warehouse:inbound-order:fba-appointment";
public static final String INBOUND_ORDER_SMART_DISPATCH =
"platform:overseas-warehouse:inbound-order:smart-dispatch";
public static final String RELABEL_ORDER_LIST = "platform:overseas-warehouse:relabel-order:list";
public static final String RELABEL_ORDER_CREATE = "platform:overseas-warehouse:relabel-order:create";
public static final String RELABEL_ORDER_STATUS = "platform:overseas-warehouse:relabel-order:status";
@@ -123,13 +123,20 @@ public class LastlegOrderService {
if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "createdFrom must not be after createdTo");
}
String currentStatus = StringUtils.hasText(query.currentStatus()) ? query.currentStatus() : query.status();
return new LambdaQueryWrapper<LastlegOrder>()
.eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId)
.like(StringUtils.hasText(query.orderNo()), LastlegOrder::getOrderNo, query.orderNo())
.eq(StringUtils.hasText(query.orderType()), LastlegOrder::getOrderType, query.orderType())
.like(StringUtils.hasText(query.inboundCode()), LastlegOrder::getInboundCode, query.inboundCode())
.like(StringUtils.hasText(query.sourceOrderNo()), LastlegOrder::getSourceOrderNo, query.sourceOrderNo())
.like(StringUtils.hasText(query.containerNo()), LastlegOrder::getContainerNo, query.containerNo())
.eq(StringUtils.hasText(query.status()), LastlegOrder::getCurrentStatus, query.status())
.eq(StringUtils.hasText(currentStatus), LastlegOrder::getCurrentStatus, currentStatus)
.and(StringUtils.hasText(query.warehouse()), wrapper -> wrapper
.like(LastlegOrder::getOverseasWarehouseId, query.warehouse())
.or().like(LastlegOrder::getOverseasWarehouseName, query.warehouse())
.or().like(LastlegOrder::getTransferWarehouseCode, query.warehouse())
.or().like(LastlegOrder::getTransferWarehouseName, query.warehouse()))
.eq(StringUtils.hasText(query.transferWarehouseCode()), LastlegOrder::getTransferWarehouseCode,
query.transferWarehouseCode())
.eq(query.abnormal() != null, LastlegOrder::getAbnormal, query.abnormal())
@@ -34,6 +34,12 @@ spring:
password: ${LASTLEG_DB_PASSWORD:123456}
management.endpoints.web.exposure.include: health,info,prometheus
lastleg:
dispatch-query-api:
enabled: ${LASTLEG_DISPATCH_QUERY_API_ENABLED:true}
smart-dispatch-api:
enabled: ${LASTLEG_SMART_DISPATCH_API_ENABLED:true}
baiyue:
clients:
domestic-warehouse:
@@ -3,6 +3,83 @@ info:
title: Lastleg Overseas Business API
version: 1.0.0
paths:
/api/platform/v1/last-leg/orders:
get:
summary: 分页查询尾程订单
description: 权限 platform:overseas-warehouse:order:list;平台管理员省略 customerId 时查询全部客户。
security: [{ bearerAuth: [] }]
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
- { name: orderNo, in: query, schema: { type: string } }
- { name: orderType, in: query, schema: { type: string, enum: [FIRST_LEG, RELABEL_APPLICATION, WAREHOUSE_TRANSFER, LOCAL_LOOSEBOARD, RETURN_RELABEL, ONE_CLICK_DROPSHIP] } }
- { name: currentStatus, in: query, schema: { type: string } }
- { name: inboundCode, in: query, schema: { type: string } }
- { name: sourceOrderNo, in: query, schema: { type: string } }
- { name: containerNo, in: query, schema: { type: string } }
- { name: warehouse, in: query, schema: { type: string } }
- { name: transferWarehouseCode, in: query, schema: { type: string } }
- { name: abnormal, in: query, schema: { type: boolean } }
- { name: detained, in: query, schema: { type: boolean } }
- { name: createdFrom, in: query, schema: { type: string, format: date-time } }
- { name: createdTo, in: query, schema: { type: string, format: date-time } }
responses: { '200': { description: 统一 ApiResponse 包装的尾程订单分页 } }
/api/platform/v1/last-leg/dispatches:
get:
summary: 分页查询排车单
description: 权限 platform:overseas-warehouse:inbound-order:list;服务端仅在当前部署恰好存在一个客户范围时查询,前端不传 customerId。
security: [{ bearerAuth: [] }]
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
- { name: dispatchNo, in: query, schema: { type: string } }
- { name: vehiclePlate, in: query, schema: { type: string } }
- { name: status, in: query, schema: { type: string } }
- { name: fbaAppointmentNo, in: query, schema: { type: string } }
responses:
'200': { description: 统一 ApiResponse 包装的排车单分页,records 元素为 DispatchResponse }
'409': { description: 当前部署检测到多个客户范围,拒绝跨客户查询 }
/api/platform/v1/last-leg/dispatches/{dispatchNo}:
get:
summary: 查询排车单详情
description: 权限 platform:overseas-warehouse:inbound-order:list;服务端解析唯一客户范围,前端不传 customerId。
security: [{ bearerAuth: [] }]
parameters:
- { name: dispatchNo, in: path, required: true, schema: { type: string } }
responses:
'200': { description: 统一 ApiResponse 包装的 DispatchResponse }
'404': { description: 排车单不存在 }
'409': { description: 当前部署检测到多个客户范围 }
/api/platform/v1/last-leg/dispatches/candidates:
get:
summary: 查询智能排车候选托盘
description: 权限 platform:overseas-warehouse:inbound-order:list;服务端解析唯一客户范围,仅返回尚未被有效排车锁定的计划或已实收托盘,前端不传 customerId。
security: [{ bearerAuth: [] }]
parameters:
- name: planIds
in: query
required: true
description: 已选入库计划 ID,逗号分隔;服务端据此解析并校验唯一客户范围。
schema: { type: array, minItems: 1, maxItems: 500, items: { type: integer, format: int64 } }
style: form
explode: false
- { name: orderNo, in: query, schema: { type: string } }
- { name: fbaWarehouseCode, in: query, schema: { type: string } }
- { name: sourceStage, in: query, schema: { type: string, enum: [PLAN, RECEIVED] } }
responses:
'200':
description: 统一 ApiResponse 包装的候选托盘数组
content: { application/json: { schema: { type: array, items: { $ref: '#/components/schemas/SmartDispatchCandidate' } } } }
/api/platform/v1/last-leg/dispatches/pre-arrange:
post:
summary: 创建智能预排车单
description: 权限 platform:overseas-warehouse:inbound-order:smart-dispatch;服务端根据所选托盘解析并校验唯一客户范围,锁定同一海外仓的托盘并创建 PENDING_DEPARTURE 排车单,前端不传 customerId;不开放编辑、取消、depart、PDA 出库或派送。
security: [{ bearerAuth: [] }]
requestBody:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/SmartDispatchPreArrange' } } }
responses:
'200': { description: 统一 ApiResponse 包装的预排车详情, content: { application/json: { schema: { $ref: '#/components/schemas/DispatchResponse' } } } }
/api/platform/v1/last-leg/overseas-procurements:
get:
summary: 分页查询海外采购订单
@@ -212,6 +289,81 @@ paths:
responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '409': { description: 非法状态迁移或关联尾程单缺失 } }
components:
schemas:
SmartDispatchCandidate:
type: object
properties:
planId: { type: string }
planPalletId: { type: string }
warehousePalletId: { type: string, nullable: true }
orderId: { type: string }
overseasWarehouseId: { type: string }
overseasWarehouseName: { type: string }
orderNo: { type: string }
orderRefs: { type: string }
inboundCode: { type: string }
palletNo: { type: string }
destinationWarehouseId: { type: string }
destinationWarehouseCode: { type: string }
destinationWarehouseName: { type: string }
fbaWarehouseCode: { type: string }
packageCount: { type: integer }
volumeCbm: { type: number }
sourceStage: { type: string, enum: [PLAN, RECEIVED] }
SmartDispatchPreArrange:
type: object
required: [planPalletIds]
properties:
planPalletIds: { type: array, minItems: 1, maxItems: 500, uniqueItems: true, items: { type: string } }
route: { type: string, nullable: true, maxLength: 255 }
fbaAppointmentNo: { type: string, nullable: true, maxLength: 64 }
fbaAppointmentStatus: { type: string, nullable: true, maxLength: 32, default: PENDING }
vehicleAppointmentStatus: { type: string, nullable: true, maxLength: 32, default: PENDING }
DispatchResponse:
type: object
properties:
id: { type: string }
dispatchNo: { type: string }
vehiclePlate: { type: string, nullable: true }
driverName: { type: string, nullable: true }
driverPhone: { type: string, nullable: true }
carrierName: { type: string, nullable: true }
orderNos: { type: string }
route: { type: string, nullable: true }
overseasWarehouseId: { type: string }
overseasWarehouseName: { type: string }
departAt: { type: string, format: date-time, nullable: true }
fbaAppointmentNo: { type: string, nullable: true }
fbaAppointmentStatus: { type: string }
vehicleAppointmentStatus: { type: string }
estimatedArrivalAt: { type: string, format: date-time, nullable: true }
status: { type: string, description: 预排新建固定为 PENDING_DEPARTURE;查询可返回历史排车状态 }
itemCount: { type: integer }
palletCount: { type: integer }
packageCount: { type: integer }
pickedPackageCount: { type: integer }
shippedPackageCount: { type: integer }
remainingPackageCount: { type: integer }
partialCompletionReason: { type: string, nullable: true }
createdAt: { type: string, format: date-time }
items: { type: array, items: { $ref: '#/components/schemas/DispatchItem' } }
DispatchItem:
type: object
properties:
id: { type: string }
planPalletId: { type: string }
warehousePalletId: { type: string, nullable: true }
palletNo: { type: string }
overseasWarehouseId: { type: string }
overseasWarehouseName: { type: string }
orderNo: { type: string }
orderRefs: { type: string }
inboundCode: { type: string }
destinationWarehouseCode: { type: string }
destinationWarehouseName: { type: string }
fbaWarehouseCode: { type: string }
packageCount: { type: integer }
volumeCbm: { type: number }
sourceStage: { type: string, enum: [PLAN, RECEIVED] }
RelabelStatus: { type: string, enum: [PENDING_PROCESSING, PROCESSING, COMPLETED, REJECTED, CANCELLED] }
RelabelCreate:
type: object
@@ -25,7 +25,7 @@ class LastlegOrderControllerTest {
private final LastlegOrderService service = mock(LastlegOrderService.class);
private final LastlegOrderController controller = controller();
private final LastlegOrderRequests.Query query = new LastlegOrderRequests.Query(
1, 20, null, null, null, null, null, null, null, null, null, null);
1, 20, null, null, null, null, null, null, null, null, null, null, null, null, null);
@Test
void platformEndpointRejectsMerchantIdentity() {
@@ -18,7 +18,7 @@ class MerchantLastlegOrderControllerTest {
private final LastlegOrderService service = mock(LastlegOrderService.class);
private final MerchantLastlegOrderController controller = controller();
private final LastlegOrderRequests.Query query = new LastlegOrderRequests.Query(
1, 20, null, null, null, null, null, null, null, null, null, null);
1, 20, null, null, null, null, null, null, null, null, null, null, null, null, null);
@Test
void merchantListUsesOnlyAuthenticatedTenant() {
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.controller;
import com.baiyue.lastleg.delivery.controller.DeliveryTransferController;
import com.baiyue.lastleg.dispatch.controller.DispatchController;
import com.baiyue.lastleg.dispatch.controller.DispatchQueryController;
import com.baiyue.lastleg.dispatch.controller.SmartDispatchController;
import com.baiyue.lastleg.dispatch.pda.controller.DispatchPdaController;
import com.baiyue.lastleg.inboundplan.pda.controller.InboundPalletAssignmentController;
import com.baiyue.lastleg.inboundplan.pda.controller.InboundPdaController;
@@ -11,10 +12,13 @@ import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import java.util.Arrays;
import java.util.Set;
import java.util.stream.Collectors;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -53,6 +57,30 @@ class Phase4BrowserApiBoundaryTest {
.contains(annotation.annotationType().getSimpleName())));
}
@Test
void dispatchQueryIsEnabledByDefaultWithoutEnablingOutboundCommands() throws Exception {
String yaml = Files.readString(Path.of("src/main/resources/application.yml"));
assertTrue(yaml.contains("enabled: ${LASTLEG_DISPATCH_QUERY_API_ENABLED:true}"));
assertTrue(yaml.contains("enabled: ${LASTLEG_SMART_DISPATCH_API_ENABLED:true}"));
assertFalse(yaml.contains("future-outbound-api:"));
}
@Test
void smartDispatchIsIndependentlyEnabledWithOnlyCandidateAndPreArrangeEndpoints() {
ConditionalOnProperty condition = SmartDispatchController.class.getAnnotation(ConditionalOnProperty.class);
assertTrue(condition != null);
assertTrue(Arrays.asList(condition.name()).contains("lastleg.smart-dispatch-api.enabled"));
assertFalse(condition.matchIfMissing());
assertTrue(Arrays.stream(SmartDispatchController.class.getDeclaredMethods())
.filter(method -> method.isAnnotationPresent(GetMapping.class)).count() == 1);
assertTrue(Arrays.stream(SmartDispatchController.class.getDeclaredMethods())
.filter(method -> method.isAnnotationPresent(PostMapping.class)).count() == 1);
assertFalse(Arrays.stream(SmartDispatchController.class.getDeclaredMethods())
.flatMap(method -> Arrays.stream(method.getAnnotations()))
.anyMatch(annotation -> Set.of("PutMapping", "DeleteMapping", "PatchMapping")
.contains(annotation.annotationType().getSimpleName())));
}
@Test
void platformInventoryCommandsDoNotMapOutboundAllocationPickingOrShipping() {
Set<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.lastleg.dispatch.dto.DispatchRequests;
import com.baiyue.lastleg.dispatch.service.DispatchService;
import com.baiyue.lastleg.security.OverseasPermissions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@@ -32,13 +34,14 @@ class DispatchQueryControllerTest {
}
@Test
void platformPageQueriesAllCustomersWithoutCustomerId() {
void platformPageUsesServerResolvedSingleCustomerWithoutCustomerId() {
DispatchRequests.Query query = new DispatchRequests.Query(1, 20, null, null, null, null);
when(service.page(null, query)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
when(service.resolveSingleCustomerScope()).thenReturn("100");
when(service.page("100", query)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
var response = controller.page(platform(), query);
verify(service).page(null, query);
verify(service).page("100", query);
assertThat(response.code()).isEqualTo("200");
assertThat(response.data().records()).isEmpty();
assertThatThrownBy(() -> controller.page(null, query))
@@ -47,6 +50,19 @@ class DispatchQueryControllerTest {
((ResponseStatusException) error).getStatusCode().value()).isEqualTo(401));
}
@Test
void listAndDetailReuseInboundOrderListAuthority() throws Exception {
PreAuthorize page = DispatchQueryController.class
.getMethod("page", AuthenticatedUser.class, DispatchRequests.Query.class)
.getAnnotation(PreAuthorize.class);
PreAuthorize detail = DispatchQueryController.class
.getMethod("detail", AuthenticatedUser.class, String.class)
.getAnnotation(PreAuthorize.class);
assertThat(page.value()).contains(OverseasPermissions.INBOUND_ORDER_LIST);
assertThat(detail.value()).contains(OverseasPermissions.INBOUND_ORDER_LIST);
}
private static AuthenticatedUser platform() {
return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB", Set.of());
}
@@ -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
void invalidCandidateStageIsRejected() {
assertThrows(ResponseStatusException.class, () -> service.candidates("100", null, null, "INVALID"));
verify(candidates, never()).available(any(), any(), any(), any());
assertThrows(ResponseStatusException.class,
() -> service.candidates("100", List.of(1L), null, null, "INVALID"));
verify(candidates, never()).available(any(), any(), any(), any(), any());
}
@Test
void resolvesCustomerFromSelectedPlansAndPalletsInMultiCustomerDeployment() {
when(candidates.findCustomerScopesByPlanIds(List.of(1L, 2L))).thenReturn(List.of("100"));
when(candidates.findCustomerScopesByPlanPalletIds(List.of(10L, 11L))).thenReturn(List.of("100"));
assertEquals("100", service.resolveCustomerScopeForPlans(List.of(2L, 1L, 1L)));
assertEquals("100", service.resolveCustomerScopeForPallets(List.of(11L, 10L)));
}
@Test
void rejectsSelectedPlansThatSpanCustomers() {
when(candidates.findCustomerScopesByPlanIds(List.of(1L, 2L))).thenReturn(List.of("100", "200"));
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.resolveCustomerScopeForPlans(List.of(1L, 2L)));
assertEquals(409, exception.getStatusCode().value());
}
@Test
void resolvesOnlyCustomerScopeAndRejectsMultipleCustomers() {
when(candidates.findCustomerScopes()).thenReturn(List.of("100"));
assertEquals("100", service.resolveSingleCustomerScope());
when(candidates.findCustomerScopes()).thenReturn(List.of("100", "200"));
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.resolveSingleCustomerScope());
assertEquals(409, exception.getStatusCode().value());
}
@Test
void emptyCustomerScopeProducesEmptyCandidatesAndRejectsPreArrange() {
assertEquals(List.of(), service.candidates(null, null, null, null, null));
verify(candidates, never()).available(any(), any(), any(), any(), any());
ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.preArrange(null, "7",
new DispatchRequests.PreArrange(List.of(10L), null, null, null, null)));
assertEquals(409, exception.getStatusCode().value());
}
@Test
@@ -105,14 +147,14 @@ class DispatchServiceTest {
}
@Test
void platformDetailFindsDispatchGloballyAndLoadsItemsWithRecordCustomer() {
void detailUsesResolvedCustomerScopeAndLoadsItems() {
DispatchOrder dispatch = new DispatchOrder();
dispatch.setId(1L);
dispatch.setCustomerId("200");
dispatch.setDispatchNo("PC1");
when(orders.findAnyByDispatchNo("PC1")).thenReturn(dispatch);
when(orders.findByDispatchNo("200", "PC1")).thenReturn(dispatch);
service.detail(null, "PC1");
service.detail("200", "PC1");
verify(items).findByDispatchOrder("200", 1L);
}
@@ -13,6 +13,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
class InboundPlanPermissionContractTest {
private static final String LIST = "platform:overseas-warehouse:inbound-order:list";
private static final String FBA_APPOINTMENT =
"platform:overseas-warehouse:inbound-order:fba-appointment";
@Test
void listAndDetailUseOnlyCanonicalInboundOrderListAuthority() {
@@ -25,4 +27,18 @@ class InboundPlanPermissionContractTest {
assertEquals("Required authority: " + LIST, method.getAnnotation(Operation.class).description());
}
}
@Test
void fbaAppointmentWritesUseDedicatedAuthority() {
Map<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));
}
@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) {
LastlegOrder order = new LastlegOrder();
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 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 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(){
EventEnvelope source=event();
JsonNode node=source.payload().deepCopy();
@@ -40,4 +59,5 @@ class LoadingCompletedConsumerTest {
verify(orders,never()).insert(any(LastlegOrder.class));
}
private EventEnvelope event(){ var item1=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-1","产品","Product",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("12"),new BigDecimal("1.2"),"wh-1","ONT8","FBA ONT8","FBA-1","SHIP-1","MARK-1"); var item2=new FirstLegLoadingPlanCompletedV1.Item("d-2","SKU-2","产品2","Product2",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("9"),new BigDecimal("0.8"),"wh-2","LAX9","FBA LAX9","FBA-2","SHIP-2","MARK-2"); var p=new FirstLegLoadingPlanCompletedV1("plan-1","LP1","book-1","MSCU1","CAB1","ow-1","洛杉矶海外仓",List.of(new FirstLegLoadingPlanCompletedV1.Order("in-1","root-1","ord-1","FL1","root-o1","RK1","wh-1","ONT8","FBA ONT8",5,50,1,new BigDecimal("10"),new BigDecimal("9"),new BigDecimal("1.2"),List.of(item1)),new FirstLegLoadingPlanCompletedV1.Order("in-2","root-2","ord-2","FL2","root-o2","RK2","wh-2","LAX9","FBA LAX9",3,30,2,new BigDecimal("8"),new BigDecimal("7"),new BigDecimal("0.8"),List.of(item2)))); JsonNode node=json.valueToTree(p); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174000",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-1",2,1L,"123e4567-e89b-12d3-a456-426614174001","FIRSTLEG_LOADING_COMPLETED:plan-1:100:1",null,node,hash); }
private EventEnvelope eventForPayload(FirstLegLoadingPlanCompletedV1 payload, long version){ JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174099",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN",payload.loadingPlanId(),2,version,"123e4567-e89b-12d3-a456-426614174098","FIRSTLEG_LOADING_COMPLETED:"+payload.loadingPlanId()+":100:1",null,node,hash); }
}
+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'));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='wh_pallet' AND column_name='fba_warehouse_code'),
'ALTER TABLE wh_pallet MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''所属FBA仓编码''',
'ALTER TABLE wh_pallet ADD COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''所属FBA仓编码'' AFTER remark'));
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
INSERT INTO wh_location (id, tenant_id, warehouse_id, zone_code, location_code, location_type,
max_load_kg, status, remark)
SELECT UUID_SHORT(), w.tenant_id, w.id, 'DEFAULT', CONCAT('LOC-', w.warehouse_code, '-DEFAULT'),
@@ -99,7 +99,8 @@ public class PlatformWarehouseController {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform access required / 需要平台端权限")
})
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.WAREHOUSE_LIST + "')")
@PreAuthorize("hasAnyAuthority('" + OverseasWarehousePermissions.WAREHOUSE_LIST + "', '"
+ OverseasWarehousePermissions.PALLET_LIST + "', '" + OverseasWarehousePermissions.LOCATION_LIST + "')")
public ApiResponse<PageResult<WarehouseEntity>> page(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid WarehouseRequests.QueryRequest request) {
@@ -22,8 +22,14 @@ public final class PalletRequests {
@NotNull @DecimalMin("0.00") BigDecimal unitPrice,
@NotBlank String status,
Long locationId,
@Size(max = 500) String remark
@Size(max = 500) String remark,
@Size(max = 128) String fbaWarehouseCode
) {
public SaveRequest(String palletCode, String palletType, BigDecimal capacityCbm,
BigDecimal unitPrice, String status, Long locationId, String remark) {
this(palletCode, palletType, capacityCbm, unitPrice, status, locationId, remark, null);
}
public SaveRequest {
if (capacityCbm == null) capacityCbm = DEFAULT_CAPACITY;
}
@@ -24,4 +24,5 @@ public class WarehousePalletEntity extends BaseEntity {
private BigDecimal unitPrice;
private String status;
private String remark;
private String fbaWarehouseCode;
}
@@ -276,6 +276,7 @@ public class WarehouseResourceService {
entity.setUnitPrice(request.unitPrice());
entity.setStatus(request.status());
entity.setRemark(request.remark());
entity.setFbaWarehouseCode(request.fbaWarehouseCode());
return entity;
}
@@ -37,6 +37,7 @@ CREATE TABLE IF NOT EXISTS wh_pallet (
unit_price DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '托盘单价',
status VARCHAR(20) NOT NULL DEFAULT 'IDLE' COMMENT '托盘状态:IDLE闲置、OCCUPIED占用、SCRAPPED废弃',
remark VARCHAR(500) DEFAULT NULL COMMENT '备注',
fba_warehouse_code VARCHAR(128) DEFAULT NULL COMMENT '所属FBA仓编码',
created_by BIGINT UNSIGNED DEFAULT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED DEFAULT NULL COMMENT '最后修改人ID',
@@ -49,6 +50,14 @@ CREATE TABLE IF NOT EXISTS wh_pallet (
KEY idx_pallet_location (customer_id, warehouse_id, location_id, deleted)
) ENGINE=InnoDB COMMENT='仓库托盘表:按租户和仓库维护托盘、容量、单价及状态';
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='wh_pallet' AND column_name='fba_warehouse_code'),
'SELECT 1',
'ALTER TABLE wh_pallet ADD COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT ''所属FBA仓编码'' AFTER remark'));
PREPARE pallet_fba_warehouse_stmt FROM @ddl;
EXECUTE pallet_fba_warehouse_stmt;
DEALLOCATE PREPARE pallet_fba_warehouse_stmt;
CREATE TABLE IF NOT EXISTS wh_resource_lock (
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户租户ID',
@@ -193,6 +202,7 @@ ALTER TABLE wh_pallet
MODIFY COLUMN unit_price DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '托盘单价',
MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'IDLE' COMMENT '托盘状态:IDLE闲置、OCCUPIED占用、SCRAPPED废弃',
MODIFY COLUMN remark VARCHAR(500) NULL COMMENT '备注',
MODIFY COLUMN fba_warehouse_code VARCHAR(128) NULL COMMENT '所属FBA仓编码',
MODIFY COLUMN created_by BIGINT UNSIGNED NULL COMMENT '创建人ID',
MODIFY COLUMN created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
MODIFY COLUMN updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID',
@@ -3,8 +3,10 @@ package com.baiyue.warehouse.controller;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.warehouse.dto.WarehouseRequests;
import com.baiyue.warehouse.security.OverseasWarehousePermissions;
import com.baiyue.warehouse.service.WarehouseApplicationService;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
@@ -13,8 +15,20 @@ import java.util.Set;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.junit.jupiter.api.Assertions.assertEquals;
class PlatformWarehouseControllerTest {
@Test
void platformPageAllowsWarehousePalletAndLocationListAuthorities() throws Exception {
PreAuthorize preAuthorize = PlatformWarehouseController.class
.getMethod("page", AuthenticatedUser.class, WarehouseRequests.QueryRequest.class)
.getAnnotation(PreAuthorize.class);
assertEquals("hasAnyAuthority('" + OverseasWarehousePermissions.WAREHOUSE_LIST + "', '"
+ OverseasWarehousePermissions.PALLET_LIST + "', '" + OverseasWarehousePermissions.LOCATION_LIST + "')",
preAuthorize.value());
}
@Test
void platformPageQueriesAllWarehousesWithoutCustomerId() {
WarehouseApplicationService service = mock(WarehouseApplicationService.class);
@@ -267,6 +267,44 @@ class WarehouseResourceServiceTest {
.hasMessageContaining("cannot be reused");
}
@Test
void updatePalletChangesCodeAndFbaWarehouseCode() {
when(repository.warehouseOwned(TENANT, WAREHOUSE)).thenReturn(true);
WarehousePalletEntity existing = new WarehousePalletEntity();
existing.setId(40003L);
existing.setPalletCode("PLT-OLD");
existing.setStatus("IDLE");
when(repository.findPallet(TENANT, WAREHOUSE, 40003L)).thenReturn(existing);
service.updatePallet(TENANT, WAREHOUSE, 40003L,
new PalletRequests.SaveRequest("PLT-NEW", "WOOD", new BigDecimal("2.5000"),
new BigDecimal("25.00"), "IDLE", null, "updated", "ONT8"));
ArgumentCaptor<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() {
WarehouseEntity warehouse = new WarehouseEntity();
warehouse.setId(WAREHOUSE);