Merge remote-tracking branch 'origin/test' into dev
This commit is contained in:
@@ -230,9 +230,9 @@ SELECT
|
||||
## 海外排车、出库与派送权限
|
||||
|
||||
- 国内固定入口已在海外仓业务 `lastleg-permissions.sql` 权限段之后完整内联 `iam/src/main/resources/db/phase5-delivery-permissions.sql`;测试和部署只执行固定入口,不再单独执行模块脚本。
|
||||
- 平台端固定使用排车列表/编辑/取消/转派送、出库列表/创建/取消、派送列表/退回/状态、异常订单列表/创建和客户回访列表/编辑十四项权限;列表权限包含详情及只读辅助接口,写操作保持独立授权。
|
||||
- 平台端固定使用排车列表/编辑/取消/转派送、出库列表/创建/取消、派送列表/退回/状态、异常订单列表/创建/处理和客户回访列表/编辑十五项权限;列表权限包含详情及只读辅助接口,写操作保持独立授权。
|
||||
- 客户端固定使用 `merchant:warehouse:delivery:list` 和 `merchant:warehouse:delivery:create`;客户默认管理员获得两项权限,其他客户角色不自动扩权。
|
||||
- `WAREHOUSE_OPERATOR` 获得十四项后台作业权限,`WAREHOUSE_TALLY` 只获得排车、出库、派送、异常订单和客户回访五项列表只读;国内、海外 PDA 岗位不得获得上述后台权限。
|
||||
- `WAREHOUSE_OPERATOR` 获得十五项后台作业权限,`WAREHOUSE_TALLY` 只获得排车、出库、派送、异常订单和客户回访五项列表只读;国内、海外 PDA 岗位不得获得上述后台权限。
|
||||
|
||||
## 海外排车与出库结构升级
|
||||
|
||||
|
||||
@@ -16966,6 +16966,7 @@ BEGIN
|
||||
OR (id = 5107093 AND permission_code <> 'platform:overseas-warehouse:outbound:cancel')
|
||||
OR (id = 5107070 AND permission_code <> 'platform:overseas-warehouse:exception-order:list')
|
||||
OR (id = 5107071 AND permission_code <> 'platform:overseas-warehouse:exception-order:create')
|
||||
OR (id = 5107073 AND permission_code <> 'platform:overseas-warehouse:exception-order:process')
|
||||
OR (id = 5107072 AND permission_code <> 'platform:overseas-warehouse:delivery:return')
|
||||
OR (id = 5107080 AND permission_code <> 'platform:overseas-warehouse:customer-visit:list')
|
||||
OR (id = 5107081 AND permission_code <> 'platform:overseas-warehouse:customer-visit:edit')
|
||||
@@ -16984,6 +16985,7 @@ BEGIN
|
||||
OR (permission_code = 'platform:overseas-warehouse:outbound:cancel' AND id <> 5107093)
|
||||
OR (permission_code = 'platform:overseas-warehouse:exception-order:list' AND id <> 5107070)
|
||||
OR (permission_code = 'platform:overseas-warehouse:exception-order:create' AND id <> 5107071)
|
||||
OR (permission_code = 'platform:overseas-warehouse:exception-order:process' AND id <> 5107073)
|
||||
OR (permission_code = 'platform:overseas-warehouse:delivery:return' AND id <> 5107072)
|
||||
OR (permission_code = 'platform:overseas-warehouse:customer-visit:list' AND id <> 5107080)
|
||||
OR (permission_code = 'platform:overseas-warehouse:customer-visit:edit' AND id <> 5107081)
|
||||
@@ -17008,6 +17010,7 @@ VALUES
|
||||
(5107018,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:transfer-delivery','排车转派送',NULL,'POST',718,'ACTIVE',1,0),
|
||||
(5107070,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:exception-order:list','海外异常订单','/overseas-warehouse/exception-orders','GET',770,'ACTIVE',1,0),
|
||||
(5107071,0,'PLATFORM','BUTTON',5107070,'platform:overseas-warehouse:exception-order:create','新增海外异常订单',NULL,'POST',771,'ACTIVE',1,0),
|
||||
(5107073,0,'PLATFORM','BUTTON',5107070,'platform:overseas-warehouse:exception-order:process','处理海外异常订单',NULL,'POST',773,'ACTIVE',1,0),
|
||||
(5107072,0,'PLATFORM','BUTTON',5107092,'platform:overseas-warehouse:delivery:return','派送单标记退货',NULL,'POST',772,'ACTIVE',1,0),
|
||||
(5107080,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:customer-visit:list','客户回访','/overseas-warehouse/customer-visits','GET',780,'ACTIVE',1,0),
|
||||
(5107081,0,'PLATFORM','BUTTON',5107080,'platform:overseas-warehouse:customer-visit:edit','保存客户回访',NULL,'PUT',781,'ACTIVE',1,0),
|
||||
@@ -17043,6 +17046,7 @@ SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3)
|
||||
'platform:overseas-warehouse:delivery:list',
|
||||
'platform:overseas-warehouse:exception-order:list',
|
||||
'platform:overseas-warehouse:exception-order:create',
|
||||
'platform:overseas-warehouse:exception-order:process',
|
||||
'platform:overseas-warehouse:delivery:return',
|
||||
'platform:overseas-warehouse:delivery:status',
|
||||
'platform:overseas-warehouse:customer-visit:list',
|
||||
@@ -17067,6 +17071,7 @@ INSERT INTO tmp_phase5_delivery_role_permission (role_code, permission_code) VAL
|
||||
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:list'),
|
||||
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:exception-order:list'),
|
||||
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:exception-order:create'),
|
||||
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:exception-order:process'),
|
||||
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:return'),
|
||||
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:status'),
|
||||
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:customer-visit:list'),
|
||||
@@ -17095,6 +17100,7 @@ WHERE role.tenant_id=0 AND role.app_code='PLATFORM'
|
||||
'platform:overseas-warehouse:delivery:list',
|
||||
'platform:overseas-warehouse:exception-order:list',
|
||||
'platform:overseas-warehouse:exception-order:create',
|
||||
'platform:overseas-warehouse:exception-order:process',
|
||||
'platform:overseas-warehouse:delivery:return',
|
||||
'platform:overseas-warehouse:delivery:status',
|
||||
'platform:overseas-warehouse:customer-visit:list',
|
||||
@@ -17142,12 +17148,13 @@ BEGIN
|
||||
'platform:overseas-warehouse:delivery:list',
|
||||
'platform:overseas-warehouse:exception-order:list',
|
||||
'platform:overseas-warehouse:exception-order:create',
|
||||
'platform:overseas-warehouse:exception-order:process',
|
||||
'platform:overseas-warehouse:delivery:return',
|
||||
'platform:overseas-warehouse:delivery:status',
|
||||
'platform:overseas-warehouse:customer-visit:list',
|
||||
'platform:overseas-warehouse:customer-visit:edit',
|
||||
'merchant:warehouse:delivery:list',
|
||||
'merchant:warehouse:delivery:create')) <> 16 THEN
|
||||
'merchant:warehouse:delivery:create')) <> 17 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:权限目录不完整';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM iam_role_permission grant_row
|
||||
@@ -17165,10 +17172,11 @@ BEGIN
|
||||
'platform:overseas-warehouse:delivery:list',
|
||||
'platform:overseas-warehouse:exception-order:list',
|
||||
'platform:overseas-warehouse:exception-order:create',
|
||||
'platform:overseas-warehouse:exception-order:process',
|
||||
'platform:overseas-warehouse:delivery:return',
|
||||
'platform:overseas-warehouse:delivery:status',
|
||||
'platform:overseas-warehouse:customer-visit:list',
|
||||
'platform:overseas-warehouse:customer-visit:edit')) <> 14 THEN
|
||||
'platform:overseas-warehouse:customer-visit:edit')) <> 15 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:平台管理员授权不完整';
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM iam_role role
|
||||
@@ -17189,17 +17197,18 @@ BEGIN
|
||||
'platform:overseas-warehouse:delivery:list',
|
||||
'platform:overseas-warehouse:exception-order:list',
|
||||
'platform:overseas-warehouse:exception-order:create',
|
||||
'platform:overseas-warehouse:exception-order:process',
|
||||
'platform:overseas-warehouse:delivery:return',
|
||||
'platform:overseas-warehouse:delivery:status',
|
||||
'platform:overseas-warehouse:customer-visit:list',
|
||||
'platform:overseas-warehouse:customer-visit:edit')) <> 14) THEN
|
||||
'platform:overseas-warehouse:customer-visit:edit')) <> 15) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:平台自定义管理员授权不完整';
|
||||
END IF;
|
||||
SELECT COALESCE(SUM(role_code='WAREHOUSE_OPERATOR'),0),
|
||||
COALESCE(SUM(role_code='WAREHOUSE_TALLY'),0)
|
||||
INTO v_warehouse_operator_permission_count, v_warehouse_tally_permission_count
|
||||
FROM tmp_phase5_delivery_role_permission;
|
||||
IF v_warehouse_operator_permission_count <> 14
|
||||
IF v_warehouse_operator_permission_count <> 15
|
||||
OR v_warehouse_tally_permission_count <> 5 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:仓库岗位最小权限矩阵异常';
|
||||
END IF;
|
||||
@@ -17218,6 +17227,7 @@ BEGIN
|
||||
'platform:overseas-warehouse:delivery:list',
|
||||
'platform:overseas-warehouse:exception-order:list',
|
||||
'platform:overseas-warehouse:exception-order:create',
|
||||
'platform:overseas-warehouse:exception-order:process',
|
||||
'platform:overseas-warehouse:delivery:return',
|
||||
'platform:overseas-warehouse:delivery:status',
|
||||
'platform:overseas-warehouse:customer-visit:list',
|
||||
@@ -18287,6 +18297,149 @@ DELIMITER ;
|
||||
CALL assert_terminal_drayage_schema();
|
||||
DROP PROCEDURE IF EXISTS assert_terminal_drayage_schema;
|
||||
|
||||
-- 货柜双工作台权限开始(与 iam/db/container-workspace-permissions.sql 保持一致)
|
||||
-- 国内送柜报关与国外清关提柜双工作台权限;国内沿用现有货柜权限,国外直接收敛到独立权限族。
|
||||
USE baiyue_iam;
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
DROP PROCEDURE IF EXISTS migrate_container_workspace_permissions;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE migrate_container_workspace_permissions()
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM iam_permission
|
||||
WHERE (id = 5102090 AND permission_code <> 'platform:firstleg:container:list')
|
||||
OR (id = 5102092 AND permission_code <> 'platform:firstleg:container:create')
|
||||
OR (id = 5102093 AND permission_code <> 'platform:firstleg:container:edit')
|
||||
OR (id = 5102200 AND permission_code <> 'platform:firstleg:terminal-drayage:list')
|
||||
OR (id = 5102201 AND permission_code <> 'platform:firstleg:terminal-drayage:document:edit')
|
||||
OR (id = 5102202 AND permission_code <> 'platform:firstleg:terminal-drayage:fleet:edit')
|
||||
OR (id = 5102203 AND permission_code <> 'platform:firstleg:terminal-drayage:pickup-appointment:edit')
|
||||
OR (id = 5102204 AND permission_code <> 'platform:firstleg:terminal-drayage:pickup:confirm')
|
||||
OR (id = 5102205 AND permission_code <> 'platform:firstleg:terminal-drayage:empty-return:confirm')
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM iam_permission
|
||||
WHERE (permission_code = 'platform:firstleg:container:list' AND id <> 5102090)
|
||||
OR (permission_code = 'platform:firstleg:container:create' AND id <> 5102092)
|
||||
OR (permission_code = 'platform:firstleg:container:edit' AND id <> 5102093)
|
||||
OR (permission_code = 'platform:firstleg:terminal-drayage:list' AND id <> 5102200)
|
||||
OR (permission_code = 'platform:firstleg:terminal-drayage:document:edit' AND id <> 5102201)
|
||||
OR (permission_code = 'platform:firstleg:terminal-drayage:fleet:edit' AND id <> 5102202)
|
||||
OR (permission_code = 'platform:firstleg:terminal-drayage:pickup-appointment:edit' AND id <> 5102203)
|
||||
OR (permission_code = 'platform:firstleg:terminal-drayage:pickup:confirm' AND id <> 5102204)
|
||||
OR (permission_code = 'platform:firstleg:terminal-drayage:empty-return:confirm' AND id <> 5102205)
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:权限ID或编码冲突';
|
||||
END IF;
|
||||
|
||||
INSERT INTO iam_permission
|
||||
(id, tenant_id, app_code, permission_type, parent_id, permission_code,
|
||||
permission_name, route_path, http_method, sort_no, status, built_in, deleted)
|
||||
VALUES
|
||||
(5102090,0,'PLATFORM','MENU',5102000,'platform:firstleg:container:list',
|
||||
'国内送柜报关','/container-loading/domestic','GET',40,'ACTIVE',1,0),
|
||||
(5102092,0,'PLATFORM','BUTTON',5102090,'platform:firstleg:container:create',
|
||||
'建立国内货柜',NULL,'POST',20,'ACTIVE',1,0),
|
||||
(5102093,0,'PLATFORM','BUTTON',5102090,'platform:firstleg:container:edit',
|
||||
'维护国内货柜资料',NULL,'PUT',30,'ACTIVE',1,0),
|
||||
(5102200,0,'PLATFORM','MENU',5102000,'platform:firstleg:terminal-drayage:list',
|
||||
'国外清关提柜','/container-loading/overseas','GET',41,'ACTIVE',1,0),
|
||||
(5102201,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:document:edit',
|
||||
'维护目的港清关与DO/PU',NULL,'POST',10,'ACTIVE',1,0),
|
||||
(5102202,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:fleet:edit',
|
||||
'维护目的港码头车队',NULL,'POST',20,'ACTIVE',1,0),
|
||||
(5102203,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:pickup-appointment:edit',
|
||||
'预约目的港提柜',NULL,'POST',30,'ACTIVE',1,0),
|
||||
(5102204,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:pickup:confirm',
|
||||
'确认目的港实际提柜',NULL,'POST',40,'ACTIVE',1,0),
|
||||
(5102205,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:empty-return:confirm',
|
||||
'确认目的港实际还柜',NULL,'POST',50,'ACTIVE',1,0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
permission_type=VALUES(permission_type), parent_id=VALUES(parent_id),
|
||||
permission_name=VALUES(permission_name), route_path=VALUES(route_path),
|
||||
http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE',
|
||||
built_in=1, deleted=0;
|
||||
|
||||
-- list 已包含详情只读;旧 container:view 不再作为别名或双权限入口。
|
||||
DELETE grant_row FROM iam_role_permission grant_row
|
||||
JOIN iam_permission permission
|
||||
ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id
|
||||
WHERE permission.tenant_id=0
|
||||
AND permission.permission_code='platform:firstleg:container:view';
|
||||
UPDATE iam_permission
|
||||
SET status='INACTIVE', deleted=1
|
||||
WHERE tenant_id=0 AND permission_code='platform:firstleg:container:view';
|
||||
|
||||
-- 平台超级管理员和现有订单运营保持货柜全链路能力;其他角色按两个菜单独立授权。
|
||||
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
|
||||
SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(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:firstleg:container:list',
|
||||
'platform:firstleg:container:create',
|
||||
'platform:firstleg:container:edit',
|
||||
'platform:firstleg:terminal-drayage:list',
|
||||
'platform:firstleg:terminal-drayage:document:edit',
|
||||
'platform:firstleg:terminal-drayage:fleet:edit',
|
||||
'platform:firstleg:terminal-drayage:pickup-appointment:edit',
|
||||
'platform:firstleg:terminal-drayage:pickup:confirm',
|
||||
'platform:firstleg:terminal-drayage:empty-return:confirm')
|
||||
WHERE role.tenant_id=0 AND role.app_code='PLATFORM'
|
||||
AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')
|
||||
AND role.status='ACTIVE' AND role.deleted=0
|
||||
AND permission.status='ACTIVE' AND permission.deleted=0;
|
||||
|
||||
IF (SELECT COUNT(*) FROM iam_permission
|
||||
WHERE tenant_id=0 AND app_code='PLATFORM' AND status='ACTIVE' AND deleted=0
|
||||
AND permission_code IN (
|
||||
'platform:firstleg:container:list',
|
||||
'platform:firstleg:container:create',
|
||||
'platform:firstleg:container:edit',
|
||||
'platform:firstleg:terminal-drayage:list',
|
||||
'platform:firstleg:terminal-drayage:document:edit',
|
||||
'platform:firstleg:terminal-drayage:fleet:edit',
|
||||
'platform:firstleg:terminal-drayage:pickup-appointment:edit',
|
||||
'platform:firstleg:terminal-drayage:pickup:confirm',
|
||||
'platform:firstleg:terminal-drayage:empty-return:confirm')) <> 9 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:权限目录不完整';
|
||||
END IF;
|
||||
|
||||
IF (SELECT COUNT(DISTINCT CONCAT(role.role_code, ':', permission.permission_code))
|
||||
FROM iam_role_permission grant_row
|
||||
JOIN iam_role role
|
||||
ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id
|
||||
JOIN iam_permission permission
|
||||
ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id
|
||||
WHERE role.tenant_id=0 AND role.app_code='PLATFORM'
|
||||
AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')
|
||||
AND permission.permission_code IN (
|
||||
'platform:firstleg:container:list',
|
||||
'platform:firstleg:container:create',
|
||||
'platform:firstleg:container:edit',
|
||||
'platform:firstleg:terminal-drayage:list',
|
||||
'platform:firstleg:terminal-drayage:document:edit',
|
||||
'platform:firstleg:terminal-drayage:fleet:edit',
|
||||
'platform:firstleg:terminal-drayage:pickup-appointment:edit',
|
||||
'platform:firstleg:terminal-drayage:pickup:confirm',
|
||||
'platform:firstleg:terminal-drayage:empty-return:confirm')) <> 18 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:平台默认角色授权不完整';
|
||||
END IF;
|
||||
|
||||
IF EXISTS (SELECT 1 FROM iam_permission
|
||||
WHERE tenant_id=0 AND permission_code='platform:firstleg:container:view'
|
||||
AND (status='ACTIVE' OR deleted=0)) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:旧查看权限仍然有效';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
CALL migrate_container_workspace_permissions();
|
||||
DROP PROCEDURE IF EXISTS migrate_container_workspace_permissions;
|
||||
-- 货柜双工作台权限结束,恢复头程数据库上下文
|
||||
USE baiyue_firstleg;
|
||||
|
||||
CALL baiyue_firstleg.assert_unified_final_schema();
|
||||
DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema;
|
||||
SELECT 'PASS' AS migration_status,
|
||||
|
||||
+59
-22
@@ -6,10 +6,12 @@ import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.Query;
|
||||
import com.baiyue.firstleg.terminaldrayage.dto.ContainerWorkbenchDtos.WorkbenchItem;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest;
|
||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||
import com.baiyue.firstleg.service.ContainerService;
|
||||
import com.baiyue.firstleg.terminaldrayage.service.ContainerWorkbenchService;
|
||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||
import com.baiyue.firstleg.service.LoadingListService;
|
||||
import com.baiyue.firstleg.dto.LoadingListDtos.BusinessDocument;
|
||||
@@ -39,21 +41,50 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
@RequestMapping("/api/platform/v1/containers")
|
||||
public class PlatformContainerController {
|
||||
@Resource private ContainerService service;
|
||||
@Resource private ContainerWorkbenchService workbenches;
|
||||
@Resource private LoadingListService loadingLists;
|
||||
@Resource private ContainerOverviewService overview;
|
||||
@GetMapping @PreAuthorize("hasAuthority('platform:firstleg:container:list')")
|
||||
public ApiResponse<PageResult<ContainerBookingEntity>> page(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid Query query) {
|
||||
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.page(query));
|
||||
@GetMapping("/domestic") @PreAuthorize("hasAuthority('platform:firstleg:container:list')")
|
||||
public ApiResponse<PageResult<WorkbenchItem>> domesticPage(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid Query query) {
|
||||
FirstlegScopeGuard.platform(user); return ApiResponse.success(workbenches.domesticPage(query, user.permissions()));
|
||||
}
|
||||
@GetMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:container:list')")
|
||||
public ApiResponse<ContainerBookingEntity> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long id) {
|
||||
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(id));
|
||||
@GetMapping("/overseas") @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')")
|
||||
public ApiResponse<PageResult<WorkbenchItem>> overseasPage(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid Query query) {
|
||||
FirstlegScopeGuard.platform(user); return ApiResponse.success(workbenches.overseasPage(query, user.permissions()));
|
||||
}
|
||||
@GetMapping("/domestic/{id}") @PreAuthorize("hasAuthority('platform:firstleg:container:list')")
|
||||
public ApiResponse<WorkbenchItem> domesticDetail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long id) {
|
||||
FirstlegScopeGuard.platform(user); return ApiResponse.success(workbenches.domesticDetail(id, user.permissions()));
|
||||
}
|
||||
@GetMapping("/overseas/{id}") @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')")
|
||||
public ApiResponse<WorkbenchItem> overseasDetail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long id) {
|
||||
FirstlegScopeGuard.platform(user); return ApiResponse.success(workbenches.overseasDetail(id, user.permissions()));
|
||||
}
|
||||
@GetMapping("/{id}/overview") @PreAuthorize("hasAuthority('platform:firstleg:container:list')")
|
||||
public ApiResponse<Overview> overview(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id) {
|
||||
FirstlegScopeGuard.platform(user); return ApiResponse.success(overview.overview(id));
|
||||
FirstlegScopeGuard.platform(user); workbenches.domesticDetail(id, user.permissions());
|
||||
return ApiResponse.success(overview.overview(id));
|
||||
}
|
||||
@GetMapping("/overseas/{id}/overview")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')")
|
||||
public ApiResponse<Overview> overseasOverview(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long id) {
|
||||
FirstlegScopeGuard.platform(user); service.requireOverseas(id);
|
||||
return ApiResponse.success(overview.overview(id));
|
||||
}
|
||||
@PutMapping("/{id}/pickup-code")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:document:edit')")
|
||||
@BusinessAudit(event="CONTAINER_PICKUP_CODE_UPDATE", resourceType="CONTAINER", resourceId="#id", description="填写PU码")
|
||||
public ApiResponse<Overview> updatePickupCode(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long id,
|
||||
@Valid @RequestBody PickupCodeRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
service.requireOverseas(id);
|
||||
return ApiResponse.success(overview.updatePickupCode(id, user.userId(), user.username(), request));
|
||||
}
|
||||
@GetMapping("/{id}/pda/photos/{fileId}/download")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:container:list')")
|
||||
@@ -61,6 +92,7 @@ public class PlatformContainerController {
|
||||
@PathVariable @Positive long id,
|
||||
@PathVariable @Positive long fileId) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
service.requireDomestic(id);
|
||||
var value = overview.downloadPhoto(id, fileId);
|
||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.inline()
|
||||
@@ -71,20 +103,20 @@ public class PlatformContainerController {
|
||||
public ApiResponse<Void> markException(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody ExceptionRequest request) {
|
||||
FirstlegScopeGuard.platform(user); overview.mark(id,user.userId(),user.username(),key,request); return ApiResponse.success(null);
|
||||
FirstlegScopeGuard.platform(user); service.requireDomestic(id); overview.mark(id,user.userId(),user.username(),key,request); return ApiResponse.success(null);
|
||||
}
|
||||
@DeleteMapping("/{id}/exception") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
|
||||
public ApiResponse<Void> cancelException(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody ExceptionRequest request) {
|
||||
FirstlegScopeGuard.platform(user); overview.cancel(id,user.userId(),user.username(),key,request); return ApiResponse.success(null);
|
||||
FirstlegScopeGuard.platform(user); service.requireDomestic(id); overview.cancel(id,user.userId(),user.username(),key,request); return ApiResponse.success(null);
|
||||
}
|
||||
@GetMapping("/{id}/documents/{documentType}") @PreAuthorize("hasAuthority('platform:firstleg:container:list')")
|
||||
public ApiResponse<BusinessDocument> logisticsDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType){FirstlegScopeGuard.platform(user);return ApiResponse.success(loadingLists.containerLogisticsDocument(id,documentType));}
|
||||
@PostMapping(value="/{id}/documents/{documentType}/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
|
||||
public ApiResponse<BusinessDocument> uploadLogisticsDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType,@RequestHeader("Idempotency-Key") String key,@RequestPart("file") MultipartFile file,@RequestParam("expectedVersion") @Min(0) int version,@RequestParam(value="operationReason",required=false) String reason){FirstlegScopeGuard.platform(user);return ApiResponse.success(loadingLists.uploadContainerLogisticsDocument(id,documentType,user.userId(),user.username(),key,version,reason,file));}
|
||||
@GetMapping("/{id}/documents/{documentType}/{fileId}/download") @PreAuthorize("hasAuthority('platform:firstleg:container:list')")
|
||||
public ResponseEntity<byte[]> downloadDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType,@PathVariable @Positive long fileId){FirstlegScopeGuard.platform(user);var d=loadingLists.downloadContainerDocument(id,documentType,fileId);return ResponseEntity.ok().cacheControl(CacheControl.noStore()).header(HttpHeaders.CONTENT_DISPOSITION,org.springframework.http.ContentDisposition.attachment().filename(d.fileName(),StandardCharsets.UTF_8).build().toString()).contentType(d.contentType()).contentLength(d.content().length).body(d.content());}
|
||||
@GetMapping("/{id}/documents/{documentType}") @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')")
|
||||
public ApiResponse<BusinessDocument> logisticsDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType){FirstlegScopeGuard.platform(user);service.requireOverseas(id);return ApiResponse.success(loadingLists.containerLogisticsDocument(id,documentType));}
|
||||
@PostMapping(value="/{id}/documents/{documentType}/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:document:edit')")
|
||||
public ApiResponse<BusinessDocument> uploadLogisticsDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType,@RequestHeader("Idempotency-Key") String key,@RequestPart("file") MultipartFile file,@RequestParam("expectedVersion") @Min(0) int version,@RequestParam(value="operationReason",required=false) String reason){FirstlegScopeGuard.platform(user);service.requireOverseas(id);return ApiResponse.success(loadingLists.uploadContainerLogisticsDocument(id,documentType,user.userId(),user.username(),key,version,reason,file));}
|
||||
@GetMapping("/{id}/documents/{documentType}/{fileId}/download") @PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')")
|
||||
public ResponseEntity<byte[]> downloadDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType,@PathVariable @Positive long fileId){FirstlegScopeGuard.platform(user);service.requireOverseas(id);var d=loadingLists.downloadContainerDocument(id,documentType,fileId);return ResponseEntity.ok().cacheControl(CacheControl.noStore()).header(HttpHeaders.CONTENT_DISPOSITION,org.springframework.http.ContentDisposition.attachment().filename(d.fileName(),StandardCharsets.UTF_8).build().toString()).contentType(d.contentType()).contentLength(d.content().length).body(d.content());}
|
||||
@PostMapping @PreAuthorize("hasAuthority('platform:firstleg:container:create')")
|
||||
@BusinessAudit(event="CONTAINER_CREATE", resourceType="CONTAINER", description="新增货柜基础资料", captureBefore=false)
|
||||
public ApiResponse<ContainerBookingEntity> create(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@@ -104,21 +136,23 @@ public class PlatformContainerController {
|
||||
return ApiResponse.success(service.updateDraft(id, user.userId(), user.username(), request));
|
||||
}
|
||||
@GetMapping("/{id}/clearance")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')")
|
||||
@Operation(summary = "查询货柜目的港清关资料")
|
||||
public ApiResponse<BusinessDocument> clearance(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long id) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
service.requireOverseas(id);
|
||||
return ApiResponse.success(loadingLists.containerClearance(id));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/clearance/document/{fileId}/download")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')")
|
||||
@Operation(summary = "下载货柜目的港清关附件", description = "校验当前清关附件和 File 业务归属,完成态仍可下载。")
|
||||
public ResponseEntity<byte[]> downloadClearance(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id,
|
||||
@PathVariable @Positive long fileId) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
service.requireOverseas(id);
|
||||
var download = loadingLists.downloadContainerClearance(id, fileId);
|
||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.attachment()
|
||||
@@ -128,19 +162,20 @@ public class PlatformContainerController {
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/clearance/document")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:document:edit')")
|
||||
@Operation(summary = "上传或替换货柜目的港清关资料")
|
||||
public ApiResponse<BusinessDocument> attachClearance(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long id,
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody DocumentAttachRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
service.requireOverseas(id);
|
||||
return ApiResponse.success(loadingLists.attachContainerClearance(
|
||||
id, user.userId(), user.username(), key, request));
|
||||
}
|
||||
|
||||
@PostMapping(value = "/{id}/clearance/document/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:document:edit')")
|
||||
@Operation(summary = "直接上传或替换货柜目的港清关资料")
|
||||
public ApiResponse<BusinessDocument> uploadClearance(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id,
|
||||
@@ -149,18 +184,20 @@ public class PlatformContainerController {
|
||||
@RequestParam("expectedVersion") @Min(0) int expectedVersion,
|
||||
@RequestParam(value = "operationReason", required = false) @Size(max = 500) String operationReason) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
service.requireOverseas(id);
|
||||
return ApiResponse.success(loadingLists.uploadContainerClearance(
|
||||
id, user.userId(), user.username(), key, expectedVersion, operationReason, file));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/clearance/complete")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:document:edit')")
|
||||
@Operation(summary = "确认货柜目的港清关完成")
|
||||
public ApiResponse<BusinessDocument> completeClearance(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long id,
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody DocumentCompleteRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
service.requireOverseas(id);
|
||||
return ApiResponse.success(loadingLists.completeContainerClearance(
|
||||
id, user.userId(), user.username(), key, request));
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ import java.time.LocalDateTime;
|
||||
public final class ContainerDtos {
|
||||
private ContainerDtos() {}
|
||||
|
||||
public enum WorkbenchScope { ACTIVE, HISTORY }
|
||||
|
||||
@Schema(name = "ContainerQuery", description = "货柜分页筛选条件")
|
||||
public record Query(
|
||||
@Size(max = 64) String containerNo,
|
||||
@@ -35,6 +37,7 @@ public final class ContainerDtos {
|
||||
SmartLoadingStatus status,
|
||||
LocalDateTime estimatedSailingFrom,
|
||||
LocalDateTime estimatedSailingTo,
|
||||
@NotNull WorkbenchScope scope,
|
||||
@Min(1) long page,
|
||||
@Min(1) @Max(200) long size
|
||||
) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@@ -21,7 +22,8 @@ public final class ContainerOverviewDtos {
|
||||
LocalDateTime estimatedWarehouseArrivalAt, LocalDateTime actualWarehouseArrivalAt,
|
||||
LocalDateTime unloadingCompletedAt, LocalDateTime unloadingPhotoUploadedAt,
|
||||
LocalDateTime estimatedEmptyReportedAt, LocalDateTime actualEmptyReportedAt,
|
||||
Long emptyReportSourceVersion,
|
||||
Long emptyReportSourceVersion, String pickupCode, Long pickupCodeUpdatedBy,
|
||||
String pickupCodeUpdatedByName, LocalDateTime pickupCodeUpdatedAt,
|
||||
Integer version) {}
|
||||
public record PdaSummary(Long taskId, Long loadingPlanId, Long containerBookingId, String taskStatus,
|
||||
String workflowStatus, Integer taskVersion, Integer itemCount, Integer completedItemCount, Integer unresolvedItemCount,
|
||||
@@ -35,4 +37,6 @@ public final class ContainerOverviewDtos {
|
||||
public record ExceptionHistory(Long id, String action, String reason, Long operatorId, String operatorName, LocalDateTime occurredAt) {}
|
||||
public record TimelineEntry(String eventType, String detail, Long operatorId, String operatorName, LocalDateTime occurredAt) {}
|
||||
public record ExceptionRequest(@NotNull @Min(0) Integer expectedVersion, @NotBlank @Size(max=500) String reason) {}
|
||||
public record PickupCodeRequest(@NotBlank @Size(max=64) @Pattern(regexp="^[0-9]+$", message="PU码只能包含数字") String pickupCode,
|
||||
@NotNull @Min(0) Integer expectedVersion) {}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,10 @@ public class ContainerBookingEntity {
|
||||
private LocalDateTime estimatedEmptyReportedAt;
|
||||
private LocalDateTime actualEmptyReportedAt;
|
||||
private Long emptyReportSourceVersion;
|
||||
private String pickupCode;
|
||||
private Long pickupCodeUpdatedBy;
|
||||
private String pickupCodeUpdatedByName;
|
||||
private LocalDateTime pickupCodeUpdatedAt;
|
||||
private Long createdBy;
|
||||
private String createdByName;
|
||||
@TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt;
|
||||
@@ -76,5 +80,6 @@ public class ContainerBookingEntity {
|
||||
@TableField(exist = false) private String destinationClearanceDocumentStatus;
|
||||
@TableField(exist = false) private String customsDeclarationDocumentStatus;
|
||||
@TableField(exist = false) private String deliveryOrderDocumentStatus;
|
||||
@TableField(exist = false) private String pickupOrderDocumentStatus;
|
||||
@TableField(exist = false) private String outboundDispatchStatus;
|
||||
@TableField(exist = false) private LocalDateTime outboundConfirmedAt;
|
||||
}
|
||||
|
||||
@@ -40,8 +40,14 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
|
||||
COALESCE((SELECT c.status FROM fl_customs_container c WHERE c.container_booking_id=b.id LIMIT 1),'MISSING') AS customs_declaration_document_status,
|
||||
COALESCE((SELECT 'UPLOADED' FROM fl_container_document d WHERE d.container_booking_id=b.id
|
||||
AND d.document_type='DELIVERY_ORDER' LIMIT 1),'MISSING') AS delivery_order_document_status,
|
||||
COALESCE((SELECT 'UPLOADED' FROM fl_container_document d WHERE d.container_booking_id=b.id
|
||||
AND d.document_type='PICKUP_ORDER' LIMIT 1),'MISSING') AS pickup_order_document_status,
|
||||
COALESCE((SELECT CASE WHEN MIN(d.confirmed_at) IS NOT NULL
|
||||
AND MAX(d.reversed_at) IS NULL THEN 'DEPARTED'
|
||||
ELSE MIN(d.status) END
|
||||
FROM fl_outbound_dispatch d WHERE d.container_booking_id=b.id),'NOT_CREATED')
|
||||
AS outbound_dispatch_status,
|
||||
(SELECT MIN(d.confirmed_at) FROM fl_outbound_dispatch d
|
||||
WHERE d.container_booking_id=b.id AND d.confirmed_at IS NOT NULL
|
||||
AND d.reversed_at IS NULL AND d.status='WAITING_FOR_LOADING') AS outbound_confirmed_at,
|
||||
CASE WHEN p.id IS NULL THEN 'WAIT_LOADING'
|
||||
WHEN p.plan_status != 'COMPLETED' AND NOT EXISTS (
|
||||
SELECT 1 FROM fl_loading_plan_item active_item
|
||||
@@ -78,6 +84,24 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
|
||||
ELSE p.plan_status END = #{query.status})
|
||||
AND (#{query.estimatedSailingFrom} IS NULL OR b.estimated_sailing_at >= #{query.estimatedSailingFrom})
|
||||
AND (#{query.estimatedSailingTo} IS NULL OR b.estimated_sailing_at <= #{query.estimatedSailingTo})
|
||||
AND ((#{workbench}='DOMESTIC' AND (
|
||||
(#{query.scope}='ACTIVE' AND NOT EXISTS (
|
||||
SELECT 1 FROM fl_outbound_dispatch departure
|
||||
WHERE departure.container_booking_id=b.id
|
||||
AND departure.confirmed_at IS NOT NULL AND departure.reversed_at IS NULL
|
||||
AND departure.status='WAITING_FOR_LOADING'))
|
||||
OR (#{query.scope}='HISTORY' AND EXISTS (
|
||||
SELECT 1 FROM fl_outbound_dispatch departure
|
||||
WHERE departure.container_booking_id=b.id
|
||||
AND departure.confirmed_at IS NOT NULL AND departure.reversed_at IS NULL
|
||||
AND departure.status='WAITING_FOR_LOADING'))))
|
||||
OR (#{workbench}='OVERSEAS' AND EXISTS (
|
||||
SELECT 1 FROM fl_outbound_dispatch departure
|
||||
WHERE departure.container_booking_id=b.id
|
||||
AND departure.confirmed_at IS NOT NULL AND departure.reversed_at IS NULL
|
||||
AND departure.status='WAITING_FOR_LOADING')
|
||||
AND ((#{query.scope}='ACTIVE' AND b.actual_empty_reported_at IS NULL)
|
||||
OR (#{query.scope}='HISTORY' AND b.actual_empty_reported_at IS NOT NULL))))
|
||||
AND (#{query.keyword} IS NULL OR b.container_no LIKE CONCAT('%', #{query.keyword}, '%')
|
||||
OR b.cabinet_no LIKE CONCAT('%', #{query.keyword}, '%')
|
||||
OR b.bill_of_lading_no LIKE CONCAT('%', #{query.keyword}, '%')
|
||||
@@ -86,7 +110,9 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
|
||||
ORDER BY b.updated_at DESC, b.id DESC
|
||||
</script>
|
||||
""")
|
||||
Page<ContainerBookingEntity> selectBookingPage(Page<ContainerBookingEntity> page, @Param("query") Query query);
|
||||
Page<ContainerBookingEntity> selectBookingPage(Page<ContainerBookingEntity> page,
|
||||
@Param("query") Query query,
|
||||
@Param("workbench") String workbench);
|
||||
|
||||
@Select("""
|
||||
SELECT b.*, p.id AS plan_id, p.plan_no AS plan_no,
|
||||
@@ -100,8 +126,14 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
|
||||
COALESCE((SELECT c.status FROM fl_customs_container c WHERE c.container_booking_id=b.id LIMIT 1),'MISSING') AS customs_declaration_document_status,
|
||||
COALESCE((SELECT 'UPLOADED' FROM fl_container_document d WHERE d.container_booking_id=b.id
|
||||
AND d.document_type='DELIVERY_ORDER' LIMIT 1),'MISSING') AS delivery_order_document_status,
|
||||
COALESCE((SELECT 'UPLOADED' FROM fl_container_document d WHERE d.container_booking_id=b.id
|
||||
AND d.document_type='PICKUP_ORDER' LIMIT 1),'MISSING') AS pickup_order_document_status,
|
||||
COALESCE((SELECT CASE WHEN MIN(d.confirmed_at) IS NOT NULL
|
||||
AND MAX(d.reversed_at) IS NULL THEN 'DEPARTED'
|
||||
ELSE MIN(d.status) END
|
||||
FROM fl_outbound_dispatch d WHERE d.container_booking_id=b.id),'NOT_CREATED')
|
||||
AS outbound_dispatch_status,
|
||||
(SELECT MIN(d.confirmed_at) FROM fl_outbound_dispatch d
|
||||
WHERE d.container_booking_id=b.id AND d.confirmed_at IS NOT NULL
|
||||
AND d.reversed_at IS NULL AND d.status='WAITING_FOR_LOADING') AS outbound_confirmed_at,
|
||||
CASE WHEN p.id IS NULL THEN 'WAIT_LOADING'
|
||||
WHEN p.plan_status != 'COMPLETED' AND NOT EXISTS (
|
||||
SELECT 1 FROM fl_loading_plan_item active_item
|
||||
@@ -119,6 +151,23 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
|
||||
@Select("SELECT * FROM fl_container_booking WHERE id=#{id} AND record_status='ACTIVE' FOR UPDATE")
|
||||
ContainerBookingEntity selectActiveBookingForUpdate(@Param("id") long id);
|
||||
|
||||
@Select("""
|
||||
SELECT COUNT(1) FROM fl_outbound_dispatch
|
||||
WHERE container_booking_id=#{id} AND confirmed_at IS NOT NULL AND reversed_at IS NULL
|
||||
AND status='WAITING_FOR_LOADING'
|
||||
""")
|
||||
int hasActiveOutboundDeparture(@Param("id") long id);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_container_booking SET pickup_code=#{pickupCode},pickup_code_updated_by=#{operatorId},
|
||||
pickup_code_updated_by_name=#{operatorName},pickup_code_updated_at=CURRENT_TIMESTAMP(3),
|
||||
updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1
|
||||
WHERE id=#{id} AND record_status='ACTIVE' AND version=#{version}
|
||||
""")
|
||||
int updatePickupCode(@Param("id") long id, @Param("pickupCode") String pickupCode,
|
||||
@Param("version") int version, @Param("operatorId") long operatorId,
|
||||
@Param("operatorName") String operatorName);
|
||||
|
||||
@Select("SELECT * FROM fl_container_booking WHERE container_no=#{containerNo} AND record_status='ACTIVE'")
|
||||
ContainerBookingEntity selectActiveByContainerNo(@Param("containerNo") String containerNo);
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import com.baiyue.firstleg.entity.ContainerDocumentEntity;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
import java.util.List;
|
||||
@Mapper public interface ContainerOverviewMapper {
|
||||
@Select("SELECT * FROM fl_container_document WHERE container_booking_id=#{id} AND document_type IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER','PICKUP_ORDER') ORDER BY document_type") List<ContainerDocumentEntity> documents(@Param("id") long id);
|
||||
@Select("SELECT * FROM fl_container_document WHERE container_booking_id=#{id} AND document_type IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER') ORDER BY document_type") List<ContainerDocumentEntity> documents(@Param("id") long id);
|
||||
@Select("""
|
||||
SELECT history.id,plan.container_booking_id containerBookingId,
|
||||
JSON_UNQUOTE(JSON_EXTRACT(history.before_snapshot,'$.containerNo')) beforeContainerNo,
|
||||
|
||||
+14
@@ -5,6 +5,7 @@ import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest;
|
||||
@@ -19,6 +20,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
@@ -28,6 +30,7 @@ import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
@@ -47,6 +50,17 @@ public class PlatformCarrierScheduleQueryController {
|
||||
return ApiResponse.success(service.carriers());
|
||||
}
|
||||
|
||||
@Operation(summary = "按货柜批量查询实际船节点摘要",
|
||||
description = "最多200柜;仅返回实际节点时间,不返回或推断预计时间;没有船节点任务的货柜不返回行。")
|
||||
@GetMapping("/container-actual-facts")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:list')")
|
||||
public ApiResponse<List<ContainerActualFacts>> containerActualFacts(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam("containerBookingIds") @Size(min = 1, max = 200) List<@Positive Long> containerBookingIds) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.containerActualFacts(containerBookingIds));
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:list')")
|
||||
public ApiResponse<PageResult<QueryView>> page(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
|
||||
+11
@@ -80,6 +80,17 @@ public final class CarrierScheduleQueryDtos {
|
||||
|
||||
public record QueryView(Task query, boolean expired) {}
|
||||
|
||||
/** Actual-only summary used by container workbenches; estimated facts are intentionally excluded. */
|
||||
public record ContainerActualFacts(Long containerBookingId, Long queryId, Integer queryVersion,
|
||||
String queryStatus, String latestActualNode,
|
||||
LocalDateTime latestActualAt,
|
||||
LocalDateTime departedWarehouseAt,
|
||||
LocalDateTime departedOriginPortAt,
|
||||
LocalDateTime arrivedDestinationPortAt,
|
||||
LocalDateTime pickupReadyAt,
|
||||
LocalDateTime pickedUpAt,
|
||||
LocalDateTime emptyReturnedAt) {}
|
||||
|
||||
public record NodeFact(Long id, String providerCode, String providerEventId,
|
||||
String providerNodeCode, String standardNode,
|
||||
int nodeSequence, LocalDateTime observedAt, LocalDateTime estimatedAt,
|
||||
|
||||
+28
@@ -3,6 +3,7 @@ package com.baiyue.firstleg.portschedule.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Query;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
|
||||
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
@@ -46,6 +47,33 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
|
||||
@Param("oceanBookingId") long oceanBookingId,
|
||||
@Param("containerBookingId") long containerBookingId);
|
||||
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT q.container_booking_id AS containerBookingId,q.id AS queryId,q.version AS queryVersion,
|
||||
q.status AS queryStatus,q.latest_standard_node AS latestActualNode,
|
||||
q.latest_node_observed_at AS latestActualAt,
|
||||
MAX(CASE WHEN f.standard_node='DEPARTED_WAREHOUSE' THEN f.actual_at END) AS departedWarehouseAt,
|
||||
MAX(CASE WHEN f.standard_node='DEPARTED_ORIGIN_PORT' THEN f.actual_at END) AS departedOriginPortAt,
|
||||
MAX(CASE WHEN f.standard_node='ARRIVED_DESTINATION_PORT' THEN f.actual_at END) AS arrivedDestinationPortAt,
|
||||
MAX(CASE WHEN f.standard_node='PICKUP_READY' THEN f.actual_at END) AS pickupReadyAt,
|
||||
MAX(CASE WHEN f.standard_node='PICKED_UP' THEN f.actual_at END) AS pickedUpAt,
|
||||
MAX(CASE WHEN f.standard_node='EMPTY_RETURNED' THEN f.actual_at END) AS emptyReturnedAt
|
||||
FROM fl_carrier_schedule_query q
|
||||
JOIN (
|
||||
SELECT container_booking_id,MAX(id) AS query_id
|
||||
FROM fl_carrier_schedule_query
|
||||
WHERE container_booking_id IN
|
||||
<foreach collection="containerBookingIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
GROUP BY container_booking_id
|
||||
) latest ON latest.query_id=q.id
|
||||
LEFT JOIN fl_carrier_schedule_node_fact f ON f.query_id=q.id AND f.actual_at IS NOT NULL
|
||||
GROUP BY q.container_booking_id,q.id,q.version,q.status,q.latest_standard_node,q.latest_node_observed_at
|
||||
ORDER BY q.container_booking_id
|
||||
</script>
|
||||
""")
|
||||
List<ContainerActualFacts> selectContainerActualFacts(
|
||||
@Param("containerBookingIds") List<Long> containerBookingIds);
|
||||
|
||||
@Select("""
|
||||
SELECT * FROM fl_carrier_schedule_query
|
||||
WHERE status IN ('READY','MANUAL_REQUIRED')
|
||||
|
||||
+4
@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Query;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
|
||||
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity;
|
||||
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
|
||||
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity;
|
||||
@@ -40,6 +41,9 @@ public class CarrierScheduleQueryRepository {
|
||||
public CarrierScheduleQueryEntity findByBusinessScope(long customerId, long oceanBookingId, long containerBookingId) {
|
||||
return queries.findByBusinessScope(customerId, oceanBookingId, containerBookingId);
|
||||
}
|
||||
public List<ContainerActualFacts> containerActualFacts(List<Long> containerBookingIds) {
|
||||
return queries.selectContainerActualFacts(containerBookingIds);
|
||||
}
|
||||
public List<CarrierScheduleQueryEntity> due(LocalDateTime now, int limit) { return queries.selectDue(now, limit); }
|
||||
public boolean claimLease(long id, String owner, LocalDateTime now, LocalDateTime leaseUntil) {
|
||||
return queries.claimLease(id, owner, now, leaseUntil) == 1;
|
||||
|
||||
+14
@@ -7,6 +7,7 @@ import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.event.ContainerNumbers;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CarrierDescriptor;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.Detail;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest;
|
||||
@@ -48,6 +49,7 @@ import java.time.Clock;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.HexFormat;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
@@ -76,6 +78,18 @@ public class CarrierScheduleQueryService {
|
||||
source.page(), source.size(), source.total(), source.pages());
|
||||
}
|
||||
|
||||
public List<ContainerActualFacts> containerActualFacts(List<Long> containerBookingIds) {
|
||||
if (containerBookingIds == null || containerBookingIds.isEmpty() || containerBookingIds.size() > 200) {
|
||||
throw badRequest("containerBookingIds数量必须在1到200之间");
|
||||
}
|
||||
Set<Long> unique = new HashSet<>();
|
||||
for (Long id : containerBookingIds) {
|
||||
if (id == null || id <= 0) throw badRequest("containerBookingIds必须全部为正数");
|
||||
if (!unique.add(id)) throw badRequest("containerBookingIds不能包含重复值");
|
||||
}
|
||||
return repository.containerActualFacts(containerBookingIds);
|
||||
}
|
||||
|
||||
public Detail detail(long id) { return detail(required(id)); }
|
||||
|
||||
public List<History> history(long id) {
|
||||
|
||||
@@ -12,15 +12,16 @@ import org.springframework.stereotype.Repository;
|
||||
@Repository
|
||||
public class ContainerRepository {
|
||||
@Resource private ContainerBookingMapper mapper;
|
||||
public PageResult<ContainerBookingEntity> page(Query query) {
|
||||
public PageResult<ContainerBookingEntity> page(String workbench, Query query) {
|
||||
Page<ContainerBookingEntity> result = mapper.selectBookingPage(
|
||||
new Page<>(query.page(), query.size()), query);
|
||||
new Page<>(query.page(), query.size()), query, workbench);
|
||||
result.getRecords().forEach(this::resolveLoadingTime);
|
||||
return PageResult.from(result);
|
||||
}
|
||||
public ContainerBookingEntity find(long id) { return resolveLoadingTime(mapper.selectBooking(id)); }
|
||||
public ContainerBookingEntity findForUpdate(long id) { return mapper.selectBookingForUpdate(id); }
|
||||
public ContainerBookingEntity findActiveForUpdate(long id) { return mapper.selectActiveBookingForUpdate(id); }
|
||||
public boolean departed(long id) { return mapper.hasActiveOutboundDeparture(id) > 0; }
|
||||
public boolean containerNoExists(String no, Long excludedId) {
|
||||
if (no == null) return false;
|
||||
return mapper.selectCount(new LambdaQueryWrapper<ContainerBookingEntity>()
|
||||
@@ -45,6 +46,9 @@ public class ContainerRepository {
|
||||
public int updateDraft(ContainerBookingEntity value, int version, long operatorId, String operatorName) {
|
||||
return mapper.updateDraft(value, version, operatorId, operatorName);
|
||||
}
|
||||
public int updatePickupCode(long id, String pickupCode, int version, long operatorId, String operatorName) {
|
||||
return mapper.updatePickupCode(id, pickupCode, version, operatorId, operatorName);
|
||||
}
|
||||
public boolean hasLoadingPlan(long id) { return mapper.hasLoadingPlan(id) > 0; }
|
||||
public boolean hasActiveLoadingItems(long id) { return mapper.hasActiveLoadingItems(id) > 0; }
|
||||
public boolean hasPickTask(long id) { return mapper.hasPickTask(id) > 0; }
|
||||
|
||||
@@ -51,7 +51,9 @@ public class ContainerOverviewService {
|
||||
booking.getActualPortArrivalAt(), booking.getEstimatedWarehouseArrivalAt(),
|
||||
booking.getActualWarehouseArrivalAt(), booking.getUnloadingCompletedAt(),
|
||||
booking.getUnloadingPhotoUploadedAt(), booking.getEstimatedEmptyReportedAt(),
|
||||
booking.getActualEmptyReportedAt(), booking.getEmptyReportSourceVersion(), booking.getVersion()),
|
||||
booking.getActualEmptyReportedAt(), booking.getEmptyReportSourceVersion(), booking.getPickupCode(),
|
||||
booking.getPickupCodeUpdatedBy(), booking.getPickupCodeUpdatedByName(), booking.getPickupCodeUpdatedAt(),
|
||||
booking.getVersion()),
|
||||
task == null ? booking.getPlanId() : task.loadingPlanId(), pda, documents,
|
||||
new ExceptionState(latestException != null && "MARK".equals(latestException.action()),
|
||||
latestException == null ? null : latestException.reason(),
|
||||
@@ -72,6 +74,18 @@ public class ContainerOverviewService {
|
||||
changeException(id, operatorId, operatorName, idempotencyKey, request, "CANCEL");
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Overview updatePickupCode(long id, long operatorId, String operatorName, PickupCodeRequest request) {
|
||||
ContainerBookingEntity booking = containers.findActiveForUpdate(id);
|
||||
if (booking == null) throw new BizException(ResultCode.NOT_FOUND, "货柜不存在");
|
||||
if (!request.expectedVersion().equals(booking.getVersion())) throw conflict("货柜版本已变化,请刷新后重试");
|
||||
String pickupCode = request.pickupCode().trim();
|
||||
if (containers.updatePickupCode(id, pickupCode, request.expectedVersion(), operatorId, operatorName) != 1) {
|
||||
throw conflict("货柜版本已变化,请刷新后重试");
|
||||
}
|
||||
return overview(id);
|
||||
}
|
||||
|
||||
private void changeException(long id, long operatorId, String operatorName, String rawKey,
|
||||
ExceptionRequest request, String action) {
|
||||
String operationId = operationId(rawKey);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
@@ -9,7 +8,6 @@ import com.baiyue.firstleg.client.WarehouseMasterDataClient;
|
||||
import com.baiyue.firstleg.domain.SmartLoadingStatus;
|
||||
import com.baiyue.firstleg.domain.ContainerSpecificationCatalog;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.Query;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest;
|
||||
@@ -41,11 +39,15 @@ public class ContainerService {
|
||||
@Resource private SmartLoadingService smartLoadingService;
|
||||
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
||||
@Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId;
|
||||
public PageResult<ContainerBookingEntity> page(Query query) {
|
||||
validateRange(query.estimatedSailingFrom(), query.estimatedSailingTo(), "预计船期筛选");
|
||||
return repository.page(normalize(query));
|
||||
public void requireOverseas(long id) {
|
||||
required(id);
|
||||
if (!repository.departed(id)) throw new BizException(ResultCode.NOT_FOUND, "国外工作台货柜不存在");
|
||||
}
|
||||
|
||||
public void requireDomestic(long id) {
|
||||
required(id);
|
||||
if (repository.departed(id)) throw new BizException(ResultCode.NOT_FOUND, "国内工作台货柜不存在");
|
||||
}
|
||||
public ContainerBookingEntity detail(long id) { return required(id); }
|
||||
|
||||
@Transactional
|
||||
public ContainerBookingEntity createSimulation(long operatorId, String operatorName, SimulationCreateRequest request) {
|
||||
@@ -220,12 +222,6 @@ public class ContainerService {
|
||||
if (from != null && to != null && to.isBefore(from))
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, label + "先后顺序不正确");
|
||||
}
|
||||
private static Query normalize(Query q) {
|
||||
return new Query(trim(q.containerNo()), trim(q.cabinetNo()), trim(q.billOfLadingNo()), trim(q.keyword()),
|
||||
q.customerId(), q.warehouseId(), trim(q.containerType()), q.shippingCompanyId(), trim(q.departurePortCode()),
|
||||
trim(q.destinationPortCode()), trim(q.destinationWarehouseCode()), q.status(), q.estimatedSailingFrom(),
|
||||
q.estimatedSailingTo(), q.page(), q.size());
|
||||
}
|
||||
private static String trim(String value) {
|
||||
if (value == null) return null;
|
||||
String result = value.trim(); return result.isEmpty() ? null : result;
|
||||
|
||||
@@ -56,10 +56,8 @@ public class LoadingListService {
|
||||
private static final Set<String> PRELOAD_STATUSES = Set.of("EDITING", "LOADING");
|
||||
public static final String DESTINATION_CLEARANCE = "DESTINATION_CLEARANCE";
|
||||
public static final String DELIVERY_ORDER = "DELIVERY_ORDER";
|
||||
public static final String PICKUP_ORDER = "PICKUP_ORDER";
|
||||
private static final String CLEARANCE_FILE_TYPE = "CONTAINER_CLEARANCE_DOCUMENT";
|
||||
private static final String DO_FILE_TYPE = "CONTAINER_DELIVERY_ORDER";
|
||||
private static final String PU_FILE_TYPE = "CONTAINER_PICKUP_ORDER";
|
||||
@Resource
|
||||
private LoadingListRepository repository;
|
||||
@Resource
|
||||
@@ -190,8 +188,8 @@ public class LoadingListService {
|
||||
}
|
||||
}
|
||||
public record DocumentDownload(String fileName,MediaType contentType,byte[] content) {}
|
||||
private static void requireLogisticsType(String type){ if(!DELIVERY_ORDER.equals(type)&&!PICKUP_ORDER.equals(type)) throw badRequest("documentType只能是DELIVERY_ORDER或PICKUP_ORDER"); }
|
||||
private static String fileType(String type){ return switch(type){case DELIVERY_ORDER->DO_FILE_TYPE; case PICKUP_ORDER->PU_FILE_TYPE; default->throw badRequest("documentType只能是DELIVERY_ORDER或PICKUP_ORDER");}; }
|
||||
private static void requireLogisticsType(String type){ if(!DELIVERY_ORDER.equals(type)) throw badRequest("documentType只能是DELIVERY_ORDER"); }
|
||||
private static String fileType(String type){ if (DELIVERY_ORDER.equals(type)) return DO_FILE_TYPE; throw badRequest("documentType只能是DELIVERY_ORDER"); }
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "CONTAINER_CLEARANCE_DOCUMENT_ATTACH", resourceType = "CONTAINER",
|
||||
|
||||
+3
-2
@@ -63,8 +63,9 @@ public class OverseasContainerEventConsumer {
|
||||
|
||||
ContainerBookingEntity container = requiredContainer(payload.sourceContainerBookingId(), payload.containerNo());
|
||||
LocalDateTime uploadedAt = utc(payload.unloadingPhotoUploadedAt());
|
||||
containers.applyEmptyReport(container.getId(), utc(payload.unloadingCompletedAt()), uploadedAt,
|
||||
uploadedAt.plusDays(2), utc(payload.actualEmptyReportedAt()), event.aggregateVersion());
|
||||
LocalDateTime unloadingCompletedAt = utc(payload.unloadingCompletedAt());
|
||||
containers.applyEmptyReport(container.getId(), unloadingCompletedAt, uploadedAt,
|
||||
unloadingCompletedAt.plusHours(72), utc(payload.actualEmptyReportedAt()), event.aggregateVersion());
|
||||
row.setStatus("PROCESSED");
|
||||
row.setProcessedAt(LocalDateTime.now(ZoneOffset.UTC));
|
||||
inbox.updateById(row);
|
||||
|
||||
+17
-13
@@ -39,24 +39,24 @@ public class PlatformTerminalDrayageController {
|
||||
private final TerminalDrayageService service;
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:container:list')")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')")
|
||||
public ApiResponse<List<View>> views(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam @Size(min = 1, max = 200)
|
||||
List<@Positive Long> containerBookingIds) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.views(containerBookingIds));
|
||||
return ApiResponse.success(service.authorize(service.views(containerBookingIds), user.permissions()));
|
||||
}
|
||||
|
||||
@GetMapping("/{containerBookingId}")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:container:list')")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')")
|
||||
public ApiResponse<View> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long containerBookingId) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.detail(containerBookingId));
|
||||
return ApiResponse.success(service.authorize(service.detail(containerBookingId), user.permissions()));
|
||||
}
|
||||
|
||||
@GetMapping("/{containerBookingId}/history")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:container:list')")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:list')")
|
||||
public ApiResponse<List<History>> history(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @Positive long containerBookingId) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
@@ -64,7 +64,7 @@ public class PlatformTerminalDrayageController {
|
||||
}
|
||||
|
||||
@PostMapping("/{containerBookingId}/fleet")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:fleet:edit')")
|
||||
@Operation(summary = "选择或更换目的港码头车队")
|
||||
@BusinessAudit(event = "TERMINAL_DRAYAGE_SELECT_FLEET", resourceType = "CONTAINER",
|
||||
resourceId = "#containerBookingId", description = "选择目的港码头车队")
|
||||
@@ -73,11 +73,12 @@ public class PlatformTerminalDrayageController {
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody FleetSelectionRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.selectFleet(containerBookingId, user.userId(), user.username(), key, request));
|
||||
return ApiResponse.success(service.authorize(service.selectFleet(
|
||||
containerBookingId, user.userId(), user.username(), key, request), user.permissions()));
|
||||
}
|
||||
|
||||
@PostMapping("/{containerBookingId}/pickup-appointment")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:pickup-appointment:edit')")
|
||||
@Operation(summary = "预约目的港提柜时间")
|
||||
@BusinessAudit(event = "TERMINAL_DRAYAGE_SCHEDULE_PICKUP", resourceType = "CONTAINER",
|
||||
resourceId = "#containerBookingId", description = "预约目的港提柜")
|
||||
@@ -86,11 +87,12 @@ public class PlatformTerminalDrayageController {
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody PickupAppointmentRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.schedulePickup(containerBookingId, user.userId(), user.username(), key, request));
|
||||
return ApiResponse.success(service.authorize(service.schedulePickup(
|
||||
containerBookingId, user.userId(), user.username(), key, request), user.permissions()));
|
||||
}
|
||||
|
||||
@PostMapping("/{containerBookingId}/pickup")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:pickup:confirm')")
|
||||
@Operation(summary = "确认目的港实际提柜")
|
||||
@BusinessAudit(event = "TERMINAL_DRAYAGE_RECORD_PICKUP", resourceType = "CONTAINER",
|
||||
resourceId = "#containerBookingId", description = "确认目的港实际提柜")
|
||||
@@ -99,11 +101,12 @@ public class PlatformTerminalDrayageController {
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody ActualTimeRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.recordPickup(containerBookingId, user.userId(), user.username(), key, request));
|
||||
return ApiResponse.success(service.authorize(service.recordPickup(
|
||||
containerBookingId, user.userId(), user.username(), key, request), user.permissions()));
|
||||
}
|
||||
|
||||
@PostMapping("/{containerBookingId}/empty-return")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:terminal-drayage:empty-return:confirm')")
|
||||
@Operation(summary = "确认实际还空柜")
|
||||
@BusinessAudit(event = "TERMINAL_DRAYAGE_RECORD_EMPTY_RETURN", resourceType = "CONTAINER",
|
||||
resourceId = "#containerBookingId", description = "确认实际还空柜")
|
||||
@@ -112,6 +115,7 @@ public class PlatformTerminalDrayageController {
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody ActualTimeRequest request) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
return ApiResponse.success(service.recordEmptyReturn(containerBookingId, user.userId(), user.username(), key, request));
|
||||
return ApiResponse.success(service.authorize(service.recordEmptyReturn(
|
||||
containerBookingId, user.userId(), user.username(), key, request), user.permissions()));
|
||||
}
|
||||
}
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.baiyue.firstleg.terminaldrayage.dto;
|
||||
|
||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
|
||||
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.View;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/** 国内、国外货柜工作台共享只读模型。 */
|
||||
public final class ContainerWorkbenchDtos {
|
||||
private ContainerWorkbenchDtos() {}
|
||||
|
||||
@Schema(name = "ContainerWorkbenchStatusSummary", description = "柜级各履约域只读状态摘要")
|
||||
public record StatusSummary(String loadingStatus, String customsStatus, String outboundStatus,
|
||||
String destinationClearanceStatus, String deliveryOrderStatus,
|
||||
String pickupCodeStatus, String latestActualNode,
|
||||
LocalDateTime latestActualAt, String terminalDrayageStatus,
|
||||
String emptyReturnWarningStatus) {}
|
||||
|
||||
@Schema(name = "ContainerWorkbenchItem", description = "同一containerBookingId的国内或国外工作台读模型")
|
||||
public record WorkbenchItem(ContainerBookingEntity container, String workbenchStage,
|
||||
StatusSummary statusSummary, List<String> allowedActions,
|
||||
ContainerActualFacts actualFacts, View terminalDrayage) {}
|
||||
}
|
||||
+3
-1
@@ -7,6 +7,7 @@ import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
|
||||
|
||||
public final class TerminalDrayageDtos {
|
||||
private TerminalDrayageDtos() {}
|
||||
@@ -33,7 +34,8 @@ public final class TerminalDrayageDtos {
|
||||
LocalDateTime pickupAppointmentAt, LocalDateTime actualPickupAt,
|
||||
LocalDateTime estimatedEmptyReturnAt, LocalDateTime actualEmptyReturnAt,
|
||||
LocalDateTime unloadingCompletedAt, Warning emptyReturnWarning,
|
||||
List<String> allowedActions, LocalDateTime updatedAt) {}
|
||||
List<String> allowedActions, ContainerActualFacts actualFacts,
|
||||
LocalDateTime updatedAt) {}
|
||||
|
||||
public record History(String action, String fromStatus, String toStatus,
|
||||
Integer fromVersion, Integer toVersion, String reason,
|
||||
|
||||
+4
-39
@@ -16,38 +16,16 @@ import java.util.List;
|
||||
public interface TerminalDrayageMapper extends BaseMapper<TerminalDrayageEntity> {
|
||||
@Select("""
|
||||
SELECT b.id AS containerBookingId,b.container_no AS containerNo,b.ocean_booking_id AS oceanBookingId,
|
||||
b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt,
|
||||
q.id AS queryId,q.latest_standard_node AS latestActualNode,
|
||||
q.latest_node_observed_at AS latestActualAt,q.version AS queryVersion,
|
||||
(SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id
|
||||
AND f.standard_node='ARRIVED_DESTINATION_PORT') AS actualArrivalNodeAt,
|
||||
(SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id
|
||||
AND f.standard_node='PICKED_UP') AS actualPickupNodeAt,
|
||||
(SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id
|
||||
AND f.standard_node='EMPTY_RETURNED') AS actualEmptyReturnNodeAt
|
||||
b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt
|
||||
FROM fl_container_booking b
|
||||
LEFT JOIN fl_carrier_schedule_query q ON q.id=(
|
||||
SELECT q2.id FROM fl_carrier_schedule_query q2
|
||||
WHERE q2.container_booking_id=b.id ORDER BY q2.id DESC LIMIT 1)
|
||||
WHERE b.id=#{containerBookingId} FOR UPDATE
|
||||
""")
|
||||
Context lockContext(@Param("containerBookingId") long containerBookingId);
|
||||
|
||||
@Select("""
|
||||
SELECT b.id AS containerBookingId,b.container_no AS containerNo,b.ocean_booking_id AS oceanBookingId,
|
||||
b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt,
|
||||
q.id AS queryId,q.latest_standard_node AS latestActualNode,
|
||||
q.latest_node_observed_at AS latestActualAt,q.version AS queryVersion,
|
||||
(SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id
|
||||
AND f.standard_node='ARRIVED_DESTINATION_PORT') AS actualArrivalNodeAt,
|
||||
(SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id
|
||||
AND f.standard_node='PICKED_UP') AS actualPickupNodeAt,
|
||||
(SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id
|
||||
AND f.standard_node='EMPTY_RETURNED') AS actualEmptyReturnNodeAt
|
||||
b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt
|
||||
FROM fl_container_booking b
|
||||
LEFT JOIN fl_carrier_schedule_query q ON q.id=(
|
||||
SELECT q2.id FROM fl_carrier_schedule_query q2
|
||||
WHERE q2.container_booking_id=b.id ORDER BY q2.id DESC LIMIT 1)
|
||||
WHERE b.id=#{containerBookingId}
|
||||
""")
|
||||
Context context(@Param("containerBookingId") long containerBookingId);
|
||||
@@ -55,19 +33,8 @@ public interface TerminalDrayageMapper extends BaseMapper<TerminalDrayageEntity>
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT b.id AS containerBookingId,b.container_no AS containerNo,b.ocean_booking_id AS oceanBookingId,
|
||||
b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt,
|
||||
q.id AS queryId,q.latest_standard_node AS latestActualNode,
|
||||
q.latest_node_observed_at AS latestActualAt,q.version AS queryVersion,
|
||||
(SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id
|
||||
AND f.standard_node='ARRIVED_DESTINATION_PORT') AS actualArrivalNodeAt,
|
||||
(SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id
|
||||
AND f.standard_node='PICKED_UP') AS actualPickupNodeAt,
|
||||
(SELECT MAX(f.actual_at) FROM fl_carrier_schedule_node_fact f WHERE f.query_id=q.id
|
||||
AND f.standard_node='EMPTY_RETURNED') AS actualEmptyReturnNodeAt
|
||||
b.record_status AS recordStatus,b.unloading_completed_at AS unloadingCompletedAt
|
||||
FROM fl_container_booking b
|
||||
LEFT JOIN fl_carrier_schedule_query q ON q.id=(
|
||||
SELECT q2.id FROM fl_carrier_schedule_query q2
|
||||
WHERE q2.container_booking_id=b.id ORDER BY q2.id DESC LIMIT 1)
|
||||
WHERE b.id IN
|
||||
<foreach collection="containerBookingIds" item="id" open="(" separator="," close=")">#{id}</foreach>
|
||||
ORDER BY b.id
|
||||
@@ -141,8 +108,6 @@ public interface TerminalDrayageMapper extends BaseMapper<TerminalDrayageEntity>
|
||||
@Param("operatorId") long operatorId);
|
||||
|
||||
record Context(Long containerBookingId, String containerNo, Long oceanBookingId, String recordStatus,
|
||||
LocalDateTime unloadingCompletedAt, Long queryId, String latestActualNode,
|
||||
LocalDateTime latestActualAt, Integer queryVersion, LocalDateTime actualArrivalNodeAt,
|
||||
LocalDateTime actualPickupNodeAt, LocalDateTime actualEmptyReturnNodeAt) {}
|
||||
LocalDateTime unloadingCompletedAt) {}
|
||||
record Operation(String operation, String requestHash, Integer resultVersion) {}
|
||||
}
|
||||
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
package com.baiyue.firstleg.terminaldrayage.service;
|
||||
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.Query;
|
||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
|
||||
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
|
||||
import com.baiyue.firstleg.repository.ContainerRepository;
|
||||
import com.baiyue.firstleg.terminaldrayage.dto.ContainerWorkbenchDtos.StatusSummary;
|
||||
import com.baiyue.firstleg.terminaldrayage.dto.ContainerWorkbenchDtos.WorkbenchItem;
|
||||
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.View;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** 基于同一货柜主档聚合国内、国外工作台读模型。 */
|
||||
@Service
|
||||
public class ContainerWorkbenchService {
|
||||
@Resource private ContainerRepository repository;
|
||||
@Resource private CarrierScheduleQueryService carrierSchedules;
|
||||
@Resource private TerminalDrayageService terminalDrayages;
|
||||
|
||||
public PageResult<WorkbenchItem> domesticPage(Query query, Set<String> permissions) {
|
||||
return page("DOMESTIC", query, permissions);
|
||||
}
|
||||
|
||||
public PageResult<WorkbenchItem> overseasPage(Query query, Set<String> permissions) {
|
||||
return page("OVERSEAS", query, permissions);
|
||||
}
|
||||
|
||||
public WorkbenchItem domesticDetail(long id, Set<String> permissions) {
|
||||
ContainerBookingEntity value = required(id);
|
||||
if (repository.departed(id)) throw new BizException(ResultCode.NOT_FOUND, "国内工作台货柜不存在");
|
||||
ContainerActualFacts facts = actualFacts(List.of(id)).get(id);
|
||||
return item("DOMESTIC", value, facts, null, permissions);
|
||||
}
|
||||
|
||||
public WorkbenchItem overseasDetail(long id, Set<String> permissions) {
|
||||
ContainerBookingEntity value = required(id);
|
||||
if (!repository.departed(id)) throw new BizException(ResultCode.NOT_FOUND, "国外工作台货柜不存在");
|
||||
View drayage = terminalDrayages.authorize(terminalDrayages.detail(id), permissions);
|
||||
return item("OVERSEAS", value, drayage.actualFacts(), drayage, permissions);
|
||||
}
|
||||
|
||||
private PageResult<WorkbenchItem> page(String workbench, Query query, Set<String> permissions) {
|
||||
validateRange(query.estimatedSailingFrom(), query.estimatedSailingTo(), "预计船期筛选");
|
||||
Query normalized = normalize(query);
|
||||
PageResult<ContainerBookingEntity> source = repository.page(workbench, normalized);
|
||||
List<Long> ids = source.records().stream().map(ContainerBookingEntity::getId).toList();
|
||||
Map<Long, ContainerActualFacts> facts = new HashMap<>();
|
||||
Map<Long, View> drayages = new HashMap<>();
|
||||
if (!ids.isEmpty() && "OVERSEAS".equals(workbench)) {
|
||||
terminalDrayages.authorize(terminalDrayages.views(ids), permissions).forEach(value -> {
|
||||
drayages.put(value.containerBookingId(), value);
|
||||
facts.put(value.containerBookingId(), value.actualFacts());
|
||||
});
|
||||
} else if (!ids.isEmpty()) {
|
||||
facts.putAll(actualFacts(ids));
|
||||
}
|
||||
List<WorkbenchItem> records = source.records().stream()
|
||||
.map(value -> item(workbench, value, facts.get(value.getId()), drayages.get(value.getId()), permissions))
|
||||
.toList();
|
||||
return new PageResult<>(records, source.page(), source.size(), source.total(), source.pages());
|
||||
}
|
||||
|
||||
private ContainerBookingEntity required(long id) {
|
||||
ContainerBookingEntity value = repository.find(id);
|
||||
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "货柜不存在");
|
||||
return value;
|
||||
}
|
||||
|
||||
private static void validateRange(LocalDateTime from, LocalDateTime to, String label) {
|
||||
if (from != null && to != null && to.isBefore(from)) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, label + "先后顺序不正确");
|
||||
}
|
||||
}
|
||||
|
||||
private static Query normalize(Query query) {
|
||||
return new Query(trim(query.containerNo()), trim(query.cabinetNo()), trim(query.billOfLadingNo()),
|
||||
trim(query.keyword()), query.customerId(), query.warehouseId(), trim(query.containerType()),
|
||||
query.shippingCompanyId(), trim(query.departurePortCode()), trim(query.destinationPortCode()),
|
||||
trim(query.destinationWarehouseCode()), query.status(), query.estimatedSailingFrom(),
|
||||
query.estimatedSailingTo(), query.scope(), query.page(), query.size());
|
||||
}
|
||||
|
||||
private Map<Long, ContainerActualFacts> actualFacts(List<Long> ids) {
|
||||
Map<Long, ContainerActualFacts> result = new HashMap<>();
|
||||
carrierSchedules.containerActualFacts(ids).forEach(value -> result.put(value.containerBookingId(), value));
|
||||
return result;
|
||||
}
|
||||
|
||||
private WorkbenchItem item(String workbench, ContainerBookingEntity container, ContainerActualFacts facts,
|
||||
View drayage, Set<String> permissions) {
|
||||
String stage = "DOMESTIC".equals(workbench) ? domesticStage(container) : overseasStage(container, facts, drayage);
|
||||
StatusSummary summary = new StatusSummary(container.getPlanStatus(),
|
||||
container.getCustomsDeclarationDocumentStatus(), container.getOutboundDispatchStatus(),
|
||||
container.getDestinationClearanceDocumentStatus(), container.getDeliveryOrderDocumentStatus(),
|
||||
trim(container.getPickupCode()) == null ? "MISSING" : "UPLOADED",
|
||||
facts == null ? null : facts.latestActualNode(), facts == null ? null : facts.latestActualAt(),
|
||||
drayage == null ? null : drayage.status(),
|
||||
drayage == null || drayage.emptyReturnWarning() == null ? null : drayage.emptyReturnWarning().status());
|
||||
List<String> actions = "DOMESTIC".equals(workbench)
|
||||
? domesticActions(container, permissions) : overseasActions(drayage, permissions);
|
||||
return new WorkbenchItem(container, stage, summary, actions, facts, drayage);
|
||||
}
|
||||
|
||||
private static String domesticStage(ContainerBookingEntity value) {
|
||||
if (value.getOutboundConfirmedAt() != null) return "DEPARTED_WAREHOUSE";
|
||||
if (value.getPlanStatus() == null || "WAIT_LOADING".equals(value.getPlanStatus())) return "WAIT_LOADING";
|
||||
if (!"COMPLETED".equals(value.getPlanStatus())) return "LOADING";
|
||||
if (!"COMPLETED".equals(value.getCustomsDeclarationDocumentStatus())) return "WAIT_CUSTOMS";
|
||||
return "WAIT_OUTBOUND";
|
||||
}
|
||||
|
||||
private static String overseasStage(ContainerBookingEntity value, ContainerActualFacts facts, View drayage) {
|
||||
if (drayage != null && "EMPTY_RETURNED".equals(drayage.status())) return "EMPTY_RETURNED";
|
||||
if (value.getUnloadingCompletedAt() != null) return "UNLOADED";
|
||||
if (drayage != null && "PICKED_UP".equals(drayage.status())) return "PICKED_UP";
|
||||
if (facts != null && facts.pickupReadyAt() != null) return "PICKUP_READY";
|
||||
if (facts != null && facts.arrivedDestinationPortAt() != null) return "ARRIVED_DESTINATION_PORT";
|
||||
if (facts != null && facts.departedOriginPortAt() != null) return "IN_TRANSIT";
|
||||
return "DEPARTED_WAREHOUSE";
|
||||
}
|
||||
|
||||
private static List<String> domesticActions(ContainerBookingEntity value, Set<String> permissions) {
|
||||
Set<String> granted = permissions == null ? Set.of() : permissions;
|
||||
List<String> actions = new ArrayList<>();
|
||||
if (granted.contains("platform:firstleg:container:edit")
|
||||
&& ("WAIT_LOADING".equals(value.getPlanStatus()) || "EDITING".equals(value.getPlanStatus()))) {
|
||||
actions.add("EDIT_CONTAINER");
|
||||
}
|
||||
if (granted.contains("platform:firstleg:customs:manage") && "COMPLETED".equals(value.getPlanStatus())
|
||||
&& value.getOutboundConfirmedAt() == null) actions.add("MANAGE_CUSTOMS");
|
||||
if (granted.contains("platform:firstleg:outbound-dispatch:confirm")
|
||||
&& "COMPLETED".equals(value.getPlanStatus())
|
||||
&& "COMPLETED".equals(value.getCustomsDeclarationDocumentStatus())
|
||||
&& value.getOutboundConfirmedAt() == null) actions.add("CONFIRM_OUTBOUND");
|
||||
return List.copyOf(actions);
|
||||
}
|
||||
|
||||
private static List<String> overseasActions(View drayage, Set<String> permissions) {
|
||||
Set<String> granted = permissions == null ? Set.of() : permissions;
|
||||
List<String> actions = new ArrayList<>();
|
||||
boolean returned = drayage != null && "EMPTY_RETURNED".equals(drayage.status());
|
||||
if (!returned && granted.contains("platform:firstleg:terminal-drayage:document:edit")) {
|
||||
actions.add("MANAGE_DESTINATION_DOCUMENTS");
|
||||
}
|
||||
if (drayage != null) actions.addAll(drayage.allowedActions());
|
||||
return List.copyOf(actions);
|
||||
}
|
||||
|
||||
private static String trim(String value) {
|
||||
if (value == null) return null;
|
||||
String result = value.trim();
|
||||
return result.isEmpty() ? null : result;
|
||||
}
|
||||
}
|
||||
+96
-49
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.portschedule.domain.CarrierScheduleNode;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest;
|
||||
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
|
||||
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
|
||||
@@ -35,6 +36,7 @@ import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -56,7 +58,10 @@ public class TerminalDrayageService {
|
||||
if (ids.size() != containerBookingIds.size()) throw badRequest("containerBookingIds不能包含空值或重复值");
|
||||
Map<Long, TerminalDrayageEntity> rows = new HashMap<>();
|
||||
mapper.findAll(ids).forEach(value -> rows.put(value.getContainerBookingId(), value));
|
||||
return mapper.contexts(ids).stream().map(value -> view(value, rows.get(value.containerBookingId()))).toList();
|
||||
Map<Long, ContainerActualFacts> facts = actualFacts(ids);
|
||||
return mapper.contexts(ids).stream().map(value -> context(value, facts.get(value.containerBookingId())))
|
||||
.filter(Objects::nonNull)
|
||||
.map(value -> view(value, rows.get(value.base().containerBookingId()))).toList();
|
||||
}
|
||||
|
||||
public List<History> histories(long containerBookingId) {
|
||||
@@ -64,11 +69,25 @@ public class TerminalDrayageService {
|
||||
return mapper.histories(containerBookingId);
|
||||
}
|
||||
|
||||
public List<View> authorize(List<View> values, Set<String> permissions) {
|
||||
return values.stream().map(value -> authorize(value, permissions)).toList();
|
||||
}
|
||||
|
||||
public View authorize(View value, Set<String> permissions) {
|
||||
Set<String> granted = permissions == null ? Set.of() : permissions;
|
||||
List<String> actions = value.allowedActions().stream()
|
||||
.filter(action -> granted.contains(permission(action))).toList();
|
||||
return new View(value.containerBookingId(), value.containerNo(), value.oceanBookingId(), value.status(),
|
||||
value.version(), value.fleetSupplierId(), value.fleetSupplierName(), value.pickupAppointmentAt(),
|
||||
value.actualPickupAt(), value.estimatedEmptyReturnAt(), value.actualEmptyReturnAt(),
|
||||
value.unloadingCompletedAt(), value.emptyReturnWarning(), actions, value.actualFacts(), value.updatedAt());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public View selectFleet(long containerBookingId, long operatorId, String operatorName, String key,
|
||||
FleetSelectionRequest request) {
|
||||
String action = "SELECT_FLEET";
|
||||
Context context = requiredLockedContext(containerBookingId);
|
||||
WorkContext context = requiredLockedContext(containerBookingId);
|
||||
View replay = replay(context, action, key, request);
|
||||
if (replay != null) return replay;
|
||||
requireArrived(context);
|
||||
@@ -90,7 +109,7 @@ public class TerminalDrayageService {
|
||||
public View schedulePickup(long containerBookingId, long operatorId, String operatorName, String key,
|
||||
PickupAppointmentRequest request) {
|
||||
String action = "SCHEDULE_PICKUP";
|
||||
Context context = requiredLockedContext(containerBookingId);
|
||||
WorkContext context = requiredLockedContext(containerBookingId);
|
||||
View replay = replay(context, action, key, request);
|
||||
if (replay != null) return replay;
|
||||
requireArrived(context);
|
||||
@@ -98,7 +117,8 @@ public class TerminalDrayageService {
|
||||
requireVersion(value, request.expectedVersion());
|
||||
if (value.getFleetSupplierId() == null) throw conflict("请先选择有效码头车队");
|
||||
if (actualPickupAt(context, value) != null) throw conflict("实际提柜后不可修改预约提柜时间");
|
||||
if (context.actualArrivalNodeAt() != null && request.pickupAppointmentAt().isBefore(context.actualArrivalNodeAt())) {
|
||||
if (context.facts().arrivedDestinationPortAt() != null
|
||||
&& request.pickupAppointmentAt().isBefore(context.facts().arrivedDestinationPortAt())) {
|
||||
throw badRequest("预约提柜时间不能早于实际到港时间");
|
||||
}
|
||||
int fromVersion = version(value);
|
||||
@@ -113,7 +133,7 @@ public class TerminalDrayageService {
|
||||
public View recordPickup(long containerBookingId, long operatorId, String operatorName, String key,
|
||||
ActualTimeRequest request) {
|
||||
String action = "RECORD_PICKUP";
|
||||
Context context = requiredLockedContext(containerBookingId);
|
||||
WorkContext context = requiredLockedContext(containerBookingId);
|
||||
View replay = replay(context, action, key, request);
|
||||
if (replay != null) return replay;
|
||||
requireAtLeast(context, CarrierScheduleNode.PICKUP_READY, "货柜尚未清关放行,不允许确认实际提柜");
|
||||
@@ -123,7 +143,7 @@ public class TerminalDrayageService {
|
||||
throw conflict("选择码头车队并预约提柜后才能确认实际提柜");
|
||||
}
|
||||
if (actualPickupAt(context, value) != null) throw conflict("实际提柜时间已确认");
|
||||
if (context.latestActualAt() != null && request.actualAt().isBefore(context.latestActualAt())) {
|
||||
if (context.facts().latestActualAt() != null && request.actualAt().isBefore(context.facts().latestActualAt())) {
|
||||
throw badRequest("实际提柜时间不能早于当前实际船节点时间");
|
||||
}
|
||||
advanceNode(context, CarrierScheduleNode.PICKED_UP, request.actualAt(), action, key,
|
||||
@@ -141,16 +161,16 @@ public class TerminalDrayageService {
|
||||
public View recordEmptyReturn(long containerBookingId, long operatorId, String operatorName, String key,
|
||||
ActualTimeRequest request) {
|
||||
String action = "RECORD_EMPTY_RETURN";
|
||||
Context context = requiredLockedContext(containerBookingId);
|
||||
WorkContext context = requiredLockedContext(containerBookingId);
|
||||
View replay = replay(context, action, key, request);
|
||||
if (replay != null) return replay;
|
||||
TerminalDrayageEntity value = requiredAggregate(containerBookingId);
|
||||
requireVersion(value, request.expectedVersion());
|
||||
LocalDateTime pickupAt = actualPickupAt(context, value);
|
||||
if (pickupAt == null) throw conflict("实际提柜后才能确认还柜");
|
||||
if (context.unloadingCompletedAt() == null) throw conflict("海外卸柜收货完成后才能确认还柜");
|
||||
LocalDateTime earliest = pickupAt.isAfter(context.unloadingCompletedAt())
|
||||
? pickupAt : context.unloadingCompletedAt();
|
||||
if (context.base().unloadingCompletedAt() == null) throw conflict("海外卸柜收货完成后才能确认还柜");
|
||||
LocalDateTime earliest = pickupAt.isAfter(context.base().unloadingCompletedAt())
|
||||
? pickupAt : context.base().unloadingCompletedAt();
|
||||
if (request.actualAt().isBefore(earliest)) throw badRequest("实际还柜时间不能早于提柜或卸柜完成时间");
|
||||
if (actualEmptyReturnAt(context, value) != null) throw conflict("实际还柜时间已确认");
|
||||
advanceNode(context, CarrierScheduleNode.EMPTY_RETURNED, request.actualAt(), action, key,
|
||||
@@ -164,40 +184,40 @@ public class TerminalDrayageService {
|
||||
fromVersion, request.reason(), operatorId, operatorName);
|
||||
}
|
||||
|
||||
private void advanceNode(Context context, CarrierScheduleNode target, LocalDateTime actualAt,
|
||||
private void advanceNode(WorkContext context, CarrierScheduleNode target, LocalDateTime actualAt,
|
||||
String action, String key, String reason, long operatorId, String operatorName) {
|
||||
if (context.queryId() == null || context.queryVersion() == null) {
|
||||
if (context.facts().queryId() == null || context.facts().queryVersion() == null) {
|
||||
throw conflict("货柜尚未建立船节点跟踪任务");
|
||||
}
|
||||
CarrierScheduleNode current = node(context.latestActualNode());
|
||||
CarrierScheduleNode current = node(context.facts().latestActualNode());
|
||||
if (current != null && current.sequence() >= target.sequence()) return;
|
||||
carrierSchedules.addManualNode(context.queryId(), operatorId, operatorName,
|
||||
"td-" + hash(context.containerBookingId() + "|" + action + "|" + key.trim()),
|
||||
new ManualNodeRequest(context.queryVersion(), target.name(), LocalDateTime.now(clock), null,
|
||||
carrierSchedules.addManualNode(context.facts().queryId(), operatorId, operatorName,
|
||||
"td-" + hash(context.base().containerBookingId() + "|" + action + "|" + key.trim()),
|
||||
new ManualNodeRequest(context.facts().queryVersion(), target.name(), LocalDateTime.now(clock), null,
|
||||
actualAt, null, null, StringUtils.hasText(reason) ? reason.trim() : "码头拖车操作同步"));
|
||||
}
|
||||
|
||||
private View finish(Context context, TerminalDrayageEntity value, String action, String key, Object request,
|
||||
private View finish(WorkContext context, TerminalDrayageEntity value, String action, String key, Object request,
|
||||
String fromStatus, int fromVersion, String reason,
|
||||
long operatorId, String operatorName) {
|
||||
TerminalDrayageEntity stored = mapper.find(context.containerBookingId());
|
||||
Context currentContext = requiredContext(context.containerBookingId());
|
||||
TerminalDrayageEntity stored = mapper.find(context.base().containerBookingId());
|
||||
WorkContext currentContext = requiredContext(context.base().containerBookingId());
|
||||
String toStatus = status(currentContext, stored);
|
||||
mapper.insertHistory(IdWorker.getId(), stored.getId(), context.containerBookingId(), action,
|
||||
mapper.insertHistory(IdWorker.getId(), stored.getId(), context.base().containerBookingId(), action,
|
||||
fromStatus, toStatus, fromVersion, version(stored), trim(reason), operatorId, operatorName);
|
||||
mapper.insertOperation(IdWorker.getId(), context.containerBookingId(), action, key.trim(), hash(request),
|
||||
mapper.insertOperation(IdWorker.getId(), context.base().containerBookingId(), action, key.trim(), hash(request),
|
||||
version(stored), operatorId);
|
||||
return view(currentContext, stored);
|
||||
}
|
||||
|
||||
private View replay(Context context, String action, String key, Object request) {
|
||||
private View replay(WorkContext context, String action, String key, Object request) {
|
||||
if (!StringUtils.hasText(key) || key.length() > 255) throw badRequest("Idempotency-Key不能为空且不能超过255字符");
|
||||
Operation operation = mapper.operation(context.containerBookingId(), key.trim());
|
||||
Operation operation = mapper.operation(context.base().containerBookingId(), key.trim());
|
||||
if (operation == null) return null;
|
||||
if (!action.equals(operation.operation()) || !hash(request).equals(operation.requestHash())) {
|
||||
throw conflict("Idempotency-Key已被不同的码头拖车操作占用");
|
||||
}
|
||||
return view(context, mapper.find(context.containerBookingId()));
|
||||
return view(context, mapper.find(context.base().containerBookingId()));
|
||||
}
|
||||
|
||||
private TerminalDrayageEntity create(long containerBookingId, long operatorId, String operatorName) {
|
||||
@@ -216,16 +236,16 @@ public class TerminalDrayageService {
|
||||
if (mapper.updateAggregate(value, expectedVersion, operatorId, operatorName) != 1) throw concurrent();
|
||||
}
|
||||
|
||||
private void applyAuthoritativeDeadline(Context context, TerminalDrayageEntity value) {
|
||||
if (context.unloadingCompletedAt() != null) {
|
||||
value.setEstimatedEmptyReturnAt(context.unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS));
|
||||
private void applyAuthoritativeDeadline(WorkContext context, TerminalDrayageEntity value) {
|
||||
if (context.base().unloadingCompletedAt() != null) {
|
||||
value.setEstimatedEmptyReturnAt(context.base().unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS));
|
||||
}
|
||||
}
|
||||
|
||||
private View view(Context context, TerminalDrayageEntity value) {
|
||||
private View view(WorkContext context, TerminalDrayageEntity value) {
|
||||
String status = status(context, value);
|
||||
LocalDateTime deadline = context.unloadingCompletedAt() == null ? null
|
||||
: context.unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS);
|
||||
LocalDateTime deadline = context.base().unloadingCompletedAt() == null ? null
|
||||
: context.base().unloadingCompletedAt().plusHours(EMPTY_RETURN_WARNING_HOURS);
|
||||
LocalDateTime returnedAt = actualEmptyReturnAt(context, value);
|
||||
String warningStatus;
|
||||
long overdueHours = 0;
|
||||
@@ -236,18 +256,18 @@ public class TerminalDrayageService {
|
||||
overdueHours = Math.max(0, Duration.between(deadline, LocalDateTime.now(clock)).toHours());
|
||||
} else warningStatus = "PENDING";
|
||||
Warning warning = new Warning(warningStatus, deadline, overdueHours, overdueHours / 24);
|
||||
return new View(context.containerBookingId(), context.containerNo(), context.oceanBookingId(), status,
|
||||
return new View(context.base().containerBookingId(), context.base().containerNo(), context.base().oceanBookingId(), status,
|
||||
version(value), value == null ? null : value.getFleetSupplierId(),
|
||||
value == null ? null : value.getFleetSupplierName(),
|
||||
value == null ? null : value.getPickupAppointmentAt(),
|
||||
actualPickupAt(context, value), deadline, returnedAt,
|
||||
context.unloadingCompletedAt(), warning, allowedActions(context, value),
|
||||
context.base().unloadingCompletedAt(), warning, allowedActions(context, value), context.facts(),
|
||||
value == null ? null : value.getUpdatedAt());
|
||||
}
|
||||
|
||||
private List<String> allowedActions(Context context, TerminalDrayageEntity value) {
|
||||
private List<String> allowedActions(WorkContext context, TerminalDrayageEntity value) {
|
||||
List<String> result = new ArrayList<>();
|
||||
CarrierScheduleNode current = node(context.latestActualNode());
|
||||
CarrierScheduleNode current = node(context.facts().latestActualNode());
|
||||
if (current != null && current.sequence() >= CarrierScheduleNode.ARRIVED_DESTINATION_PORT.sequence()
|
||||
&& current.sequence() < CarrierScheduleNode.PICKED_UP.sequence()
|
||||
&& actualPickupAt(context, value) == null) {
|
||||
@@ -257,27 +277,27 @@ public class TerminalDrayageService {
|
||||
if (current != null && current.sequence() >= CarrierScheduleNode.PICKUP_READY.sequence()
|
||||
&& value != null && value.getFleetSupplierId() != null && value.getPickupAppointmentAt() != null
|
||||
&& actualPickupAt(context, value) == null) result.add("RECORD_PICKUP");
|
||||
if (value != null && context.unloadingCompletedAt() != null && actualPickupAt(context, value) != null
|
||||
if (value != null && context.base().unloadingCompletedAt() != null && actualPickupAt(context, value) != null
|
||||
&& actualEmptyReturnAt(context, value) == null) result.add("RECORD_EMPTY_RETURN");
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
private String status(Context context, TerminalDrayageEntity value) {
|
||||
private String status(WorkContext context, TerminalDrayageEntity value) {
|
||||
if (actualEmptyReturnAt(context, value) != null) return "EMPTY_RETURNED";
|
||||
if (context.unloadingCompletedAt() != null && actualPickupAt(context, value) != null) return "UNLOADED";
|
||||
if (context.base().unloadingCompletedAt() != null && actualPickupAt(context, value) != null) return "UNLOADED";
|
||||
if (actualPickupAt(context, value) != null) return "PICKED_UP";
|
||||
if (value != null && value.getPickupAppointmentAt() != null) return "PICKUP_SCHEDULED";
|
||||
CarrierScheduleNode current = node(context.latestActualNode());
|
||||
CarrierScheduleNode current = node(context.facts().latestActualNode());
|
||||
return current != null && current.sequence() >= CarrierScheduleNode.ARRIVED_DESTINATION_PORT.sequence()
|
||||
? "ARRIVED" : "WAITING_FOR_ARRIVAL";
|
||||
}
|
||||
|
||||
private void requireArrived(Context context) {
|
||||
private void requireArrived(WorkContext context) {
|
||||
requireAtLeast(context, CarrierScheduleNode.ARRIVED_DESTINATION_PORT, "货柜实际到达目的港后才能安排码头车队");
|
||||
}
|
||||
|
||||
private void requireAtLeast(Context context, CarrierScheduleNode required, String message) {
|
||||
CarrierScheduleNode current = node(context.latestActualNode());
|
||||
private void requireAtLeast(WorkContext context, CarrierScheduleNode required, String message) {
|
||||
CarrierScheduleNode current = node(context.facts().latestActualNode());
|
||||
if (current == null || current.sequence() < required.sequence()) throw conflict(message);
|
||||
}
|
||||
|
||||
@@ -285,27 +305,45 @@ public class TerminalDrayageService {
|
||||
return StringUtils.hasText(value) ? CarrierScheduleNode.require(value) : null;
|
||||
}
|
||||
|
||||
private LocalDateTime actualPickupAt(Context context, TerminalDrayageEntity value) {
|
||||
return value != null && value.getActualPickupAt() != null ? value.getActualPickupAt() : context.actualPickupNodeAt();
|
||||
private LocalDateTime actualPickupAt(WorkContext context, TerminalDrayageEntity value) {
|
||||
return value != null && value.getActualPickupAt() != null ? value.getActualPickupAt() : context.facts().pickedUpAt();
|
||||
}
|
||||
|
||||
private LocalDateTime actualEmptyReturnAt(Context context, TerminalDrayageEntity value) {
|
||||
private LocalDateTime actualEmptyReturnAt(WorkContext context, TerminalDrayageEntity value) {
|
||||
return value != null && value.getActualEmptyReturnAt() != null
|
||||
? value.getActualEmptyReturnAt() : context.actualEmptyReturnNodeAt();
|
||||
? value.getActualEmptyReturnAt() : context.facts().emptyReturnedAt();
|
||||
}
|
||||
|
||||
private Context requiredContext(long id) {
|
||||
private WorkContext requiredContext(long id) {
|
||||
Context value = mapper.context(id);
|
||||
if (value == null || !"ACTIVE".equals(value.recordStatus())) throw notFound();
|
||||
return value;
|
||||
WorkContext context = context(value, actualFacts(List.of(id)).get(id));
|
||||
if (context == null) throw notFound();
|
||||
return context;
|
||||
}
|
||||
|
||||
private Context requiredLockedContext(long id) {
|
||||
private WorkContext requiredLockedContext(long id) {
|
||||
Context value = mapper.lockContext(id);
|
||||
if (value == null || !"ACTIVE".equals(value.recordStatus())) throw notFound();
|
||||
return value;
|
||||
WorkContext context = context(value, actualFacts(List.of(id)).get(id));
|
||||
if (context == null) throw notFound();
|
||||
return context;
|
||||
}
|
||||
|
||||
private Map<Long, ContainerActualFacts> actualFacts(List<Long> ids) {
|
||||
Map<Long, ContainerActualFacts> result = new HashMap<>();
|
||||
carrierSchedules.containerActualFacts(ids).forEach(value -> result.put(value.containerBookingId(), value));
|
||||
return result;
|
||||
}
|
||||
|
||||
private WorkContext context(Context base, ContainerActualFacts facts) {
|
||||
if (base == null || !"ACTIVE".equals(base.recordStatus()) || facts == null
|
||||
|| facts.departedWarehouseAt() == null) return null;
|
||||
return new WorkContext(base, facts);
|
||||
}
|
||||
|
||||
private record WorkContext(Context base, ContainerActualFacts facts) {}
|
||||
|
||||
private TerminalDrayageEntity requiredAggregate(long id) {
|
||||
TerminalDrayageEntity value = mapper.lock(id);
|
||||
if (value == null) throw conflict("请先选择码头车队");
|
||||
@@ -330,6 +368,15 @@ public class TerminalDrayageService {
|
||||
}
|
||||
|
||||
private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; }
|
||||
private static String permission(String action) {
|
||||
return switch (action) {
|
||||
case "SELECT_FLEET" -> "platform:firstleg:terminal-drayage:fleet:edit";
|
||||
case "SCHEDULE_PICKUP" -> "platform:firstleg:terminal-drayage:pickup-appointment:edit";
|
||||
case "RECORD_PICKUP" -> "platform:firstleg:terminal-drayage:pickup:confirm";
|
||||
case "RECORD_EMPTY_RETURN" -> "platform:firstleg:terminal-drayage:empty-return:confirm";
|
||||
default -> "";
|
||||
};
|
||||
}
|
||||
private static BizException badRequest(String message) { return new BizException(ResultCode.BAD_REQUEST, message); }
|
||||
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); }
|
||||
private static BizException concurrent() { return conflict("码头拖车记录已变化,请刷新后重试"); }
|
||||
|
||||
@@ -74,9 +74,13 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
|
||||
actual_warehouse_arrival_at DATETIME(3) NULL COMMENT '实际到仓时间',
|
||||
unloading_completed_at DATETIME(3) NULL COMMENT '海外仓卸柜完成时间(UTC)',
|
||||
unloading_photo_uploaded_at DATETIME(3) NULL COMMENT '海外仓卸柜照片上传成功时间(UTC)',
|
||||
estimated_empty_reported_at DATETIME(3) NULL COMMENT '预计报空时间(卸柜照片上传成功时间加两天,UTC)',
|
||||
estimated_empty_reported_at DATETIME(3) NULL COMMENT '预计还空时间(卸柜完成时间加72小时,UTC)',
|
||||
actual_empty_reported_at DATETIME(3) NULL COMMENT '实际报空时间(UTC)',
|
||||
empty_report_source_version BIGINT UNSIGNED NULL COMMENT '海外货柜事件聚合版本,用于阻止乱序覆盖',
|
||||
pickup_code VARCHAR(64) NULL COMMENT 'PU数字码;按字符串保存以保留前导零',
|
||||
pickup_code_updated_by BIGINT UNSIGNED NULL COMMENT 'PU码最后填写人ID',
|
||||
pickup_code_updated_by_name VARCHAR(64) NULL COMMENT 'PU码最后填写人名称快照',
|
||||
pickup_code_updated_at DATETIME(3) NULL COMMENT 'PU码最后填写时间',
|
||||
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
|
||||
created_by_name VARCHAR(64) NULL COMMENT '创建人名称快照',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
@@ -96,6 +100,7 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
|
||||
CONSTRAINT chk_container_booking_record_status CHECK (record_status IN ('ACTIVE','MERGED')),
|
||||
CONSTRAINT chk_container_booking_capacity CHECK (capacity_cbm IS NULL OR capacity_cbm > 0),
|
||||
CONSTRAINT chk_container_booking_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0),
|
||||
CONSTRAINT chk_container_booking_pickup_code CHECK (pickup_code IS NULL OR pickup_code REGEXP '^[0-9]{1,64}$'),
|
||||
CONSTRAINT chk_container_booking_iso6346 CHECK
|
||||
(container_no IS NULL OR container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$')
|
||||
) ENGINE=InnoDB COMMENT='平台货柜预订资料;预装状态唯一来源为装柜计划';
|
||||
@@ -162,7 +167,7 @@ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='fl_container_booking' AND column_name='estimated_empty_reported_at') THEN
|
||||
ALTER TABLE fl_container_booking ADD COLUMN estimated_empty_reported_at DATETIME(3) NULL
|
||||
COMMENT '预计报空时间(卸柜照片上传成功时间加两天,UTC)' AFTER unloading_photo_uploaded_at;
|
||||
COMMENT '预计还空时间(卸柜完成时间加72小时,UTC)' AFTER unloading_photo_uploaded_at;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='fl_container_booking' AND column_name='actual_empty_reported_at') THEN
|
||||
@@ -174,6 +179,26 @@ BEGIN
|
||||
ALTER TABLE fl_container_booking ADD COLUMN empty_report_source_version BIGINT UNSIGNED NULL
|
||||
COMMENT '海外货柜事件聚合版本,用于阻止乱序覆盖' AFTER actual_empty_reported_at;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='fl_container_booking' AND column_name='pickup_code') THEN
|
||||
ALTER TABLE fl_container_booking ADD COLUMN pickup_code VARCHAR(64) NULL
|
||||
COMMENT 'PU数字码;按字符串保存以保留前导零' AFTER empty_report_source_version;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='fl_container_booking' AND column_name='pickup_code_updated_by') THEN
|
||||
ALTER TABLE fl_container_booking ADD COLUMN pickup_code_updated_by BIGINT UNSIGNED NULL
|
||||
COMMENT 'PU码最后填写人ID' AFTER pickup_code;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='fl_container_booking' AND column_name='pickup_code_updated_by_name') THEN
|
||||
ALTER TABLE fl_container_booking ADD COLUMN pickup_code_updated_by_name VARCHAR(64) NULL
|
||||
COMMENT 'PU码最后填写人名称快照' AFTER pickup_code_updated_by;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='fl_container_booking' AND column_name='pickup_code_updated_at') THEN
|
||||
ALTER TABLE fl_container_booking ADD COLUMN pickup_code_updated_at DATETIME(3) NULL
|
||||
COMMENT 'PU码最后填写时间' AFTER pickup_code_updated_by_name;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
|
||||
AND table_name='fl_container_booking' AND index_name='uk_container_booking_container_no') THEN
|
||||
ALTER TABLE fl_container_booking ADD UNIQUE KEY uk_container_booking_container_no (container_no);
|
||||
@@ -197,6 +222,11 @@ BEGIN
|
||||
ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_iso6346
|
||||
CHECK (container_no IS NULL OR container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$');
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE()
|
||||
AND table_name='fl_container_booking' AND constraint_name='chk_container_booking_pickup_code') THEN
|
||||
ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_pickup_code
|
||||
CHECK (pickup_code IS NULL OR pickup_code REGEXP '^[0-9]{1,64}$');
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL phase3_container_real_unit_columns();
|
||||
@@ -324,11 +354,11 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item (
|
||||
CONSTRAINT chk_loading_item_deleted CHECK (deleted IN (0, 1))
|
||||
) ENGINE=InnoDB COMMENT='智能装柜客户货物明细;客户归属仅在明细层';
|
||||
|
||||
-- 柜次级简单文件仅承载目的港清关、提货单和取货单;出口报关由 customs-v1 柜级聚合承载。
|
||||
-- 柜次级简单文件仅承载目的港清关和提货单;PU资料作为数字码保存在货柜主表。
|
||||
CREATE TABLE IF NOT EXISTS fl_container_document (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花ID)',
|
||||
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID',
|
||||
document_type VARCHAR(32) NOT NULL COMMENT '文件类型:DESTINATION_CLEARANCE目的港清关、DELIVERY_ORDER提货单、PICKUP_ORDER取货单',
|
||||
document_type VARCHAR(32) NOT NULL COMMENT '文件类型:DESTINATION_CLEARANCE目的港清关、DELIVERY_ORDER提货单',
|
||||
file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID,不建立跨库外键',
|
||||
file_name VARCHAR(255) NOT NULL COMMENT '文件名称快照',
|
||||
file_hash VARCHAR(64) NULL COMMENT 'File服务SHA-256快照',
|
||||
@@ -347,8 +377,17 @@ CREATE TABLE IF NOT EXISTS fl_container_document (
|
||||
CONSTRAINT fk_container_document_booking FOREIGN KEY (container_booking_id)
|
||||
REFERENCES fl_container_booking (id),
|
||||
CONSTRAINT chk_container_document_type
|
||||
CHECK (document_type IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER'))
|
||||
) ENGINE=InnoDB COMMENT='平台货柜清关、DO与PU文件关联';
|
||||
CHECK (document_type IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER'))
|
||||
) ENGINE=InnoDB COMMENT='平台货柜清关与DO文件关联';
|
||||
|
||||
SET @has_container_document_type_check=(SELECT COUNT(*) FROM information_schema.table_constraints
|
||||
WHERE table_schema=DATABASE() AND table_name='fl_container_document'
|
||||
AND constraint_name='chk_container_document_type' AND constraint_type='CHECK');
|
||||
SET @ddl=IF(@has_container_document_type_check=0,'SELECT 1',
|
||||
'ALTER TABLE fl_container_document DROP CHECK chk_container_document_type');
|
||||
PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement;
|
||||
ALTER TABLE fl_container_document ADD CONSTRAINT chk_container_document_type
|
||||
CHECK (document_type IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER'));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_container_exception_history (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '货柜异常历史ID', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '物理货柜ID',
|
||||
@@ -1152,7 +1191,7 @@ BEGIN
|
||||
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
|
||||
WHERE container_booking_id = NEW.container_booking_id;
|
||||
IF current_plan_status = 'COMPLETED'
|
||||
AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN
|
||||
AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增';
|
||||
END IF;
|
||||
END$$
|
||||
@@ -1168,7 +1207,7 @@ BEGIN
|
||||
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
|
||||
WHERE container_booking_id = OLD.container_booking_id;
|
||||
IF current_plan_status = 'COMPLETED'
|
||||
AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN
|
||||
AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可修改';
|
||||
END IF;
|
||||
END$$
|
||||
|
||||
@@ -10,6 +10,25 @@ paths:
|
||||
x-permission: platform:firstleg:port-schedule-query:list
|
||||
responses:
|
||||
'200': {description: 'MATSON, SML, ZIM, COSCO, EMC and HEDE descriptors'}
|
||||
/api/platform/v1/carrier-schedule-queries/container-actual-facts:
|
||||
get:
|
||||
summary: Batch query actual carrier-node facts for container workbenches
|
||||
description: Returns at most one row for each container that has a carrier-schedule query. Missing rows mean that no query exists. Estimated facts are never returned or inferred.
|
||||
x-permission: platform:firstleg:port-schedule-query:list
|
||||
parameters:
|
||||
- name: containerBookingIds
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 200
|
||||
uniqueItems: true
|
||||
items: {type: integer, format: int64, minimum: 1}
|
||||
responses:
|
||||
'200':
|
||||
description: Actual-only summaries ordered by containerBookingId
|
||||
content: {application/json: {schema: {$ref: '#/components/schemas/ContainerActualFactsListEnvelope'}}}
|
||||
/api/platform/v1/carrier-schedule-queries:
|
||||
get:
|
||||
summary: Query carrier schedule tasks
|
||||
@@ -175,6 +194,30 @@ components:
|
||||
properties:
|
||||
query: {$ref: '#/components/schemas/Task'}
|
||||
expired: {type: boolean}
|
||||
ContainerActualFacts:
|
||||
type: object
|
||||
required: [containerBookingId, queryId, queryVersion, queryStatus]
|
||||
description: Actual facts only. All node times are null until an immutable fact with actualAt exists; estimatedAt is intentionally absent.
|
||||
properties:
|
||||
containerBookingId: {type: integer, format: int64}
|
||||
queryId: {type: integer, format: int64}
|
||||
queryVersion: {type: integer, minimum: 0}
|
||||
queryStatus: {$ref: '#/components/schemas/QueryStatus'}
|
||||
latestActualNode: {allOf: [{$ref: '#/components/schemas/StandardNode'}], nullable: true}
|
||||
latestActualAt: {type: string, format: date-time, nullable: true}
|
||||
departedWarehouseAt: {type: string, format: date-time, nullable: true}
|
||||
departedOriginPortAt: {type: string, format: date-time, nullable: true}
|
||||
arrivedDestinationPortAt: {type: string, format: date-time, nullable: true}
|
||||
pickupReadyAt: {type: string, format: date-time, nullable: true}
|
||||
pickedUpAt: {type: string, format: date-time, nullable: true}
|
||||
emptyReturnedAt: {type: string, format: date-time, nullable: true}
|
||||
ContainerActualFactsListEnvelope:
|
||||
type: object
|
||||
required: [code, data]
|
||||
properties:
|
||||
code: {type: string, example: '200'}
|
||||
message: {type: string, nullable: true}
|
||||
data: {type: array, items: {$ref: '#/components/schemas/ContainerActualFacts'}}
|
||||
NodeFact:
|
||||
type: object
|
||||
required: [id, providerCode, providerEventId, providerNodeCode, standardNode, nodeSequence, observedAt, rawPayloadHash, createdAt]
|
||||
|
||||
@@ -406,25 +406,6 @@ paths:
|
||||
'200': {description: 成功或业务失败,按响应体 code 判断}
|
||||
'403': {description: 无分配权限}
|
||||
/api/platform/v1/containers:
|
||||
get:
|
||||
summary: 分页查询平台货柜
|
||||
description: 需要 platform:firstleg:container:list 权限;货柜为平台级数据,不传 tenant/account
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/Page'}
|
||||
- {$ref: '#/components/parameters/Size'}
|
||||
- {name: keyword, in: query, schema: {type: string, maxLength: 128}}
|
||||
- {name: customerId, in: query, description: 仅返回包含该客户货物的柜次, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: containerNo, in: query, schema: {type: string, maxLength: 64}}
|
||||
- {name: cabinetNo, in: query, schema: {type: string, maxLength: 64}}
|
||||
- {name: warehouseId, in: query, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: status, in: query, schema: {type: string, enum: [WAIT_LOADING, EDITING, LOADING, COMPLETED]}}
|
||||
responses:
|
||||
'200':
|
||||
description: 成功或业务失败,按响应体 code 判断
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/ContainerBookingPageEnvelope'}
|
||||
'403': {description: 无货柜查询权限}
|
||||
post:
|
||||
summary: 新增内部模拟柜
|
||||
description: 需要 platform:firstleg:container:create 权限;服务端生成稳定 containerBookingId/cabinetNo,不要求真实柜号或订舱
|
||||
@@ -436,6 +417,43 @@ paths:
|
||||
responses:
|
||||
'200': {description: 成功或重复号、参数校验等业务失败}
|
||||
'403': {description: 无新增按钮权限}
|
||||
/api/platform/v1/containers/domestic:
|
||||
get:
|
||||
summary: 分页查询国内货柜工作台
|
||||
description: 需要 platform:firstleg:container:list;ACTIVE仅真实国内离仓前,HISTORY仅已真实离仓
|
||||
parameters: &ContainerWorkbenchQueryParameters
|
||||
- {$ref: '#/components/parameters/Page'}
|
||||
- {$ref: '#/components/parameters/Size'}
|
||||
- {name: scope, in: query, required: true, schema: {type: string, enum: [ACTIVE, HISTORY]}}
|
||||
- {name: keyword, in: query, schema: {type: string, maxLength: 128}}
|
||||
- {name: customerId, in: query, description: 仅返回包含该客户货物的柜次, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: containerNo, in: query, schema: {type: string, maxLength: 64}}
|
||||
- {name: cabinetNo, in: query, schema: {type: string, maxLength: 64}}
|
||||
- {name: warehouseId, in: query, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: status, in: query, description: 仅筛选装柜子状态,不作为出仓或船节点状态, schema: {type: string, enum: [WAIT_LOADING, EDITING, LOADING, COMPLETED]}}
|
||||
responses:
|
||||
'200': {description: 成功, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerWorkbenchPageEnvelope'}}}}
|
||||
'403': {description: 无国内货柜查询权限}
|
||||
/api/platform/v1/containers/overseas:
|
||||
get:
|
||||
summary: 分页查询国外货柜工作台
|
||||
description: 需要 platform:firstleg:terminal-drayage:list;ACTIVE从真实DEPARTED_WAREHOUSE至EMPTY_RETURNED前,HISTORY仅已还空柜
|
||||
parameters: *ContainerWorkbenchQueryParameters
|
||||
responses:
|
||||
'200': {description: 成功, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerWorkbenchPageEnvelope'}}}}
|
||||
'403': {description: 无国外货柜查询权限}
|
||||
/api/platform/v1/containers/domestic/{id}:
|
||||
get:
|
||||
summary: 查询国内货柜工作台详情
|
||||
description: 需要 platform:firstleg:container:list;装柜清单只返回唯一loadingPlanId/planNo/planStatus关联
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
|
||||
responses: {'200': {description: 成功, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerWorkbenchEnvelope'}}}}}
|
||||
/api/platform/v1/containers/overseas/{id}:
|
||||
get:
|
||||
summary: 查询国外货柜工作台详情
|
||||
description: 需要 platform:firstleg:terminal-drayage:list;同一containerBookingId只读聚合柜内组成和海外履约
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
|
||||
responses: {'200': {description: 成功, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerWorkbenchEnvelope'}}}}}
|
||||
/api/platform/v1/containers/{id}/ocean-booking-link:
|
||||
post:
|
||||
summary: 将内部模拟柜关联到已发布订舱需求
|
||||
@@ -444,14 +462,6 @@ paths:
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerOceanBookingLinkRequest'}}}}
|
||||
responses: {'200': {description: 关联后的同一模拟柜 containerBookingId/cabinetNo;列表仍只有这一条 ACTIVE 操作身份}}
|
||||
/api/platform/v1/containers/{id}:
|
||||
get:
|
||||
summary: 查询平台货柜详情
|
||||
description: 需要 platform:firstleg:container:list 权限
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/ContainerBookingId'}
|
||||
responses:
|
||||
'200': {description: 成功或货柜不存在}
|
||||
'403': {description: 无货柜查询权限}
|
||||
put:
|
||||
summary: 草稿阶段编辑货柜基础资料
|
||||
description: 需要 platform:firstleg:container:edit 权限;仅待装柜或计划为 EDITING 时允许;请求不包含 containerNo/cabinetNo,不形成真实柜号或内部柜次号写入口
|
||||
@@ -465,16 +475,23 @@ paths:
|
||||
responses:
|
||||
'200': {description: 成功或状态、版本、重复号冲突}
|
||||
'403': {description: 无货柜编辑权限}
|
||||
/api/platform/v1/containers/{id}/pickup-code:
|
||||
put:
|
||||
summary: 填写或更新PU码
|
||||
description: 需要 platform:firstleg:terminal-drayage:document:edit 权限;仅国外工作台货柜;PU资料为1至64位数字码,按字符串保存以保留前导零
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerPickupCodeRequest'}}}}
|
||||
responses: {'200': {description: 返回最新货柜履约概览}, '403': {description: 无货柜编辑权限}}
|
||||
/api/platform/v1/containers/{id}/clearance:
|
||||
get:
|
||||
summary: 查询货柜目的港清关资料
|
||||
description: 需要 platform:firstleg:customs:manage 权限
|
||||
description: 需要 platform:firstleg:terminal-drayage:list 权限;仅国外工作台货柜
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
|
||||
responses: {'200': {description: 成功或货柜不存在}}
|
||||
/api/platform/v1/containers/{id}/clearance/document:
|
||||
post:
|
||||
summary: 上传或替换货柜目的港清关资料
|
||||
description: 需要 platform:firstleg:customs:manage 权限,fileId 必须严格绑定当前货柜
|
||||
description: 需要 platform:firstleg:terminal-drayage:document:edit 权限,fileId 必须严格绑定当前货柜
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -483,7 +500,7 @@ paths:
|
||||
/api/platform/v1/containers/{id}/clearance/document/{fileId}/download:
|
||||
get:
|
||||
summary: 下载货柜目的港清关附件
|
||||
description: 需要 platform:firstleg:customs:manage 权限;服务端校验当前 DESTINATION_CLEARANCE fileId 及 File 业务归属,完成态仍可下载,响应禁止缓存
|
||||
description: 需要 platform:firstleg:terminal-drayage:list 权限;服务端校验当前 DESTINATION_CLEARANCE fileId 及 File 业务归属,完成态仍可下载,响应禁止缓存
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/ContainerBookingId'}
|
||||
- {name: fileId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
@@ -493,7 +510,7 @@ paths:
|
||||
/api/platform/v1/containers/{id}/clearance/document/upload:
|
||||
post:
|
||||
summary: 直接选择文件上传或替换货柜目的港清关资料
|
||||
description: Firstleg 使用内部服务令牌上传 File,浏览器不得传递 fileId 或内部令牌;旧 JSON document 接口仅兼容存量调用。
|
||||
description: 需要 platform:firstleg:terminal-drayage:document:edit;Firstleg 使用内部服务令牌上传 File,浏览器不得传递 fileId 或内部令牌。
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -504,7 +521,7 @@ paths:
|
||||
/api/platform/v1/containers/{id}/clearance/complete:
|
||||
post:
|
||||
summary: 点击完成目的港清关
|
||||
description: 需要 platform:firstleg:customs:manage 权限
|
||||
description: 需要 platform:firstleg:terminal-drayage:document:edit 权限
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -1987,7 +2004,7 @@ components:
|
||||
publicRemark: {type: string, nullable: true}
|
||||
expressCompanyName: {type: string, nullable: true}
|
||||
expressTrackingNo: {type: string, nullable: true}
|
||||
ContainerBookingPageEnvelope:
|
||||
ContainerWorkbenchPageEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
code: {type: string, example: '200'}
|
||||
@@ -1995,14 +2012,78 @@ components:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
records: {type: array, items: {$ref: '#/components/schemas/ContainerBooking'}}
|
||||
records: {type: array, items: {$ref: '#/components/schemas/ContainerWorkbenchItem'}}
|
||||
page: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
size: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
total: {type: string, pattern: '^[0-9]+$'}
|
||||
pages: {type: string, pattern: '^[0-9]+$'}
|
||||
ContainerWorkbenchEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
code: {type: string, example: '200'}
|
||||
message: {type: string}
|
||||
data: {$ref: '#/components/schemas/ContainerWorkbenchItem'}
|
||||
ContainerWorkbenchItem:
|
||||
type: object
|
||||
required: [container, workbenchStage, statusSummary, allowedActions]
|
||||
properties:
|
||||
container: {$ref: '#/components/schemas/ContainerBooking'}
|
||||
workbenchStage: {type: string, enum: [WAIT_LOADING, LOADING, WAIT_CUSTOMS, WAIT_OUTBOUND, DEPARTED_WAREHOUSE, IN_TRANSIT, ARRIVED_DESTINATION_PORT, PICKUP_READY, PICKED_UP, UNLOADED, EMPTY_RETURNED]}
|
||||
statusSummary: {$ref: '#/components/schemas/ContainerWorkbenchStatusSummary'}
|
||||
allowedActions:
|
||||
type: array
|
||||
description: 已同时按真实业务状态与当前用户权限裁剪
|
||||
items: {type: string, enum: [EDIT_CONTAINER, MANAGE_CUSTOMS, CONFIRM_OUTBOUND, MANAGE_DESTINATION_DOCUMENTS, SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN]}
|
||||
actualFacts: {$ref: '#/components/schemas/ContainerActualFacts'}
|
||||
terminalDrayage: {$ref: '#/components/schemas/ContainerTerminalDrayage'}
|
||||
ContainerWorkbenchStatusSummary:
|
||||
type: object
|
||||
properties:
|
||||
loadingStatus: {type: string, nullable: true}
|
||||
customsStatus: {type: string, nullable: true}
|
||||
outboundStatus: {type: string, nullable: true}
|
||||
destinationClearanceStatus: {$ref: '#/components/schemas/ContainerDocumentStatus'}
|
||||
deliveryOrderStatus: {$ref: '#/components/schemas/ContainerDocumentStatus'}
|
||||
pickupCodeStatus: {type: string, enum: [MISSING, UPLOADED]}
|
||||
latestActualNode: {type: string, nullable: true}
|
||||
latestActualAt: {type: string, format: date-time, nullable: true}
|
||||
terminalDrayageStatus: {type: string, nullable: true}
|
||||
emptyReturnWarningStatus: {type: string, enum: [NOT_APPLICABLE, PENDING, OVERDUE, RESOLVED], nullable: true}
|
||||
ContainerActualFacts:
|
||||
type: object
|
||||
nullable: true
|
||||
description: 仅来自最新船节点任务的actual_at;无任务时为null,不用预计时间推断
|
||||
properties:
|
||||
containerBookingId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
queryId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
queryVersion: {type: integer, minimum: 0}
|
||||
queryStatus: {type: string}
|
||||
latestActualNode: {type: string, nullable: true}
|
||||
latestActualAt: {type: string, format: date-time, nullable: true}
|
||||
departedWarehouseAt: {type: string, format: date-time, nullable: true}
|
||||
departedOriginPortAt: {type: string, format: date-time, nullable: true}
|
||||
arrivedDestinationPortAt: {type: string, format: date-time, nullable: true}
|
||||
pickupReadyAt: {type: string, format: date-time, nullable: true}
|
||||
pickedUpAt: {type: string, format: date-time, nullable: true}
|
||||
emptyReturnedAt: {type: string, format: date-time, nullable: true}
|
||||
ContainerTerminalDrayage:
|
||||
type: object
|
||||
nullable: true
|
||||
properties:
|
||||
status: {type: string, enum: [WAITING_FOR_ARRIVAL, ARRIVED, PICKUP_SCHEDULED, PICKED_UP, UNLOADED, EMPTY_RETURNED]}
|
||||
version: {type: integer, minimum: 0}
|
||||
fleetSupplierId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
|
||||
fleetSupplierName: {type: string, nullable: true}
|
||||
pickupAppointmentAt: {type: string, format: date-time, nullable: true}
|
||||
actualPickupAt: {type: string, format: date-time, nullable: true}
|
||||
estimatedEmptyReturnAt: {type: string, format: date-time, nullable: true, description: unloadingCompletedAt加72小时}
|
||||
actualEmptyReturnAt: {type: string, format: date-time, nullable: true}
|
||||
unloadingCompletedAt: {type: string, format: date-time, nullable: true}
|
||||
emptyReturnWarning: {type: object, nullable: true}
|
||||
allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN]}}
|
||||
ContainerBooking:
|
||||
type: object
|
||||
required: [id, bookingNo, cabinetNo, sourceType, recordStatus, warehouseId, warehouseName, containerType, capacityCbm, maxWeightKg, planStatus, exceptionActive, destinationClearanceDocumentStatus, customsDeclarationDocumentStatus, deliveryOrderDocumentStatus, pickupOrderDocumentStatus, version]
|
||||
required: [id, bookingNo, cabinetNo, sourceType, recordStatus, warehouseId, warehouseName, containerType, capacityCbm, maxWeightKg, planStatus, exceptionActive, destinationClearanceDocumentStatus, customsDeclarationDocumentStatus, deliveryOrderDocumentStatus, version]
|
||||
properties:
|
||||
id: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
planId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 关联智能装柜计划ID;未创建计划时为空}
|
||||
@@ -2044,7 +2125,7 @@ components:
|
||||
actualWarehouseArrivalAt: {type: string, format: date-time, nullable: true}
|
||||
unloadingCompletedAt: {type: string, format: date-time, nullable: true, description: 海外仓卸柜完成时间}
|
||||
unloadingPhotoUploadedAt: {type: string, format: date-time, nullable: true, description: 海外仓卸柜照片上传成功时间}
|
||||
estimatedEmptyReportedAt: {type: string, format: date-time, nullable: true, description: 预计报空时间,由卸柜照片上传成功时间加两天计算}
|
||||
estimatedEmptyReportedAt: {type: string, format: date-time, nullable: true, description: 预计还空时间,统一按卸柜完成时间加72小时计算}
|
||||
actualEmptyReportedAt: {type: string, format: date-time, nullable: true, description: 实际报空时间}
|
||||
emptyReportSourceVersion: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 海外货柜事件聚合版本}
|
||||
planStatus: {type: string, enum: [WAIT_LOADING, EDITING, LOADING, COMPLETED]}
|
||||
@@ -2052,12 +2133,17 @@ components:
|
||||
destinationClearanceDocumentStatus: {$ref: '#/components/schemas/ContainerDocumentStatus'}
|
||||
customsDeclarationDocumentStatus: {$ref: '#/components/schemas/ContainerDocumentStatus'}
|
||||
deliveryOrderDocumentStatus: {$ref: '#/components/schemas/ContainerDocumentStatus'}
|
||||
pickupOrderDocumentStatus: {$ref: '#/components/schemas/ContainerDocumentStatus'}
|
||||
outboundDispatchStatus: {type: string, nullable: true, description: 国内出仓聚合状态,不使用planStatus推断}
|
||||
outboundConfirmedAt: {type: string, format: date-time, nullable: true, description: 真实国内离仓确认时间}
|
||||
pickupCode: {type: string, pattern: '^[0-9]{1,64}$', nullable: true, description: PU数字码;字符串保存以保留前导零}
|
||||
pickupCodeUpdatedBy: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
|
||||
pickupCodeUpdatedByName: {type: string, nullable: true}
|
||||
pickupCodeUpdatedAt: {type: string, format: date-time, nullable: true}
|
||||
version: {type: integer, minimum: 0}
|
||||
ContainerDocumentStatus:
|
||||
type: string
|
||||
enum: [MISSING, UPLOADED, COMPLETED]
|
||||
description: 单证聚合状态;DO/PU 无完成动作,因此只返回 MISSING 或 UPLOADED
|
||||
description: 单证聚合状态;DO 无完成动作,因此只返回 MISSING 或 UPLOADED
|
||||
ContainerSaveRequest:
|
||||
type: object
|
||||
required: [warehouseId, warehouseName, containerType, capacityCbm, maxWeightKg]
|
||||
@@ -2367,6 +2453,12 @@ components:
|
||||
file: {type: string, format: binary, description: 用户直接选择的清关资料}
|
||||
expectedVersion: {type: integer, minimum: 0, description: 当前文档乐观锁版本,首次上传为 0}
|
||||
operationReason: {type: string, maxLength: 500, nullable: true}
|
||||
ContainerPickupCodeRequest:
|
||||
type: object
|
||||
required: [pickupCode, expectedVersion]
|
||||
properties:
|
||||
pickupCode: {type: string, pattern: '^[0-9]{1,64}$'}
|
||||
expectedVersion: {type: integer, minimum: 0}
|
||||
MerchantInboundBillingEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -7,6 +7,19 @@ paths:
|
||||
description: '权限:platform:firstleg:container:list'
|
||||
parameters: [{$ref: '#/components/parameters/ContainerId'}]
|
||||
responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/OverviewEnvelope'}}}}}
|
||||
/api/platform/v1/containers/overseas/{id}/overview:
|
||||
get:
|
||||
operationId: getOverseasContainerOverview
|
||||
description: '权限:platform:firstleg:terminal-drayage:list;仅真实国内离仓后的国外工作台货柜'
|
||||
parameters: [{$ref: '#/components/parameters/ContainerId'}]
|
||||
responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/OverviewEnvelope'}}}}}
|
||||
/api/platform/v1/containers/{id}/pickup-code:
|
||||
put:
|
||||
operationId: updateContainerPickupCode
|
||||
description: '权限:platform:firstleg:terminal-drayage:document:edit;PU资料为1至64位数字码,以字符串保存并保留前导零'
|
||||
parameters: [{$ref: '#/components/parameters/ContainerId'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PickupCodeRequest'}}}}
|
||||
responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/OverviewEnvelope'}}}}}
|
||||
/api/platform/v1/containers/{id}/exception:
|
||||
parameters: [{$ref: '#/components/parameters/ContainerId'}]
|
||||
post: {operationId: markContainerException, description: '权限:platform:firstleg:container:edit', parameters: [{name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}}], requestBody: {$ref: '#/components/requestBodies/ExceptionRequest'}, responses: {'200': {description: OK}}}
|
||||
@@ -20,32 +33,32 @@ paths:
|
||||
/api/platform/v1/containers/{id}/documents/{documentType}:
|
||||
get:
|
||||
operationId: getContainerDocument
|
||||
description: '权限:platform:firstleg:container:list;仅DO/PU,completed恒为false'
|
||||
description: '权限:platform:firstleg:terminal-drayage:list;仅国外工作台DO,completed恒为false'
|
||||
parameters: [{$ref: '#/components/parameters/ContainerId'}, {$ref: '#/components/parameters/DocumentType'}]
|
||||
responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentEnvelope'}}}}}
|
||||
/api/platform/v1/containers/{id}/documents/{documentType}/upload:
|
||||
post:
|
||||
operationId: uploadContainerDocument
|
||||
description: '权限:platform:firstleg:container:edit;仅DO/PU,无完成动作'
|
||||
description: '权限:platform:firstleg:terminal-drayage:document:edit;仅国外工作台DO,无完成动作'
|
||||
parameters: [{$ref: '#/components/parameters/ContainerId'}, {$ref: '#/components/parameters/DocumentType'}, {name: Idempotency-Key, in: header, required: true, schema: {type: string}}]
|
||||
requestBody: {required: true, content: {multipart/form-data: {schema: {type: object, required: [file, expectedVersion], properties: {file: {type: string, format: binary}, expectedVersion: {type: integer, minimum: 0}, operationReason: {type: string, maxLength: 500}}}}}}
|
||||
responses: {'200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentEnvelope'}}}}}
|
||||
/api/platform/v1/containers/{id}/documents/{documentType}/{fileId}/download:
|
||||
get:
|
||||
operationId: downloadContainerDocument
|
||||
description: '权限:platform:firstleg:container:list;Firstleg代理File内部下载,不向调用方暴露内部令牌'
|
||||
description: '权限:platform:firstleg:terminal-drayage:list;Firstleg代理File内部下载,不向调用方暴露内部令牌'
|
||||
parameters: [{$ref: '#/components/parameters/ContainerId'}, {$ref: '#/components/parameters/DocumentType'}, {name: fileId, in: path, required: true, schema: {type: integer, format: int64}}]
|
||||
responses: {'200': {description: file bytes, content: {application/octet-stream: {schema: {type: string, format: binary}}}}}
|
||||
/api/platform/v1/containers/{id}/clearance/document/{fileId}/download:
|
||||
get:
|
||||
operationId: downloadContainerDestinationClearance
|
||||
description: '权限:platform:firstleg:customs:manage;校验当前 DESTINATION_CLEARANCE fileId 以及 File tenantId=0、businessType=CONTAINER_CLEARANCE_DOCUMENT、businessId=id;返回 attachment 与 no-store,完成态仍可下载'
|
||||
description: '权限:platform:firstleg:terminal-drayage:list;校验当前 DESTINATION_CLEARANCE fileId 以及 File tenantId=0、businessType=CONTAINER_CLEARANCE_DOCUMENT、businessId=id;返回 attachment 与 no-store,完成态仍可下载'
|
||||
parameters: [{$ref: '#/components/parameters/ContainerId'}, {name: fileId, in: path, required: true, schema: {type: integer, format: int64}}]
|
||||
responses: {'200': {description: clearance file bytes, content: {application/octet-stream: {schema: {type: string, format: binary}}}}, '404': {description: current file or ownership mismatch}}
|
||||
components:
|
||||
parameters:
|
||||
ContainerId: {name: id, in: path, required: true, schema: {type: integer, format: int64}}
|
||||
DocumentType: {name: documentType, in: path, required: true, schema: {type: string, enum: [DELIVERY_ORDER, PICKUP_ORDER]}, description: '通用单证仅支持DO/PU;目的港清关使用 /clearance;出口报关由 customs-v1 柜级聚合接口承载'}
|
||||
DocumentType: {name: documentType, in: path, required: true, schema: {type: string, enum: [DELIVERY_ORDER]}, description: '通用单证仅支持DO;PU资料使用 /pickup-code;目的港清关使用 /clearance;出口报关由 customs-v1 柜级聚合接口承载'}
|
||||
requestBodies:
|
||||
ExceptionRequest: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ExceptionRequest'}}}}
|
||||
schemas:
|
||||
@@ -57,16 +70,17 @@ components:
|
||||
container: {$ref: '#/components/schemas/Container'}
|
||||
loadingPlanId: {type: integer, format: int64}
|
||||
pda: {$ref: '#/components/schemas/PdaSummary'}
|
||||
documents: {type: array, description: '按 documentType 稳定排序返回 DESTINATION_CLEARANCE、DELIVERY_ORDER、PICKUP_ORDER 的当前唯一记录和真实 version;出口报关只由 customs-v1 柜级聚合承载', items: {$ref: '#/components/schemas/Document'}}
|
||||
documents: {type: array, description: '按 documentType 稳定排序返回 DESTINATION_CLEARANCE、DELIVERY_ORDER 的当前唯一记录和真实 version;PU码位于 container.pickupCode;出口报关只由 customs-v1 柜级聚合承载', items: {$ref: '#/components/schemas/Document'}}
|
||||
exception: {$ref: '#/components/schemas/ExceptionState'}
|
||||
exceptionHistory: {type: array, items: {$ref: '#/components/schemas/ExceptionHistory'}}
|
||||
timeline: {type: array, items: {$ref: '#/components/schemas/TimelineEntry'}}
|
||||
Container: {type: object, properties: {id: {type: integer, format: int64}, bookingNo: {type: string, pattern: '^CB-[0-9]{6}-[0-9]{4}$', example: CB-260818-0001}, cabinetNo: {type: string, pattern: '^CAB-[0-9]{6}-[0-9]{4}$', example: CAB-260818-0001}, containerNo: {type: string}, status: {type: string}, warehouseId: {type: integer, format: int64}, warehouseName: {type: string}, voyageNo: {type: string}, vesselName: {type: string}, version: {type: integer, minimum: 0}}}
|
||||
Container: {type: object, properties: {id: {type: integer, format: int64}, bookingNo: {type: string, pattern: '^CB-[0-9]{6}-[0-9]{4}$', example: CB-260818-0001}, cabinetNo: {type: string, pattern: '^CAB-[0-9]{6}-[0-9]{4}$', example: CAB-260818-0001}, containerNo: {type: string}, status: {type: string}, warehouseId: {type: integer, format: int64}, warehouseName: {type: string}, voyageNo: {type: string}, vesselName: {type: string}, pickupCode: {type: string, pattern: '^[0-9]{1,64}$', nullable: true}, pickupCodeUpdatedBy: {type: integer, format: int64, nullable: true}, pickupCodeUpdatedByName: {type: string, nullable: true}, pickupCodeUpdatedAt: {type: string, format: date-time, nullable: true}, version: {type: integer, minimum: 0}}}
|
||||
PdaSummary: {type: object, properties: {taskId: {type: integer, format: int64}, loadingPlanId: {type: integer, format: int64}, containerBookingId: {type: integer, format: int64}, taskStatus: {type: string, description: '持久化任务态:LOADING/COMPLETED'}, workflowStatus: {type: string, enum: [IN_PROGRESS, AWAITING_REVIEW, COMPLETED], description: 'PDA作业展示态'}, taskVersion: {type: integer}, itemCount: {type: integer}, completedItemCount: {type: integer}, unresolvedItemCount: {type: integer}, photoCounts: {type: array, items: {$ref: '#/components/schemas/PhotoCount'}}, photos: {type: array, items: {$ref: '#/components/schemas/Photo'}}, exceptionReasons: {type: array, items: {type: string}}}}
|
||||
PhotoCount: {type: object, properties: {evidenceType: {type: string}, count: {type: integer}}}
|
||||
Photo: {type: object, properties: {evidenceId: {type: integer, format: int64}, tenantId: {type: integer, format: int64}, inboundId: {type: integer, format: int64}, evidenceType: {type: string}, fileId: {type: integer, format: int64}, fileName: {type: string}, status: {type: string, enum: [ACTIVE, VOID]}, downloadPath: {type: string, nullable: true, description: '仅 ACTIVE 照片提供鉴权下载路径;VOID 历史照片为空'}, uploadedAt: {type: string, format: date-time}}}
|
||||
Document: {type: object, properties: {id: {type: integer, format: int64}, documentType: {type: string, enum: [DESTINATION_CLEARANCE, DELIVERY_ORDER, PICKUP_ORDER]}, fileId: {type: integer, format: int64}, fileName: {type: string}, fileHash: {type: string}, uploadedBy: {type: integer, format: int64}, uploadedByName: {type: string}, uploadedAt: {type: string, format: date-time}, version: {type: integer, description: '当前唯一记录的真实乐观锁版本;更新时必须原样作为 expectedVersion'}, completed: {type: boolean}}}
|
||||
Document: {type: object, properties: {id: {type: integer, format: int64}, documentType: {type: string, enum: [DESTINATION_CLEARANCE, DELIVERY_ORDER]}, fileId: {type: integer, format: int64}, fileName: {type: string}, fileHash: {type: string}, uploadedBy: {type: integer, format: int64}, uploadedByName: {type: string}, uploadedAt: {type: string, format: date-time}, version: {type: integer, description: '当前唯一记录的真实乐观锁版本;更新时必须原样作为 expectedVersion'}, completed: {type: boolean}}}
|
||||
ExceptionState: {type: object, properties: {active: {type: boolean}, reason: {type: string}, markedBy: {type: integer, format: int64}, markedByName: {type: string}, markedAt: {type: string, format: date-time}}}
|
||||
ExceptionHistory: {type: object, properties: {id: {type: integer, format: int64}, action: {type: string, enum: [MARK, CANCEL]}, reason: {type: string}, operatorId: {type: integer, format: int64}, operatorName: {type: string}, occurredAt: {type: string, format: date-time}}}
|
||||
TimelineEntry: {type: object, properties: {eventType: {type: string}, detail: {type: string}, operatorId: {type: integer, format: int64}, operatorName: {type: string}, occurredAt: {type: string, format: date-time}}}
|
||||
ExceptionRequest: {type: object, required: [expectedVersion, reason], properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, maxLength: 500}}}
|
||||
PickupCodeRequest: {type: object, required: [pickupCode, expectedVersion], properties: {pickupCode: {type: string, pattern: '^[0-9]{1,64}$'}, expectedVersion: {type: integer, minimum: 0}}}
|
||||
|
||||
@@ -4,7 +4,7 @@ paths:
|
||||
/api/platform/v1/terminal-drayages:
|
||||
get:
|
||||
summary: 批量查询货柜码头拖车及服务端报空预警
|
||||
x-permission: platform:firstleg:container:list
|
||||
x-permission: platform:firstleg:terminal-drayage:list
|
||||
parameters:
|
||||
- name: containerBookingIds
|
||||
in: query
|
||||
@@ -14,40 +14,40 @@ paths:
|
||||
/api/platform/v1/terminal-drayages/{containerBookingId}:
|
||||
get:
|
||||
summary: 查询单柜码头拖车详情
|
||||
x-permission: platform:firstleg:container:list
|
||||
x-permission: platform:firstleg:terminal-drayage:list
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
|
||||
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
|
||||
/api/platform/v1/terminal-drayages/{containerBookingId}/history:
|
||||
get:
|
||||
summary: 查询单柜码头拖车操作历史
|
||||
x-permission: platform:firstleg:container:list
|
||||
x-permission: platform:firstleg:terminal-drayage:list
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
|
||||
responses: {'200': {$ref: '#/components/responses/HistoryResponse'}}
|
||||
/api/platform/v1/terminal-drayages/{containerBookingId}/fleet:
|
||||
post:
|
||||
summary: 到港后选择或更换码头车队
|
||||
x-permission: platform:firstleg:container:edit
|
||||
x-permission: platform:firstleg:terminal-drayage:fleet:edit
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/FleetSelectionRequest'}}}}
|
||||
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
|
||||
/api/platform/v1/terminal-drayages/{containerBookingId}/pickup-appointment:
|
||||
post:
|
||||
summary: 预约目的港提柜时间
|
||||
x-permission: platform:firstleg:container:edit
|
||||
x-permission: platform:firstleg:terminal-drayage:pickup-appointment:edit
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/PickupAppointmentRequest'}}}}
|
||||
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
|
||||
/api/platform/v1/terminal-drayages/{containerBookingId}/pickup:
|
||||
post:
|
||||
summary: 清关可提柜后确认实际提柜并推进PICKED_UP节点
|
||||
x-permission: platform:firstleg:container:edit
|
||||
x-permission: platform:firstleg:terminal-drayage:pickup:confirm
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActualTimeRequest'}}}}
|
||||
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
|
||||
/api/platform/v1/terminal-drayages/{containerBookingId}/empty-return:
|
||||
post:
|
||||
summary: 海外卸柜收货完成后确认实际还柜并推进EMPTY_RETURNED节点
|
||||
x-permission: platform:firstleg:container:edit
|
||||
x-permission: platform:firstleg:terminal-drayage:empty-return:confirm
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActualTimeRequest'}}}}
|
||||
responses: {'200': {$ref: '#/components/responses/ViewResponse'}}
|
||||
@@ -88,7 +88,7 @@ components:
|
||||
overdueDays: {type: integer, format: int64, minimum: 0}
|
||||
TerminalDrayageView:
|
||||
type: object
|
||||
required: [containerBookingId, status, version, emptyReturnWarning, allowedActions]
|
||||
required: [containerBookingId, status, version, emptyReturnWarning, allowedActions, actualFacts]
|
||||
properties:
|
||||
containerBookingId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
containerNo: {type: string, nullable: true}
|
||||
@@ -104,7 +104,24 @@ components:
|
||||
unloadingCompletedAt: {type: string, format: date-time, nullable: true}
|
||||
emptyReturnWarning: {$ref: '#/components/schemas/Warning'}
|
||||
allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN]}}
|
||||
actualFacts: {$ref: '#/components/schemas/ContainerActualFacts'}
|
||||
updatedAt: {type: string, format: date-time, nullable: true}
|
||||
ContainerActualFacts:
|
||||
type: object
|
||||
required: [containerBookingId, queryId, queryVersion, queryStatus]
|
||||
properties:
|
||||
containerBookingId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
queryId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
queryVersion: {type: integer, minimum: 0}
|
||||
queryStatus: {type: string}
|
||||
latestActualNode: {type: string, nullable: true}
|
||||
latestActualAt: {type: string, format: date-time, nullable: true}
|
||||
departedWarehouseAt: {type: string, format: date-time, nullable: true}
|
||||
departedOriginPortAt: {type: string, format: date-time, nullable: true}
|
||||
arrivedDestinationPortAt: {type: string, format: date-time, nullable: true}
|
||||
pickupReadyAt: {type: string, format: date-time, nullable: true}
|
||||
pickedUpAt: {type: string, format: date-time, nullable: true}
|
||||
emptyReturnedAt: {type: string, format: date-time, nullable: true}
|
||||
History:
|
||||
type: object
|
||||
required: [action, fromStatus, toStatus, fromVersion, toVersion, operatorId, createdAt]
|
||||
|
||||
+19
-10
@@ -18,30 +18,35 @@ class ContainerControllerContractTest {
|
||||
@Test
|
||||
@DisplayName("查询、新增和编辑按钮权限彼此独立")
|
||||
void separatesButtonPermissions() {
|
||||
assertPermission("page", "platform:firstleg:container:list");
|
||||
assertPermission("detail", "platform:firstleg:container:list");
|
||||
assertPermission("domesticPage", "platform:firstleg:container:list");
|
||||
assertPermission("overseasPage", "platform:firstleg:terminal-drayage:list");
|
||||
assertPermission("domesticDetail", "platform:firstleg:container:list");
|
||||
assertPermission("overseasDetail", "platform:firstleg:terminal-drayage:list");
|
||||
assertPermission("create", "platform:firstleg:container:create");
|
||||
assertPermission("linkOceanBooking", "platform:firstleg:container:edit");
|
||||
assertPermission("updateDraft", "platform:firstleg:container:edit");
|
||||
assertPermission("uploadClearance", "platform:firstleg:customs:manage");
|
||||
assertPermission("uploadClearance", "platform:firstleg:terminal-drayage:document:edit");
|
||||
assertPermission("overview", "platform:firstleg:container:list");
|
||||
assertPermission("overseasOverview", "platform:firstleg:terminal-drayage:list");
|
||||
assertPermission("updatePickupCode", "platform:firstleg:terminal-drayage:document:edit");
|
||||
assertPermission("downloadPdaPhoto", "platform:firstleg:container:list");
|
||||
assertPermission("markException", "platform:firstleg:container:edit");
|
||||
assertPermission("cancelException", "platform:firstleg:container:edit");
|
||||
assertPermission("logisticsDocument", "platform:firstleg:container:list");
|
||||
assertPermission("uploadLogisticsDocument", "platform:firstleg:container:edit");
|
||||
assertPermission("downloadDocument", "platform:firstleg:container:list");
|
||||
assertPermission("downloadClearance", "platform:firstleg:customs:manage");
|
||||
assertPermission("logisticsDocument", "platform:firstleg:terminal-drayage:list");
|
||||
assertPermission("uploadLogisticsDocument", "platform:firstleg:terminal-drayage:document:edit");
|
||||
assertPermission("downloadDocument", "platform:firstleg:terminal-drayage:list");
|
||||
assertPermission("downloadClearance", "platform:firstleg:terminal-drayage:list");
|
||||
}
|
||||
|
||||
@Test void overviewOpenApiFreezesContainerIdBasedPdaContract() throws Exception {
|
||||
String openapi = new ClassPathResource("openapi/phase3-container-overview-v1.yaml")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
assertThat(openapi).contains("/api/platform/v1/containers/{id}/overview:",
|
||||
"/api/platform/v1/containers/overseas/{id}/overview:",
|
||||
"/documents/{documentType}/{fileId}/download:", "ExceptionRequest",
|
||||
"/clearance/document/{fileId}/download:",
|
||||
"DESTINATION_CLEARANCE、DELIVERY_ORDER、PICKUP_ORDER",
|
||||
"enum: [DELIVERY_ORDER, PICKUP_ORDER]", "Idempotency-Key", "photoCounts:",
|
||||
"/api/platform/v1/containers/{id}/pickup-code:", "PickupCodeRequest",
|
||||
"enum: [DELIVERY_ORDER]", "Idempotency-Key", "photoCounts:",
|
||||
"/pda/photos/{fileId}/download:", "downloadPath:", "workflowStatus:",
|
||||
"AWAITING_REVIEW", "version: {type: integer, minimum: 0}",
|
||||
"downloadPath: {type: string, nullable: true",
|
||||
@@ -59,6 +64,9 @@ class ContainerControllerContractTest {
|
||||
|
||||
assertThat(section)
|
||||
.contains("/api/platform/v1/containers/{id}/ocean-booking-link:")
|
||||
.contains("/api/platform/v1/containers/domestic:")
|
||||
.contains("/api/platform/v1/containers/overseas:")
|
||||
.contains("scope, in: query, required: true")
|
||||
.contains("首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜")
|
||||
.contains("RELEASED oceanBookingId")
|
||||
.contains("未满配 requirementId")
|
||||
@@ -72,7 +80,7 @@ class ContainerControllerContractTest {
|
||||
.contains("/api/platform/v1/containers/{id}/clearance/document/upload:")
|
||||
.contains("multipart/form-data")
|
||||
.contains("ContainerDocumentUploadRequest")
|
||||
.contains("platform:firstleg:container:list")
|
||||
.contains("platform:firstleg:terminal-drayage:list")
|
||||
.contains("EDITING")
|
||||
.contains("WAIT_LOADING")
|
||||
.contains("LOADING")
|
||||
@@ -80,6 +88,7 @@ class ContainerControllerContractTest {
|
||||
.doesNotContain("tenantId")
|
||||
.doesNotContain("accountId");
|
||||
assertThat(openapi)
|
||||
.contains("ContainerWorkbenchItem:", "allowedActions")
|
||||
.contains("/api/platform/v1/containers/{id}/clearance/document/{fileId}/download:")
|
||||
.contains("Content-Disposition 与 Cache-Control no-store")
|
||||
.contains("required: [oceanBookingId, requirementId, expectedVersion]")
|
||||
|
||||
+12
@@ -14,6 +14,18 @@ class CarrierScheduleQueryOpenApiContractTest {
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
assertThat(yaml)
|
||||
.contains("/api/platform/v1/carrier-schedule-queries:")
|
||||
.contains("/api/platform/v1/carrier-schedule-queries/container-actual-facts:")
|
||||
.contains("name: containerBookingIds")
|
||||
.contains("maxItems: 200")
|
||||
.contains("uniqueItems: true")
|
||||
.contains("ContainerActualFacts:")
|
||||
.contains("departedWarehouseAt:")
|
||||
.contains("departedOriginPortAt:")
|
||||
.contains("arrivedDestinationPortAt:")
|
||||
.contains("pickupReadyAt:")
|
||||
.contains("pickedUpAt:")
|
||||
.contains("emptyReturnedAt:")
|
||||
.contains("Estimated facts are never returned or inferred")
|
||||
.contains("/api/platform/v1/carrier-schedule-queries/{queryId}/manual-results:")
|
||||
.contains("/api/platform/v1/carrier-schedule-queries/{queryId}/manual-nodes:")
|
||||
.contains("x-permission: platform:firstleg:port-schedule-query:manual")
|
||||
|
||||
+1
@@ -18,6 +18,7 @@ class PlatformCarrierScheduleQueryControllerContractTest {
|
||||
.containsExactly("/api/platform/v1/carrier-schedule-queries");
|
||||
Map<String, String> permissions = Map.of(
|
||||
"carriers", "platform:firstleg:port-schedule-query:list",
|
||||
"containerActualFacts", "platform:firstleg:port-schedule-query:list",
|
||||
"page", "platform:firstleg:port-schedule-query:list",
|
||||
"detail", "platform:firstleg:port-schedule-query:list",
|
||||
"history", "platform:firstleg:port-schedule-query:list",
|
||||
|
||||
+25
@@ -9,10 +9,31 @@ import org.springframework.core.io.ClassPathResource;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class CarrierScheduleQuerySqlContractTest {
|
||||
@Test
|
||||
void batchesActualContainerFactsWithoutEstimatedOrPerNodeQueries() throws Exception {
|
||||
String sql = String.join("\n", CarrierScheduleQueryMapper.class
|
||||
.getMethod("selectContainerActualFacts", List.class)
|
||||
.getAnnotation(Select.class).value());
|
||||
|
||||
assertThat(sql)
|
||||
.contains("container_booking_id IN")
|
||||
.contains("f.actual_at IS NOT NULL")
|
||||
.contains("MAX(CASE WHEN f.standard_node='DEPARTED_WAREHOUSE'")
|
||||
.contains("MAX(CASE WHEN f.standard_node='DEPARTED_ORIGIN_PORT'")
|
||||
.contains("MAX(CASE WHEN f.standard_node='ARRIVED_DESTINATION_PORT'")
|
||||
.contains("MAX(CASE WHEN f.standard_node='PICKUP_READY'")
|
||||
.contains("MAX(CASE WHEN f.standard_node='PICKED_UP'")
|
||||
.contains("MAX(CASE WHEN f.standard_node='EMPTY_RETURNED'")
|
||||
.contains("ORDER BY q.container_booking_id")
|
||||
.doesNotContain("estimated_at");
|
||||
assertThat(count(sql, "fl_carrier_schedule_node_fact")).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void annotationSqlUsesExecutableComparisonOperators() throws Exception {
|
||||
String selectDue = String.join("\n", CarrierScheduleQueryMapper.class
|
||||
@@ -149,4 +170,8 @@ class CarrierScheduleQuerySqlContractTest {
|
||||
return new ClassPathResource("db/phase3-carrier-schedule-query-schema.sql")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static int count(String source, String needle) {
|
||||
return source.split(java.util.regex.Pattern.quote(needle), -1).length - 1;
|
||||
}
|
||||
}
|
||||
|
||||
+30
@@ -4,6 +4,7 @@ import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObservationRequest;
|
||||
@@ -72,6 +73,35 @@ class CarrierScheduleQueryServiceTest {
|
||||
when(portSchedules.containerBooking(20L)).thenReturn(container);
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsBatchActualFactsFromOneRepositoryCall() {
|
||||
ContainerActualFacts facts = new ContainerActualFacts(20L, 30L, 2, "READY",
|
||||
"PICKUP_READY", NOW, NOW.minusDays(12), NOW.minusDays(10), NOW.minusDays(2),
|
||||
NOW, null, null);
|
||||
when(repository.containerActualFacts(List.of(20L, 21L))).thenReturn(List.of(facts));
|
||||
|
||||
assertThat(service.containerActualFacts(List.of(20L, 21L))).containsExactly(facts);
|
||||
verify(repository).containerActualFacts(List.of(20L, 21L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidActualFactBatchBeforeRepositoryAccess() {
|
||||
assertThatThrownBy(() -> service.containerActualFacts(null))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("1到200");
|
||||
assertThatThrownBy(() -> service.containerActualFacts(List.of()))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("1到200");
|
||||
assertThatThrownBy(() -> service.containerActualFacts(java.util.Collections.nCopies(201, 1L)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("1到200");
|
||||
assertThatThrownBy(() -> service.containerActualFacts(java.util.Arrays.asList(1L, null)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("正数");
|
||||
assertThatThrownBy(() -> service.containerActualFacts(List.of(1L, 0L)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("正数");
|
||||
assertThatThrownBy(() -> service.containerActualFacts(List.of(1L, 1L)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("重复");
|
||||
|
||||
verify(repository, never()).containerActualFacts(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsTaskOnlyForBookingCustomerAndAllocatedContainer() {
|
||||
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ class ContainerBookingMapperContractTest {
|
||||
void filtersPageByStableCustomerId() throws Exception {
|
||||
String page = String.join("\n", ContainerBookingMapper.class
|
||||
.getDeclaredMethod("selectBookingPage", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class,
|
||||
com.baiyue.firstleg.dto.ContainerDtos.Query.class)
|
||||
com.baiyue.firstleg.dto.ContainerDtos.Query.class, String.class)
|
||||
.getAnnotation(Select.class).value());
|
||||
assertThat(page)
|
||||
.contains("fl_ocean_booking_container_allocation customer_allocation")
|
||||
|
||||
+3
-2
@@ -12,11 +12,12 @@ import java.nio.file.Path;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ContainerOverviewSqlContractTest {
|
||||
@Test void overviewIncludesExactlyTheThreeCurrentBusinessDocumentTypes() throws Exception {
|
||||
@Test void overviewIncludesExactlyTheTwoCurrentBusinessDocumentTypes() throws Exception {
|
||||
String sql = String.join(" ", ContainerOverviewMapper.class.getDeclaredMethod("documents", long.class)
|
||||
.getAnnotation(Select.class).value());
|
||||
assertThat(sql)
|
||||
.contains("document_type IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER','PICKUP_ORDER')")
|
||||
.contains("document_type IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER')")
|
||||
.doesNotContain("PICKUP_ORDER")
|
||||
.contains("ORDER BY document_type");
|
||||
}
|
||||
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baiyue.firstleg.mapper.ContainerBookingMapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ContainerWorkbenchSqlContractTest {
|
||||
@Test
|
||||
void pagesDomesticAndOverseasScopesFromOwnedActualFactsWithoutCopyingShipNodeSql() throws Exception {
|
||||
String sql = String.join("\n", ContainerBookingMapper.class
|
||||
.getMethod("selectBookingPage", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class,
|
||||
com.baiyue.firstleg.dto.ContainerDtos.Query.class, String.class)
|
||||
.getAnnotation(Select.class).value());
|
||||
|
||||
assertThat(sql).contains("#{workbench}='DOMESTIC'", "#{workbench}='OVERSEAS'",
|
||||
"#{query.scope}='ACTIVE'", "#{query.scope}='HISTORY'", "fl_outbound_dispatch",
|
||||
"confirmed_at IS NOT NULL", "b.actual_empty_reported_at IS NULL",
|
||||
"b.actual_empty_reported_at IS NOT NULL", "outbound_dispatch_status")
|
||||
.doesNotContain("fl_carrier_schedule_query", "fl_carrier_schedule_node_fact");
|
||||
}
|
||||
|
||||
@Test
|
||||
void baselineDocumentsTheSingleSeventyTwoHourEmptyReturnRule() throws Exception {
|
||||
String sql = Files.readString(Path.of("src/main/resources/db/phase3-smart-loading-schema.sql"));
|
||||
assertThat(sql).contains("预计还空时间(卸柜完成时间加72小时,UTC)")
|
||||
.doesNotContain("卸柜照片上传成功时间加两天");
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -162,10 +162,11 @@ class SmartLoadingSqlContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("货柜简单文件只保留目的港清关与DO/PU")
|
||||
void shouldAllowExactlyThreeSimpleContainerDocumentTypes() throws Exception {
|
||||
for(String sql:java.util.List.of(migration(),unifiedMigration()))assertThat(sql)
|
||||
.contains("'DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER'")
|
||||
@DisplayName("货柜简单文件只保留目的港清关与DO,PU改为数字码")
|
||||
void shouldAllowExactlyTwoSimpleContainerDocumentTypes() throws Exception {
|
||||
assertThat(migration())
|
||||
.contains("'DESTINATION_CLEARANCE', 'DELIVERY_ORDER'")
|
||||
.contains("pickup_code VARCHAR(64) NULL")
|
||||
.contains("file_hash VARCHAR(64) NULL")
|
||||
.contains("completed_by BIGINT UNSIGNED NULL")
|
||||
.contains("completed_at DATETIME(3) NULL");
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.dto.ContainerOverviewDtos.ExceptionRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerOverviewDtos.PickupCodeRequest;
|
||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||
import com.baiyue.firstleg.mapper.ContainerOverviewMapper;
|
||||
import com.baiyue.firstleg.repository.ContainerRepository;
|
||||
@@ -154,6 +155,22 @@ class ContainerOverviewServiceTest {
|
||||
eq("operator"), eq("mark-1"), matches("[0-9a-f]{64}"));
|
||||
}
|
||||
|
||||
@Test void pickupCodePreservesLeadingZerosAndUsesContainerVersion() {
|
||||
ContainerBookingEntity locked = new ContainerBookingEntity(); locked.setId(41L); locked.setVersion(3);
|
||||
ContainerBookingEntity refreshed = new ContainerBookingEntity(); refreshed.setId(41L); refreshed.setVersion(4); refreshed.setPickupCode("00123456");
|
||||
when(containers.findActiveForUpdate(41L)).thenReturn(locked);
|
||||
when(containers.updatePickupCode(41L, "00123456", 3, 7L, "operator")).thenReturn(1);
|
||||
when(containers.find(41L)).thenReturn(refreshed);
|
||||
when(mapper.documents(41L)).thenReturn(java.util.List.of());
|
||||
when(mapper.exceptions(41L)).thenReturn(java.util.List.of());
|
||||
when(mapper.transport(41L)).thenReturn(java.util.List.of());
|
||||
|
||||
var result = service.updatePickupCode(41L, 7L, "operator", new PickupCodeRequest("00123456", 3));
|
||||
|
||||
assertThat(result.container().pickupCode()).isEqualTo("00123456");
|
||||
assertThat(result.container().version()).isEqualTo(4);
|
||||
}
|
||||
|
||||
@Test void exactReplayReturnsWithoutLockingOrWriting() {
|
||||
String hash = hash("MARK|41|3|broken seal");
|
||||
when(mapper.exceptionOperation(41L, 7L, "mark-1")).thenReturn(new ContainerOverviewMapper.ExceptionRow(
|
||||
|
||||
@@ -632,13 +632,13 @@ class LoadingListServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void genericContainerDocumentsOnlyAcceptDeliveryAndPickupOrders() {
|
||||
void genericContainerDocumentsOnlyAcceptDeliveryOrders() {
|
||||
assertThatThrownBy(() -> service.containerLogisticsDocument(30L,
|
||||
LoadingListService.DESTINATION_CLEARANCE)).isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("DELIVERY_ORDER或PICKUP_ORDER");
|
||||
.hasMessageContaining("DELIVERY_ORDER");
|
||||
assertThatThrownBy(() -> service.containerLogisticsDocument(30L,
|
||||
"UNKNOWN_DOCUMENT")).isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("DELIVERY_ORDER或PICKUP_ORDER");
|
||||
.hasMessageContaining("DELIVERY_ORDER");
|
||||
verify(repository, never()).aggregateByBooking(30L);
|
||||
}
|
||||
|
||||
@@ -670,36 +670,6 @@ class LoadingListServiceTest {
|
||||
eq("COMPLETED"), eq("COMPLETED"), anyString(), anyString(), eq(9L), eq("operator"), isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pickupOrderUpdateUsesCurrentVersionAndReturnsUpdatedDocument() {
|
||||
AggregateRow plan = row("COMPLETED", 4);
|
||||
byte[] content = "pickup-order".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
var file = new MockMultipartFile("file", "pu.pdf", "application/pdf", content);
|
||||
String contentHash = sha256Bytes(content);
|
||||
ContainerDocumentEntity current = document(52L, LoadingListService.PICKUP_ORDER, 2, false);
|
||||
ContainerDocumentEntity saved = document(52L, LoadingListService.PICKUP_ORDER, 3, false);
|
||||
saved.setFileId(102L); saved.setFileName("pu.pdf"); saved.setFileHash(contentHash);
|
||||
var metadata = new LoadingDocumentFileClient.Metadata(102L, 0L,
|
||||
"CONTAINER_PICKUP_ORDER", "30", "pu.pdf", contentHash, "application/pdf", (long) content.length);
|
||||
when(repository.aggregateByBooking(30L)).thenReturn(plan);
|
||||
when(repository.history(anyString())).thenReturn(null);
|
||||
when(repository.document(30L, LoadingListService.PICKUP_ORDER)).thenReturn(current, saved);
|
||||
when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("CONTAINER_PICKUP_ORDER"),
|
||||
eq("30"), eq(file))).thenReturn(ApiResponse.success(metadata));
|
||||
when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_PICKUP_ORDER"),
|
||||
eq("30"), eq(102L))).thenReturn(ApiResponse.success(metadata));
|
||||
when(repository.replaceDocument(any())).thenReturn(1);
|
||||
|
||||
var result = service.uploadContainerLogisticsDocument(30L, LoadingListService.PICKUP_ORDER,
|
||||
9L, "operator", "pu-update", 2, null, file);
|
||||
|
||||
assertThat(result.fileId()).isEqualTo(102L);
|
||||
assertThat(result.version()).isEqualTo(3);
|
||||
verify(repository).replaceDocument(any());
|
||||
verify(repository).appendHistory(eq(10L), anyString(), eq("UPLOAD_PICKUP_ORDER"),
|
||||
eq("COMPLETED"), eq("COMPLETED"), anyString(), anyString(), eq(9L), eq("operator"), isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleDeliveryOrderVersionReturnsConflictBeforeUploadingAnotherFile() {
|
||||
AggregateRow plan = row("COMPLETED", 4);
|
||||
|
||||
+2
-2
@@ -41,7 +41,7 @@ class OverseasContainerEventConsumerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void derivesEstimatedEmptyReportedAtFromPhotoUploadAndPersistsInbox() {
|
||||
void derivesEstimatedEmptyReportedAtFromUnloadCompletionAndPersistsInbox() {
|
||||
ContainerBookingEntity container = container();
|
||||
when(containers.selectActiveBookingForUpdate(1001L)).thenReturn(container);
|
||||
Instant completedAt = Instant.parse("2026-08-20T03:00:00Z");
|
||||
@@ -52,7 +52,7 @@ class OverseasContainerEventConsumerTest {
|
||||
|
||||
verify(containers).applyEmptyReport(eq(1001L), eq(LocalDateTime.parse("2026-08-20T03:00:00")),
|
||||
eq(LocalDateTime.parse("2026-08-20T02:30:00")),
|
||||
eq(LocalDateTime.parse("2026-08-22T02:30:00")), isNull(), eq(7L));
|
||||
eq(LocalDateTime.parse("2026-08-23T03:00:00")), isNull(), eq(7L));
|
||||
ArgumentCaptor<ContainerEventInboxEntity> captor = ArgumentCaptor.forClass(ContainerEventInboxEntity.class);
|
||||
verify(inbox).insert(captor.capture());
|
||||
assertThat(captor.getValue().getAggregateId()).isEqualTo("1001");
|
||||
|
||||
+25
-2
@@ -1,6 +1,7 @@
|
||||
package com.baiyue.firstleg.terminaldrayage.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -8,6 +9,17 @@ import java.nio.file.Path;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class TerminalDrayageOpenApiContractTest {
|
||||
@Test
|
||||
void controllerUsesFrozenOverseasPermissionsWithoutGenericContainerFallback() throws Exception {
|
||||
assertPermission("views", "platform:firstleg:terminal-drayage:list");
|
||||
assertPermission("detail", "platform:firstleg:terminal-drayage:list");
|
||||
assertPermission("history", "platform:firstleg:terminal-drayage:list");
|
||||
assertPermission("selectFleet", "platform:firstleg:terminal-drayage:fleet:edit");
|
||||
assertPermission("schedulePickup", "platform:firstleg:terminal-drayage:pickup-appointment:edit");
|
||||
assertPermission("recordPickup", "platform:firstleg:terminal-drayage:pickup:confirm");
|
||||
assertPermission("recordEmptyReturn", "platform:firstleg:terminal-drayage:empty-return:confirm");
|
||||
}
|
||||
|
||||
@Test
|
||||
void freezesActionsPermissionsAndServerWarningFields() throws Exception {
|
||||
String yaml = Files.readString(Path.of("src/main/resources/openapi/terminal-drayage-v1.yaml"));
|
||||
@@ -16,9 +28,20 @@ class TerminalDrayageOpenApiContractTest {
|
||||
"/api/platform/v1/terminal-drayages/{containerBookingId}/pickup-appointment:",
|
||||
"/api/platform/v1/terminal-drayages/{containerBookingId}/pickup:",
|
||||
"/api/platform/v1/terminal-drayages/{containerBookingId}/empty-return:",
|
||||
"platform:firstleg:container:list", "platform:firstleg:container:edit",
|
||||
"platform:firstleg:terminal-drayage:list",
|
||||
"platform:firstleg:terminal-drayage:fleet:edit",
|
||||
"platform:firstleg:terminal-drayage:pickup-appointment:edit",
|
||||
"platform:firstleg:terminal-drayage:pickup:confirm",
|
||||
"platform:firstleg:terminal-drayage:empty-return:confirm",
|
||||
"expectedVersion", "Idempotency-Key", "estimatedEmptyReturnAt",
|
||||
"emptyReturnWarning", "deadlineAt", "overdueHours", "overdueDays");
|
||||
"emptyReturnWarning", "actualFacts", "deadlineAt", "overdueHours", "overdueDays");
|
||||
assertThat(yaml).contains("description: 卸柜完成时间加72小时");
|
||||
}
|
||||
|
||||
private static void assertPermission(String methodName, String permission) {
|
||||
var method = java.util.Arrays.stream(PlatformTerminalDrayageController.class.getDeclaredMethods())
|
||||
.filter(value -> methodName.equals(value.getName())).findFirst().orElseThrow();
|
||||
assertThat(method.getAnnotation(PreAuthorize.class).value())
|
||||
.isEqualTo("hasAuthority('" + permission + "')");
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -22,7 +22,8 @@ class TerminalDrayageSqlContractTest {
|
||||
"服务端按卸柜完成时间加72小时计算的预计还柜时间");
|
||||
assertThat(sql).doesNotContain("ON DELETE CASCADE");
|
||||
assertThat(mapper).contains("WHERE b.id=#{containerBookingId} FOR UPDATE",
|
||||
"WHERE container_booking_id=#{value.containerBookingId} AND version=#{expectedVersion}");
|
||||
"WHERE container_booking_id=#{value.containerBookingId} AND version=#{expectedVersion}")
|
||||
.doesNotContain("fl_carrier_schedule_query", "fl_carrier_schedule_node_fact");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package com.baiyue.firstleg.terminaldrayage.service;
|
||||
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.Query;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.WorkbenchScope;
|
||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
|
||||
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
|
||||
import com.baiyue.firstleg.repository.ContainerRepository;
|
||||
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.View;
|
||||
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.Warning;
|
||||
import com.baiyue.firstleg.terminaldrayage.service.TerminalDrayageService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class ContainerWorkbenchServiceTest {
|
||||
private ContainerRepository repository;
|
||||
private CarrierScheduleQueryService carrierSchedules;
|
||||
private TerminalDrayageService terminalDrayages;
|
||||
private ContainerWorkbenchService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
repository = mock(ContainerRepository.class);
|
||||
carrierSchedules = mock(CarrierScheduleQueryService.class);
|
||||
terminalDrayages = mock(TerminalDrayageService.class);
|
||||
service = new ContainerWorkbenchService();
|
||||
ReflectionTestUtils.setField(service, "repository", repository);
|
||||
ReflectionTestUtils.setField(service, "carrierSchedules", carrierSchedules);
|
||||
ReflectionTestUtils.setField(service, "terminalDrayages", terminalDrayages);
|
||||
}
|
||||
|
||||
@Test
|
||||
void domesticPageKeepsServerPaginationAndUsesOneActualFactBatch() {
|
||||
ContainerBookingEntity container = container();
|
||||
container.setPlanStatus("COMPLETED");
|
||||
container.setCustomsDeclarationDocumentStatus("COMPLETED");
|
||||
when(repository.page(eq("DOMESTIC"), any())).thenReturn(new PageResult<>(List.of(container), 1, 20, 1, 1));
|
||||
when(carrierSchedules.containerActualFacts(List.of(11L))).thenReturn(List.of());
|
||||
|
||||
var result = service.domesticPage(query(WorkbenchScope.ACTIVE), Set.of(
|
||||
"platform:firstleg:customs:manage", "platform:firstleg:outbound-dispatch:confirm"));
|
||||
|
||||
assertThat(result.total()).isEqualTo(1);
|
||||
assertThat(result.records().get(0).workbenchStage()).isEqualTo("WAIT_OUTBOUND");
|
||||
assertThat(result.records().get(0).actualFacts()).isNull();
|
||||
assertThat(result.records().get(0).allowedActions())
|
||||
.containsExactly("MANAGE_CUSTOMS", "CONFIRM_OUTBOUND");
|
||||
verify(carrierSchedules, times(1)).containerActualFacts(List.of(11L));
|
||||
verifyNoInteractions(terminalDrayages);
|
||||
}
|
||||
|
||||
@Test
|
||||
void overseasPageConsumesTerminalAggregateOnceAndFiltersActionsByPermission() {
|
||||
ContainerBookingEntity container = container();
|
||||
container.setUnloadingCompletedAt(LocalDateTime.of(2026, 9, 5, 8, 0));
|
||||
ContainerActualFacts facts = facts("PICKED_UP");
|
||||
View raw = new View(11L, "MSCU1234567", 21L, "UNLOADED", 3, 91L, "车队",
|
||||
LocalDateTime.of(2026, 9, 4, 8, 0), LocalDateTime.of(2026, 9, 4, 9, 0),
|
||||
LocalDateTime.of(2026, 9, 8, 8, 0), null, container.getUnloadingCompletedAt(),
|
||||
new Warning("PENDING", LocalDateTime.of(2026, 9, 8, 8, 0), 0, 0),
|
||||
List.of("RECORD_EMPTY_RETURN"), facts, LocalDateTime.of(2026, 9, 5, 9, 0));
|
||||
Set<String> permissions = Set.of("platform:firstleg:terminal-drayage:empty-return:confirm");
|
||||
when(repository.page(eq("OVERSEAS"), any())).thenReturn(new PageResult<>(List.of(container), 1, 20, 1, 1));
|
||||
when(terminalDrayages.views(List.of(11L))).thenReturn(List.of(raw));
|
||||
when(terminalDrayages.authorize(List.of(raw), permissions)).thenReturn(List.of(raw));
|
||||
|
||||
var result = service.overseasPage(query(WorkbenchScope.ACTIVE), permissions);
|
||||
|
||||
assertThat(result.records().get(0).workbenchStage()).isEqualTo("UNLOADED");
|
||||
assertThat(result.records().get(0).actualFacts()).isSameAs(facts);
|
||||
assertThat(result.records().get(0).allowedActions()).containsExactly("RECORD_EMPTY_RETURN");
|
||||
verify(terminalDrayages, times(1)).views(List.of(11L));
|
||||
verifyNoInteractions(carrierSchedules);
|
||||
}
|
||||
|
||||
private static Query query(WorkbenchScope scope) {
|
||||
return new Query(null, null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, scope, 1, 20);
|
||||
}
|
||||
|
||||
private static ContainerBookingEntity container() {
|
||||
ContainerBookingEntity value = new ContainerBookingEntity();
|
||||
value.setId(11L); value.setContainerNo("MSCU1234567"); value.setPlanId(31L);
|
||||
value.setPlanNo("LP-260908-0001"); value.setPlanStatus("COMPLETED");
|
||||
value.setDestinationClearanceDocumentStatus("MISSING");
|
||||
value.setDeliveryOrderDocumentStatus("MISSING"); value.setOutboundDispatchStatus("NOT_CREATED");
|
||||
return value;
|
||||
}
|
||||
|
||||
private static ContainerActualFacts facts(String node) {
|
||||
LocalDateTime now = LocalDateTime.of(2026, 9, 4, 9, 0);
|
||||
return new ContainerActualFacts(11L, 41L, 3, "READY", node, now,
|
||||
now.minusDays(5), now.minusDays(4), now.minusDays(1), now.minusHours(2), now, null);
|
||||
}
|
||||
}
|
||||
+46
-3
@@ -3,6 +3,7 @@ package com.baiyue.firstleg.terminaldrayage.service;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.client.WarehouseSupplierClient;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
|
||||
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
|
||||
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
|
||||
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.ActualTimeRequest;
|
||||
@@ -21,6 +22,9 @@ import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Deque;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -41,12 +45,18 @@ class TerminalDrayageServiceTest {
|
||||
private WarehouseSupplierSnapshotService suppliers;
|
||||
private CarrierScheduleQueryService carrierSchedules;
|
||||
private TerminalDrayageService service;
|
||||
private Deque<ContainerActualFacts> facts;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mapper = mock(TerminalDrayageMapper.class);
|
||||
suppliers = mock(WarehouseSupplierSnapshotService.class);
|
||||
carrierSchedules = mock(CarrierScheduleQueryService.class);
|
||||
facts = new ArrayDeque<>();
|
||||
when(carrierSchedules.containerActualFacts(any())).thenAnswer(ignored -> {
|
||||
if (facts.isEmpty()) return List.of();
|
||||
return List.of(facts.size() > 1 ? facts.removeFirst() : facts.peekFirst());
|
||||
});
|
||||
service = new TerminalDrayageService(mapper, suppliers, carrierSchedules, new ObjectMapper().findAndRegisterModules());
|
||||
service.setClock(Clock.fixed(Instant.parse("2026-09-05T02:00:00Z"), ZoneOffset.UTC));
|
||||
}
|
||||
@@ -167,6 +177,32 @@ class TerminalDrayageServiceTest {
|
||||
assertThat(result.allowedActions()).doesNotContain("RECORD_EMPTY_RETURN");
|
||||
}
|
||||
|
||||
@Test
|
||||
void bulkViewOmitsContainerWhenActualFactPortHasNoTrackingRecord() {
|
||||
when(mapper.contexts(List.of(11L))).thenReturn(List.of(
|
||||
new Context(11L, "MSCU1234567", 21L, "ACTIVE", null)));
|
||||
when(mapper.findAll(List.of(11L))).thenReturn(List.of());
|
||||
|
||||
assertThat(service.views(List.of(11L))).isEmpty();
|
||||
verify(carrierSchedules).containerActualFacts(List.of(11L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void allowedActionsAreReducedByFrozenPermissionMatrix() {
|
||||
Context context = context("PICKUP_READY", null);
|
||||
TerminalDrayageEntity row = row(3);
|
||||
row.setPickupAppointmentAt(LocalDateTime.of(2026, 9, 2, 9, 0));
|
||||
when(mapper.context(11L)).thenReturn(context);
|
||||
when(mapper.find(11L)).thenReturn(row);
|
||||
|
||||
var raw = service.detail(11L);
|
||||
var authorized = service.authorize(raw,
|
||||
Set.of("platform:firstleg:terminal-drayage:pickup:confirm"));
|
||||
|
||||
assertThat(raw.allowedActions()).contains("SELECT_FLEET", "SCHEDULE_PICKUP", "RECORD_PICKUP");
|
||||
assertThat(authorized.allowedActions()).containsExactly("RECORD_PICKUP");
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameIdempotencyKeyWithDifferentPayloadConflictsWithoutWriting() {
|
||||
Context context = context("ARRIVED_DESTINATION_PORT", null);
|
||||
@@ -249,10 +285,17 @@ class TerminalDrayageServiceTest {
|
||||
case "ARRIVED_DESTINATION_PORT" -> 3;
|
||||
default -> 2;
|
||||
};
|
||||
return new Context(11L, "MSCU1234567", 21L, "ACTIVE", unloadedAt,
|
||||
31L, node, ARRIVED_AT, sequenceVersion, ARRIVED_AT,
|
||||
facts.addLast(new ContainerActualFacts(11L, 31L, sequenceVersion, "READY", node, ARRIVED_AT,
|
||||
ARRIVED_AT.minusDays(5), ARRIVED_AT.minusDays(4),
|
||||
switch (node) {
|
||||
case "IN_TRANSIT" -> null;
|
||||
default -> ARRIVED_AT;
|
||||
},
|
||||
"PICKUP_READY".equals(node) || "PICKED_UP".equals(node) || "EMPTY_RETURNED".equals(node)
|
||||
? ARRIVED_AT.plusHours(2) : null,
|
||||
"PICKED_UP".equals(node) || "EMPTY_RETURNED".equals(node) ? ARRIVED_AT.plusDays(1) : null,
|
||||
"EMPTY_RETURNED".equals(node) ? ARRIVED_AT.plusDays(3) : null);
|
||||
"EMPTY_RETURNED".equals(node) ? ARRIVED_AT.plusDays(3) : null));
|
||||
return new Context(11L, "MSCU1234567", 21L, "ACTIVE", unloadedAt);
|
||||
}
|
||||
|
||||
private TerminalDrayageEntity row(int version) {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
-- 国内送柜报关与国外清关提柜双工作台权限;国内沿用现有货柜权限,国外直接收敛到独立权限族。
|
||||
USE baiyue_iam;
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
DROP PROCEDURE IF EXISTS migrate_container_workspace_permissions;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE migrate_container_workspace_permissions()
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM iam_permission
|
||||
WHERE (id = 5102090 AND permission_code <> 'platform:firstleg:container:list')
|
||||
OR (id = 5102092 AND permission_code <> 'platform:firstleg:container:create')
|
||||
OR (id = 5102093 AND permission_code <> 'platform:firstleg:container:edit')
|
||||
OR (id = 5102200 AND permission_code <> 'platform:firstleg:terminal-drayage:list')
|
||||
OR (id = 5102201 AND permission_code <> 'platform:firstleg:terminal-drayage:document:edit')
|
||||
OR (id = 5102202 AND permission_code <> 'platform:firstleg:terminal-drayage:fleet:edit')
|
||||
OR (id = 5102203 AND permission_code <> 'platform:firstleg:terminal-drayage:pickup-appointment:edit')
|
||||
OR (id = 5102204 AND permission_code <> 'platform:firstleg:terminal-drayage:pickup:confirm')
|
||||
OR (id = 5102205 AND permission_code <> 'platform:firstleg:terminal-drayage:empty-return:confirm')
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM iam_permission
|
||||
WHERE (permission_code = 'platform:firstleg:container:list' AND id <> 5102090)
|
||||
OR (permission_code = 'platform:firstleg:container:create' AND id <> 5102092)
|
||||
OR (permission_code = 'platform:firstleg:container:edit' AND id <> 5102093)
|
||||
OR (permission_code = 'platform:firstleg:terminal-drayage:list' AND id <> 5102200)
|
||||
OR (permission_code = 'platform:firstleg:terminal-drayage:document:edit' AND id <> 5102201)
|
||||
OR (permission_code = 'platform:firstleg:terminal-drayage:fleet:edit' AND id <> 5102202)
|
||||
OR (permission_code = 'platform:firstleg:terminal-drayage:pickup-appointment:edit' AND id <> 5102203)
|
||||
OR (permission_code = 'platform:firstleg:terminal-drayage:pickup:confirm' AND id <> 5102204)
|
||||
OR (permission_code = 'platform:firstleg:terminal-drayage:empty-return:confirm' AND id <> 5102205)
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:权限ID或编码冲突';
|
||||
END IF;
|
||||
|
||||
INSERT INTO iam_permission
|
||||
(id, tenant_id, app_code, permission_type, parent_id, permission_code,
|
||||
permission_name, route_path, http_method, sort_no, status, built_in, deleted)
|
||||
VALUES
|
||||
(5102090,0,'PLATFORM','MENU',5102000,'platform:firstleg:container:list',
|
||||
'国内送柜报关','/container-loading/domestic','GET',40,'ACTIVE',1,0),
|
||||
(5102092,0,'PLATFORM','BUTTON',5102090,'platform:firstleg:container:create',
|
||||
'建立国内货柜',NULL,'POST',20,'ACTIVE',1,0),
|
||||
(5102093,0,'PLATFORM','BUTTON',5102090,'platform:firstleg:container:edit',
|
||||
'维护国内货柜资料',NULL,'PUT',30,'ACTIVE',1,0),
|
||||
(5102200,0,'PLATFORM','MENU',5102000,'platform:firstleg:terminal-drayage:list',
|
||||
'国外清关提柜','/container-loading/overseas','GET',41,'ACTIVE',1,0),
|
||||
(5102201,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:document:edit',
|
||||
'维护目的港清关与DO/PU',NULL,'POST',10,'ACTIVE',1,0),
|
||||
(5102202,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:fleet:edit',
|
||||
'维护目的港码头车队',NULL,'POST',20,'ACTIVE',1,0),
|
||||
(5102203,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:pickup-appointment:edit',
|
||||
'预约目的港提柜',NULL,'POST',30,'ACTIVE',1,0),
|
||||
(5102204,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:pickup:confirm',
|
||||
'确认目的港实际提柜',NULL,'POST',40,'ACTIVE',1,0),
|
||||
(5102205,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:empty-return:confirm',
|
||||
'确认目的港实际还柜',NULL,'POST',50,'ACTIVE',1,0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
permission_type=VALUES(permission_type), parent_id=VALUES(parent_id),
|
||||
permission_name=VALUES(permission_name), route_path=VALUES(route_path),
|
||||
http_method=VALUES(http_method), sort_no=VALUES(sort_no), status='ACTIVE',
|
||||
built_in=1, deleted=0;
|
||||
|
||||
-- list 已包含详情只读;旧 container:view 不再作为别名或双权限入口。
|
||||
DELETE grant_row FROM iam_role_permission grant_row
|
||||
JOIN iam_permission permission
|
||||
ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id
|
||||
WHERE permission.tenant_id=0
|
||||
AND permission.permission_code='platform:firstleg:container:view';
|
||||
UPDATE iam_permission
|
||||
SET status='INACTIVE', deleted=1
|
||||
WHERE tenant_id=0 AND permission_code='platform:firstleg:container:view';
|
||||
|
||||
-- 平台超级管理员和现有订单运营保持货柜全链路能力;其他角色按两个菜单独立授权。
|
||||
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
|
||||
SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(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:firstleg:container:list',
|
||||
'platform:firstleg:container:create',
|
||||
'platform:firstleg:container:edit',
|
||||
'platform:firstleg:terminal-drayage:list',
|
||||
'platform:firstleg:terminal-drayage:document:edit',
|
||||
'platform:firstleg:terminal-drayage:fleet:edit',
|
||||
'platform:firstleg:terminal-drayage:pickup-appointment:edit',
|
||||
'platform:firstleg:terminal-drayage:pickup:confirm',
|
||||
'platform:firstleg:terminal-drayage:empty-return:confirm')
|
||||
WHERE role.tenant_id=0 AND role.app_code='PLATFORM'
|
||||
AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')
|
||||
AND role.status='ACTIVE' AND role.deleted=0
|
||||
AND permission.status='ACTIVE' AND permission.deleted=0;
|
||||
|
||||
IF (SELECT COUNT(*) FROM iam_permission
|
||||
WHERE tenant_id=0 AND app_code='PLATFORM' AND status='ACTIVE' AND deleted=0
|
||||
AND permission_code IN (
|
||||
'platform:firstleg:container:list',
|
||||
'platform:firstleg:container:create',
|
||||
'platform:firstleg:container:edit',
|
||||
'platform:firstleg:terminal-drayage:list',
|
||||
'platform:firstleg:terminal-drayage:document:edit',
|
||||
'platform:firstleg:terminal-drayage:fleet:edit',
|
||||
'platform:firstleg:terminal-drayage:pickup-appointment:edit',
|
||||
'platform:firstleg:terminal-drayage:pickup:confirm',
|
||||
'platform:firstleg:terminal-drayage:empty-return:confirm')) <> 9 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:权限目录不完整';
|
||||
END IF;
|
||||
|
||||
IF (SELECT COUNT(DISTINCT CONCAT(role.role_code, ':', permission.permission_code))
|
||||
FROM iam_role_permission grant_row
|
||||
JOIN iam_role role
|
||||
ON role.id=grant_row.role_id AND role.tenant_id=grant_row.tenant_id
|
||||
JOIN iam_permission permission
|
||||
ON permission.id=grant_row.permission_id AND permission.tenant_id=grant_row.tenant_id
|
||||
WHERE role.tenant_id=0 AND role.app_code='PLATFORM'
|
||||
AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')
|
||||
AND permission.permission_code IN (
|
||||
'platform:firstleg:container:list',
|
||||
'platform:firstleg:container:create',
|
||||
'platform:firstleg:container:edit',
|
||||
'platform:firstleg:terminal-drayage:list',
|
||||
'platform:firstleg:terminal-drayage:document:edit',
|
||||
'platform:firstleg:terminal-drayage:fleet:edit',
|
||||
'platform:firstleg:terminal-drayage:pickup-appointment:edit',
|
||||
'platform:firstleg:terminal-drayage:pickup:confirm',
|
||||
'platform:firstleg:terminal-drayage:empty-return:confirm')) <> 18 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:平台默认角色授权不完整';
|
||||
END IF;
|
||||
|
||||
IF EXISTS (SELECT 1 FROM iam_permission
|
||||
WHERE tenant_id=0 AND permission_code='platform:firstleg:container:view'
|
||||
AND (status='ACTIVE' OR deleted=0)) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜双工作台权限初始化失败:旧查看权限仍然有效';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
|
||||
CALL migrate_container_workspace_permissions();
|
||||
DROP PROCEDURE IF EXISTS migrate_container_workspace_permissions;
|
||||
@@ -18,6 +18,7 @@ BEGIN
|
||||
OR (id = 5107093 AND permission_code <> 'platform:overseas-warehouse:outbound:cancel')
|
||||
OR (id = 5107070 AND permission_code <> 'platform:overseas-warehouse:exception-order:list')
|
||||
OR (id = 5107071 AND permission_code <> 'platform:overseas-warehouse:exception-order:create')
|
||||
OR (id = 5107073 AND permission_code <> 'platform:overseas-warehouse:exception-order:process')
|
||||
OR (id = 5107072 AND permission_code <> 'platform:overseas-warehouse:delivery:return')
|
||||
OR (id = 5107080 AND permission_code <> 'platform:overseas-warehouse:customer-visit:list')
|
||||
OR (id = 5107081 AND permission_code <> 'platform:overseas-warehouse:customer-visit:edit')
|
||||
@@ -36,6 +37,7 @@ BEGIN
|
||||
OR (permission_code = 'platform:overseas-warehouse:outbound:cancel' AND id <> 5107093)
|
||||
OR (permission_code = 'platform:overseas-warehouse:exception-order:list' AND id <> 5107070)
|
||||
OR (permission_code = 'platform:overseas-warehouse:exception-order:create' AND id <> 5107071)
|
||||
OR (permission_code = 'platform:overseas-warehouse:exception-order:process' AND id <> 5107073)
|
||||
OR (permission_code = 'platform:overseas-warehouse:delivery:return' AND id <> 5107072)
|
||||
OR (permission_code = 'platform:overseas-warehouse:customer-visit:list' AND id <> 5107080)
|
||||
OR (permission_code = 'platform:overseas-warehouse:customer-visit:edit' AND id <> 5107081)
|
||||
@@ -60,6 +62,7 @@ VALUES
|
||||
(5107018,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:transfer-delivery','排车转派送',NULL,'POST',718,'ACTIVE',1,0),
|
||||
(5107070,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:exception-order:list','海外异常订单','/overseas-warehouse/exception-orders','GET',770,'ACTIVE',1,0),
|
||||
(5107071,0,'PLATFORM','BUTTON',5107070,'platform:overseas-warehouse:exception-order:create','新增海外异常订单',NULL,'POST',771,'ACTIVE',1,0),
|
||||
(5107073,0,'PLATFORM','BUTTON',5107070,'platform:overseas-warehouse:exception-order:process','处理海外异常订单',NULL,'POST',773,'ACTIVE',1,0),
|
||||
(5107072,0,'PLATFORM','BUTTON',5107092,'platform:overseas-warehouse:delivery:return','派送单标记退货',NULL,'POST',772,'ACTIVE',1,0),
|
||||
(5107080,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:customer-visit:list','客户回访','/overseas-warehouse/customer-visits','GET',780,'ACTIVE',1,0),
|
||||
(5107081,0,'PLATFORM','BUTTON',5107080,'platform:overseas-warehouse:customer-visit:edit','保存客户回访',NULL,'PUT',781,'ACTIVE',1,0),
|
||||
@@ -95,6 +98,7 @@ SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3)
|
||||
'platform:overseas-warehouse:delivery:list',
|
||||
'platform:overseas-warehouse:exception-order:list',
|
||||
'platform:overseas-warehouse:exception-order:create',
|
||||
'platform:overseas-warehouse:exception-order:process',
|
||||
'platform:overseas-warehouse:delivery:return',
|
||||
'platform:overseas-warehouse:delivery:status',
|
||||
'platform:overseas-warehouse:customer-visit:list',
|
||||
@@ -119,6 +123,7 @@ INSERT INTO tmp_phase5_delivery_role_permission (role_code, permission_code) VAL
|
||||
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:list'),
|
||||
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:exception-order:list'),
|
||||
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:exception-order:create'),
|
||||
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:exception-order:process'),
|
||||
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:return'),
|
||||
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:status'),
|
||||
('WAREHOUSE_OPERATOR','platform:overseas-warehouse:customer-visit:list'),
|
||||
@@ -147,6 +152,7 @@ WHERE role.tenant_id=0 AND role.app_code='PLATFORM'
|
||||
'platform:overseas-warehouse:delivery:list',
|
||||
'platform:overseas-warehouse:exception-order:list',
|
||||
'platform:overseas-warehouse:exception-order:create',
|
||||
'platform:overseas-warehouse:exception-order:process',
|
||||
'platform:overseas-warehouse:delivery:return',
|
||||
'platform:overseas-warehouse:delivery:status',
|
||||
'platform:overseas-warehouse:customer-visit:list',
|
||||
@@ -194,12 +200,13 @@ BEGIN
|
||||
'platform:overseas-warehouse:delivery:list',
|
||||
'platform:overseas-warehouse:exception-order:list',
|
||||
'platform:overseas-warehouse:exception-order:create',
|
||||
'platform:overseas-warehouse:exception-order:process',
|
||||
'platform:overseas-warehouse:delivery:return',
|
||||
'platform:overseas-warehouse:delivery:status',
|
||||
'platform:overseas-warehouse:customer-visit:list',
|
||||
'platform:overseas-warehouse:customer-visit:edit',
|
||||
'merchant:warehouse:delivery:list',
|
||||
'merchant:warehouse:delivery:create')) <> 16 THEN
|
||||
'merchant:warehouse:delivery:create')) <> 17 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:权限目录不完整';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM iam_role_permission grant_row
|
||||
@@ -217,10 +224,11 @@ BEGIN
|
||||
'platform:overseas-warehouse:delivery:list',
|
||||
'platform:overseas-warehouse:exception-order:list',
|
||||
'platform:overseas-warehouse:exception-order:create',
|
||||
'platform:overseas-warehouse:exception-order:process',
|
||||
'platform:overseas-warehouse:delivery:return',
|
||||
'platform:overseas-warehouse:delivery:status',
|
||||
'platform:overseas-warehouse:customer-visit:list',
|
||||
'platform:overseas-warehouse:customer-visit:edit')) <> 14 THEN
|
||||
'platform:overseas-warehouse:customer-visit:edit')) <> 15 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:平台管理员授权不完整';
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM iam_role role
|
||||
@@ -241,17 +249,18 @@ BEGIN
|
||||
'platform:overseas-warehouse:delivery:list',
|
||||
'platform:overseas-warehouse:exception-order:list',
|
||||
'platform:overseas-warehouse:exception-order:create',
|
||||
'platform:overseas-warehouse:exception-order:process',
|
||||
'platform:overseas-warehouse:delivery:return',
|
||||
'platform:overseas-warehouse:delivery:status',
|
||||
'platform:overseas-warehouse:customer-visit:list',
|
||||
'platform:overseas-warehouse:customer-visit:edit')) <> 14) THEN
|
||||
'platform:overseas-warehouse:customer-visit:edit')) <> 15) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:平台自定义管理员授权不完整';
|
||||
END IF;
|
||||
SELECT COALESCE(SUM(role_code='WAREHOUSE_OPERATOR'),0),
|
||||
COALESCE(SUM(role_code='WAREHOUSE_TALLY'),0)
|
||||
INTO v_warehouse_operator_permission_count, v_warehouse_tally_permission_count
|
||||
FROM tmp_phase5_delivery_role_permission;
|
||||
IF v_warehouse_operator_permission_count <> 14
|
||||
IF v_warehouse_operator_permission_count <> 15
|
||||
OR v_warehouse_tally_permission_count <> 5 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:仓库岗位最小权限矩阵异常';
|
||||
END IF;
|
||||
@@ -270,6 +279,7 @@ BEGIN
|
||||
'platform:overseas-warehouse:delivery:list',
|
||||
'platform:overseas-warehouse:exception-order:list',
|
||||
'platform:overseas-warehouse:exception-order:create',
|
||||
'platform:overseas-warehouse:exception-order:process',
|
||||
'platform:overseas-warehouse:delivery:return',
|
||||
'platform:overseas-warehouse:delivery:status',
|
||||
'platform:overseas-warehouse:customer-visit:list',
|
||||
|
||||
@@ -124,7 +124,7 @@ paths:
|
||||
get:
|
||||
tags: [IAM Resources / IAM 资源]
|
||||
summary: 查询当前用户有效权限
|
||||
description: 返回当前有效平台成员权限编码;只要求有效 PLATFORM JWT,不依赖 `platform:role:list`。入库单扣货与解除扣货返回独立写权限 `platform:firstleg:inbound:hold`,不会由 `platform:firstleg:inbound:list` 查看权限自动获得。资金账户充值审核与手工调账分别返回 `platform:finance:recharge:review`、`platform:finance:customer-account:adjust`,并与 `platform:finance:customer-account:list` 只读权限分离。海外仓返回 `platform:overseas-warehouse:dispatch:list`、`platform:overseas-warehouse:dispatch:edit`、`platform:overseas-warehouse:dispatch:cancel`、`platform:overseas-warehouse:dispatch:transfer-delivery`、`platform:overseas-warehouse:outbound:list`、`platform:overseas-warehouse:outbound:create`、`platform:overseas-warehouse:outbound:cancel`、`platform:overseas-warehouse:delivery:list`、`platform:overseas-warehouse:delivery:return`、`platform:overseas-warehouse:delivery:status`、`platform:overseas-warehouse:exception-order:list`、`platform:overseas-warehouse:exception-order:create`、`platform:overseas-warehouse:customer-visit:list`、`platform:overseas-warehouse:customer-visit:edit`;海外 PDA 盘点、移库、换标和现场查询统一返回 `lastleg:pda:operate`,且仅限 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`。列表权限包含详情和只读辅助接口,写操作独立授权。
|
||||
description: 返回当前有效平台成员权限编码;只要求有效 PLATFORM JWT,不依赖 `platform:role:list`。入库单扣货与解除扣货返回独立写权限 `platform:firstleg:inbound:hold`,不会由 `platform:firstleg:inbound:list` 查看权限自动获得。资金账户充值审核与手工调账分别返回 `platform:finance:recharge:review`、`platform:finance:customer-account:adjust`,并与 `platform:finance:customer-account:list` 只读权限分离。海外仓返回 `platform:overseas-warehouse:dispatch:list`、`platform:overseas-warehouse:dispatch:edit`、`platform:overseas-warehouse:dispatch:cancel`、`platform:overseas-warehouse:dispatch:transfer-delivery`、`platform:overseas-warehouse:outbound:list`、`platform:overseas-warehouse:outbound:create`、`platform:overseas-warehouse:outbound:cancel`、`platform:overseas-warehouse:delivery:list`、`platform:overseas-warehouse:delivery:return`、`platform:overseas-warehouse:delivery:status`、`platform:overseas-warehouse:exception-order:list`、`platform:overseas-warehouse:exception-order:create`、`platform:overseas-warehouse:exception-order:process`、`platform:overseas-warehouse:customer-visit:list`、`platform:overseas-warehouse:customer-visit:edit`;海外 PDA 盘点、移库、换标和现场查询统一返回 `lastleg:pda:operate`,且仅限 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`。货柜双工作台以 `platform:firstleg:container:list/create/edit` 维护国内送柜报关,国外清关提柜独立返回 `platform:firstleg:terminal-drayage:list`、`platform:firstleg:terminal-drayage:document:edit`、`platform:firstleg:terminal-drayage:fleet:edit`、`platform:firstleg:terminal-drayage:pickup-appointment:edit`、`platform:firstleg:terminal-drayage:pickup:confirm`、`platform:firstleg:terminal-drayage:empty-return:confirm`,不以 `container:list/edit` 兼容放行国外操作。列表权限包含详情和只读辅助接口,写操作独立授权。
|
||||
responses:
|
||||
'200':
|
||||
description: 当前平台成员权限查询成功
|
||||
|
||||
@@ -31,9 +31,18 @@ class IamOpenApiContractTest {
|
||||
.contains("platform:overseas-warehouse:delivery:status")
|
||||
.contains("platform:overseas-warehouse:exception-order:list")
|
||||
.contains("platform:overseas-warehouse:exception-order:create")
|
||||
.contains("platform:overseas-warehouse:exception-order:process")
|
||||
.contains("platform:overseas-warehouse:customer-visit:list")
|
||||
.contains("platform:overseas-warehouse:customer-visit:edit")
|
||||
.contains("platform:overseas-warehouse:outbound:cancel")
|
||||
.contains("platform:firstleg:container:list/create/edit")
|
||||
.contains("platform:firstleg:terminal-drayage:list")
|
||||
.contains("platform:firstleg:terminal-drayage:document:edit")
|
||||
.contains("platform:firstleg:terminal-drayage:fleet:edit")
|
||||
.contains("platform:firstleg:terminal-drayage:pickup-appointment:edit")
|
||||
.contains("platform:firstleg:terminal-drayage:pickup:confirm")
|
||||
.contains("platform:firstleg:terminal-drayage:empty-return:confirm")
|
||||
.contains("不以 `container:list/edit` 兼容放行国外操作")
|
||||
.contains("merchant:warehouse:delivery:list")
|
||||
.contains("merchant:warehouse:delivery:create")
|
||||
.contains("merchant:cross-border:procurement")
|
||||
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
package com.baiyue.iam.repository;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ContainerWorkspacePermissionSqlContractTest {
|
||||
private static final Pattern CODE = Pattern.compile("platform:firstleg:(?:container|terminal-drayage):[a-z0-9:-]+");
|
||||
private static final Set<String> REQUIRED = Set.of(
|
||||
"platform:firstleg:container:list",
|
||||
"platform:firstleg:container:create",
|
||||
"platform:firstleg:container:edit",
|
||||
"platform:firstleg:terminal-drayage:list",
|
||||
"platform:firstleg:terminal-drayage:document:edit",
|
||||
"platform:firstleg:terminal-drayage:fleet:edit",
|
||||
"platform:firstleg:terminal-drayage:pickup-appointment:edit",
|
||||
"platform:firstleg:terminal-drayage:pickup:confirm",
|
||||
"platform:firstleg:terminal-drayage:empty-return:confirm");
|
||||
|
||||
@Test
|
||||
void definesSeparateDomesticAndOverseasMenusWithoutAliases() throws Exception {
|
||||
String sql = sql();
|
||||
|
||||
assertThat(codes(catalog(sql))).containsExactlyInAnyOrderElementsOf(REQUIRED);
|
||||
assertThat(sql).contains(
|
||||
"'platform:firstleg:container:list',\n '国内送柜报关','/container-loading/domestic','GET'",
|
||||
"'platform:firstleg:terminal-drayage:list',\n '国外清关提柜','/container-loading/overseas','GET'",
|
||||
"permission.permission_code='platform:firstleg:container:view'",
|
||||
"SET status='INACTIVE', deleted=1");
|
||||
assertThat(codes(catalog(sql))).noneMatch(code -> code.endsWith(":view"));
|
||||
assertThat(sql).doesNotContain(
|
||||
"platform:firstleg:domestic-container:",
|
||||
"platform:firstleg:overseas-container:",
|
||||
"platform:firstleg:terminal-drayage:edit");
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsDomesticWritesAndDefinesFineGrainedOverseasWrites() throws Exception {
|
||||
assertThat(sql()).contains(
|
||||
"'platform:firstleg:container:create',\n '建立国内货柜',NULL,'POST'",
|
||||
"'platform:firstleg:container:edit',\n '维护国内货柜资料',NULL,'PUT'",
|
||||
"'platform:firstleg:terminal-drayage:document:edit',\n '维护目的港清关与DO/PU',NULL,'POST'",
|
||||
"'platform:firstleg:terminal-drayage:fleet:edit',\n '维护目的港码头车队',NULL,'POST'",
|
||||
"'platform:firstleg:terminal-drayage:pickup-appointment:edit',\n '预约目的港提柜',NULL,'POST'",
|
||||
"'platform:firstleg:terminal-drayage:pickup:confirm',\n '确认目的港实际提柜',NULL,'POST'",
|
||||
"'platform:firstleg:terminal-drayage:empty-return:confirm',\n '确认目的港实际还柜',NULL,'POST'");
|
||||
}
|
||||
|
||||
@Test
|
||||
void grantsTheCompleteMatrixOnlyToTheExistingDefaultPlatformRoles() throws Exception {
|
||||
String sql = sql();
|
||||
int grantStart = sql.indexOf("-- 平台超级管理员和现有订单运营保持货柜全链路能力");
|
||||
int assertionStart = sql.indexOf("IF (SELECT COUNT(*) FROM iam_permission", grantStart);
|
||||
String grant = sql.substring(grantStart, assertionStart);
|
||||
|
||||
assertThat(grant).contains(
|
||||
"role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')",
|
||||
"platform:firstleg:container:list",
|
||||
"platform:firstleg:terminal-drayage:list",
|
||||
"platform:firstleg:terminal-drayage:empty-return:confirm");
|
||||
assertThat(grant).doesNotContain(
|
||||
"PLATFORM_CUSTOM_ADMIN",
|
||||
"WAREHOUSE_OPERATOR",
|
||||
"WAREHOUSE_TALLY",
|
||||
"OVERSEAS_WAREHOUSE_PDA_OPERATOR",
|
||||
"CN_FIRSTLEG_PDA_OPERATOR",
|
||||
"MERCHANT_OWNER");
|
||||
assertThat(sql).contains(
|
||||
"permission_code IN (",
|
||||
")) <> 9 THEN",
|
||||
")) <> 18 THEN");
|
||||
}
|
||||
|
||||
@Test
|
||||
void failsClosedForFixedIdentityConflictsAndRemainsReplaySafe() throws Exception {
|
||||
assertThat(sql()).contains(
|
||||
"migrate_container_workspace_permissions",
|
||||
"id = 5102090 AND permission_code <> 'platform:firstleg:container:list'",
|
||||
"id = 5102205 AND permission_code <> 'platform:firstleg:terminal-drayage:empty-return:confirm'",
|
||||
"permission_code = 'platform:firstleg:terminal-drayage:list' AND id <> 5102200",
|
||||
"SIGNAL SQLSTATE '45000'",
|
||||
"ON DUPLICATE KEY UPDATE",
|
||||
"INSERT IGNORE INTO iam_role_permission");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unifiedIncrementContainsTheCompleteModuleSql() throws Exception {
|
||||
Path unifiedPath = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
|
||||
if (!Files.exists(unifiedPath)) {
|
||||
unifiedPath = Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
|
||||
}
|
||||
String unified = Files.readString(unifiedPath, StandardCharsets.UTF_8);
|
||||
String startMarker = "-- 货柜双工作台权限开始(与 iam/db/container-workspace-permissions.sql 保持一致)";
|
||||
String endMarker = "-- 货柜双工作台权限结束,恢复头程数据库上下文";
|
||||
int start = unified.indexOf(startMarker);
|
||||
int bodyStart = unified.indexOf('\n', start) + 1;
|
||||
int end = unified.indexOf(endMarker, bodyStart);
|
||||
|
||||
assertThat(start).isGreaterThanOrEqualTo(0);
|
||||
assertThat(bodyStart).isGreaterThan(start);
|
||||
assertThat(end).isGreaterThan(bodyStart);
|
||||
assertThat(unified.substring(bodyStart, end).trim()).isEqualTo(sql().trim());
|
||||
}
|
||||
|
||||
private static String sql() throws Exception {
|
||||
return new ClassPathResource("db/container-workspace-permissions.sql")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static Set<String> codes(String source) {
|
||||
Set<String> values = new LinkedHashSet<>();
|
||||
CODE.matcher(source).results().map(result -> result.group()).forEach(values::add);
|
||||
return values;
|
||||
}
|
||||
|
||||
private static String catalog(String sql) {
|
||||
int start = sql.indexOf("INSERT INTO iam_permission");
|
||||
int end = sql.indexOf("ON DUPLICATE KEY UPDATE", start);
|
||||
assertThat(start).isGreaterThanOrEqualTo(0);
|
||||
assertThat(end).isGreaterThan(start);
|
||||
return sql.substring(start, end);
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -25,6 +25,7 @@ class Phase5DeliveryPermissionSqlContractTest {
|
||||
"platform:overseas-warehouse:delivery:list",
|
||||
"platform:overseas-warehouse:exception-order:list",
|
||||
"platform:overseas-warehouse:exception-order:create",
|
||||
"platform:overseas-warehouse:exception-order:process",
|
||||
"platform:overseas-warehouse:delivery:return",
|
||||
"platform:overseas-warehouse:delivery:status",
|
||||
"platform:overseas-warehouse:customer-visit:list",
|
||||
@@ -54,6 +55,7 @@ class Phase5DeliveryPermissionSqlContractTest {
|
||||
"'platform:overseas-warehouse:dispatch:cancel','取消海外排车',NULL,'DELETE'",
|
||||
"'platform:overseas-warehouse:exception-order:list','海外异常订单','/overseas-warehouse/exception-orders','GET'",
|
||||
"'platform:overseas-warehouse:exception-order:create','新增海外异常订单',NULL,'POST'",
|
||||
"'platform:overseas-warehouse:exception-order:process','处理海外异常订单',NULL,'POST'",
|
||||
"'platform:overseas-warehouse:delivery:return','派送单标记退货',NULL,'POST'",
|
||||
"'platform:overseas-warehouse:delivery:status','编辑派送单状态',NULL,'PUT'",
|
||||
"'platform:overseas-warehouse:customer-visit:list','客户回访','/overseas-warehouse/customer-visits','GET'",
|
||||
@@ -75,6 +77,7 @@ class Phase5DeliveryPermissionSqlContractTest {
|
||||
"('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:edit')",
|
||||
"('WAREHOUSE_OPERATOR','platform:overseas-warehouse:outbound:create')",
|
||||
"('WAREHOUSE_OPERATOR','platform:overseas-warehouse:exception-order:create')",
|
||||
"('WAREHOUSE_OPERATOR','platform:overseas-warehouse:exception-order:process')",
|
||||
"('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:return')",
|
||||
"('WAREHOUSE_OPERATOR','platform:overseas-warehouse:delivery:status')",
|
||||
"('WAREHOUSE_OPERATOR','platform:overseas-warehouse:customer-visit:edit')",
|
||||
@@ -89,6 +92,7 @@ class Phase5DeliveryPermissionSqlContractTest {
|
||||
"('WAREHOUSE_TALLY','platform:overseas-warehouse:dispatch:edit')",
|
||||
"('WAREHOUSE_TALLY','platform:overseas-warehouse:outbound:create')",
|
||||
"('WAREHOUSE_TALLY','platform:overseas-warehouse:exception-order:create')",
|
||||
"('WAREHOUSE_TALLY','platform:overseas-warehouse:exception-order:process')",
|
||||
"('WAREHOUSE_TALLY','platform:overseas-warehouse:delivery:return')",
|
||||
"('WAREHOUSE_TALLY','platform:overseas-warehouse:delivery:status')",
|
||||
"('WAREHOUSE_TALLY','platform:overseas-warehouse:customer-visit:edit')",
|
||||
@@ -106,7 +110,7 @@ class Phase5DeliveryPermissionSqlContractTest {
|
||||
"SELECT COALESCE(SUM(role_code='WAREHOUSE_OPERATOR'),0)",
|
||||
"COALESCE(SUM(role_code='WAREHOUSE_TALLY'),0)",
|
||||
"INTO v_warehouse_operator_permission_count, v_warehouse_tally_permission_count",
|
||||
"IF v_warehouse_operator_permission_count <> 14",
|
||||
"IF v_warehouse_operator_permission_count <> 15",
|
||||
"OR v_warehouse_tally_permission_count <> 5 THEN",
|
||||
"IF EXISTS (SELECT 1 FROM iam_role role",
|
||||
"AND UPPER(role.role_code)='PLATFORM_CUSTOM_ADMIN'",
|
||||
@@ -160,7 +164,7 @@ class Phase5DeliveryPermissionSqlContractTest {
|
||||
"海外排车、出库与派送权限",
|
||||
"测试和部署只执行固定入口,不再单独执行模块脚本",
|
||||
"排车列表/编辑/取消/转派送",
|
||||
"异常订单列表/创建和客户回访列表/编辑十四项权限",
|
||||
"异常订单列表/创建/处理和客户回访列表/编辑十五项权限",
|
||||
"merchant:warehouse:delivery:list",
|
||||
"merchant:warehouse:delivery:create",
|
||||
"PDA 岗位不得获得上述后台权限");
|
||||
|
||||
+14
-14
@@ -37,12 +37,12 @@ class Phase5DeliveryPermissionSqlExecutionTest {
|
||||
assertThat(customAdministratorViolations(connection)).isEqualTo(1);
|
||||
|
||||
executeGrant(connection);
|
||||
assertThat(grantCount(connection, 11L)).isEqualTo(14);
|
||||
assertThat(grantCount(connection, 11L)).isEqualTo(15);
|
||||
assertThat(customAdministratorViolations(connection)).isZero();
|
||||
|
||||
executeGrant(connection);
|
||||
assertThat(grantCount(connection, 11L)).isEqualTo(14);
|
||||
assertThat(totalGrantCount(connection)).isEqualTo(14);
|
||||
assertThat(grantCount(connection, 11L)).isEqualTo(15);
|
||||
assertThat(totalGrantCount(connection)).isEqualTo(15);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,14 +51,14 @@ class Phase5DeliveryPermissionSqlExecutionTest {
|
||||
try (Connection connection = database()) {
|
||||
insertRole(connection, 21L, "PLATFORM_CUSTOM_ADMIN");
|
||||
insertRole(connection, 22L, "Platform_Custom_Admin");
|
||||
grantFirstPermissions(connection, 21L, 14);
|
||||
grantFirstPermissions(connection, 22L, 13);
|
||||
grantFirstPermissions(connection, 21L, 15);
|
||||
grantFirstPermissions(connection, 22L, 14);
|
||||
|
||||
assertThat(customAdministratorViolations(connection)).isEqualTo(1);
|
||||
|
||||
executeGrant(connection);
|
||||
assertThat(grantCount(connection, 21L)).isEqualTo(14);
|
||||
assertThat(grantCount(connection, 22L)).isEqualTo(14);
|
||||
assertThat(grantCount(connection, 21L)).isEqualTo(15);
|
||||
assertThat(grantCount(connection, 22L)).isEqualTo(15);
|
||||
assertThat(customAdministratorViolations(connection)).isZero();
|
||||
}
|
||||
}
|
||||
@@ -68,16 +68,16 @@ class Phase5DeliveryPermissionSqlExecutionTest {
|
||||
try (Connection connection = database()) {
|
||||
insertRole(connection, 31L, "PLATFORM_CUSTOM_ADMIN");
|
||||
insertRole(connection, 32L, "platform_custom_admin");
|
||||
grantFirstPermissions(connection, 31L, 14);
|
||||
grantFirstPermissions(connection, 32L, 14);
|
||||
grantFirstPermissions(connection, 31L, 15);
|
||||
grantFirstPermissions(connection, 32L, 15);
|
||||
|
||||
assertThat(customAdministratorViolations(connection)).isZero();
|
||||
executeGrant(connection);
|
||||
executeGrant(connection);
|
||||
|
||||
assertThat(grantCount(connection, 31L)).isEqualTo(14);
|
||||
assertThat(grantCount(connection, 32L)).isEqualTo(14);
|
||||
assertThat(totalGrantCount(connection)).isEqualTo(28);
|
||||
assertThat(grantCount(connection, 31L)).isEqualTo(15);
|
||||
assertThat(grantCount(connection, 32L)).isEqualTo(15);
|
||||
assertThat(totalGrantCount(connection)).isEqualTo(30);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,7 +182,7 @@ class Phase5DeliveryPermissionSqlExecutionTest {
|
||||
AND (SELECT COUNT(*) FROM iam_role_permission grant_row
|
||||
JOIN iam_permission permission ON permission.id=grant_row.permission_id
|
||||
WHERE grant_row.role_id=role.id AND grant_row.tenant_id=role.tenant_id
|
||||
AND permission.permission_code IN (%s)) <> 14
|
||||
AND permission.permission_code IN (%s)) <> 15
|
||||
""".formatted(placeholders);
|
||||
try (PreparedStatement query = connection.prepareStatement(sql)) {
|
||||
int index = 1;
|
||||
@@ -209,7 +209,7 @@ class Phase5DeliveryPermissionSqlExecutionTest {
|
||||
LinkedHashSet<String> codes = new LinkedHashSet<>();
|
||||
String grant = administratorGrantSql();
|
||||
PLATFORM_CODE.matcher(grant).results().map(result -> result.group()).forEach(codes::add);
|
||||
assertThat(codes).hasSize(14);
|
||||
assertThat(codes).hasSize(15);
|
||||
return codes;
|
||||
}
|
||||
|
||||
|
||||
@@ -41,8 +41,8 @@
|
||||
| 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java`、`transfer/service/WarehouseTransferService.java` |
|
||||
| 一件代发接收、SKU/箱唛库存 | 内部接收入口 `dropship/controller/DropshipOrderController.java` 的 `/internal/v1/lastleg/dropship/receive`,必须经 `InternalServiceTokenGuard`;版本化契约 `src/main/resources/openapi/lastleg-internal-dropship-v1.yaml`;处理 `dropship/service/DropshipOrderService.java`、`dropship/entity/`、`dropship/mapper/` |
|
||||
| 货物废弃、待审锁箱、审批扣减 | `discard/controller/CargoDiscardController.java`、`discard/service/CargoDiscardService.java` |
|
||||
| 客户拜访、派送状态与签收附件 | `customervisit/controller/CustomerVisitController.java`、`customervisit/service/CustomerVisitService.java`;平台维护拜访、附件上传、派送完成/状态 |
|
||||
| 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java`、`exceptionorder/service/ExceptionOrderService.java`;异常订单建档、关联解析、派送退回候选与标记 |
|
||||
| 客户拜访、派送状态与签收附件 | `customervisit/controller/CustomerVisitController.java`、`customervisit/service/CustomerVisitService.java`、`delivery/model/DeliveryStatusPolicy.java`;派送单返回服务端 `allowedActions/version`,状态只允许单调迁移,同状态幂等,完成后生成回访记录 |
|
||||
| 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java`、`exceptionorder/service/ExceptionOrderService.java`、`exceptionorder/gateway/ExceptionEvidenceFileGateway.java`、`inventory/command/service/InventoryCommandService.java`;证据固定按客户、异常单 ID 和 `OVERSEAS_EXCEPTION_EVIDENCE` 校验。退回候选按原出库拣货事实冻结,不接受自由 SKU 或数量;异常按 `PENDING→PROCESSING→COMPLETED`(待处理可取消)、版本与幂等键流转。退回海外仓须把实收按原出库来源恢复到服务端校验的真实托盘/库位并写 `RETURN_STOCK_IN` 流水,库存恢复、异常完成与派送 `RETURNED` 在同一事务完成 |
|
||||
| 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;客户入口要求 `merchant:cross-border:procurement` 且只取 Token 客户范围;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` |
|
||||
| 海外业务资金指令与结果 | `wallet/WalletCommandOutboxService.java`、`WalletCommandCompletedConsumer.java`、`WalletResultHandler.java`;采购和换标通过 `WALLET_COMMAND_REQUESTED` 请求国内 Finance 冻结、结算或释放,并以 `WALLET_COMMAND_COMPLETED` 幂等回写业务资金状态;不得跨区同步调用 Finance 钱包或在 Lastleg 自建余额账本 |
|
||||
| 海外业务费用来源 | `financefee/controller/PlatformOverseasFeeSourceController.java`、`financefee/service/OverseasFeeSourceService.java`、`OverseasFeeBusinessGuard.java`、`OverseasFeeOutboxService.java`、`OverseasFeeOutboxRelay.java`;海外入仓、采购、美国卡车、本土散板和海外仓服务费必须匹配当前客户的真实业务,入仓记录和入仓码按事实选填;审核通过、调整或作废后以版本化完整快照经 Outbox/Event Bridge 进入独立 Finance,OpenAPI `src/main/resources/openapi/overseas-finance-fees-v1.yaml` |
|
||||
|
||||
+2
-2
@@ -68,8 +68,8 @@ public class CustomerVisitController {
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_STATUS + "')")
|
||||
public ApiResponse<CustomerVisitResponses.Completed> completeDelivery(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
|
||||
@PathVariable String deliveryNo) {
|
||||
return ApiResponse.success(service.completeDelivery(requiredScope(user, customerId), operator(user), deliveryNo));
|
||||
@PathVariable String deliveryNo, @Valid @RequestBody CustomerVisitRequests.DeliveryStatus request) {
|
||||
return ApiResponse.success(service.completeDelivery(requiredScope(user, customerId), operator(user), deliveryNo, request));
|
||||
}
|
||||
|
||||
@PutMapping("/delivery-orders/{deliveryNo}/status")
|
||||
|
||||
+1
@@ -31,5 +31,6 @@ public final class CustomerVisitRequests {
|
||||
@NotNull @Min(0) Long sizeBytes) { }
|
||||
|
||||
public record DeliveryStatus(@NotBlank @Size(max = 32) String status,
|
||||
@NotNull @Min(0) Long expectedVersion,
|
||||
@Size(max = 500) String remark) { }
|
||||
}
|
||||
|
||||
+19
-14
@@ -15,10 +15,8 @@ import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
|
||||
import com.baiyue.lastleg.delivery.model.DeliveryStatusPolicy;
|
||||
import com.baiyue.lastleg.entity.LastlegInboundOrder;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
|
||||
import com.baiyue.lastleg.mapper.LastlegInboundOrderMapper;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -52,7 +50,6 @@ public class CustomerVisitService {
|
||||
@Resource private DeliveryOperationMapper deliveryOperations;
|
||||
@Resource private LastlegOrderMapper lastMileOrders;
|
||||
@Resource private LastlegInboundOrderMapper inboundOrders;
|
||||
@Resource private LastlegOrderStatusHistoryMapper histories;
|
||||
@Resource private CustomerVisitFileGateway files;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
|
||||
@@ -80,29 +77,31 @@ public class CustomerVisitService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CustomerVisitResponses.Completed completeDelivery(String customerId, String operatorId, String deliveryNo) {
|
||||
DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo);
|
||||
public CustomerVisitResponses.Completed completeDelivery(String customerId, String operatorId, String deliveryNo,
|
||||
CustomerVisitRequests.DeliveryStatus request) {
|
||||
if (!"DELIVERED".equals(trim(request.status()).toUpperCase(Locale.ROOT))) {
|
||||
throw badRequest("派送完成接口只接受 DELIVERED 状态");
|
||||
}
|
||||
DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo);
|
||||
if (delivery == null) throw notFound("派送单不存在");
|
||||
boolean replayed = "DELIVERED".equals(delivery.getStatus());
|
||||
if (!replayed) requireDeliveryTransition(delivery.getStatus(), "DELIVERED");
|
||||
if (!replayed) {
|
||||
requireVersion(delivery, request.expectedVersion());
|
||||
requireDeliveryTransition(delivery.getStatus(), "DELIVERED");
|
||||
}
|
||||
LocalDateTime now = replayed && delivery.getSignedAt() != null ? delivery.getSignedAt() : LocalDateTime.now();
|
||||
if (!replayed) {
|
||||
delivery.setStatus("DELIVERED");
|
||||
delivery.setSignedAt(now);
|
||||
delivery.setUpdatedAt(now);
|
||||
delivery.setVersion(version(delivery) + 1);
|
||||
deliveryOrders.updateById(delivery);
|
||||
for (LastlegOrder order : lastMileOrders.selectList(new LambdaQueryWrapper<LastlegOrder>()
|
||||
.eq(LastlegOrder::getCustomerId, customerId)
|
||||
.eq(LastlegOrder::getDeliveryOrderNo, deliveryNo))) {
|
||||
String from = order.getCurrentStatus();
|
||||
order.setCurrentStatus("DELIVERED");
|
||||
order.setSubOrderStatus("DELIVERY:DELIVERED");
|
||||
order.setUpdatedAt(now);
|
||||
lastMileOrders.updateById(order);
|
||||
LastlegOrderStatusHistory history = new LastlegOrderStatusHistory();
|
||||
history.setCustomerId(customerId); history.setOrderId(order.getId()); history.setFromStatus(from);
|
||||
history.setToStatus("DELIVERED"); history.setEventId("DELIVERY_COMPLETED:" + deliveryNo);
|
||||
history.setRemark("派送完成"); history.setCreatedAt(now); histories.insert(history);
|
||||
}
|
||||
recordOperation(delivery, operatorId, "COMPLETE_DELIVERY", "派送完成并生成客户回访记录", now);
|
||||
}
|
||||
@@ -118,20 +117,22 @@ public class CustomerVisitService {
|
||||
if (!DeliveryStatusPolicy.isEditableTarget(target)) {
|
||||
throw badRequest("不支持的派送状态");
|
||||
}
|
||||
if ("DELIVERED".equals(target)) return completeDelivery(customerId, operatorId, deliveryNo);
|
||||
DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo);
|
||||
if ("DELIVERED".equals(target)) return completeDelivery(customerId, operatorId, deliveryNo, request);
|
||||
DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo);
|
||||
if (delivery == null) throw notFound("派送单不存在");
|
||||
if (target.equals(delivery.getStatus())) {
|
||||
CustomerVisit existing = visits.findByDelivery(customerId, delivery.getId());
|
||||
return new CustomerVisitResponses.Completed(existing == null ? null : existing.getId(), deliveryNo,
|
||||
delivery.getStatus(), existing == null ? null : existing.getStatus(), true);
|
||||
}
|
||||
requireVersion(delivery, request.expectedVersion());
|
||||
requireDeliveryTransition(delivery.getStatus(), target);
|
||||
String from = delivery.getStatus();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
delivery.setStatus(target);
|
||||
if ("DELIVERING".equals(target) && delivery.getDispatchedAt() == null) delivery.setDispatchedAt(now);
|
||||
delivery.setUpdatedAt(now);
|
||||
delivery.setVersion(version(delivery) + 1);
|
||||
deliveryOrders.updateById(delivery);
|
||||
String result = "派送状态由 " + from + " 修改为 " + target;
|
||||
if (StringUtils.hasText(request.remark())) result += ",备注:" + trim(request.remark());
|
||||
@@ -313,6 +314,10 @@ public class CustomerVisitService {
|
||||
throw conflict("派送状态不允许由 " + from + " 变更为 " + target);
|
||||
}
|
||||
}
|
||||
private static void requireVersion(DeliveryOrder delivery, long expectedVersion) {
|
||||
if (version(delivery) != expectedVersion) throw conflict("派送单版本已变化,请刷新后重试");
|
||||
}
|
||||
private static long version(DeliveryOrder delivery) { return Objects.requireNonNullElse(delivery.getVersion(), 0L); }
|
||||
private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); }
|
||||
private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
|
||||
private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); }
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ public final class DeliveryResponses {
|
||||
int packageCount, BigDecimal totalWeightKg, BigDecimal totalVolumeCbm,
|
||||
LocalDateTime plannedDeliveryAt, LocalDateTime dispatchedAt,
|
||||
LocalDateTime expectedArrivalAt, LocalDateTime signedAt,
|
||||
String remark, LocalDateTime createdAt, List<String> allowedActions) {}
|
||||
String remark, LocalDateTime createdAt, long version, List<String> allowedActions) {}
|
||||
|
||||
public record DeliverySku(String inboundCode, String customerName, String destinationWarehouseCode,
|
||||
String receivingAddress, String shipmentNo, String fbaTrackingNo,
|
||||
|
||||
@@ -47,6 +47,7 @@ public class DeliveryOrder {
|
||||
private String remark;
|
||||
private String idempotencyKey;
|
||||
private String createdBy;
|
||||
private Long version;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
|
||||
+2
@@ -10,6 +10,8 @@ import org.apache.ibatis.annotations.Select;
|
||||
public interface DeliveryOrderMapper extends BaseMapper<DeliveryOrder> {
|
||||
@Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND delivery_no=#{deliveryNo} LIMIT 1")
|
||||
DeliveryOrder findByNo(@Param("customerId") String customerId, @Param("deliveryNo") String deliveryNo);
|
||||
@Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND delivery_no=#{deliveryNo} LIMIT 1 FOR UPDATE")
|
||||
DeliveryOrder lockByNo(@Param("customerId") String customerId, @Param("deliveryNo") String deliveryNo);
|
||||
@Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1")
|
||||
DeliveryOrder findByIdempotency(@Param("customerId") String customerId, @Param("key") String key);
|
||||
@Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND outbound_order_id=#{outboundOrderId} LIMIT 1 FOR UPDATE")
|
||||
|
||||
+2
-1
@@ -13,6 +13,7 @@ public final class DeliveryStatusPolicy {
|
||||
"DELIVERED", Set.of(),
|
||||
"REJECTED", Set.of(),
|
||||
"RETURN_PENDING", Set.of(),
|
||||
"RETURNED", Set.of(),
|
||||
"CANCELLED", Set.of());
|
||||
|
||||
private DeliveryStatusPolicy() { }
|
||||
@@ -28,7 +29,7 @@ public final class DeliveryStatusPolicy {
|
||||
public static List<String> allowedActions(String status) {
|
||||
return switch (status == null ? "" : status) {
|
||||
case "WAIT_DELIVERY" -> List.of(
|
||||
"START_DELIVERY", "COMPLETE_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY", "MARK_RETURN");
|
||||
"START_DELIVERY", "COMPLETE_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY");
|
||||
case "DELIVERING" -> List.of("COMPLETE_DELIVERY", "REJECT_DELIVERY", "MARK_RETURN");
|
||||
case "DELIVERED", "REJECTED" -> List.of("MARK_RETURN");
|
||||
default -> List.of();
|
||||
|
||||
+3
-3
@@ -170,7 +170,7 @@ public class DeliveryTransferService {
|
||||
delivery.setDispatchOrderId(dispatch.getId()); delivery.setDispatchNo(dispatch.getDispatchNo());
|
||||
delivery.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); delivery.setOverseasWarehouseName(dispatch.getOverseasWarehouseName());
|
||||
LastlegOrder firstOrder = firstTailOrder(customerId, items);
|
||||
delivery.setCustomerName(firstOrder == null ? null : firstOrder.getCustomerName()); delivery.setStatus("WAIT_DELIVERY");
|
||||
delivery.setCustomerName(firstOrder == null ? null : firstOrder.getCustomerName()); delivery.setStatus("WAIT_DELIVERY"); delivery.setVersion(0L);
|
||||
delivery.setWaybillNo(trim(request.waybillNo())); delivery.setDeliveryMethod(request.deliveryMethod().trim());
|
||||
delivery.setCarrierName(firstText(request.carrierName(), dispatch.getCarrierName())); delivery.setPickupMethod(trim(request.pickupMethod()));
|
||||
delivery.setLoadingType(trim(request.loadingType())); delivery.setVehicleType(trim(request.vehicleType())); delivery.setVehicleSize(trim(request.vehicleSize()));
|
||||
@@ -387,7 +387,7 @@ public class DeliveryTransferService {
|
||||
throw conflict("Outbound order cannot be cancelled while delivery is in progress");
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
DeliveryOrder delivery = existingDelivery;
|
||||
if (delivery != null) { delivery.setStatus("CANCELLED"); delivery.setUpdatedAt(now); deliveryOrders.updateById(delivery); }
|
||||
if (delivery != null) { delivery.setStatus("CANCELLED"); delivery.setVersion(value(delivery.getVersion()) + 1); delivery.setUpdatedAt(now); deliveryOrders.updateById(delivery); }
|
||||
OutboundPdaTask task = pdaTasks.lockByOutbound(customerId, outbound.getId());
|
||||
if (task != null) { task.setStatus("CANCELLED"); task.setUpdatedAt(now); pdaTasks.updateById(task); }
|
||||
outbound.setStatus("CANCELLED"); outbound.setUpdatedAt(now); outboundOrders.updateById(outbound);
|
||||
@@ -614,7 +614,7 @@ public class DeliveryTransferService {
|
||||
candidate.getInboundCode(), candidate.getSkuCode(), candidate.getProductName(),
|
||||
value(candidate.getAvailablePackages()));
|
||||
}
|
||||
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), DeliveryStatusPolicy.allowedActions(d.getStatus())); }
|
||||
private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus())); }
|
||||
private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), null, d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); }
|
||||
private DeliveryResponses.DeliveryFeeRow feeRow(DeliveryFee f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getFeeDirection(), f.getFeeType(), f.getCurrency(), f.getAmount(), f.getRemark()); }
|
||||
private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); }
|
||||
|
||||
+10
@@ -10,10 +10,12 @@ import com.baiyue.lastleg.security.OverseasPermissions;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -53,6 +55,14 @@ public class ExceptionOrderController {
|
||||
return ApiResponse.success(service.detail(requiredScope(user, customerId), exceptionNo));
|
||||
}
|
||||
|
||||
@PostMapping(value = "/exception-orders/{exceptionNo}/evidence", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_PROCESS + "')")
|
||||
public ApiResponse<ExceptionOrderResponses.UploadedEvidence> uploadEvidence(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId,
|
||||
@PathVariable String exceptionNo, @RequestPart("file") MultipartFile file) {
|
||||
return ApiResponse.success(service.uploadEvidence(requiredScope(user, customerId), operatorId(user), exceptionNo, file));
|
||||
}
|
||||
|
||||
@PostMapping("/exception-orders/{exceptionNo}/start-processing")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.EXCEPTION_ORDER_PROCESS + "')")
|
||||
public ApiResponse<ExceptionOrderResponses.CommandResult> startProcessing(
|
||||
|
||||
+10
-10
@@ -26,15 +26,15 @@ public final class ExceptionOrderRequests {
|
||||
@Min(1) Integer packageCount,
|
||||
@Size(max = 128) String skuCode) {}
|
||||
|
||||
public record MarkReturn(@NotNull Long inboundOrderId,
|
||||
public record MarkReturn(@NotNull @Min(1) Long inboundOrderId,
|
||||
@NotBlank @Size(max = 1000) String reason,
|
||||
@NotBlank @Size(max = 32) String returnDirection,
|
||||
@Size(max = 128) String returnLogisticsNo,
|
||||
@NotNull @Size(min = 1, max = 200) List<ReturnItem> items,
|
||||
@NotNull @Size(min = 1, max = 200) List<@NotNull ReturnItem> items,
|
||||
@NotBlank @Size(max = 255) String idempotencyKey) {}
|
||||
|
||||
public record ReturnItem(@NotNull Long outboundPalletItemId,
|
||||
@NotNull Long inventoryPositionId,
|
||||
public record ReturnItem(@NotNull @Min(1) Long outboundPalletItemId,
|
||||
@NotNull @Min(1) Long inventoryPositionId,
|
||||
@Min(1) long packageCount) {}
|
||||
|
||||
public record StartProcessing(@Min(0) long expectedVersion,
|
||||
@@ -44,15 +44,15 @@ public final class ExceptionOrderRequests {
|
||||
public record Complete(@Min(0) long expectedVersion,
|
||||
@NotBlank @Size(max = 255) String idempotencyKey,
|
||||
@NotBlank @Size(max = 2000) String handlingResult,
|
||||
@NotNull @Size(min = 1, max = 20) List<String> evidenceFileIds,
|
||||
@Size(max = 200) List<ReturnReceipt> receipts) {}
|
||||
@NotNull @Size(min = 1, max = 20) List<@NotNull @Min(1) Long> evidenceFileIds,
|
||||
@Size(max = 200) List<@NotNull ReturnReceipt> receipts) {}
|
||||
|
||||
public record ReturnReceipt(@NotNull Long outboundPalletItemId,
|
||||
@NotNull Long inventoryPositionId,
|
||||
public record ReturnReceipt(@NotNull @Min(1) Long outboundPalletItemId,
|
||||
@NotNull @Min(1) Long inventoryPositionId,
|
||||
@Min(1) long receivedPackages,
|
||||
@NotNull Long targetPalletId,
|
||||
@NotNull @Min(1) Long targetPalletId,
|
||||
@NotBlank @Size(max = 128) String targetPalletNo,
|
||||
@NotNull Long targetLocationId,
|
||||
@NotNull @Min(1) Long targetLocationId,
|
||||
@NotBlank @Size(max = 128) String targetLocationCode) {}
|
||||
|
||||
public record Cancel(@Min(0) long expectedVersion,
|
||||
|
||||
+3
-2
@@ -16,7 +16,7 @@ public final class ExceptionOrderResponses {
|
||||
String returnLogisticsNo, String handlingAction,
|
||||
String handlingResult, String handlerName, LocalDateTime exceptionAt,
|
||||
LocalDateTime completedAt, LocalDateTime updatedAt, long version,
|
||||
List<String> evidenceFileIds, List<String> allowedActions) {}
|
||||
List<Long> evidenceFileIds, List<String> allowedActions) {}
|
||||
|
||||
public record Relation(Long lastMileOrderId, String lastMileOrderNo,
|
||||
Long inboundOrderId, String inboundCode,
|
||||
@@ -31,5 +31,6 @@ public final class ExceptionOrderResponses {
|
||||
public record CommandResult(Long id, String exceptionNo, String status, long version,
|
||||
Long inventoryAdjustmentId, long restoredPackages,
|
||||
LocalDateTime completedAt, boolean replayed) {}
|
||||
public record Created(Long id, String exceptionNo, String status, long version) {}
|
||||
public record UploadedEvidence(Long fileId, String fileName, String contentType, long sizeBytes) {}
|
||||
public record Created(Long id, String exceptionNo, String status, long version, boolean replayed) {}
|
||||
}
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.baiyue.lastleg.exceptionorder.gateway;
|
||||
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
public interface ExceptionEvidenceFileGateway {
|
||||
Uploaded upload(String customerId, long exceptionOrderId, String operatorId, MultipartFile file);
|
||||
void validate(String customerId, long exceptionOrderId, long fileId);
|
||||
|
||||
record Uploaded(long fileId, String fileName, String contentType, long sizeBytes) { }
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
package com.baiyue.lastleg.exceptionorder.gateway;
|
||||
|
||||
import com.baiyue.lastleg.config.LastlegRestClientFactory;
|
||||
import com.baiyue.lastleg.config.LastlegRestClientProperties;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.client.MultipartBodyBuilder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@Component
|
||||
public class HttpExceptionEvidenceFileGateway implements ExceptionEvidenceFileGateway {
|
||||
private static final String BUSINESS_TYPE = "OVERSEAS_EXCEPTION_EVIDENCE";
|
||||
@Resource private RestClient.Builder restClientBuilder;
|
||||
@Resource private LastlegRestClientFactory restClientFactory;
|
||||
@Resource private LastlegRestClientProperties restClientProperties;
|
||||
@Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") private String baseUrl;
|
||||
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
||||
private RestClient client;
|
||||
|
||||
@PostConstruct
|
||||
void initializeClient() {
|
||||
client = restClientFactory.build(restClientBuilder, baseUrl, restClientProperties.getCustomerVisitFile());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Uploaded upload(String customerId, long exceptionOrderId, String operatorId, MultipartFile file) {
|
||||
MultipartBodyBuilder body = new MultipartBodyBuilder();
|
||||
try {
|
||||
body.part("file", new ByteArrayResource(file.getBytes()) {
|
||||
@Override public String getFilename() { return file.getOriginalFilename(); }
|
||||
}).contentType(file.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM
|
||||
: MediaType.parseMediaType(file.getContentType()));
|
||||
} catch (IOException exception) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "无法读取异常处理证据", exception); }
|
||||
MetadataEnvelope response = client.post()
|
||||
.uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}",
|
||||
tenantId(customerId), BUSINESS_TYPE, exceptionOrderId)
|
||||
.header("X-Internal-Service-Token", internalToken).header("X-Operator-Id", operatorId)
|
||||
.contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve()
|
||||
.onStatus(HttpStatusCode::isError, (request, result) -> { throw new ResponseStatusException(
|
||||
result.getStatusCode().is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE,
|
||||
"异常处理证据上传失败"); }).body(MetadataEnvelope.class);
|
||||
Metadata data = require(response);
|
||||
return new Uploaded(data.fileId(), data.fileName(), data.contentType(), data.sizeBytes() == null ? 0 : data.sizeBytes());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validate(String customerId, long exceptionOrderId, long fileId) {
|
||||
long tenantId = tenantId(customerId);
|
||||
MetadataEnvelope response = client.get()
|
||||
.uri("/internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}",
|
||||
tenantId, BUSINESS_TYPE, exceptionOrderId, fileId)
|
||||
.header("X-Internal-Service-Token", internalToken).retrieve()
|
||||
.onStatus(HttpStatusCode::isError, (request, result) -> { throw new ResponseStatusException(
|
||||
result.getStatusCode().is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE,
|
||||
"异常处理证据不存在或归属不正确"); }).body(MetadataEnvelope.class);
|
||||
Metadata data = require(response);
|
||||
if (data.fileId() != fileId || data.tenantId() == null || data.tenantId() != tenantId
|
||||
|| !BUSINESS_TYPE.equals(data.businessType()) || !String.valueOf(exceptionOrderId).equals(data.businessId())) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "异常处理证据与当前异常单不匹配");
|
||||
}
|
||||
}
|
||||
|
||||
private static Metadata require(MetadataEnvelope response) {
|
||||
if (response == null || !"200".equals(response.code()) || response.data() == null || response.data().fileId() == null) {
|
||||
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "文件服务未返回异常处理证据元数据");
|
||||
}
|
||||
return response.data();
|
||||
}
|
||||
private static long tenantId(String value) {
|
||||
try { return Long.parseLong(value); }
|
||||
catch (NumberFormatException exception) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "客户ID格式无效", exception); }
|
||||
}
|
||||
private record MetadataEnvelope(String code, String message, Metadata data) { }
|
||||
private record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
|
||||
String fileName, String contentType, Long sizeBytes) { }
|
||||
}
|
||||
-1
@@ -5,7 +5,6 @@ import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrder;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
@Mapper
|
||||
public interface ExceptionOrderMapper extends BaseMapper<ExceptionOrder> {
|
||||
|
||||
+48
-12
@@ -14,6 +14,7 @@ import com.baiyue.lastleg.delivery.entity.OutboundPdaPick;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper;
|
||||
import com.baiyue.lastleg.delivery.mapper.OutboundPdaPickMapper;
|
||||
import com.baiyue.lastleg.delivery.model.DeliveryStatusPolicy;
|
||||
import com.baiyue.lastleg.entity.LastlegInboundOrder;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderRequests;
|
||||
@@ -21,6 +22,7 @@ import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderResponses;
|
||||
import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrder;
|
||||
import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrderCommand;
|
||||
import com.baiyue.lastleg.exceptionorder.entity.ExceptionReturnItem;
|
||||
import com.baiyue.lastleg.exceptionorder.gateway.ExceptionEvidenceFileGateway;
|
||||
import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderCommandMapper;
|
||||
import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderMapper;
|
||||
import com.baiyue.lastleg.exceptionorder.mapper.ExceptionReturnItemMapper;
|
||||
@@ -39,9 +41,9 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
@@ -67,6 +69,7 @@ public class ExceptionOrderService {
|
||||
@Resource private ExceptionReturnItemMapper returnItems;
|
||||
@Resource private ExceptionOrderCommandMapper commands;
|
||||
@Resource private InventoryCommandService inventoryCommands;
|
||||
@Resource private ExceptionEvidenceFileGateway evidenceFiles;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
|
||||
public PageResult<ExceptionOrderResponses.Row> page(String scopedCustomerId, ExceptionOrderRequests.Query query) {
|
||||
@@ -102,9 +105,28 @@ public class ExceptionOrderService {
|
||||
return new ExceptionOrderResponses.Detail(row(order), storedReturnSources(order));
|
||||
}
|
||||
|
||||
public ExceptionOrderResponses.UploadedEvidence uploadEvidence(String customerId, String operatorId,
|
||||
String exceptionNo, MultipartFile file) {
|
||||
ExceptionOrder order = exceptionOrders.findByNo(customerId, exceptionNo);
|
||||
if (order == null) throw notFound("异常订单不存在");
|
||||
if (file == null || file.isEmpty()) throw badRequest("请选择异常处理证据文件");
|
||||
ExceptionEvidenceFileGateway.Uploaded uploaded = evidenceFiles.upload(customerId, order.getId(), operatorId, file);
|
||||
return new ExceptionOrderResponses.UploadedEvidence(uploaded.fileId(), uploaded.fileName(),
|
||||
uploaded.contentType(), uploaded.sizeBytes());
|
||||
}
|
||||
|
||||
public List<ExceptionOrderResponses.Relation> returnCandidates(String customerId, String deliveryNo) {
|
||||
DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo);
|
||||
if (delivery == null) throw notFound("派送单不存在");
|
||||
if (!DeliveryStatusPolicy.allowedActions(delivery.getStatus()).contains("MARK_RETURN")) {
|
||||
throw conflict("当前派送状态不允许标记退回");
|
||||
}
|
||||
return returnCandidates(delivery);
|
||||
}
|
||||
|
||||
private List<ExceptionOrderResponses.Relation> returnCandidates(DeliveryOrder delivery) {
|
||||
String customerId = delivery.getCustomerId();
|
||||
String deliveryNo = delivery.getDeliveryNo();
|
||||
return lastMileOrders.selectList(new LambdaQueryWrapper<LastlegOrder>()
|
||||
.eq(LastlegOrder::getCustomerId, customerId)
|
||||
.eq(LastlegOrder::getDeliveryOrderNo, deliveryNo)
|
||||
@@ -129,12 +151,15 @@ public class ExceptionOrderService {
|
||||
String requestHash = hash(request);
|
||||
ExceptionOrderCommand replay = commands.findByKey(customerId, "MARK_RETURN", request.idempotencyKey().trim());
|
||||
if (replay != null) return replayCreated(replay, requestHash);
|
||||
DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo);
|
||||
DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo);
|
||||
if (delivery == null) throw notFound("派送单不存在");
|
||||
if (!DeliveryStatusPolicy.allowedActions(delivery.getStatus()).contains("MARK_RETURN")) {
|
||||
throw conflict("当前派送状态不允许标记退回");
|
||||
}
|
||||
ExceptionOrder existing = exceptionOrders.findDeliveryReturn(customerId, delivery.getId());
|
||||
if (existing != null) throw conflict("该派送单已存在退回异常单");
|
||||
|
||||
List<ExceptionOrderResponses.Relation> candidates = returnCandidates(customerId, deliveryNo);
|
||||
List<ExceptionOrderResponses.Relation> candidates = returnCandidates(delivery);
|
||||
ExceptionOrderResponses.Relation selected = candidates.stream()
|
||||
.filter(candidate -> Objects.equals(candidate.inboundOrderId(), request.inboundOrderId()))
|
||||
.findFirst().orElseThrow(() -> conflict("所选入仓单不属于该派送单"));
|
||||
@@ -145,8 +170,12 @@ public class ExceptionOrderService {
|
||||
Set<String> skus = available.stream().filter(source -> requested.stream().anyMatch(item -> sameSource(source, item)))
|
||||
.map(ExceptionOrderResponses.ReturnSource::skuCode).filter(StringUtils::hasText).collect(java.util.stream.Collectors.toSet());
|
||||
String sku = skus.size() == 1 ? skus.iterator().next() : "MIXED";
|
||||
String returnDirection = trim(request.returnDirection()).toUpperCase(java.util.Locale.ROOT);
|
||||
if (!Set.of("OVERSEAS_WAREHOUSE", "CUSTOMER", "OTHER").contains(returnDirection)) {
|
||||
throw badRequest("不支持的退回方向");
|
||||
}
|
||||
ExceptionOrderResponses.Created created = insert(selected, delivery, "RETURN", request.reason(),
|
||||
packages, sku, request.returnDirection(), request.returnLogisticsNo(),
|
||||
packages, sku, returnDirection, request.returnLogisticsNo(),
|
||||
"DELIVERY_RETURN", operatorId, operatorName);
|
||||
|
||||
for (ExceptionOrderRequests.ReturnItem requestedItem : requested) {
|
||||
@@ -163,11 +192,12 @@ public class ExceptionOrderService {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
delivery.setStatus("RETURN_PENDING");
|
||||
delivery.setUpdatedAt(now);
|
||||
delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(), 0L) + 1);
|
||||
deliveryOrders.updateById(delivery);
|
||||
LastlegOrder tail = lastMileOrders.selectById(selected.lastMileOrderId());
|
||||
if (tail != null) {
|
||||
tail.setAbnormal(true);
|
||||
tail.setSubOrderStatus("RETURN_PENDING");
|
||||
tail.setSubOrderStatus("DELIVERY:RETURN_PENDING");
|
||||
tail.setUpdatedAt(now);
|
||||
lastMileOrders.updateById(tail);
|
||||
}
|
||||
@@ -222,6 +252,7 @@ public class ExceptionOrderService {
|
||||
ExceptionOrderCommand replay = commands.findByKey(customerId, "COMPLETE", request.idempotencyKey().trim());
|
||||
if (replay != null) return replayResult(replay, requestHash);
|
||||
ExceptionOrder order = lock(customerId, exceptionNo, request.expectedVersion(), "PROCESSING");
|
||||
request.evidenceFileIds().stream().distinct().forEach(fileId -> evidenceFiles.validate(customerId, order.getId(), fileId));
|
||||
ReturnedInventoryRestoreResponse restored = null;
|
||||
if ("RETURN".equals(order.getExceptionType()) && "OVERSEAS_WAREHOUSE".equals(order.getReturnDirection())) {
|
||||
List<ExceptionReturnItem> stored = returnItems.findByException(customerId, order.getId());
|
||||
@@ -237,7 +268,7 @@ public class ExceptionOrderService {
|
||||
receipt.outboundPalletItemId(), receipt.inventoryPositionId(), receipt.receivedPackages(),
|
||||
receipt.targetPalletId(), receipt.targetPalletNo(), receipt.targetLocationId(),
|
||||
receipt.targetLocationCode())).toList(), request.idempotencyKey().trim(),
|
||||
request.evidenceFileIds()));
|
||||
request.evidenceFileIds().stream().map(String::valueOf).toList()));
|
||||
} else if (request.receipts() != null && !request.receipts().isEmpty()) {
|
||||
throw badRequest("非退回海外仓异常不能提交库存实收明细");
|
||||
}
|
||||
@@ -318,7 +349,7 @@ public class ExceptionOrderService {
|
||||
value.setExceptionAt(now); value.setCreatedAt(now); value.setUpdatedAt(now);
|
||||
try { exceptionOrders.insert(value); }
|
||||
catch (DuplicateKeyException exception) { throw conflict("该关联单据已存在异常退货单"); }
|
||||
return new ExceptionOrderResponses.Created(value.getId(), value.getExceptionNo(), value.getStatus(), version(value));
|
||||
return new ExceptionOrderResponses.Created(value.getId(), value.getExceptionNo(), value.getStatus(), version(value), false);
|
||||
}
|
||||
|
||||
private LastlegOrder uniqueTail(String customerId, String orderNo, String inboundCode) {
|
||||
@@ -364,7 +395,7 @@ public class ExceptionOrderService {
|
||||
value.getOutboundNo(), value.getSkuCode(), value.getPackageCount(), value.getReturnDirection(),
|
||||
value.getReturnLogisticsNo(), value.getHandlingAction(), value.getHandlingResult(), value.getHandlerName(),
|
||||
value.getExceptionAt(), value.getCompletedAt(), value.getUpdatedAt(), version(value),
|
||||
readStringList(value.getEvidenceFileIdsJson()), allowedActions(value));
|
||||
readEvidenceFileIds(value.getEvidenceFileIdsJson()), allowedActions(value));
|
||||
}
|
||||
|
||||
private ExceptionOrderResponses.Relation withReturnSources(ExceptionOrderResponses.Relation relation,
|
||||
@@ -430,11 +461,13 @@ public class ExceptionOrderService {
|
||||
|
||||
private void closeReturnedDelivery(ExceptionOrder order, String operatorId, LocalDateTime now) {
|
||||
if (!"RETURN".equals(order.getExceptionType()) || !StringUtils.hasText(order.getDeliveryNo())) return;
|
||||
DeliveryOrder delivery = deliveryOrders.findByNo(order.getCustomerId(), order.getDeliveryNo());
|
||||
DeliveryOrder delivery = deliveryOrders.lockByNo(order.getCustomerId(), order.getDeliveryNo());
|
||||
if (delivery == null || !"RETURN_PENDING".equals(delivery.getStatus())) {
|
||||
throw conflict("关联派送单不是退货处理中状态");
|
||||
}
|
||||
delivery.setStatus("RETURNED"); delivery.setUpdatedAt(now); deliveryOrders.updateById(delivery);
|
||||
delivery.setStatus("RETURNED"); delivery.setUpdatedAt(now);
|
||||
delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(), 0L) + 1);
|
||||
deliveryOrders.updateById(delivery);
|
||||
for (LastlegOrder tail : lastMileOrders.selectList(new LambdaQueryWrapper<LastlegOrder>()
|
||||
.eq(LastlegOrder::getCustomerId, order.getCustomerId())
|
||||
.eq(LastlegOrder::getDeliveryOrderNo, order.getDeliveryNo()))) {
|
||||
@@ -482,7 +515,10 @@ public class ExceptionOrderService {
|
||||
|
||||
private ExceptionOrderResponses.Created replayCreated(ExceptionOrderCommand command, String requestHash) {
|
||||
requireReplayHash(command, requestHash);
|
||||
try { return objectMapper.readValue(command.getResultJson(), ExceptionOrderResponses.Created.class); }
|
||||
try {
|
||||
ExceptionOrderResponses.Created result = objectMapper.readValue(command.getResultJson(), ExceptionOrderResponses.Created.class);
|
||||
return new ExceptionOrderResponses.Created(result.id(), result.exceptionNo(), result.status(), result.version(), true);
|
||||
}
|
||||
catch (JsonProcessingException exception) { throw new IllegalStateException("异常命令结果损坏", exception); }
|
||||
}
|
||||
|
||||
@@ -500,7 +536,7 @@ public class ExceptionOrderService {
|
||||
catch (JsonProcessingException exception) { throw new IllegalStateException("无法保存异常处理结果", exception); }
|
||||
}
|
||||
|
||||
private List<String> readStringList(String value) {
|
||||
private List<Long> readEvidenceFileIds(String value) {
|
||||
if (!StringUtils.hasText(value)) return List.of();
|
||||
try { return objectMapper.readValue(value, new TypeReference<>() { }); }
|
||||
catch (JsonProcessingException exception) { throw new IllegalStateException("异常处理证据数据损坏", exception); }
|
||||
|
||||
+10
@@ -56,6 +56,16 @@ public class InventoryQueryController {
|
||||
return ApiResponse.success(service.balances(platformScope(user, customerId), query));
|
||||
}
|
||||
|
||||
@GetMapping(BASE_PATH + "/balances/{balanceId}")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_LIST + "')")
|
||||
@Operation(summary = "Get one inventory balance", description = "Returns one non-zero inventory balance by ID and optional customer scope. Required authority: " + OverseasPermissions.INVENTORY_LIST)
|
||||
public ApiResponse<InventoryBalanceView> balance(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable long balanceId,
|
||||
@RequestParam(required = false) String customerId) {
|
||||
return ApiResponse.success(service.balance(platformScope(user, customerId), balanceId));
|
||||
}
|
||||
|
||||
@GetMapping(BASE_PATH + "/balances/{balanceId}/positions")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_LIST + "')")
|
||||
@Operation(summary = "List pallet and batch positions for one inbound-order balance", description = "Required authority: " + OverseasPermissions.INVENTORY_LIST)
|
||||
|
||||
+11
@@ -111,6 +111,17 @@ public interface InventoryQueryMapper {
|
||||
@Select("SELECT * FROM ll_inventory_balance WHERE id=#{balanceId} LIMIT 1")
|
||||
InventoryBalance findBalance(@Param("balanceId") long balanceId);
|
||||
|
||||
@Select("SELECT * FROM ll_inventory_balance WHERE customer_id=#{customerId} AND id=#{balanceId} " +
|
||||
"AND (available_packages+hold_packages+allocated_packages+picked_packages+in_transit_packages+" +
|
||||
"receiving_packages+procurement_packages)>0 LIMIT 1")
|
||||
InventoryBalance ownedVisibleBalance(@Param("customerId") String customerId,
|
||||
@Param("balanceId") long balanceId);
|
||||
|
||||
@Select("SELECT * FROM ll_inventory_balance WHERE id=#{balanceId} " +
|
||||
"AND (available_packages+hold_packages+allocated_packages+picked_packages+in_transit_packages+" +
|
||||
"receiving_packages+procurement_packages)>0 LIMIT 1")
|
||||
InventoryBalance visibleBalance(@Param("balanceId") long balanceId);
|
||||
|
||||
String LEDGER_FILTER = " WHERE 1=1" +
|
||||
"<if test='customerId != null and customerId != \"\"'> AND customer_id=#{customerId}</if>" +
|
||||
"<if test='q.overseasWarehouseId != null and q.overseasWarehouseId != \"\"'> AND overseas_warehouse_id=#{q.overseasWarehouseId}</if>" +
|
||||
|
||||
+10
@@ -57,6 +57,16 @@ public class InventoryQueryService {
|
||||
return page(records, query.page(), query.size(), total);
|
||||
}
|
||||
|
||||
public InventoryBalanceView balance(String customerId, long balanceId) {
|
||||
InventoryBalance balance = customerId == null
|
||||
? mapper.visibleBalance(balanceId)
|
||||
: mapper.ownedVisibleBalance(customerId, balanceId);
|
||||
if (balance == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inventory balance not found");
|
||||
}
|
||||
return balanceView(balance);
|
||||
}
|
||||
|
||||
public List<InventoryPositionView> positions(String customerId, long balanceId,
|
||||
InventoryQueryRequests.PositionQuery query) {
|
||||
validateStatus(query.inventoryStatus(), POSITION_STATUSES);
|
||||
|
||||
@@ -282,6 +282,7 @@ CREATE TABLE IF NOT EXISTS ll_delivery_order (
|
||||
remark VARCHAR(1000) NULL COMMENT '派送备注',
|
||||
idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键',
|
||||
created_by VARCHAR(128) NULL COMMENT '创建人ID',
|
||||
version BIGINT NOT NULL DEFAULT 0 COMMENT '派送单乐观锁版本',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
PRIMARY KEY (id),
|
||||
@@ -1919,9 +1920,12 @@ CREATE TABLE IF NOT EXISTS ll_exception_order (
|
||||
delivery_order_id BIGINT NULL COMMENT '关联派送单ID', delivery_no VARCHAR(64) NULL COMMENT '关联派送单号', outbound_no VARCHAR(64) NULL COMMENT '关联出库单号',
|
||||
customer_name VARCHAR(255) NULL COMMENT '客户名称快照', overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID快照', overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照',
|
||||
sku_code VARCHAR(128) NULL COMMENT '异常货物SKU', package_count INT NULL COMMENT '异常箱数', return_direction VARCHAR(32) NULL COMMENT '退回方向', return_logistics_no VARCHAR(128) NULL COMMENT '退回物流单号',
|
||||
handling_action VARCHAR(500) NULL COMMENT '异常处理措施', handler_id VARCHAR(128) NULL COMMENT '处理人ID', handler_name VARCHAR(128) NULL COMMENT '处理人名称',
|
||||
handling_action VARCHAR(500) NULL COMMENT '异常处理措施', handling_result VARCHAR(2000) NULL COMMENT '异常处理结果',
|
||||
evidence_file_ids_json JSON NULL COMMENT '异常处理证据文件ID列表', inventory_adjustment_id BIGINT NULL COMMENT '退回库存恢复操作ID',
|
||||
handler_id VARCHAR(128) NULL COMMENT '处理人ID', handler_name VARCHAR(128) NULL COMMENT '处理人名称',
|
||||
source_type VARCHAR(32) NOT NULL DEFAULT 'MANUAL' COMMENT '异常来源类型', created_by VARCHAR(128) NULL COMMENT '创建人ID',
|
||||
exception_at DATETIME(3) NOT NULL COMMENT '异常发生时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
exception_at DATETIME(3) NOT NULL COMMENT '异常发生时间', completed_at DATETIME(3) NULL COMMENT '异常处理完成时间',
|
||||
version BIGINT NOT NULL DEFAULT 0 COMMENT '异常订单乐观锁版本', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_exception_no(customer_id,exception_no),
|
||||
UNIQUE KEY uk_ll_exception_delivery_return(customer_id,delivery_order_id,exception_type),
|
||||
@@ -1929,6 +1933,25 @@ CREATE TABLE IF NOT EXISTS ll_exception_order (
|
||||
KEY idx_ll_exception_inbound(customer_id,inbound_code)
|
||||
) ENGINE=InnoDB COMMENT='海外仓异常订单';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_exception_return_item (
|
||||
id BIGINT NOT NULL COMMENT '异常退回明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
|
||||
exception_order_id BIGINT NOT NULL COMMENT '异常订单ID', outbound_pallet_item_id BIGINT NOT NULL COMMENT '原出库托盘明细ID',
|
||||
inventory_position_id BIGINT NOT NULL COMMENT '原出库库存位置ID', pallet_no VARCHAR(128) NULL COMMENT '原出库托盘码快照',
|
||||
sku_code VARCHAR(128) NULL COMMENT '原出库SKU快照', max_return_packages BIGINT NOT NULL COMMENT '原出库可退箱数上限',
|
||||
approved_return_packages BIGINT NOT NULL COMMENT '批准退回箱数', created_at DATETIME(3) NOT NULL COMMENT '创建时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_exception_return_source(customer_id,exception_order_id,outbound_pallet_item_id,inventory_position_id),
|
||||
KEY idx_ll_exception_return_order(customer_id,exception_order_id)
|
||||
) ENGINE=InnoDB COMMENT='异常退回原出库库存冻结明细';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_exception_order_command (
|
||||
id BIGINT NOT NULL COMMENT '异常处理命令ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
|
||||
exception_order_id BIGINT NOT NULL COMMENT '异常订单ID', action VARCHAR(32) NOT NULL COMMENT '异常处理动作',
|
||||
idempotency_key VARCHAR(255) NOT NULL COMMENT '幂等键', request_hash VARCHAR(64) NOT NULL COMMENT '请求内容SHA256哈希',
|
||||
result_json JSON NOT NULL COMMENT '命令结果快照', created_at DATETIME(3) NOT NULL COMMENT '创建时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_exception_command_key(customer_id,action,idempotency_key),
|
||||
KEY idx_ll_exception_command_order(customer_id,exception_order_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='异常处理命令幂等审计';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_customer_visit (
|
||||
id BIGINT NOT NULL COMMENT '客户回访记录ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
|
||||
|
||||
@@ -3,6 +3,18 @@ info:
|
||||
title: Lastleg Overseas Business API
|
||||
version: 1.0.0
|
||||
paths:
|
||||
/api/platform/v1/last-leg/inventory/balances/{balanceId}:
|
||||
get:
|
||||
summary: 查询单条库存余额详情
|
||||
description: 仅平台且要求 lastleg:inventory:list;按 balanceId 直接查询非零库存余额,不依赖列表分页。可选 customerId 用于精确客户隔离,越权或不存在统一返回404。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { in: path, name: balanceId, required: true, schema: { type: string, pattern: '^[1-9][0-9]*$', description: 长整型ID按字符串传输 } }
|
||||
- { in: query, name: customerId, required: false, schema: { type: string } }
|
||||
responses:
|
||||
'200': { description: 单条库存余额, content: { application/json: { schema: { $ref: '#/components/schemas/InventoryBalanceApiResponse' } } } }
|
||||
'403': { description: 非平台身份或缺少库存查看权限 }
|
||||
'404': { description: 库存余额不存在、已归零或不属于指定客户 }
|
||||
/api/platform/v1/last-leg/inventory/inbound-code-relationships/{inboundCode}:
|
||||
get:
|
||||
summary: 按入仓码查询海外关系摘要
|
||||
@@ -631,6 +643,49 @@ paths:
|
||||
security: [{ bearerAuth: [] }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { type: object } } } }
|
||||
responses: { '200': { description: 已创建异常订单 } }
|
||||
/api/platform/v1/last-leg/exception-orders/{exceptionNo}:
|
||||
get:
|
||||
summary: 查询异常订单详情与冻结退回明细
|
||||
description: 权限 platform:overseas-warehouse:exception-order:list;返回 version 和服务端 allowedActions。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters: [{ name: exceptionNo, in: path, required: true, schema: { type: string } }]
|
||||
responses: { '200': { description: 异常订单详情 }, '404': { description: 异常订单不存在 } }
|
||||
/api/platform/v1/last-leg/exception-orders/{exceptionNo}/start-processing:
|
||||
post:
|
||||
summary: 开始处理异常订单
|
||||
description: 权限 platform:overseas-warehouse:exception-order:process;仅 PENDING→PROCESSING,同版本与同幂等请求可重放。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters: [{ name: exceptionNo, in: path, required: true, schema: { type: string } }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ExceptionStartProcessing' } } } }
|
||||
responses: { '200': { description: 异常处理命令结果 }, '409': { description: 版本、状态或幂等冲突 } }
|
||||
/api/platform/v1/last-leg/exception-orders/{exceptionNo}/evidence:
|
||||
post:
|
||||
summary: 上传异常处理证据
|
||||
description: 权限 platform:overseas-warehouse:exception-order:process;文件按当前客户和异常订单绑定为 OVERSEAS_EXCEPTION_EVIDENCE。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters: [{ name: exceptionNo, in: path, required: true, schema: { type: string } }]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } }
|
||||
responses: { '200': { description: 异常处理证据元数据 }, '400': { description: 文件或归属无效 } }
|
||||
/api/platform/v1/last-leg/exception-orders/{exceptionNo}/complete:
|
||||
post:
|
||||
summary: 完成异常处理并原子恢复退回库存
|
||||
description: 权限 platform:overseas-warehouse:exception-order:process;仅 PROCESSING→COMPLETED。退回海外仓必须提交证据和实收目标,库存 RETURN_STOCK_IN 成功后才完成异常单和派送退回。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters: [{ name: exceptionNo, in: path, required: true, schema: { type: string } }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ExceptionComplete' } } } }
|
||||
responses: { '200': { description: 异常处理与库存恢复结果 }, '409': { description: 版本、状态、归属、数量或幂等冲突 } }
|
||||
/api/platform/v1/last-leg/exception-orders/{exceptionNo}/cancel:
|
||||
post:
|
||||
summary: 取消未处理异常订单
|
||||
description: 权限 platform:overseas-warehouse:exception-order:process;仅 PENDING→CANCELLED。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters: [{ name: exceptionNo, in: path, required: true, schema: { type: string } }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ExceptionCancel' } } } }
|
||||
responses: { '200': { description: 异常取消结果 }, '409': { description: 版本、状态或幂等冲突 } }
|
||||
/api/platform/v1/last-leg/exception-orders/resolve:
|
||||
get:
|
||||
summary: 解析异常订单关联关系
|
||||
@@ -640,17 +695,17 @@ paths:
|
||||
/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/return-inbound-options:
|
||||
get:
|
||||
summary: 查询派送退回候选入仓单
|
||||
description: 权限 platform:overseas-warehouse:exception-order:list;属于异常订单列表只读辅助接口。
|
||||
description: 权限 platform:overseas-warehouse:exception-order:list;返回按原出库拣货事实解析的 returnSources,页面不得自由填写 SKU 或猜测库存位置。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }]
|
||||
responses: { '200': { description: 退回候选入仓单 } }
|
||||
/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/mark-return:
|
||||
post:
|
||||
summary: 将派送单标记为退回入仓
|
||||
description: 权限 platform:overseas-warehouse:delivery:return。
|
||||
description: 权限 platform:overseas-warehouse:delivery:return;items 必须来自 return-inbound-options,服务端冻结源出库明细与批准退回上限。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { type: object } } } }
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryMarkReturn' } } } }
|
||||
responses: { '200': { description: 已创建退回异常单 } }
|
||||
/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/complete:
|
||||
post:
|
||||
@@ -658,7 +713,8 @@ paths:
|
||||
description: 权限 platform:overseas-warehouse:delivery:status。仅 WAIT_DELIVERY/DELIVERING 可完成;DELIVERED 同状态重放成功,其他终态或倒退返回 409。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }]
|
||||
responses: { '200': { description: 派送状态与回访记录 } }
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryStatusChange' } } } }
|
||||
responses: { '200': { description: 派送状态与回访记录 }, '409': { description: 版本或状态冲突 } }
|
||||
/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/status:
|
||||
put:
|
||||
summary: 更新派送状态
|
||||
@@ -1103,6 +1159,39 @@ paths:
|
||||
responses: { '200': { description: 统一 ApiResponse 包装的换标单详情 }, '409': { description: 非法状态迁移或关联尾程单缺失 } }
|
||||
components:
|
||||
schemas:
|
||||
InventoryBalance:
|
||||
type: object
|
||||
required: [id, customerId, totalPackages, availablePackages, holdPackages, allocatedPackages, pickedPackages, inTransitPackages, receivingPackages, procurementPackages]
|
||||
properties:
|
||||
id: { type: string, pattern: '^[1-9][0-9]*$', description: 库存余额长整型ID,按字符串传输 }
|
||||
customerId: { type: string }
|
||||
customerName: { type: string, nullable: true }
|
||||
overseasWarehouseId: { type: string, nullable: true }
|
||||
overseasWarehouseCode: { type: string, nullable: true }
|
||||
overseasWarehouseName: { type: string, nullable: true }
|
||||
destinationWarehouseId: { type: string, nullable: true }
|
||||
destinationWarehouseCode: { type: string, nullable: true }
|
||||
destinationWarehouseName: { type: string, nullable: true }
|
||||
inboundOrderId: { type: string, nullable: true, pattern: '^[1-9][0-9]*$', description: 长整型ID按字符串传输 }
|
||||
inboundOrderNo: { type: string, nullable: true }
|
||||
inboundCode: { type: string, nullable: true }
|
||||
skuCode: { type: string, nullable: true }
|
||||
productNameZh: { type: string, nullable: true }
|
||||
productNameEn: { type: string, nullable: true }
|
||||
totalPackages: { type: integer, format: int64, minimum: 0 }
|
||||
availablePackages: { type: integer, format: int64, minimum: 0 }
|
||||
holdPackages: { type: integer, format: int64, minimum: 0 }
|
||||
allocatedPackages: { type: integer, format: int64, minimum: 0 }
|
||||
pickedPackages: { type: integer, format: int64, minimum: 0 }
|
||||
inTransitPackages: { type: integer, format: int64, minimum: 0 }
|
||||
receivingPackages: { type: integer, format: int64, minimum: 0 }
|
||||
procurementPackages: { type: integer, format: int64, minimum: 0 }
|
||||
updatedAt: { type: string, format: date-time, nullable: true }
|
||||
InventoryBalanceApiResponse:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/ReceivingApiResponseBase' }
|
||||
- type: object
|
||||
properties: { data: { $ref: '#/components/schemas/InventoryBalance' } }
|
||||
InboundCodeRelationship:
|
||||
type: object
|
||||
required: [inboundCode, found, taillegOrders, inboundPlans, palletCodes, inventory]
|
||||
@@ -1664,14 +1753,74 @@ components:
|
||||
fees: { type: array, minItems: 1, maxItems: 20, items: { $ref: '#/components/schemas/DeliveryFeeInput' } }
|
||||
DeliveryStatusChange:
|
||||
type: object
|
||||
required: [status]
|
||||
required: [status, expectedVersion]
|
||||
properties:
|
||||
status: { type: string, enum: [WAIT_DELIVERY, DELIVERING, DELIVERED, REJECTED, CANCELLED] }
|
||||
expectedVersion: { type: integer, format: int64, minimum: 0 }
|
||||
remark: { type: string, nullable: true, maxLength: 500 }
|
||||
DeliveryAllowedAction:
|
||||
type: string
|
||||
enum: [START_DELIVERY, COMPLETE_DELIVERY, REJECT_DELIVERY, CANCEL_DELIVERY, MARK_RETURN]
|
||||
description: START/COMPLETE/REJECT/CANCEL 使用通用 status 接口;MARK_RETURN 使用 mark-return 接口。
|
||||
DeliveryMarkReturn:
|
||||
type: object
|
||||
required: [inboundOrderId, reason, returnDirection, items, idempotencyKey]
|
||||
properties:
|
||||
inboundOrderId: { type: string, pattern: '^[1-9][0-9]*$' }
|
||||
reason: { type: string, minLength: 1, maxLength: 1000 }
|
||||
returnDirection: { type: string, enum: [OVERSEAS_WAREHOUSE, CUSTOMER, OTHER] }
|
||||
returnLogisticsNo: { type: string, nullable: true, maxLength: 128 }
|
||||
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
|
||||
items:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 200
|
||||
items:
|
||||
type: object
|
||||
required: [outboundPalletItemId, inventoryPositionId, packageCount]
|
||||
properties:
|
||||
outboundPalletItemId: { type: string, pattern: '^[1-9][0-9]*$' }
|
||||
inventoryPositionId: { type: string, pattern: '^[1-9][0-9]*$' }
|
||||
packageCount: { type: integer, format: int64, minimum: 1 }
|
||||
ExceptionStartProcessing:
|
||||
type: object
|
||||
required: [expectedVersion, idempotencyKey]
|
||||
properties:
|
||||
expectedVersion: { type: integer, format: int64, minimum: 0 }
|
||||
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
|
||||
handlingAction: { type: string, nullable: true, maxLength: 1000 }
|
||||
ExceptionCancel:
|
||||
type: object
|
||||
required: [expectedVersion, idempotencyKey, reason]
|
||||
properties:
|
||||
expectedVersion: { type: integer, format: int64, minimum: 0 }
|
||||
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
|
||||
reason: { type: string, minLength: 1, maxLength: 1000 }
|
||||
ExceptionComplete:
|
||||
type: object
|
||||
required: [expectedVersion, idempotencyKey, handlingResult, evidenceFileIds]
|
||||
properties:
|
||||
expectedVersion: { type: integer, format: int64, minimum: 0 }
|
||||
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
|
||||
handlingResult: { type: string, minLength: 1, maxLength: 2000 }
|
||||
evidenceFileIds: { type: array, minItems: 1, maxItems: 20, items: { type: string } }
|
||||
receipts:
|
||||
type: array
|
||||
maxItems: 200
|
||||
items:
|
||||
type: object
|
||||
required: [outboundPalletItemId, inventoryPositionId, receivedPackages, targetPalletId, targetPalletNo, targetLocationId, targetLocationCode]
|
||||
properties:
|
||||
outboundPalletItemId: { type: string, pattern: '^[1-9][0-9]*$' }
|
||||
inventoryPositionId: { type: string, pattern: '^[1-9][0-9]*$' }
|
||||
receivedPackages: { type: integer, format: int64, minimum: 1 }
|
||||
targetPalletId: { type: string, pattern: '^[1-9][0-9]*$' }
|
||||
targetPalletNo: { type: string, minLength: 1, maxLength: 128 }
|
||||
targetLocationId: { type: string, pattern: '^[1-9][0-9]*$' }
|
||||
targetLocationCode: { type: string, minLength: 1, maxLength: 128 }
|
||||
ExceptionAllowedAction:
|
||||
type: string
|
||||
enum: [START_PROCESSING, COMPLETE_EXCEPTION, CANCEL_EXCEPTION]
|
||||
DeliveryFeeInput:
|
||||
type: object
|
||||
required: [feeType, currency, amount]
|
||||
|
||||
+41
-4
@@ -6,6 +6,8 @@ import com.baiyue.lastleg.customervisit.mapper.CustomerVisitMapper;
|
||||
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
|
||||
import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper;
|
||||
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
@@ -26,6 +28,7 @@ class CustomerVisitServiceTest {
|
||||
@Mock private CustomerVisitMapper visits;
|
||||
@Mock private DeliveryOrderMapper deliveryOrders;
|
||||
@Mock private DeliveryOperationMapper deliveryOperations;
|
||||
@Mock private LastlegOrderMapper lastMileOrders;
|
||||
private CustomerVisitService service;
|
||||
|
||||
@BeforeEach
|
||||
@@ -34,15 +37,16 @@ class CustomerVisitServiceTest {
|
||||
ReflectionTestUtils.setField(service, "visits", visits);
|
||||
ReflectionTestUtils.setField(service, "deliveryOrders", deliveryOrders);
|
||||
ReflectionTestUtils.setField(service, "deliveryOperations", deliveryOperations);
|
||||
ReflectionTestUtils.setField(service, "lastMileOrders", lastMileOrders);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsRollbackFromDeliveredToWaitDeliveryWithoutWritingHistory() {
|
||||
DeliveryOrder delivery = delivery("DELIVERED");
|
||||
when(deliveryOrders.findByNo("1001", "DL-1")).thenReturn(delivery);
|
||||
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
|
||||
|
||||
assertThatThrownBy(() -> service.updateDeliveryStatus("1001", "9001", "DL-1",
|
||||
new CustomerVisitRequests.DeliveryStatus("WAIT_DELIVERY", "错误倒退")))
|
||||
new CustomerVisitRequests.DeliveryStatus("WAIT_DELIVERY", 0L, "错误倒退")))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(error -> assertThat(((ResponseStatusException) error).getStatusCode().value()).isEqualTo(409))
|
||||
.hasMessageContaining("DELIVERED").hasMessageContaining("WAIT_DELIVERY");
|
||||
@@ -57,11 +61,11 @@ class CustomerVisitServiceTest {
|
||||
CustomerVisit visit = new CustomerVisit();
|
||||
visit.setId(33L);
|
||||
visit.setStatus("PENDING");
|
||||
when(deliveryOrders.findByNo("1001", "DL-1")).thenReturn(delivery);
|
||||
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
|
||||
when(visits.findByDelivery("1001", 11L)).thenReturn(visit);
|
||||
|
||||
var result = service.updateDeliveryStatus("1001", "9001", "DL-1",
|
||||
new CustomerVisitRequests.DeliveryStatus("DELIVERED", null));
|
||||
new CustomerVisitRequests.DeliveryStatus("DELIVERED", 0L, null));
|
||||
|
||||
assertThat(result.replayed()).isTrue();
|
||||
assertThat(result.deliveryStatus()).isEqualTo("DELIVERED");
|
||||
@@ -70,6 +74,39 @@ class CustomerVisitServiceTest {
|
||||
verifyNoInteractions(deliveryOperations);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsForwardTransitionWithStaleVersion() {
|
||||
DeliveryOrder delivery = delivery("WAIT_DELIVERY");
|
||||
delivery.setVersion(3L);
|
||||
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
|
||||
|
||||
assertThatThrownBy(() -> service.updateDeliveryStatus("1001", "9001", "DL-1",
|
||||
new CustomerVisitRequests.DeliveryStatus("DELIVERING", 2L, null)))
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("版本");
|
||||
|
||||
verify(deliveryOrders, never()).updateById(delivery);
|
||||
verifyNoInteractions(deliveryOperations);
|
||||
}
|
||||
|
||||
@Test
|
||||
void completingDeliveryUpdatesOnlySubOrderStatusAndKeepsMainFlowStatus() {
|
||||
DeliveryOrder delivery = delivery("DELIVERING");
|
||||
delivery.setVersion(1L);
|
||||
LastlegOrder tail = new LastlegOrder();
|
||||
tail.setId(21L); tail.setCustomerId("1001"); tail.setCurrentStatus("INBOUND_COMPLETED");
|
||||
CustomerVisit visit = new CustomerVisit(); visit.setId(33L); visit.setStatus("PENDING");
|
||||
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
|
||||
when(lastMileOrders.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(java.util.List.of(tail));
|
||||
when(visits.findByDelivery("1001", 11L)).thenReturn(visit);
|
||||
|
||||
var result = service.updateDeliveryStatus("1001", "9001", "DL-1",
|
||||
new CustomerVisitRequests.DeliveryStatus("DELIVERED", 1L, null));
|
||||
|
||||
assertThat(result.replayed()).isFalse();
|
||||
assertThat(tail.getCurrentStatus()).isEqualTo("INBOUND_COMPLETED");
|
||||
assertThat(tail.getSubOrderStatus()).isEqualTo("DELIVERY:DELIVERED");
|
||||
}
|
||||
|
||||
private DeliveryOrder delivery(String status) {
|
||||
DeliveryOrder delivery = new DeliveryOrder();
|
||||
delivery.setId(11L);
|
||||
|
||||
+1
-1
@@ -66,6 +66,6 @@ class DeliveryLongIdJsonContractTest {
|
||||
DELIVERY_ID,
|
||||
null, null, "PS-1", null, null, null, null, "CREATED", null,
|
||||
null, null, null, null, null, null, null, null, null, null, null,
|
||||
0, 0, null, null, null, null, null, null, null, null, java.util.List.of());
|
||||
0, 0, null, null, null, null, null, null, null, null, 0, java.util.List.of());
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ class DeliveryStatusPolicyTest {
|
||||
@Test
|
||||
void exposesOnlyServerAuthorizedActionsForEachState() {
|
||||
assertThat(DeliveryStatusPolicy.allowedActions("WAIT_DELIVERY"))
|
||||
.containsExactly("START_DELIVERY", "COMPLETE_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY", "MARK_RETURN");
|
||||
.containsExactly("START_DELIVERY", "COMPLETE_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY");
|
||||
assertThat(DeliveryStatusPolicy.allowedActions("DELIVERING"))
|
||||
.containsExactly("COMPLETE_DELIVERY", "REJECT_DELIVERY", "MARK_RETURN");
|
||||
assertThat(DeliveryStatusPolicy.allowedActions("DELIVERED")).containsExactly("MARK_RETURN");
|
||||
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
package com.baiyue.lastleg.exceptionorder.service;
|
||||
|
||||
import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderRequests;
|
||||
import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderResponses;
|
||||
import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrder;
|
||||
import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrderCommand;
|
||||
import com.baiyue.lastleg.exceptionorder.entity.ExceptionReturnItem;
|
||||
import com.baiyue.lastleg.exceptionorder.gateway.ExceptionEvidenceFileGateway;
|
||||
import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderCommandMapper;
|
||||
import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderMapper;
|
||||
import com.baiyue.lastleg.exceptionorder.mapper.ExceptionReturnItemMapper;
|
||||
import com.baiyue.lastleg.inventory.command.dto.ReturnedInventoryRestoreRequest;
|
||||
import com.baiyue.lastleg.inventory.command.dto.ReturnedInventoryRestoreResponse;
|
||||
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
|
||||
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
|
||||
import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper;
|
||||
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ExceptionOrderLifecycleTest {
|
||||
@Mock private ExceptionOrderMapper exceptionOrders;
|
||||
@Mock private ExceptionOrderCommandMapper commands;
|
||||
@Mock private ExceptionReturnItemMapper returnItems;
|
||||
@Mock private InventoryCommandService inventoryCommands;
|
||||
@Mock private ExceptionEvidenceFileGateway evidenceFiles;
|
||||
@Mock private DeliveryOrderMapper deliveryOrders;
|
||||
@Mock private DeliveryOperationMapper deliveryOperations;
|
||||
@Mock private LastlegOrderMapper lastMileOrders;
|
||||
private ExceptionOrderService service;
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new ExceptionOrderService();
|
||||
objectMapper = new ObjectMapper().findAndRegisterModules();
|
||||
ReflectionTestUtils.setField(service, "exceptionOrders", exceptionOrders);
|
||||
ReflectionTestUtils.setField(service, "commands", commands);
|
||||
ReflectionTestUtils.setField(service, "returnItems", returnItems);
|
||||
ReflectionTestUtils.setField(service, "inventoryCommands", inventoryCommands);
|
||||
ReflectionTestUtils.setField(service, "evidenceFiles", evidenceFiles);
|
||||
ReflectionTestUtils.setField(service, "deliveryOrders", deliveryOrders);
|
||||
ReflectionTestUtils.setField(service, "deliveryOperations", deliveryOperations);
|
||||
ReflectionTestUtils.setField(service, "lastMileOrders", lastMileOrders);
|
||||
ReflectionTestUtils.setField(service, "objectMapper", objectMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void startsPendingExceptionWithVersionGateAndPersistsIdempotentResult() {
|
||||
ExceptionOrder order = order("PENDING", 0);
|
||||
when(commands.findByKey("1001", "START_PROCESSING", "start-1")).thenReturn(null);
|
||||
when(exceptionOrders.lockByNo("1001", "EX-1")).thenReturn(order);
|
||||
|
||||
var result = service.startProcessing("1001", "9001", "EX-1",
|
||||
new ExceptionOrderRequests.StartProcessing(0, "start-1", "联系客户并核对退件"));
|
||||
|
||||
assertThat(result.status()).isEqualTo("PROCESSING");
|
||||
assertThat(result.version()).isEqualTo(1);
|
||||
assertThat(result.replayed()).isFalse();
|
||||
verify(exceptionOrders).updateById(order);
|
||||
ArgumentCaptor<ExceptionOrderCommand> command = ArgumentCaptor.forClass(ExceptionOrderCommand.class);
|
||||
verify(commands).insert(command.capture());
|
||||
assertThat(command.getValue().getResultJson()).contains("PROCESSING");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsStaleVersionWithoutMutation() {
|
||||
when(commands.findByKey("1001", "START_PROCESSING", "start-2")).thenReturn(null);
|
||||
when(exceptionOrders.lockByNo("1001", "EX-1")).thenReturn(order("PENDING", 2));
|
||||
|
||||
assertThatThrownBy(() -> service.startProcessing("1001", "9001", "EX-1",
|
||||
new ExceptionOrderRequests.StartProcessing(1, "start-2", null)))
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("版本");
|
||||
|
||||
verify(exceptionOrders, never()).updateById(any(ExceptionOrder.class));
|
||||
verify(commands, never()).insert(any(ExceptionOrderCommand.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void replaysSameCommandWithoutUpdatingAgain() throws Exception {
|
||||
ExceptionOrderResponses.CommandResult saved = new ExceptionOrderResponses.CommandResult(
|
||||
1L, "EX-1", "PROCESSING", 1, null, 0, null, false);
|
||||
ExceptionOrderRequests.StartProcessing request =
|
||||
new ExceptionOrderRequests.StartProcessing(0, "start-3", null);
|
||||
ExceptionOrderCommand command = new ExceptionOrderCommand();
|
||||
command.setRequestHash(hashFor(request));
|
||||
command.setResultJson(objectMapper.writeValueAsString(saved));
|
||||
when(commands.findByKey("1001", "START_PROCESSING", "start-3")).thenReturn(command);
|
||||
|
||||
var replayed = service.startProcessing("1001", "9001", "EX-1", request);
|
||||
|
||||
assertThat(replayed.replayed()).isTrue();
|
||||
assertThat(replayed.status()).isEqualTo("PROCESSING");
|
||||
verify(exceptionOrders, never()).lockByNo(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void completesWarehouseReturnOnlyAfterInventoryRestoreSucceeds() {
|
||||
ExceptionOrder order = order("PROCESSING", 1);
|
||||
order.setExceptionType("RETURN"); order.setReturnDirection("OVERSEAS_WAREHOUSE");
|
||||
order.setOutboundNo("OUT-1"); order.setOverseasWarehouseId("WH-1"); order.setInboundOrderId(44L);
|
||||
order.setDeliveryNo("DL-1");
|
||||
ExceptionReturnItem source = new ExceptionReturnItem();
|
||||
source.setOutboundPalletItemId(51L); source.setInventoryPositionId(61L);
|
||||
source.setSkuCode("SKU-1"); source.setApprovedReturnPackages(2L);
|
||||
DeliveryOrder delivery = new DeliveryOrder();
|
||||
delivery.setId(71L); delivery.setCustomerId("1001"); delivery.setDeliveryNo("DL-1");
|
||||
delivery.setStatus("RETURN_PENDING"); delivery.setVersion(2L);
|
||||
LocalDateTime completedAt = LocalDateTime.of(2026, 9, 8, 18, 30);
|
||||
when(commands.findByKey("1001", "COMPLETE", "complete-1")).thenReturn(null);
|
||||
when(exceptionOrders.lockByNo("1001", "EX-1")).thenReturn(order);
|
||||
when(returnItems.findByException("1001", 1L)).thenReturn(List.of(source));
|
||||
when(inventoryCommands.restoreReturnedInventory(any(), any(), any(ReturnedInventoryRestoreRequest.class)))
|
||||
.thenReturn(new ReturnedInventoryRestoreResponse(81L, List.of(91L), 0, 2, 2, false, completedAt));
|
||||
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
|
||||
when(lastMileOrders.selectList(any())).thenReturn(List.of());
|
||||
ExceptionOrderRequests.Complete request = new ExceptionOrderRequests.Complete(1, "complete-1", "已退回入仓",
|
||||
List.of(101L), List.of(new ExceptionOrderRequests.ReturnReceipt(51L, 61L, 2, 201L,
|
||||
"PLT-201", 301L, "LOC-301")));
|
||||
|
||||
var result = service.complete("1001", "9001", "EX-1", request);
|
||||
|
||||
assertThat(result.status()).isEqualTo("COMPLETED");
|
||||
assertThat(result.inventoryAdjustmentId()).isEqualTo(81L);
|
||||
assertThat(result.restoredPackages()).isEqualTo(2);
|
||||
assertThat(delivery.getStatus()).isEqualTo("RETURNED");
|
||||
verify(evidenceFiles).validate("1001", 1L, 101L);
|
||||
verify(inventoryCommands).restoreReturnedInventory(any(), any(), any(ReturnedInventoryRestoreRequest.class));
|
||||
}
|
||||
|
||||
private String hashFor(Object request) {
|
||||
return (String) ReflectionTestUtils.invokeMethod(service, "hash", request);
|
||||
}
|
||||
|
||||
private ExceptionOrder order(String status, long version) {
|
||||
ExceptionOrder order = new ExceptionOrder();
|
||||
order.setId(1L); order.setCustomerId("1001"); order.setExceptionNo("EX-1");
|
||||
order.setStatus(status); order.setVersion(version);
|
||||
return order;
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package com.baiyue.lastleg.inventory.query;
|
||||
|
||||
import com.baiyue.common.web.LongIdJacksonConfiguration;
|
||||
import com.baiyue.lastleg.inventory.query.controller.InventoryQueryController;
|
||||
import com.baiyue.lastleg.inventory.query.dto.InventoryBalanceView;
|
||||
import com.baiyue.lastleg.security.OverseasPermissions;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class InventoryBalanceDetailContractTest {
|
||||
private static final long BALANCE_ID = 19_000_000_000_000_001L;
|
||||
private static final long INBOUND_ORDER_ID = 19_000_000_000_000_002L;
|
||||
|
||||
@Test
|
||||
void controllerFreezesDetailPathAndInventoryListPermission() throws Exception {
|
||||
var method = InventoryQueryController.class.getMethod("balance",
|
||||
com.baiyue.common.security.AuthenticatedUser.class, long.class, String.class);
|
||||
GetMapping mapping = method.getAnnotation(GetMapping.class);
|
||||
PreAuthorize permission = method.getAnnotation(PreAuthorize.class);
|
||||
|
||||
assertNotNull(mapping);
|
||||
assertEquals("/api/platform/v1/last-leg/inventory/balances/{balanceId}", mapping.value()[0]);
|
||||
assertNotNull(permission);
|
||||
assertTrue(permission.value().contains(OverseasPermissions.INVENTORY_LIST));
|
||||
}
|
||||
|
||||
@Test
|
||||
void openApiDefinesDirectDetailResponseAndStringLongIds() throws Exception {
|
||||
String yaml;
|
||||
try (var stream = getClass().getClassLoader().getResourceAsStream("openapi/overseas-business-v1.yaml")) {
|
||||
assertNotNull(stream);
|
||||
yaml = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
assertTrue(yaml.contains("/api/platform/v1/last-leg/inventory/balances/{balanceId}:"));
|
||||
assertTrue(yaml.contains("$ref: '#/components/schemas/InventoryBalanceApiResponse'"));
|
||||
assertTrue(yaml.contains("id: { type: string, pattern: '^[1-9][0-9]*$'"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void balanceAndInboundOrderIdsSerializeAsJsonStrings() {
|
||||
new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(
|
||||
JacksonAutoConfiguration.class, LongIdJacksonConfiguration.class)).run(context -> {
|
||||
ObjectMapper mapper = context.getBean(ObjectMapper.class);
|
||||
InventoryBalanceView view = new InventoryBalanceView(BALANCE_ID, "100", "Customer", "900",
|
||||
"US-LAX-01", "Los Angeles", "800", "ONT8", "ONT8", INBOUND_ORDER_ID,
|
||||
"IBP-1", "RK-1", "SKU-1", "产品", "Product", 3, 3, 0, 0, 0, 0, 0, 0, null);
|
||||
|
||||
JsonNode json = mapper.readTree(mapper.writeValueAsString(view));
|
||||
|
||||
assertEquals(Long.toString(BALANCE_ID), json.path("id").textValue());
|
||||
assertEquals(Long.toString(INBOUND_ORDER_ID), json.path("inboundOrderId").textValue());
|
||||
});
|
||||
}
|
||||
}
|
||||
+14
@@ -34,6 +34,20 @@ class InventoryQueryControllerTest {
|
||||
verify(service).balances(eq("10001"), eq(query));
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformBalanceDetailQueriesByIdWithoutPaging() {
|
||||
controller.balance(user(0L, "PLATFORM"), 9007199254740993L, null);
|
||||
|
||||
verify(service).balance(null, 9007199254740993L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformBalanceDetailUsesExplicitCustomerScope() {
|
||||
controller.balance(user(0L, "PLATFORM"), 99L, " 10001 ");
|
||||
|
||||
verify(service).balance("10001", 99L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void merchantCannotUsePlatformInventoryController() {
|
||||
assertThrows(ResponseStatusException.class,
|
||||
|
||||
+27
@@ -19,6 +19,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
@@ -115,6 +116,32 @@ class InventoryQueryServiceTest {
|
||||
verify(mapper).countBalances(null, query);
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformBalanceDetailReadsOneVisibleBalanceWithoutPaging() {
|
||||
InventoryBalance balance = new InventoryBalance();
|
||||
balance.setId(99L);
|
||||
balance.setCustomerId("200");
|
||||
balance.setAvailablePackages(3L);
|
||||
when(mapper.visibleBalance(99L)).thenReturn(balance);
|
||||
|
||||
var result = service.balance(null, 99L);
|
||||
|
||||
assertEquals(99L, result.id());
|
||||
assertEquals("200", result.customerId());
|
||||
assertEquals(3L, result.totalPackages());
|
||||
verify(mapper).visibleBalance(99L);
|
||||
verify(mapper, never()).countBalances(any(), any());
|
||||
verify(mapper, never()).balances(any(), any(), anyLong(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void scopedBalanceDetailDoesNotLeakAnotherCustomer() {
|
||||
assertThrows(ResponseStatusException.class, () -> service.balance("100", 99L));
|
||||
|
||||
verify(mapper).ownedVisibleBalance("100", 99L);
|
||||
verify(mapper, never()).visibleBalance(99L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownStatusIsRejectedBeforeDatabaseQuery() {
|
||||
InventoryQueryRequests.BalanceQuery query = balanceQuery("DAMAGED");
|
||||
|
||||
+10
@@ -24,6 +24,11 @@ class Phase5AdminPermissionContractTest {
|
||||
assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_LIST), permissions.get("page"));
|
||||
assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_LIST), permissions.get("resolve"));
|
||||
assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_CREATE), permissions.get("create"));
|
||||
assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_LIST), permissions.get("detail"));
|
||||
assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_PROCESS), permissions.get("uploadEvidence"));
|
||||
assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_PROCESS), permissions.get("startProcessing"));
|
||||
assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_PROCESS), permissions.get("complete"));
|
||||
assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_PROCESS), permissions.get("cancel"));
|
||||
assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_LIST), permissions.get("returnOptions"));
|
||||
assertEquals(authority(OverseasPermissions.DELIVERY_RETURN), permissions.get("markReturn"));
|
||||
}
|
||||
@@ -49,6 +54,7 @@ class Phase5AdminPermissionContractTest {
|
||||
OverseasPermissions.DISPATCH_LIST,
|
||||
OverseasPermissions.EXCEPTION_ORDER_LIST,
|
||||
OverseasPermissions.EXCEPTION_ORDER_CREATE,
|
||||
OverseasPermissions.EXCEPTION_ORDER_PROCESS,
|
||||
OverseasPermissions.DELIVERY_RETURN,
|
||||
OverseasPermissions.DELIVERY_STATUS,
|
||||
OverseasPermissions.CUSTOMER_VISIT_LIST,
|
||||
@@ -59,6 +65,10 @@ class Phase5AdminPermissionContractTest {
|
||||
for (String path : new String[] {
|
||||
"/api/platform/v1/last-leg/dispatches:",
|
||||
"/api/platform/v1/last-leg/exception-orders:",
|
||||
"/api/platform/v1/last-leg/exception-orders/{exceptionNo}:",
|
||||
"/api/platform/v1/last-leg/exception-orders/{exceptionNo}/start-processing:",
|
||||
"/api/platform/v1/last-leg/exception-orders/{exceptionNo}/complete:",
|
||||
"/api/platform/v1/last-leg/exception-orders/{exceptionNo}/cancel:",
|
||||
"/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/mark-return:",
|
||||
"/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/status:",
|
||||
"/api/platform/v1/last-leg/customer-visits:",
|
||||
|
||||
Reference in New Issue
Block a user