This commit is contained in:
2026-08-21 14:42:40 +08:00
parent 5fd00d3151
commit e534784052
20 changed files with 274 additions and 34 deletions
@@ -15914,6 +15914,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),
@@ -15970,6 +15972,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 出库拣货、派送和其他库存离仓能力虽然已存在于服务代码或表结构中,但不属于当前四期开发与验收范围,不得因代码已存在而自动纳入。
- 历史排车编辑/取消/出库、PDA 出库和 `delivery/` Controller 默认不注册;只有后续阶段明确启用 `lastleg.future-outbound-api.enabled=true` 才可恢复。平台排车列表与详情由 `lastleg.dispatch-query-api.enabled=true` 独立启用;入库单智能排车候选和预排创建由 `lastleg.smart-dispatch-api.enabled=true` 独立启用。四个排车平台入口均不接收 `customerId`,服务端从尾程库排车/入仓数据解析唯一客户范围,检测到多客户时拒绝请求;预排只能创建待出库排车并锁定托盘,不得借此开放编辑、取消、depart、PDA 出库或派送。四期平台库存 Controller 不映射 allocate、unallocate、pick、ship。 - 历史排车编辑/取消/出库、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/`,本服务不得复制仓储资源主数据或另建公网认证入口。
## 数据库表索引与注释约束 ## 数据库表索引与注释约束
@@ -38,23 +38,24 @@ public class SmartDispatchController {
description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST) description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST)
public ApiResponse<List<DispatchPalletCandidate>> candidates( public ApiResponse<List<DispatchPalletCandidate>> candidates(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@RequestParam List<Long> planIds,
@RequestParam(required = false) String orderNo, @RequestParam(required = false) String orderNo,
@RequestParam(required = false) String fbaWarehouseCode, @RequestParam(required = false) String fbaWarehouseCode,
@RequestParam(required = false) String sourceStage) { @RequestParam(required = false) String sourceStage) {
requirePlatform(user); requirePlatform(user);
return ApiResponse.success(service.candidates(service.resolveSingleCustomerScope(), orderNo, return ApiResponse.success(service.candidates(service.resolveCustomerScopeForPlans(planIds), planIds, orderNo,
fbaWarehouseCode, sourceStage)); fbaWarehouseCode, sourceStage));
} }
@PostMapping("/pre-arrange") @PostMapping("/pre-arrange")
@PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')") @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')")
@Operation(summary = "Create a pending-departure dispatch and lock all selected pallets", @Operation(summary = "Create a pending-departure dispatch and lock all selected pallets",
description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_EDIT) description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH)
public ApiResponse<DispatchResponse> preArrange( public ApiResponse<DispatchResponse> preArrange(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody DispatchRequests.PreArrange request) { @Valid @RequestBody DispatchRequests.PreArrange request) {
requirePlatform(user); requirePlatform(user);
String scopedCustomer = service.resolveSingleCustomerScope(); String scopedCustomer = service.resolveCustomerScopeForPallets(request.planPalletIds());
String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId()); String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId());
return ApiResponse.success(service.preArrange(scopedCustomer, operatorId, request)); return ApiResponse.success(service.preArrange(scopedCustomer, operatorId, request));
} }
@@ -15,6 +15,21 @@ public interface DispatchCandidateMapper {
"ORDER BY customer_id LIMIT 2") "ORDER BY customer_id LIMIT 2")
List<String> findCustomerScopes(); 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," +
@@ -31,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 " +
@@ -50,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);
@@ -50,13 +50,26 @@ public class DispatchService {
return customerIds.isEmpty() ? null : customerIds.get(0); return customerIds.isEmpty() ? null : customerIds.get(0);
} }
public List<DispatchPalletCandidate> candidates(String customerId, String orderNo, 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");
} }
if (!StringUtils.hasText(customerId)) return List.of(); if (!StringUtils.hasText(customerId)) return List.of();
return candidates.available(customerId, trimToNull(orderNo), trimToNull(fbaWarehouseCode), trimToNull(sourceStage)); List<Long> scopedPlanIds = normalizeIds(planIds, "Plan IDs");
return candidates.available(customerId, scopedPlanIds, trimToNull(orderNo), trimToNull(fbaWarehouseCode),
trimToNull(sourceStage));
} }
@Transactional @Transactional
@@ -278,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())
@@ -3,6 +3,27 @@ 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: /api/platform/v1/last-leg/dispatches:
get: get:
summary: 分页查询排车单 summary: 分页查询排车单
@@ -35,6 +56,13 @@ paths:
description: 权限 platform:overseas-warehouse:inbound-order:list;服务端解析唯一客户范围,仅返回尚未被有效排车锁定的计划或已实收托盘,前端不传 customerId。 description: 权限 platform:overseas-warehouse:inbound-order:list;服务端解析唯一客户范围,仅返回尚未被有效排车锁定的计划或已实收托盘,前端不传 customerId。
security: [{ bearerAuth: [] }] security: [{ bearerAuth: [] }]
parameters: 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: orderNo, in: query, schema: { type: string } }
- { name: fbaWarehouseCode, in: query, schema: { type: string } } - { name: fbaWarehouseCode, in: query, schema: { type: string } }
- { name: sourceStage, in: query, schema: { type: string, enum: [PLAN, RECEIVED] } } - { name: sourceStage, in: query, schema: { type: string, enum: [PLAN, RECEIVED] } }
@@ -45,7 +73,7 @@ paths:
/api/platform/v1/last-leg/dispatches/pre-arrange: /api/platform/v1/last-leg/dispatches/pre-arrange:
post: post:
summary: 创建智能预排车单 summary: 创建智能预排车单
description: 权限 platform:overseas-warehouse:inbound-order:edit;服务端解析唯一客户范围,锁定同一海外仓的所选托盘并创建 PENDING_DEPARTURE 排车单,前端不传 customerId;不开放编辑、取消、depart、PDA 出库或派送。 description: 权限 platform:overseas-warehouse:inbound-order:smart-dispatch;服务端根据所选托盘解析并校验唯一客户范围,锁定同一海外仓的托盘并创建 PENDING_DEPARTURE 排车单,前端不传 customerId;不开放编辑、取消、depart、PDA 出库或派送。
security: [{ bearerAuth: [] }] security: [{ bearerAuth: [] }]
requestBody: requestBody:
required: true required: true
@@ -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() {
@@ -17,9 +17,11 @@ class SmartDispatchOpenApiContractTest {
assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/candidates:")); assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/candidates:"));
assertTrue(yaml.contains("/api/platform/v1/last-leg/dispatches/pre-arrange:")); 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:list"));
assertTrue(yaml.contains("platform:overseas-warehouse:inbound-order:edit")); assertTrue(yaml.contains("platform:overseas-warehouse:inbound-order:smart-dispatch"));
assertTrue(yaml.contains("SmartDispatchPreArrange")); assertTrue(yaml.contains("SmartDispatchPreArrange"));
assertTrue(yaml.contains("SmartDispatchCandidate")); assertTrue(yaml.contains("SmartDispatchCandidate"));
assertTrue(yaml.contains("name: planIds"));
assertTrue(yaml.contains("required: true"));
String dispatchContract = yaml.substring(0, String dispatchContract = yaml.substring(0,
yaml.indexOf("/api/platform/v1/last-leg/overseas-procurements:")); yaml.indexOf("/api/platform/v1/last-leg/overseas-procurements:"));
assertFalse(dispatchContract.contains("name: customerId")); assertFalse(dispatchContract.contains("name: customerId"));
@@ -31,20 +31,20 @@ class SmartDispatchControllerTest {
} }
@Test @Test
void candidatesResolveSingleCustomerScopeWithoutRequestParameter() { void candidatesResolveCustomerScopeFromSelectedPlans() {
when(service.resolveSingleCustomerScope()).thenReturn("100"); when(service.resolveCustomerScopeForPlans(List.of(1L, 2L))).thenReturn("100");
when(service.candidates("100", "LMO-1", "ONT8", "PLAN")).thenReturn(List.of()); when(service.candidates("100", List.of(1L, 2L), "LMO-1", "ONT8", "PLAN")).thenReturn(List.of());
controller.candidates(platform(), "LMO-1", "ONT8", "PLAN"); controller.candidates(platform(), List.of(1L, 2L), "LMO-1", "ONT8", "PLAN");
verify(service).candidates("100", "LMO-1", "ONT8", "PLAN"); verify(service).candidates("100", List.of(1L, 2L), "LMO-1", "ONT8", "PLAN");
} }
@Test @Test
void preArrangeResolvesSingleCustomerAndAuthenticatedOperator() { void preArrangeResolvesSingleCustomerAndAuthenticatedOperator() {
DispatchRequests.PreArrange request = new DispatchRequests.PreArrange( DispatchRequests.PreArrange request = new DispatchRequests.PreArrange(
List.of(10L, 11L), "ONT8 -> LAX9", "APT-1", "SUCCESS", "PENDING"); List.of(10L, 11L), "ONT8 -> LAX9", "APT-1", "SUCCESS", "PENDING");
when(service.resolveSingleCustomerScope()).thenReturn("100"); when(service.resolveCustomerScopeForPallets(List.of(10L, 11L))).thenReturn("100");
controller.preArrange(platform(), request); controller.preArrange(platform(), request);
@@ -52,20 +52,20 @@ class SmartDispatchControllerTest {
} }
@Test @Test
void endpointsReuseInboundOrderListAndEditAuthorities() throws Exception { void endpointsUseInboundOrderListAndDedicatedSmartDispatchAuthorities() throws Exception {
PreAuthorize candidates = SmartDispatchController.class PreAuthorize candidates = SmartDispatchController.class
.getMethod("candidates", AuthenticatedUser.class, String.class, String.class, String.class) .getMethod("candidates", AuthenticatedUser.class, List.class, String.class, String.class, String.class)
.getAnnotation(PreAuthorize.class); .getAnnotation(PreAuthorize.class);
PreAuthorize preArrange = SmartDispatchController.class PreAuthorize preArrange = SmartDispatchController.class
.getMethod("preArrange", AuthenticatedUser.class, DispatchRequests.PreArrange.class) .getMethod("preArrange", AuthenticatedUser.class, DispatchRequests.PreArrange.class)
.getAnnotation(PreAuthorize.class); .getAnnotation(PreAuthorize.class);
assertThat(candidates.value()).contains(OverseasPermissions.INBOUND_ORDER_LIST); assertThat(candidates.value()).contains(OverseasPermissions.INBOUND_ORDER_LIST);
assertThat(preArrange.value()).contains(OverseasPermissions.INBOUND_ORDER_EDIT); assertThat(preArrange.value()).contains(OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH);
} }
private static AuthenticatedUser platform() { private static AuthenticatedUser platform() {
return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB", return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB",
Set.of(OverseasPermissions.INBOUND_ORDER_LIST, OverseasPermissions.INBOUND_ORDER_EDIT)); Set.of(OverseasPermissions.INBOUND_ORDER_LIST, OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH));
} }
} }
@@ -82,8 +82,28 @@ 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 @Test
@@ -99,8 +119,8 @@ class DispatchServiceTest {
@Test @Test
void emptyCustomerScopeProducesEmptyCandidatesAndRejectsPreArrange() { void emptyCustomerScopeProducesEmptyCandidatesAndRejectsPreArrange() {
assertEquals(List.of(), service.candidates(null, null, null, null)); assertEquals(List.of(), service.candidates(null, null, null, null, null));
verify(candidates, never()).available(any(), any(), any(), any()); verify(candidates, never()).available(any(), any(), any(), any(), any());
ResponseStatusException exception = assertThrows(ResponseStatusException.class, ResponseStatusException exception = assertThrows(ResponseStatusException.class,
() -> service.preArrange(null, "7", () -> service.preArrange(null, "7",
@@ -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); }
} }