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