Compare commits

...
Author SHA1 Message Date
ouyangxq a563f63857 feat(appointments): expose destination warehouse options 2026-10-19 17:02:25 +08:00
5 changed files with 62 additions and 0 deletions
@@ -35,6 +35,11 @@ public class AppointmentCargoService {
return new PageResult<>(rows, page, size, total, (total + size - 1) / size);
}
/** 查询下拉框与货物分页共用权威目录范围,不从当前页猜测目的仓。 */
public List<String> destinations() {
return scope().destinations().stream().distinct().sorted().toList();
}
private record Scope(List<String> origins, List<String> destinations) { }
private Scope scope() {
final List<AppointmentWarehouseClient.Warehouse> catalog;
@@ -53,6 +53,14 @@ public class AppointmentPlanningController {
return ApiResponse.success(new Resolved(source,result.get(0)));
}
/** 目的仓选项沿用货物查询权限,只返回同范围的美国FBA仓码。 */
@GetMapping("/cargo/destinations")
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')")
public ApiResponse<java.util.List<String>> destinations(@AuthenticationPrincipal AuthenticatedUser user) {
requirePlatform(user);
return ApiResponse.success(service.destinations());
}
@GetMapping("/cargo")
@PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')")
public ApiResponse<PageResult<AppointmentCargo>> cargo(@AuthenticationPrincipal AuthenticatedUser user,
@@ -6,6 +6,26 @@ info:
security:
- bearerAuth: []
paths:
/api/platform/v1/last-leg/appointment-planning/cargo/destinations:
get:
operationId: listAppointmentCargoDestinations
summary: 读取预约货物查询的目的FBA仓选项
description: >-
仅平台;同时要求platform:appointment:list与platform:overseas-warehouse:last-mile-order:list。
与货物分页共用权威启用美国FBA目录,返回去重、按仓码排序的完整选项,不受货物当前分页或关键词限制。
不增加仓库主档读取权限;目录失败返回503,不当作空选项。
responses:
'200':
description: 业务响应;data为目的FBA仓码数组
content:
application/json:
schema:
type: object
properties:
code: {type: string}
data: {type: array, items: {type: string}, example: [ABE4, ONT8]}
'403': {description: 非平台或缺少任一读取权限}
'503': {description: 权威目录不可用}
/api/platform/v1/last-leg/appointment-planning/sources/resolve-order:
get:
operationId: resolveOrderAppointmentSource
@@ -8,6 +8,21 @@ import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class AppointmentCargoServiceTest {
/** 选项取完整权威美国FBA目录,不依赖当前页货物或新增仓库主档权限。 */
@Test void destinationOptionsUseTheSameAmericanFbaScopeWithoutQueryingCargoRows() {
var mapper = mock(AppointmentCargoMapper.class);
var warehouses = mock(AppointmentWarehouseClient.class);
when(warehouses.catalog("token")).thenReturn(ApiResponse.success(List.of(
new AppointmentWarehouseClient.Warehouse(1L,"US-WH","美国仓","OVERSEAS","US"),
new AppointmentWarehouseClient.Warehouse(2L,"ONT8","ONT8","FBA","US"),
new AppointmentWarehouseClient.Warehouse(3L,"ABE4","ABE4","FBA","US"),
new AppointmentWarehouseClient.Warehouse(4L,"YYZ1","YYZ1","FBA","CA"),
new AppointmentWarehouseClient.Warehouse(5L,"ONT8","ONT8","FBA","US"))));
assertEquals(List.of("ABE4","ONT8"),new AppointmentCargoService(mapper,warehouses,"token").destinations());
verifyNoInteractions(mapper);
when(warehouses.catalog("token")).thenThrow(new IllegalStateException("offline"));
assertEquals(503,assertThrows(ResponseStatusException.class,()->new AppointmentCargoService(mapper,warehouses,"token").destinations()).getStatusCode().value());
}
@Test void authenticUsDirectoryScopesTheDatabaseQueryAndPreservesServerPagination() {
var mapper = mock(AppointmentCargoMapper.class);
var warehouses = mock(AppointmentWarehouseClient.class);
@@ -17,6 +17,20 @@ import static org.mockito.Mockito.*;
class AppointmentPlanningAuthorizationTest {
private static final String LIST="platform:appointment:list", CARGO="platform:overseas-warehouse:last-mile-order:list", WRITE="platform:appointment:followup";
/** 目的仓选项不要求仓库管理权限,但保留原两项读取权限和平台身份边界。 */
@Test void destinationOptionsRequireTheExistingCargoReadPermissionsAndPlatformIdentity() {
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
var controller=context.getBean(AppointmentPlanningController.class);
var service=context.getBean(AppointmentCargoService.class);
login(LIST);assertThrows(AccessDeniedException.class,()->controller.destinations(user(0L,"PLATFORM")));
login(CARGO);assertThrows(AccessDeniedException.class,()->controller.destinations(user(0L,"PLATFORM")));
login(LIST,CARGO);
assertThrows(ResponseStatusException.class,()->controller.destinations(user(1L,"CLIENT")));
verifyNoInteractions(service);
when(service.destinations()).thenReturn(List.of("ABE4","ONT8"));
assertEquals(List.of("ABE4","ONT8"),controller.destinations(user(0L,"PLATFORM")).data());
} finally { SecurityContextHolder.clearContext(); }
}
@Test void orderLookupUsesCanonicalBookingSourceAndEnforcesCustomerAndReadPermissions() {
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
var controller=context.getBean(AppointmentPlanningController.class);