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