fix: complete phase5 overseas deployment contract
This commit is contained in:
@@ -27,7 +27,7 @@
|
||||
| Firstleg 内部模块化与依赖基线 | `firstleg/` | `firstleg/docs/firstleg-internal-modularization-baseline.md`、`firstleg/scripts/analyze-firstleg-dependencies.mjs`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 |
|
||||
| Mapper 新增、移动、Bean 缺失与扫描遗漏 | 目标持久化服务 | 所有 Mapper 显式 `@Mapper`,启动类按服务根包注解过滤扫描;先运行 `node scripts/mapper-scan-contract.mjs`,再按目标模块执行 `test-compile/test`,不得等部署启动发现 |
|
||||
| 脚本管理、全量验证、测试造数与诊断 | 仓库根 `scripts/`;服务专属脚本留在 `<service>/scripts/` | 先读 `scripts/AGENTS.md` 和 `scripts/README.md`;Admin HTTP 内部模块继续读 `scripts/admin-test-env-modules/README.md`。新增入口必须登记用途、目标环境、数据副作用和报告位置,并通过 `scripts/script-documentation-contract.test.mjs` |
|
||||
| 分柜、柜次、智能排柜、预装/装柜清单、真实柜号后补、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位;预装/正式详情由 `/loading-plans/{planId}/lifecycle` 解析。真实集装箱号不阻塞订舱关联、计划确认或 PDA task 创建,可在已 push 的 `EDITING/LOADING` 首次补录,但必须在 PDA 最终完成前存在 |
|
||||
| 分柜、柜次、智能排柜、预装/装柜清单、真实柜号后补、PDA | `firstleg/` | `firstleg/AGENTS.md`,再按 `split/`、`loading/`、`outbound/` 定位;创建柜次时原子生成唯一预装计划,加入入仓码时同步建立关联,不设推送步骤;预装/正式详情由 `/loading-plans/{planId}/lifecycle` 解析。真实集装箱号不阻塞订舱关联、计划确认或 PDA task 创建,可在 `EDITING/LOADING` 首次补录,但必须在 PDA 最终完成前存在 |
|
||||
| 合柜报关 / 同柜不可拆组、跨客户合柜、整组分配/移除 | `firstleg/` | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`、`src/main/resources/openapi/coload-groups-v1.yaml`;按订单建组并冻结全部可用入仓片段,仅要求同国内仓、同渠道 |
|
||||
| 智能装柜入仓码候选与箱数分配 | `firstleg/` | `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/plans/{planId}/inbound-assignments`,业务编排 `service/SmartLoadingService.java` |
|
||||
| 订舱资料/打印、船期、订舱级拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`TruckAppointmentService.java`、`outbound/dispatch/`、`customs/`;拖车只绑定 `RELEASED` 订舱,不携带客户、柜次、装柜计划或真实柜号,每订舱最多一张非终态预约,订舱文件联查 `file/AGENTS.md` |
|
||||
|
||||
@@ -237,6 +237,7 @@ SELECT
|
||||
## 五期海外排车与出库结构升级
|
||||
|
||||
- 五期数据库部署仍只执行 `海外区域统一增量-MySQL8.sql`;空库先执行 Lastleg 与 Warehouse Overseas 当前基线,再完整执行固定入口并确认最末 `migration_status = PASS`。
|
||||
- 该海外固定入口会在任何五期 `ALTER` 与数据门禁前幂等创建排车 PDA、出库、派送、一件代发、异常订单和客户回访的完整当前结构,因此既有四期库不依赖模块基线或历史 `scripts/db/incremental.sql`。国内固定入口只保留 IAM 五期权限目录,不创建或修改 `baiyue_lastleg` 业务表。
|
||||
- 固定入口只补目标字段并做 fail-fast 校验,不回填、改号、清理或从其他表推断开发旧行。`ll_dispatch_pda_task.warehouse_id` 存在 `NULL`、空值或 `0`,或任一待收敛全局业务号/任务号、完成幂等键发生冲突时,脚本立即 `SIGNAL`;应重新造数,重置或清理仍需用户另行授权。
|
||||
- 数据门禁通过后,脚本原位把 `dispatch_no`、`outbound_no`、排车 PDA `task_no`、出库 PDA `task_no` 收敛为全局唯一,并建立 `(customer_id, completion_idempotency_key)` 完成命令唯一约束;旧客户复合索引不保留。
|
||||
- 必须在启动或重启 Lastleg Service 前完成固定入口;不得单独执行模块基线补丁、日期脚本或手工兼容 SQL。
|
||||
|
||||
+17
-157
@@ -5018,10 +5018,6 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan (
|
||||
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID',
|
||||
plan_status VARCHAR(24) NOT NULL DEFAULT 'EDITING'
|
||||
COMMENT '装柜计划状态:EDITING编辑中、LOADING装柜中、COMPLETED装柜完成;建议须经人工确认',
|
||||
push_idempotency_key VARCHAR(128) NULL COMMENT '推送预装幂等键',
|
||||
preload_pushed_by BIGINT UNSIGNED NULL COMMENT '推送预装操作人ID',
|
||||
preload_pushed_by_name VARCHAR(64) NULL COMMENT '推送预装操作人名称快照',
|
||||
preload_pushed_at DATETIME(3) NULL COMMENT '开始装柜并进入LOADING的时间',
|
||||
preload_completed_by BIGINT UNSIGNED NULL COMMENT '预装完成操作人ID',
|
||||
preload_completed_by_name VARCHAR(64) NULL COMMENT '预装完成操作人名称快照',
|
||||
preload_completed_at DATETIME(3) NULL COMMENT '预装完成时间',
|
||||
@@ -5040,7 +5036,6 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan (
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_loading_plan_no (plan_no),
|
||||
UNIQUE KEY uk_loading_plan_booking (container_booking_id),
|
||||
UNIQUE KEY uk_loading_plan_push_key (push_idempotency_key),
|
||||
KEY idx_loading_plan_status (plan_status, updated_at, id),
|
||||
CONSTRAINT fk_loading_plan_booking FOREIGN KEY (container_booking_id)
|
||||
REFERENCES fl_container_booking (id),
|
||||
@@ -5652,10 +5647,6 @@ BEGIN
|
||||
IF OLD.plan_status = 'LOADING' AND (
|
||||
NOT (OLD.id <=> NEW.id) OR NOT (OLD.plan_no <=> NEW.plan_no)
|
||||
OR NOT (OLD.container_booking_id <=> NEW.container_booking_id)
|
||||
OR NOT (OLD.push_idempotency_key <=> NEW.push_idempotency_key)
|
||||
OR NOT (OLD.preload_pushed_by <=> NEW.preload_pushed_by)
|
||||
OR NOT (OLD.preload_pushed_by_name <=> NEW.preload_pushed_by_name)
|
||||
OR NOT (OLD.preload_pushed_at <=> NEW.preload_pushed_at)
|
||||
OR NOT (OLD.remark <=> NEW.remark)
|
||||
OR NOT (OLD.created_by <=> NEW.created_by)
|
||||
OR NOT (OLD.created_by_name <=> NEW.created_by_name)
|
||||
@@ -5675,12 +5666,7 @@ BEGIN
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '同状态乐观锁触碰不得修改阶段审计字段';
|
||||
END IF;
|
||||
IF OLD.plan_status = 'EDITING' AND NEW.plan_status = 'LOADING' THEN
|
||||
IF NEW.push_idempotency_key IS NULL OR CHAR_LENGTH(TRIM(NEW.push_idempotency_key)) = 0 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '开始装柜必须提供幂等键';
|
||||
END IF;
|
||||
SET NEW.preload_pushed_at = COALESCE(NEW.preload_pushed_at, CURRENT_TIMESTAMP(3));
|
||||
ELSEIF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN
|
||||
IF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN
|
||||
IF EXISTS (SELECT 1 FROM fl_loading_plan_item item
|
||||
WHERE item.loading_plan_id = OLD.id AND item.deleted = 0
|
||||
AND NOT EXISTS (
|
||||
@@ -6061,6 +6047,13 @@ DELIMITER ;
|
||||
CALL assert_smart_loading_permission_ids_available();
|
||||
DROP PROCEDURE assert_smart_loading_permission_ids_available;
|
||||
|
||||
DELETE role_permission FROM iam_role_permission role_permission
|
||||
JOIN iam_permission permission ON permission.id = role_permission.permission_id
|
||||
WHERE permission.tenant_id = 0 AND permission.permission_code IN (
|
||||
'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:push');
|
||||
DELETE FROM iam_permission WHERE tenant_id = 0 AND permission_code IN (
|
||||
'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:push');
|
||||
|
||||
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)
|
||||
@@ -6075,14 +6068,10 @@ VALUES
|
||||
'编辑货柜', NULL, 'PUT', 30, 'ACTIVE', 1, 0),
|
||||
(5102094, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:smart-loading:list',
|
||||
'智能装柜', '/first-leg/smart-loading', 'GET', 50, 'ACTIVE', 1, 0),
|
||||
(5102096, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:create',
|
||||
'创建智能装柜计划', NULL, 'POST', 20, 'ACTIVE', 1, 0),
|
||||
(5102097, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:assign',
|
||||
'分配装柜明细', NULL, 'POST', 30, 'ACTIVE', 1, 0),
|
||||
(5102098, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:delete',
|
||||
'删除装柜明细', NULL, 'DELETE', 40, 'ACTIVE', 1, 0),
|
||||
(5102099, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:push',
|
||||
'推送预装', NULL, 'POST', 50, 'ACTIVE', 1, 0),
|
||||
(5102100, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:preload:list',
|
||||
'预装清单', '/first-leg/preload-list', 'GET', 60, 'ACTIVE', 1, 0),
|
||||
(5102102, 0, 'PLATFORM', 'BUTTON', 5102100, 'platform:firstleg:preload:view',
|
||||
@@ -6112,8 +6101,7 @@ FROM iam_role role
|
||||
'platform:firstleg:container:list', 'platform:firstleg:container:view',
|
||||
'platform:firstleg:container:create',
|
||||
'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list',
|
||||
'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:assign',
|
||||
'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:push',
|
||||
'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete',
|
||||
'platform:firstleg:preload:list', 'platform:firstleg:preload:view',
|
||||
'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete',
|
||||
'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list',
|
||||
@@ -6237,12 +6225,11 @@ BEGIN
|
||||
AND permission_code IN (
|
||||
'platform:firstleg:container:list', 'platform:firstleg:container:view',
|
||||
'platform:firstleg:container:create', 'platform:firstleg:container:edit',
|
||||
'platform:firstleg:smart-loading:list', 'platform:firstleg:smart-loading:create',
|
||||
'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete',
|
||||
'platform:firstleg:smart-loading:push', 'platform:firstleg:preload:list',
|
||||
'platform:firstleg:smart-loading:list', 'platform:firstleg:smart-loading:assign',
|
||||
'platform:firstleg:smart-loading:delete', 'platform:firstleg:preload:list',
|
||||
'platform:firstleg:preload:view', 'platform:firstleg:preload:edit',
|
||||
'platform:firstleg:preload:complete', 'platform:firstleg:preload:confirm',
|
||||
'platform:firstleg:loading-list:list', 'platform:firstleg:loading-list:view')) <> 16 THEN
|
||||
'platform:firstleg:loading-list:list', 'platform:firstleg:loading-list:view')) <> 14 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台权限资源未完整创建';
|
||||
END IF;
|
||||
IF (SELECT COUNT(DISTINCT CONCAT(role.role_code, ':', permission.permission_code))
|
||||
@@ -6255,12 +6242,11 @@ BEGIN
|
||||
'platform:firstleg:container:list', 'platform:firstleg:container:view',
|
||||
'platform:firstleg:container:create',
|
||||
'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list',
|
||||
'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:assign',
|
||||
'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:push',
|
||||
'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete',
|
||||
'platform:firstleg:preload:list', 'platform:firstleg:preload:view',
|
||||
'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete',
|
||||
'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list',
|
||||
'platform:firstleg:loading-list:view')) <> 32 THEN
|
||||
'platform:firstleg:loading-list:view')) <> 28 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台角色授权未完整写入';
|
||||
END IF;
|
||||
END$$
|
||||
@@ -7858,10 +7844,8 @@ VALUES
|
||||
(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),
|
||||
(5102094, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:smart-loading:list', '智能装柜', '/first-leg/smart-loading', 'GET', 50, 'ACTIVE', 1, 0),
|
||||
(5102096, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:create', '创建智能装柜计划', NULL, 'POST', 20, 'ACTIVE', 1, 0),
|
||||
(5102097, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:assign', '分配装柜明细', NULL, 'POST', 30, 'ACTIVE', 1, 0),
|
||||
(5102098, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:delete', '删除装柜明细', NULL, 'DELETE', 40, 'ACTIVE', 1, 0),
|
||||
(5102099, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:push', '推送预装', NULL, 'POST', 50, 'ACTIVE', 1, 0)
|
||||
(5102098, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:delete', '删除装柜明细', NULL, 'DELETE', 40, '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),
|
||||
@@ -7929,8 +7913,7 @@ SELECT role.id, permission.id, role.tenant_id
|
||||
AND permission.permission_code IN (
|
||||
'platform:firstleg:container:view', 'platform:firstleg:container:create',
|
||||
'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list',
|
||||
'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:assign',
|
||||
'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:push'
|
||||
'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete'
|
||||
)
|
||||
WHERE role.tenant_id = 0 AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR');
|
||||
|
||||
@@ -13757,10 +13740,6 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan', 'plan_status', '
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan', 'preload_completed_at', '预装完成时间');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan', 'preload_completed_by', '预装完成操作人ID');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan', 'preload_completed_by_name', '预装完成操作人名称快照');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan', 'preload_pushed_at', '开始装柜并进入LOADING的时间');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan', 'preload_pushed_by', '推送预装操作人ID');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan', 'preload_pushed_by_name', '推送预装操作人名称快照');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan', 'push_idempotency_key', '推送预装幂等键');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan', 'remark', '平台装柜计划备注');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan', 'updated_at', '装柜计划最后更新时间');
|
||||
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan', 'updated_by', '最后修改人ID');
|
||||
@@ -15195,10 +15174,9 @@ BEGIN
|
||||
OR (id = 5102092 AND permission_code = 'platform:firstleg:container:create' AND parent_id = 5102090)
|
||||
OR (id = 5102093 AND permission_code = 'platform:firstleg:container:edit' AND parent_id = 5102090)
|
||||
OR (id = 5102094 AND permission_code = 'platform:firstleg:smart-loading:list' AND parent_id = 5102000)
|
||||
OR (id = 5102096 AND permission_code = 'platform:firstleg:smart-loading:create' AND parent_id = 5102094)
|
||||
OR (id = 5102097 AND permission_code = 'platform:firstleg:smart-loading:assign' AND parent_id = 5102094)
|
||||
OR (id = 5102098 AND permission_code = 'platform:firstleg:smart-loading:delete' AND parent_id = 5102094)
|
||||
OR (id = 5102099 AND permission_code = 'platform:firstleg:smart-loading:push' AND parent_id = 5102094))) <> 8 THEN
|
||||
)) <> 6 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = '统一迁移失败:智能装柜权限 ID 或父子关系不正确';
|
||||
END IF;
|
||||
@@ -16799,124 +16777,6 @@ DROP PROCEDURE assert_phase5_delivery_permissions;
|
||||
DROP TEMPORARY TABLE tmp_phase5_delivery_role_permission;
|
||||
|
||||
|
||||
-- 海外智能排车草稿锁:拖入即锁定,移除或关闭草稿即释放。
|
||||
CREATE DATABASE IF NOT EXISTS baiyue_lastleg
|
||||
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||
USE baiyue_lastleg;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_warehouse_id'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN source_warehouse_id VARCHAR(128) NULL COMMENT ''来源国内装柜仓 ID 快照'' AFTER customer_name'));
|
||||
PREPARE lastleg_order_source_warehouse_stmt FROM @ddl; EXECUTE lastleg_order_source_warehouse_stmt; DEALLOCATE PREPARE lastleg_order_source_warehouse_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_order' AND column_name='source_warehouse_name'), 'SELECT 1', 'ALTER TABLE ll_order ADD COLUMN source_warehouse_name VARCHAR(255) NULL COMMENT ''来源国内装柜仓名称快照'' AFTER source_warehouse_id'));
|
||||
PREPARE lastleg_order_source_warehouse_stmt FROM @ddl; EXECUTE lastleg_order_source_warehouse_stmt; DEALLOCATE PREPARE lastleg_order_source_warehouse_stmt;
|
||||
CREATE TABLE IF NOT EXISTS ll_dispatch_pallet_lock (
|
||||
id BIGINT NOT NULL COMMENT '主键ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID',
|
||||
source_pallet_key VARCHAR(160) NOT NULL COMMENT '来源托盘业务键',
|
||||
active_key VARCHAR(160) NULL COMMENT '生效中的资源排他键',
|
||||
plan_id BIGINT NULL COMMENT '入仓计划ID',
|
||||
plan_pallet_id BIGINT NULL COMMENT '计划托盘ID',
|
||||
warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID',
|
||||
dispatch_order_id BIGINT NULL COMMENT '正式派车单ID,草稿锁阶段为空',
|
||||
draft_token VARCHAR(64) NULL COMMENT '智能排车草稿随机标识',
|
||||
operator_id VARCHAR(128) NULL COMMENT '草稿锁操作人ID',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '业务状态',
|
||||
locked_at DATETIME(3) NOT NULL COMMENT '锁定时间',
|
||||
expires_at DATETIME(3) NULL COMMENT '草稿锁失效时间,正式派车锁为空',
|
||||
released_at DATETIME(3) NULL 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_dispatch_active_pallet (customer_id,active_key),
|
||||
KEY idx_ll_dispatch_pallet_lock_order (customer_id,dispatch_order_id,status),
|
||||
KEY idx_ll_dispatch_pallet_lock_source (customer_id,source_pallet_key,status)
|
||||
) ENGINE=InnoDB COMMENT='派车托盘排他锁';
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='draft_token'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN draft_token VARCHAR(64) NULL COMMENT ''智能排车草稿随机标识'' AFTER dispatch_order_id'));
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='operator_id'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN operator_id VARCHAR(128) NULL COMMENT ''草稿锁操作人ID'' AFTER draft_token'));
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='expires_at'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN expires_at DATETIME(3) NULL COMMENT ''草稿锁失效时间,正式派车锁为空'' AFTER locked_at'));
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='dispatch_order_id' AND is_nullable='YES'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock MODIFY COLUMN dispatch_order_id BIGINT NULL COMMENT ''正式派车单ID,草稿锁阶段为空'''));
|
||||
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 尾程库存余额按客户入仓单与海外目的仓汇总箱数,SKU仅保留为快照。
|
||||
SET @ddl = (SELECT CASE
|
||||
WHEN EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_inventory_balance' AND index_name='uk_ll_inventory_dimension' AND column_name='sku_code')
|
||||
THEN 'ALTER TABLE ll_inventory_balance DROP INDEX uk_ll_inventory_dimension, ADD UNIQUE KEY uk_ll_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code)'
|
||||
WHEN NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_inventory_balance' AND index_name='uk_ll_inventory_dimension')
|
||||
THEN 'ALTER TABLE ll_inventory_balance ADD UNIQUE KEY uk_ll_inventory_dimension (customer_id,overseas_warehouse_id,destination_warehouse_code,inbound_order_id,inbound_code)'
|
||||
ELSE 'SELECT 1' END);
|
||||
PREPARE lastleg_inventory_dimension_stmt FROM @ddl; EXECUTE lastleg_inventory_dimension_stmt; DEALLOCATE PREPARE lastleg_inventory_dimension_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_inventory_balance' AND column_name='sku_code'),
|
||||
'ALTER TABLE ll_inventory_balance MODIFY COLUMN sku_code VARCHAR(128) NOT NULL DEFAULT ''UNSPECIFIED'' COMMENT ''SKU快照;多SKU汇总时为MIXED,不参与当前库存唯一维度''',
|
||||
'SELECT 1'));
|
||||
PREPARE lastleg_inventory_dimension_stmt FROM @ddl; EXECUTE lastleg_inventory_dimension_stmt; DEALLOCATE PREPARE lastleg_inventory_dimension_stmt;
|
||||
ALTER TABLE ll_inventory_balance COMMENT='客户入仓单与海外目的仓维度实时库存余额';
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_local_looseboard_order' AND column_name='lastleg_order_id'), 'SELECT 1',
|
||||
'ALTER TABLE ll_local_looseboard_order ADD COLUMN lastleg_order_id BIGINT NULL COMMENT ''关联尾程主订单ID'' AFTER customer_id'));
|
||||
PREPARE lastleg_looseboard_tail_stmt FROM @ddl; EXECUTE lastleg_looseboard_tail_stmt; DEALLOCATE PREPARE lastleg_looseboard_tail_stmt;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_local_looseboard_order' AND index_name='uk_ll_local_looseboard_lastleg'), 'SELECT 1',
|
||||
'ALTER TABLE ll_local_looseboard_order ADD UNIQUE KEY uk_ll_local_looseboard_lastleg(lastleg_order_id)'));
|
||||
PREPARE lastleg_looseboard_tail_stmt FROM @ddl; EXECUTE lastleg_looseboard_tail_stmt; DEALLOCATE PREPARE lastleg_looseboard_tail_stmt;
|
||||
|
||||
-- 派送单列表与详情快照、转派送费用及操作记录(当前上下文为尾程库)。
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='overseas_warehouse_name'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN overseas_warehouse_name VARCHAR(255) NULL COMMENT ''海外仓名称快照'', ADD COLUMN customer_name VARCHAR(255) NULL COMMENT ''客户名称快照'', ADD COLUMN waybill_no VARCHAR(128) NULL COMMENT ''承运运单号'', ADD COLUMN delivery_method VARCHAR(32) NULL COMMENT ''派送方式'', ADD COLUMN carrier_name VARCHAR(255) NULL COMMENT ''派送承运商'', ADD COLUMN pickup_method VARCHAR(64) NULL COMMENT ''提货方式'', ADD COLUMN loading_type VARCHAR(64) NULL COMMENT ''装车类型'', ADD COLUMN vehicle_type VARCHAR(64) NULL COMMENT ''车辆类型'', ADD COLUMN vehicle_size VARCHAR(64) NULL COMMENT ''车辆大小'', ADD COLUMN vehicle_plate VARCHAR(64) NULL COMMENT ''车牌号'', ADD COLUMN driver_name VARCHAR(128) NULL COMMENT ''司机或车队名称'', ADD COLUMN driver_phone VARCHAR(64) NULL COMMENT ''车队联系电话'', ADD COLUMN appointment_account VARCHAR(128) NULL COMMENT ''预约账号'', ADD COLUMN seller_name VARCHAR(128) NULL COMMENT ''卖家姓名'', ADD COLUMN total_weight_kg DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''总重量(千克)'', ADD COLUMN total_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''总体积(立方米)'', ADD COLUMN planned_delivery_at DATETIME(3) NULL COMMENT ''预计派送时间'', ADD COLUMN dispatched_at DATETIME(3) NULL COMMENT ''实际派送时间'', ADD COLUMN expected_arrival_at DATETIME(3) NULL COMMENT ''预计到达时间'', ADD COLUMN signed_at DATETIME(3) NULL COMMENT ''签收时间'', ADD COLUMN remark VARCHAR(1000) NULL COMMENT ''派送备注'''));
|
||||
PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt;
|
||||
CREATE TABLE IF NOT EXISTS ll_delivery_fee (id BIGINT NOT NULL COMMENT '派送费用主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', delivery_order_id BIGINT NOT NULL COMMENT '派送单ID', fee_direction VARCHAR(16) NOT NULL DEFAULT 'PAYABLE' COMMENT '应收应付方向', fee_type VARCHAR(64) NOT NULL COMMENT '费用类型', currency VARCHAR(8) NOT NULL COMMENT '币种', amount DECIMAL(18,2) NOT NULL COMMENT '费用金额', remark VARCHAR(500) NULL COMMENT '费用备注', created_by VARCHAR(128) NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), KEY idx_ll_delivery_fee_order(customer_id,delivery_order_id,id)) ENGINE=InnoDB COMMENT='派送单费用明细';
|
||||
CREATE TABLE IF NOT EXISTS ll_delivery_operation (id BIGINT NOT NULL COMMENT '派送操作主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', delivery_order_id BIGINT NOT NULL COMMENT '派送单ID', operation_node VARCHAR(64) NOT NULL COMMENT '操作节点', operator_id VARCHAR(128) NULL COMMENT '操作人ID', operator_name VARCHAR(128) NULL COMMENT '操作人名称', result VARCHAR(500) NOT NULL COMMENT '处理结果', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', PRIMARY KEY(id), KEY idx_ll_delivery_operation_order(customer_id,delivery_order_id,created_at,id)) ENGINE=InnoDB COMMENT='派送单操作记录';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_exception_order (
|
||||
id BIGINT NOT NULL COMMENT '异常订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', exception_no VARCHAR(64) NOT NULL COMMENT '异常订单号',
|
||||
exception_type VARCHAR(32) NOT NULL COMMENT '异常类型', status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '异常处理状态', reason VARCHAR(1000) NOT NULL COMMENT '异常原因',
|
||||
last_mile_order_id BIGINT NULL COMMENT '关联尾程订单ID', last_mile_order_no VARCHAR(64) NULL COMMENT '关联尾程订单号', inbound_order_id BIGINT NULL COMMENT '关联入仓单ID', inbound_code VARCHAR(128) NULL COMMENT '关联入仓码',
|
||||
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 '处理人名称',
|
||||
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 '创建时间',
|
||||
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),
|
||||
KEY idx_ll_exception_list(customer_id,status,exception_at), KEY idx_ll_exception_tail(customer_id,last_mile_order_no),
|
||||
KEY idx_ll_exception_inbound(customer_id,inbound_code)
|
||||
) ENGINE=InnoDB COMMENT='海外仓异常订单';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_customer_visit (
|
||||
id BIGINT NOT NULL COMMENT '客户回访记录ID',
|
||||
customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键',
|
||||
delivery_order_id BIGINT NOT NULL COMMENT '关联派送单ID',
|
||||
delivery_no VARCHAR(64) NOT NULL COMMENT '关联派送单号',
|
||||
customer_name VARCHAR(255) NULL COMMENT '客户名称快照',
|
||||
contact_name VARCHAR(128) NULL COMMENT '客户联系人姓名快照',
|
||||
contact_phone VARCHAR(64) NULL COMMENT '客户联系人电话快照',
|
||||
destination VARCHAR(500) NULL COMMENT '派送目的仓编码汇总快照',
|
||||
delivery_completed_at DATETIME(3) NOT NULL COMMENT '派送完成时间',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '回访状态:PENDING待回访或COMPLETED已回访',
|
||||
selected_last_mile_order_id BIGINT NULL COMMENT '本次回访选择的尾程订单ID',
|
||||
selected_last_mile_order_no VARCHAR(64) NULL COMMENT '本次回访选择的尾程订单号',
|
||||
selected_inbound_order_id BIGINT NULL COMMENT '本次回访选择的入仓单ID',
|
||||
selected_inbound_code VARCHAR(128) NULL COMMENT '本次回访选择的入仓码',
|
||||
visit_result VARCHAR(32) NULL COMMENT '最近一次回访结果',
|
||||
customer_feedback VARCHAR(2000) NULL COMMENT '最近一次客户反馈',
|
||||
remark VARCHAR(1000) NULL COMMENT '最近一次回访备注',
|
||||
attachment_json JSON NULL COMMENT '当前回访资料文件元数据列表',
|
||||
visited_by VARCHAR(128) NULL COMMENT '最近一次回访操作人ID',
|
||||
visited_at DATETIME(3) NULL COMMENT '最近一次回访时间',
|
||||
visit_count INT 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_customer_visit_delivery(customer_id,delivery_order_id),
|
||||
KEY idx_ll_customer_visit_list(customer_id,status,delivery_completed_at),
|
||||
KEY idx_ll_customer_visit_delivery_no(customer_id,delivery_no)
|
||||
) ENGINE=InnoDB COMMENT='派送单维度客户回访记录';
|
||||
|
||||
|
||||
-- IAM 客户提交即成功,默认尺寸通过持久化任务后台初始化;已有客户按历史事实标记 READY。
|
||||
USE baiyue_iam;
|
||||
SET @customer_dimension_status_ddl = (SELECT IF(
|
||||
|
||||
+265
-12
@@ -1094,23 +1094,244 @@ CREATE TABLE IF NOT EXISTS ll_order_operation_log (
|
||||
PRIMARY KEY(id), KEY idx_ll_order_operation_customer_order(customer_id,order_id,created_at)
|
||||
) ENGINE=InnoDB COMMENT='尾程订单操作记录';
|
||||
|
||||
-- 五期正式结构必须由海外固定入口自行补齐;既有四期库不得依赖当前基线或历史脚本。
|
||||
CREATE TABLE IF NOT EXISTS ll_dispatch_pda_task (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', dispatch_order_id BIGINT NOT NULL COMMENT '派车单ID',
|
||||
task_no VARCHAR(64) NOT NULL COMMENT 'PDA任务号', status VARCHAR(32) NOT NULL COMMENT '业务状态', operator_id VARCHAR(128) NULL COMMENT '操作人ID',
|
||||
warehouse_id VARCHAR(128) NOT NULL COMMENT 'Token绑定的海外仓库ID', operator_name VARCHAR(128) NULL COMMENT '操作人姓名',
|
||||
pushed_at DATETIME(3) NOT NULL COMMENT '任务下发时间', completed_at DATETIME(3) NULL 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_dispatch_pda_task_dispatch(customer_id,dispatch_order_id),
|
||||
UNIQUE KEY uk_ll_dispatch_pda_task_no(task_no)
|
||||
) ENGINE=InnoDB COMMENT='派车PDA拣货任务';
|
||||
CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', task_id BIGINT NOT NULL COMMENT 'PDA任务ID',
|
||||
dispatch_item_id BIGINT NOT NULL COMMENT '派车明细ID', inventory_position_id BIGINT NOT NULL COMMENT '库存仓位记录ID',
|
||||
packages BIGINT NOT NULL COMMENT '本次操作箱数', product_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '产品数量',
|
||||
idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', picked_at DATETIME(3) NOT NULL COMMENT '拣货时间',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_dispatch_pda_pick_key(customer_id,idempotency_key),
|
||||
KEY idx_ll_dispatch_pda_pick_item(customer_id,task_id,dispatch_item_id)
|
||||
) ENGINE=InnoDB COMMENT='派车PDA拣货记录';
|
||||
CREATE TABLE IF NOT EXISTS ll_dispatch_pda_operation (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', task_id BIGINT NULL COMMENT 'PDA任务ID',
|
||||
operation_type VARCHAR(32) NOT NULL COMMENT 'PDA操作类型', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键',
|
||||
request_hash CHAR(64) NOT NULL COMMENT '请求内容哈希', result_json LONGTEXT NULL COMMENT '处理结果JSON',
|
||||
status VARCHAR(32) NOT NULL COMMENT 'PDA操作处理状态', 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_dispatch_pda_operation_key(customer_id,idempotency_key)
|
||||
) ENGINE=InnoDB COMMENT='派车PDA操作幂等记录';
|
||||
CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick_order (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', pick_id BIGINT NOT NULL COMMENT 'PDA拣货记录ID',
|
||||
receipt_id BIGINT NOT NULL COMMENT '入仓实收记录ID', order_id BIGINT NOT NULL COMMENT '订单ID', packages BIGINT NOT NULL COMMENT '本次操作箱数',
|
||||
product_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '产品数量', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
PRIMARY KEY(id), KEY idx_ll_dispatch_pick_order_pick(customer_id,pick_id),
|
||||
KEY idx_ll_dispatch_pick_order_receipt(customer_id,receipt_id)
|
||||
) ENGINE=InnoDB COMMENT='PDA拣货订单归属记录';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_outbound_order (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', outbound_no VARCHAR(64) NOT NULL COMMENT '出库单号',
|
||||
dispatch_order_id BIGINT NULL COMMENT '派车单ID,一件代发为空', dispatch_no VARCHAR(64) NULL COMMENT '派车单号,一件代发为空',
|
||||
overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_OUTBOUND' COMMENT '业务状态',
|
||||
package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', pallet_count INT NOT NULL DEFAULT 0 COMMENT '托盘数',
|
||||
idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', created_by VARCHAR(128) NULL COMMENT '创建人ID',
|
||||
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 '更新时间',
|
||||
outbound_type VARCHAR(32) NOT NULL DEFAULT 'DISPATCH_DELIVERY' COMMENT '出库类型:派送转出库、一件代发',
|
||||
recipient_info VARCHAR(1000) NULL COMMENT '一件代发收件信息', PRIMARY KEY(id), UNIQUE KEY uk_ll_outbound_no(outbound_no),
|
||||
UNIQUE KEY uk_ll_outbound_idempotency(customer_id,idempotency_key), UNIQUE KEY uk_ll_outbound_dispatch(customer_id,dispatch_order_id),
|
||||
KEY idx_ll_outbound_list(customer_id,status,created_at)
|
||||
) ENGINE=InnoDB COMMENT='尾程出库单';
|
||||
CREATE TABLE IF NOT EXISTS ll_outbound_pallet_item (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', outbound_order_id BIGINT NOT NULL COMMENT '出库单ID',
|
||||
warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号',
|
||||
destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照',
|
||||
destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', package_count INT NOT NULL DEFAULT 0 COMMENT '箱数',
|
||||
picked_package_count INT NOT NULL DEFAULT 0 COMMENT '已拣箱数', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_PICK' COMMENT '拣货状态',
|
||||
sku_code VARCHAR(128) NULL COMMENT '一件代发SKU', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_outbound_pallet(customer_id,outbound_order_id,warehouse_pallet_id),
|
||||
KEY idx_ll_outbound_pallet_list(customer_id,outbound_order_id), KEY idx_ll_outbound_pallet_destination(customer_id,destination_warehouse_code)
|
||||
) ENGINE=InnoDB COMMENT='出库托盘明细';
|
||||
CREATE TABLE IF NOT EXISTS ll_outbound_pda_task (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', outbound_order_id BIGINT NOT NULL COMMENT '出库单ID',
|
||||
task_no VARCHAR(64) NOT NULL COMMENT 'PDA任务号', status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '任务状态',
|
||||
completion_idempotency_key VARCHAR(255) NULL COMMENT '完成命令幂等键',
|
||||
pushed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '推送时间', completed_at DATETIME(3) NULL 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_outbound_pda_task_order(customer_id,outbound_order_id), UNIQUE KEY uk_ll_outbound_pda_task_no(task_no),
|
||||
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', outbound_order_id BIGINT NOT NULL COMMENT '出库单ID',
|
||||
task_no VARCHAR(64) NOT NULL COMMENT 'PDA任务号', status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '任务状态:待拣货、拣货中、已完成',
|
||||
completion_idempotency_key VARCHAR(255) NULL COMMENT '完成命令幂等键', pushed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '推送时间',
|
||||
completed_at DATETIME(3) NULL 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_outbound_pda_task_order(customer_id,outbound_order_id), UNIQUE KEY uk_ll_outbound_pda_task_no(task_no),
|
||||
UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key)
|
||||
) ENGINE=InnoDB COMMENT='一件代发出库PDA拣货任务';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_outbound_pda_pick (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', task_id BIGINT NOT NULL COMMENT 'PDA任务ID',
|
||||
outbound_pallet_item_id BIGINT NOT NULL COMMENT '出库托盘明细ID', inventory_position_id BIGINT NOT NULL COMMENT '库存仓位ID', packages BIGINT NOT NULL COMMENT '拣货箱数',
|
||||
idempotency_key VARCHAR(255) NOT NULL COMMENT '拣货幂等键', picked_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '拣货时间',
|
||||
PRIMARY KEY(id), UNIQUE KEY uk_ll_outbound_pda_pick_key(customer_id,idempotency_key)
|
||||
outbound_pallet_item_id BIGINT NOT NULL COMMENT '出库托盘明细ID', inventory_position_id BIGINT NOT NULL COMMENT '库存仓位ID',
|
||||
packages BIGINT NOT NULL COMMENT '拣货箱数', idempotency_key VARCHAR(255) NOT NULL COMMENT '拣货幂等键',
|
||||
picked_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '拣货时间', PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_outbound_pda_pick_key(customer_id,idempotency_key)
|
||||
) ENGINE=InnoDB COMMENT='一件代发出库PDA托盘拣货记录';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_delivery_order (
|
||||
id BIGINT NOT NULL COMMENT '派送单主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', delivery_no VARCHAR(64) NOT NULL COMMENT '派送单号',
|
||||
outbound_order_id BIGINT NOT NULL COMMENT '关联出库单ID', outbound_no VARCHAR(64) NOT NULL COMMENT '关联出库单号',
|
||||
dispatch_order_id BIGINT NOT NULL COMMENT '关联派车单ID', dispatch_no VARCHAR(64) NOT NULL COMMENT '关联派车单号',
|
||||
overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', customer_name VARCHAR(255) NULL COMMENT '客户名称快照',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'WAIT_DELIVERY' COMMENT '派送状态:WAIT_DELIVERY待派送及后续正式状态',
|
||||
waybill_no VARCHAR(128) NULL COMMENT '承运运单号', delivery_method VARCHAR(32) NULL COMMENT '派送方式', carrier_name VARCHAR(255) NULL COMMENT '派送承运商',
|
||||
pickup_method VARCHAR(64) NULL COMMENT '提货方式', loading_type VARCHAR(64) NULL COMMENT '装车类型', vehicle_type VARCHAR(64) NULL COMMENT '车辆类型',
|
||||
vehicle_size VARCHAR(64) NULL COMMENT '车辆大小', vehicle_plate VARCHAR(64) NULL COMMENT '车牌号', driver_name VARCHAR(128) NULL COMMENT '司机或车队名称',
|
||||
driver_phone VARCHAR(64) NULL COMMENT '车队联系电话', appointment_account VARCHAR(128) NULL COMMENT '预约账号', seller_name VARCHAR(128) NULL COMMENT '卖家姓名',
|
||||
package_count INT NOT NULL DEFAULT 0 COMMENT '派送箱数', pallet_count INT NOT NULL DEFAULT 0 COMMENT '派送托盘数',
|
||||
total_weight_kg DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '总重量(千克)', total_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '总体积(立方米)',
|
||||
planned_delivery_at DATETIME(3) NULL COMMENT '预计派送时间', dispatched_at DATETIME(3) NULL COMMENT '实际派送时间',
|
||||
expected_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', signed_at DATETIME(3) NULL COMMENT '签收时间', remark VARCHAR(1000) NULL COMMENT '派送备注',
|
||||
idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', created_by VARCHAR(128) NULL COMMENT '创建人ID',
|
||||
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_delivery_no(customer_id,delivery_no), UNIQUE KEY uk_ll_delivery_idempotency(customer_id,idempotency_key),
|
||||
UNIQUE KEY uk_ll_delivery_outbound(customer_id,outbound_order_id), KEY idx_ll_delivery_list(customer_id,status,created_at)
|
||||
) ENGINE=InnoDB COMMENT='尾程派送单';
|
||||
CREATE TABLE IF NOT EXISTS ll_delivery_fee (
|
||||
id BIGINT NOT NULL COMMENT '派送费用主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', delivery_order_id BIGINT NOT NULL COMMENT '派送单ID',
|
||||
fee_direction VARCHAR(16) NOT NULL DEFAULT 'PAYABLE' COMMENT '应收应付方向', fee_type VARCHAR(64) NOT NULL COMMENT '费用类型', currency VARCHAR(8) NOT NULL COMMENT '币种',
|
||||
amount DECIMAL(18,2) NOT NULL COMMENT '费用金额', remark VARCHAR(500) NULL COMMENT '费用备注', created_by VARCHAR(128) NULL COMMENT '创建人ID',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), KEY idx_ll_delivery_fee_order(customer_id,delivery_order_id,id)
|
||||
) ENGINE=InnoDB COMMENT='派送单费用明细';
|
||||
CREATE TABLE IF NOT EXISTS ll_delivery_operation (
|
||||
id BIGINT NOT NULL COMMENT '派送操作主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', delivery_order_id BIGINT NOT NULL COMMENT '派送单ID',
|
||||
operation_node VARCHAR(64) NOT NULL COMMENT '操作节点', operator_id VARCHAR(128) NULL COMMENT '操作人ID', operator_name VARCHAR(128) NULL COMMENT '操作人名称',
|
||||
result VARCHAR(500) NOT NULL COMMENT '处理结果', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间',
|
||||
PRIMARY KEY(id), KEY idx_ll_delivery_operation_order(customer_id,delivery_order_id,created_at,id)
|
||||
) ENGINE=InnoDB COMMENT='派送单操作记录';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_dropship_order_profile (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID',
|
||||
warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号', source_pallet_no VARCHAR(128) NOT NULL COMMENT '来源托盘号快照',
|
||||
inventory_position_id BIGINT NULL COMMENT '库存仓位记录ID', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_INBOUND' COMMENT '业务状态',
|
||||
idempotency_key VARCHAR(255) NOT 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_dropship_profile_order_pallet(customer_id,order_id,source_pallet_no),
|
||||
UNIQUE KEY uk_ll_dropship_profile_key(customer_id,idempotency_key), KEY idx_ll_dropship_profile_order(customer_id,order_id)
|
||||
) ENGINE=InnoDB COMMENT='一件代发托盘档案';
|
||||
CREATE TABLE IF NOT EXISTS ll_dropship_carton (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', profile_id BIGINT NOT NULL COMMENT '托盘档案ID',
|
||||
warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码',
|
||||
carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛', carton_sequence INT NOT NULL COMMENT '箱唛序号', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_INBOUND' COMMENT '业务状态',
|
||||
outbound_order_no VARCHAR(128) 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_dropship_carton(customer_id,order_id,carton_mark), KEY idx_ll_dropship_carton_status(customer_id,status),
|
||||
KEY idx_ll_dropship_carton_sku(customer_id,sku_code,status)
|
||||
) ENGINE=InnoDB COMMENT='一件代发箱唛明细';
|
||||
CREATE TABLE IF NOT EXISTS ll_dropship_sku_inventory (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', profile_id BIGINT NOT NULL COMMENT '托盘档案ID',
|
||||
sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name VARCHAR(255) NULL COMMENT '产品名称', package_count BIGINT NOT NULL COMMENT '箱数',
|
||||
product_quantity BIGINT NOT NULL 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_dropship_sku_inventory(profile_id,sku_code), KEY idx_ll_dropship_sku_order(customer_id,order_id,sku_code)
|
||||
) ENGINE=InnoDB COMMENT='一件代发SKU库存汇总';
|
||||
CREATE TABLE IF NOT EXISTS ll_dropship_carton_ledger (
|
||||
id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_id BIGINT NOT NULL COMMENT '订单ID', carton_id BIGINT NOT NULL COMMENT '箱唛记录ID',
|
||||
carton_mark VARCHAR(128) NOT NULL COMMENT '箱唛', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型',
|
||||
before_packages BIGINT NOT NULL COMMENT '变动前箱数', delta_packages BIGINT NOT NULL COMMENT '箱数变动量', after_packages BIGINT NOT NULL COMMENT '变动后箱数',
|
||||
business_order_no VARCHAR(128) NULL COMMENT '业务单号', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', remark VARCHAR(1000) NULL COMMENT '备注',
|
||||
occurred_at DATETIME(3) NOT NULL COMMENT '业务发生时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id),
|
||||
UNIQUE KEY uk_ll_dropship_ledger_key(customer_id,idempotency_key), KEY idx_ll_dropship_ledger_sku(customer_id,sku_code,carton_mark)
|
||||
) ENGINE=InnoDB COMMENT='一件代发箱唛库存流水';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ll_exception_order (
|
||||
id BIGINT NOT NULL COMMENT '异常订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', exception_no VARCHAR(64) NOT NULL COMMENT '异常订单号',
|
||||
exception_type VARCHAR(32) NOT NULL COMMENT '异常类型', status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '异常处理状态', reason VARCHAR(1000) NOT NULL COMMENT '异常原因',
|
||||
last_mile_order_id BIGINT NULL COMMENT '关联尾程订单ID', last_mile_order_no VARCHAR(64) NULL COMMENT '关联尾程订单号', inbound_order_id BIGINT NULL COMMENT '关联入仓单ID', inbound_code VARCHAR(128) NULL COMMENT '关联入仓码',
|
||||
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 '处理人名称',
|
||||
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 '创建时间',
|
||||
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),
|
||||
KEY idx_ll_exception_list(customer_id,status,exception_at), KEY idx_ll_exception_tail(customer_id,last_mile_order_no), KEY idx_ll_exception_inbound(customer_id,inbound_code)
|
||||
) ENGINE=InnoDB COMMENT='海外仓异常订单';
|
||||
CREATE TABLE IF NOT EXISTS ll_customer_visit (
|
||||
id BIGINT NOT NULL COMMENT '客户回访记录ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', delivery_order_id BIGINT NOT NULL COMMENT '关联派送单ID',
|
||||
delivery_no VARCHAR(64) NOT NULL COMMENT '关联派送单号', customer_name VARCHAR(255) NULL COMMENT '客户名称快照', contact_name VARCHAR(128) NULL COMMENT '客户联系人姓名快照',
|
||||
contact_phone VARCHAR(64) NULL COMMENT '客户联系人电话快照', destination VARCHAR(500) NULL COMMENT '派送目的仓编码汇总快照',
|
||||
delivery_completed_at DATETIME(3) NOT NULL COMMENT '派送完成时间', status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '回访状态:PENDING待回访或COMPLETED已回访',
|
||||
selected_last_mile_order_id BIGINT NULL COMMENT '本次回访选择的尾程订单ID', selected_last_mile_order_no VARCHAR(64) NULL COMMENT '本次回访选择的尾程订单号',
|
||||
selected_inbound_order_id BIGINT NULL COMMENT '本次回访选择的入仓单ID', selected_inbound_code VARCHAR(128) NULL COMMENT '本次回访选择的入仓码',
|
||||
visit_result VARCHAR(32) NULL COMMENT '最近一次回访结果', customer_feedback VARCHAR(2000) NULL COMMENT '最近一次客户反馈', remark VARCHAR(1000) NULL COMMENT '最近一次回访备注',
|
||||
attachment_json JSON NULL COMMENT '当前回访资料文件元数据列表', visited_by VARCHAR(128) NULL COMMENT '最近一次回访操作人ID', visited_at DATETIME(3) NULL COMMENT '最近一次回访时间',
|
||||
visit_count INT 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_customer_visit_delivery(customer_id,delivery_order_id), KEY idx_ll_customer_visit_list(customer_id,status,delivery_completed_at),
|
||||
KEY idx_ll_customer_visit_delivery_no(customer_id,delivery_no)
|
||||
) ENGINE=InnoDB COMMENT='派送单维度客户回访记录';
|
||||
|
||||
-- 收敛曾由历史局部脚本建立的五期中间结构;只补结构,不回填或修复旧数据。
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='customer_id'), 'SELECT 1', 'ALTER TABLE ll_outbound_order RENAME COLUMN tenant_id TO customer_id'));
|
||||
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='customer_id'), 'SELECT 1', 'ALTER TABLE ll_outbound_pallet_item RENAME COLUMN tenant_id TO customer_id'));
|
||||
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='customer_id'), 'SELECT 1', 'ALTER TABLE ll_delivery_order RENAME COLUMN tenant_id TO customer_id'));
|
||||
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='outbound_type'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN outbound_type VARCHAR(32) NOT NULL DEFAULT ''DISPATCH_DELIVERY'' COMMENT ''出库类型:派送转出库、一件代发'' AFTER status'));
|
||||
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='recipient_info'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN recipient_info VARCHAR(1000) NULL COMMENT ''一件代发收件信息'' AFTER outbound_type'));
|
||||
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='sku_code'), 'SELECT 1', 'ALTER TABLE ll_outbound_pallet_item ADD COLUMN sku_code VARCHAR(128) NULL COMMENT ''一件代发SKU'''));
|
||||
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='picked_package_count'), 'SELECT 1', 'ALTER TABLE ll_outbound_pallet_item ADD COLUMN picked_package_count INT NOT NULL DEFAULT 0 COMMENT ''已拣箱数'' AFTER package_count'));
|
||||
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='status'), 'SELECT 1', 'ALTER TABLE ll_outbound_pallet_item ADD COLUMN status VARCHAR(32) NOT NULL DEFAULT ''WAIT_PICK'' COMMENT ''拣货状态'' AFTER picked_package_count'));
|
||||
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_pallet_item' AND column_name='warehouse_pallet_id' AND is_nullable='NO'), 'ALTER TABLE ll_outbound_pallet_item MODIFY COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''仓库实物托盘ID'', MODIFY COLUMN pallet_no VARCHAR(128) NULL COMMENT ''托盘号''', 'SELECT 1'));
|
||||
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='dispatch_order_id' AND is_nullable='YES'), 'SELECT 1', 'ALTER TABLE ll_outbound_order MODIFY COLUMN dispatch_order_id BIGINT NULL COMMENT ''派车单ID,一件代发为空'', MODIFY COLUMN dispatch_no VARCHAR(64) NULL COMMENT ''派车单号,一件代发为空'''));
|
||||
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='overseas_warehouse_name'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN overseas_warehouse_name VARCHAR(255) NULL COMMENT ''海外仓名称快照'' AFTER overseas_warehouse_id'));
|
||||
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='customer_name'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN customer_name VARCHAR(255) NULL COMMENT ''客户名称快照'' AFTER overseas_warehouse_name'));
|
||||
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='waybill_no'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN waybill_no VARCHAR(128) NULL COMMENT ''承运运单号'', ADD COLUMN delivery_method VARCHAR(32) NULL COMMENT ''派送方式'', ADD COLUMN carrier_name VARCHAR(255) NULL COMMENT ''派送承运商'', ADD COLUMN pickup_method VARCHAR(64) NULL COMMENT ''提货方式'', ADD COLUMN loading_type VARCHAR(64) NULL COMMENT ''装车类型'', ADD COLUMN vehicle_type VARCHAR(64) NULL COMMENT ''车辆类型'', ADD COLUMN vehicle_size VARCHAR(64) NULL COMMENT ''车辆大小'', ADD COLUMN vehicle_plate VARCHAR(64) NULL COMMENT ''车牌号'', ADD COLUMN driver_name VARCHAR(128) NULL COMMENT ''司机或车队名称'', ADD COLUMN driver_phone VARCHAR(64) NULL COMMENT ''车队联系电话'', ADD COLUMN appointment_account VARCHAR(128) NULL COMMENT ''预约账号'', ADD COLUMN seller_name VARCHAR(128) NULL COMMENT ''卖家姓名'' AFTER status'));
|
||||
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='total_weight_kg'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN total_weight_kg DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''总重量(千克)'', ADD COLUMN total_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''总体积(立方米)'', ADD COLUMN planned_delivery_at DATETIME(3) NULL COMMENT ''预计派送时间'', ADD COLUMN dispatched_at DATETIME(3) NULL COMMENT ''实际派送时间'', ADD COLUMN expected_arrival_at DATETIME(3) NULL COMMENT ''预计到达时间'', ADD COLUMN signed_at DATETIME(3) NULL COMMENT ''签收时间'', ADD COLUMN remark VARCHAR(1000) NULL COMMENT ''派送备注'' AFTER pallet_count'));
|
||||
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='source_pallet_no'), 'SELECT 1', 'ALTER TABLE ll_dropship_order_profile ADD COLUMN source_pallet_no VARCHAR(128) NULL COMMENT ''来源托盘号快照'' AFTER pallet_no'));
|
||||
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND index_name='uk_ll_dropship_profile_order'), 'ALTER TABLE ll_dropship_order_profile DROP INDEX uk_ll_dropship_profile_order', 'SELECT 1'));
|
||||
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND index_name='uk_ll_dropship_profile_order_pallet'), 'SELECT 1', 'ALTER TABLE ll_dropship_order_profile ADD UNIQUE KEY uk_ll_dropship_profile_order_pallet(customer_id,order_id,source_pallet_no)'));
|
||||
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND index_name='idx_ll_dropship_profile_order'), 'SELECT 1', 'ALTER TABLE ll_dropship_order_profile ADD KEY idx_ll_dropship_profile_order(customer_id,order_id)'));
|
||||
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_sku_inventory' AND index_name='uk_ll_dropship_sku_profile'), 'ALTER TABLE ll_dropship_sku_inventory DROP INDEX uk_ll_dropship_sku_profile', 'SELECT 1'));
|
||||
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
|
||||
SET @index_ready = (SELECT COUNT(*)=2 AND MIN(non_unique)=0
|
||||
AND SUM(seq_in_index=1 AND column_name='profile_id')=1
|
||||
AND SUM(seq_in_index=2 AND column_name='sku_code')=1
|
||||
FROM information_schema.statistics WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_dropship_sku_inventory' AND index_name='uk_ll_dropship_sku_inventory');
|
||||
SET @index_exists = (SELECT COUNT(*)>0 FROM information_schema.statistics WHERE table_schema=DATABASE()
|
||||
AND table_name='ll_dropship_sku_inventory' AND index_name='uk_ll_dropship_sku_inventory');
|
||||
SET @ddl = IF(@index_ready, 'SELECT 1', IF(@index_exists,
|
||||
'ALTER TABLE ll_dropship_sku_inventory DROP INDEX uk_ll_dropship_sku_inventory, ADD UNIQUE KEY uk_ll_dropship_sku_inventory(profile_id,sku_code)',
|
||||
'ALTER TABLE ll_dropship_sku_inventory ADD UNIQUE KEY uk_ll_dropship_sku_inventory(profile_id,sku_code)'));
|
||||
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dropship_sku_inventory' AND index_name='idx_ll_dropship_sku_order'), 'SELECT 1', 'ALTER TABLE ll_dropship_sku_inventory ADD KEY idx_ll_dropship_sku_order(customer_id,order_id,sku_code)'));
|
||||
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='warehouse_pallet_id' AND is_nullable='NO'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''仓库实物托盘ID'', MODIFY COLUMN pallet_no VARCHAR(128) NULL COMMENT ''托盘号''', 'SELECT 1'));
|
||||
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND column_name='warehouse_pallet_id' AND is_nullable='NO'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN warehouse_pallet_id BIGINT NULL COMMENT ''仓库实物托盘ID'', MODIFY COLUMN pallet_no VARCHAR(128) NULL COMMENT ''托盘号''', 'SELECT 1'));
|
||||
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='status' AND column_default<>'PENDING_INBOUND'), 'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING_INBOUND'' COMMENT ''业务状态''', 'SELECT 1'));
|
||||
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
|
||||
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dropship_carton' AND column_name='status' AND column_default<>'PENDING_INBOUND'), 'ALTER TABLE ll_dropship_carton MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT ''PENDING_INBOUND'' COMMENT ''业务状态''', 'SELECT 1'));
|
||||
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
|
||||
|
||||
-- 五期排车、出库与海外PDA正式业务号/幂等约束。只补结构并 fail-fast 校验开发数据,不回填或修复旧行。
|
||||
SET @ddl = (SELECT IF(EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
@@ -1132,8 +1353,13 @@ CREATE PROCEDURE assert_phase5_lastleg_identifiers_migratable()
|
||||
BEGIN
|
||||
IF (SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema='baiyue_lastleg' AND table_name IN (
|
||||
'll_dispatch_order','ll_outbound_order','ll_dispatch_pda_task','ll_outbound_pda_task')) <> 4 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期排车、出库或PDA任务表未完整部署';
|
||||
'll_dispatch_order','ll_dispatch_item','ll_dispatch_pallet_lock','ll_dispatch_draft','ll_dispatch_draft_container',
|
||||
'll_dispatch_pda_task','ll_dispatch_pda_pick','ll_dispatch_pda_operation','ll_dispatch_pda_pick_order',
|
||||
'll_outbound_order','ll_outbound_pallet_item','ll_outbound_pda_task','ll_outbound_pda_pick',
|
||||
'll_delivery_order','ll_delivery_fee','ll_delivery_operation',
|
||||
'll_dropship_order_profile','ll_dropship_carton','ll_dropship_sku_inventory','ll_dropship_carton_ledger',
|
||||
'll_exception_order','ll_customer_visit')) <> 22 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期排车、出库、派送、一件代发、异常或回访表未完整部署';
|
||||
END IF;
|
||||
IF EXISTS (SELECT dispatch_no FROM ll_dispatch_order GROUP BY dispatch_no HAVING COUNT(*)>1) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='ll_dispatch_order存在跨客户重复dispatch_no,无法收敛全局唯一';
|
||||
@@ -1156,6 +1382,10 @@ BEGIN
|
||||
WHERE warehouse_id IS NULL OR TRIM(warehouse_id)='' OR TRIM(warehouse_id)='0') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='ll_dispatch_pda_task存在空或0仓库范围;请重新造数后重跑,脚本不回填旧行';
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM ll_dropship_order_profile
|
||||
WHERE source_pallet_no IS NULL OR TRIM(source_pallet_no)='') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='ll_dropship_order_profile存在空来源托盘号;请重新造数后重跑,脚本不回填旧行';
|
||||
END IF;
|
||||
END$$
|
||||
DELIMITER ;
|
||||
CALL assert_phase5_lastleg_identifiers_migratable();
|
||||
@@ -1169,6 +1399,14 @@ SET @ddl = (SELECT IF(EXISTS (
|
||||
'ALTER TABLE ll_dispatch_pda_task MODIFY COLUMN warehouse_id VARCHAR(128) NOT NULL COMMENT ''Token绑定的海外仓库ID'''));
|
||||
PREPARE phase5_pda_scope_ddl FROM @ddl; EXECUTE phase5_pda_scope_ddl; DEALLOCATE PREPARE phase5_pda_scope_ddl;
|
||||
|
||||
SET @ddl = (SELECT IF(EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema=DATABASE() AND table_name='ll_dropship_order_profile' AND column_name='source_pallet_no'
|
||||
AND is_nullable='NO' AND column_type='varchar(128)' AND column_comment='来源托盘号快照'
|
||||
), 'SELECT 1',
|
||||
'ALTER TABLE ll_dropship_order_profile MODIFY COLUMN source_pallet_no VARCHAR(128) NOT NULL COMMENT ''来源托盘号快照'''));
|
||||
PREPARE phase5_structure_ddl FROM @ddl; EXECUTE phase5_structure_ddl; DEALLOCATE PREPARE phase5_structure_ddl;
|
||||
|
||||
SET @index_ready = (SELECT COUNT(*)=1 AND MIN(non_unique)=0
|
||||
AND MAX(seq_in_index=1 AND column_name='dispatch_no')=1
|
||||
FROM information_schema.statistics WHERE table_schema=DATABASE()
|
||||
@@ -1229,6 +1467,16 @@ DROP PROCEDURE IF EXISTS assert_phase5_lastleg_identifiers_ready;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE assert_phase5_lastleg_identifiers_ready()
|
||||
BEGIN
|
||||
IF (SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema='baiyue_lastleg' AND table_name IN (
|
||||
'll_dispatch_order','ll_dispatch_item','ll_dispatch_pallet_lock','ll_dispatch_draft','ll_dispatch_draft_container',
|
||||
'll_dispatch_pda_task','ll_dispatch_pda_pick','ll_dispatch_pda_operation','ll_dispatch_pda_pick_order',
|
||||
'll_outbound_order','ll_outbound_pallet_item','ll_outbound_pda_task','ll_outbound_pda_pick',
|
||||
'll_delivery_order','ll_delivery_fee','ll_delivery_operation',
|
||||
'll_dropship_order_profile','ll_dropship_carton','ll_dropship_sku_inventory','ll_dropship_carton_ledger',
|
||||
'll_exception_order','ll_customer_visit')) <> 22 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期完整业务表集合未部署';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg'
|
||||
AND table_name='ll_dispatch_pda_task' AND column_name='warehouse_id' AND is_nullable='NO') <> 1 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='ll_dispatch_pda_task.warehouse_id未收敛为非空';
|
||||
@@ -1237,6 +1485,11 @@ BEGIN
|
||||
WHERE warehouse_id IS NULL OR TRIM(warehouse_id)='' OR TRIM(warehouse_id)='0') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='ll_dispatch_pda_task仍存在空或0仓库范围';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg'
|
||||
AND table_name='ll_dropship_order_profile' AND column_name='source_pallet_no'
|
||||
AND column_type='varchar(128)' AND is_nullable='NO' AND column_comment='来源托盘号快照') <> 1 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='一件代发来源托盘号字段未收敛';
|
||||
END IF;
|
||||
IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg'
|
||||
AND table_name='ll_outbound_pda_task' AND column_name='completion_idempotency_key'
|
||||
AND column_type='varchar(255)' AND is_nullable='YES') <> 1 THEN
|
||||
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package com.baiyue.file.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.file.service.InternalBusinessFileService;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/merchant/v1/files/order-batch-imports")
|
||||
public class MerchantOrderBatchImportFileController {
|
||||
private static final String ERROR_REPORT = "ORDER_BATCH_IMPORT_ERROR_REPORT";
|
||||
|
||||
@Resource private InternalBusinessFileService files;
|
||||
|
||||
@GetMapping("/{batchNo}/error-reports/{fileId}")
|
||||
@PreAuthorize("hasAuthority('merchant:order:create')")
|
||||
public ResponseEntity<byte[]> downloadErrorReport(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String batchNo,
|
||||
@PathVariable long fileId) {
|
||||
var value = files.download(merchantTenant(user), ERROR_REPORT, batchNo, fileId);
|
||||
MediaType contentType;
|
||||
try {
|
||||
contentType = MediaType.parseMediaType(value.contentType());
|
||||
} catch (IllegalArgumentException exception) {
|
||||
contentType = MediaType.APPLICATION_OCTET_STREAM;
|
||||
}
|
||||
return ResponseEntity.ok()
|
||||
.cacheControl(CacheControl.noStore())
|
||||
.contentType(contentType)
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
ContentDisposition.attachment().filename(value.fileName(), StandardCharsets.UTF_8).build().toString())
|
||||
.contentLength(value.content().length)
|
||||
.body(value.content());
|
||||
}
|
||||
|
||||
private static long merchantTenant(AuthenticatedUser user) {
|
||||
if (user == null || !"MERCHANT".equalsIgnoreCase(user.appCode())
|
||||
|| user.tenantId() == null || user.tenantId() <= 0
|
||||
|| user.userId() == null || user.userId() <= 0) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "Merchant identity is required");
|
||||
}
|
||||
return user.tenantId();
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -87,7 +87,7 @@ public class PlatformQualificationFileController {
|
||||
public ApiResponse<QualificationPackageService.PackageUploadResult> uploadRequiredPackage(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable long tenantId,
|
||||
@RequestPart("businessLicense") MultipartFile businessLicense,
|
||||
@RequestPart(value = "businessLicense", required = false) MultipartFile businessLicense,
|
||||
@RequestPart(value = "legalPersonId", required = false) MultipartFile legalPersonId,
|
||||
@RequestParam String unifiedSocialCreditCode,
|
||||
@RequestParam(required = false) LocalDate businessLicenseValidTo,
|
||||
|
||||
@@ -78,7 +78,7 @@ public class QualificationFileController {
|
||||
@PreAuthorize("hasAuthority('merchant:qualification:upload')")
|
||||
public ApiResponse<QualificationPackageService.PackageUploadResult> uploadRequiredPackage(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestPart("businessLicense") MultipartFile businessLicense,
|
||||
@RequestPart(value = "businessLicense", required = false) MultipartFile businessLicense,
|
||||
@RequestPart(value = "legalPersonId", required = false) MultipartFile legalPersonId,
|
||||
@RequestParam String unifiedSocialCreditCode,
|
||||
@RequestParam(required = false) LocalDate businessLicenseValidTo,
|
||||
|
||||
@@ -8,6 +8,23 @@ info:
|
||||
x-lifecycle-owner: ORDER
|
||||
x-file-object-semantics: IMMUTABLE_CREATE_ONLY
|
||||
paths:
|
||||
/api/merchant/v1/files/order-batch-imports/{batchNo}/error-reports/{fileId}:
|
||||
get:
|
||||
summary: 客户下载本客户批量导入错误报告
|
||||
description: 需要merchant:order:create;客户作用域只取登录态,文件类别固定ORDER_BATCH_IMPORT_ERROR_REPORT。
|
||||
security: [{bearerAuth: []}]
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/BatchNo'}
|
||||
- {$ref: '#/components/parameters/FileId'}
|
||||
responses:
|
||||
'200':
|
||||
description: 精确归属的XLSX错误报告
|
||||
headers:
|
||||
Cache-Control: {schema: {type: string, enum: [no-store]}}
|
||||
Content-Disposition: {schema: {type: string}}
|
||||
content:
|
||||
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet:
|
||||
schema: {type: string, format: binary}
|
||||
/internal/file/v1/business-files/{tenantId}/{businessType}/{batchNo}:
|
||||
post:
|
||||
summary: Order上传批量导入源文件或错误报告
|
||||
@@ -62,6 +79,7 @@ paths:
|
||||
schema: {type: string, format: binary}
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
|
||||
internalServiceToken: {type: apiKey, in: header, name: X-Internal-Service-Token}
|
||||
parameters:
|
||||
OperatorId:
|
||||
|
||||
+2
@@ -27,6 +27,8 @@ class QualificationAndOrderFileOpenApiContractTest {
|
||||
|
||||
assertThat(yaml)
|
||||
.contains("ORDER_BATCH_IMPORT_SOURCE", "ORDER_BATCH_IMPORT_ERROR_REPORT")
|
||||
.contains("/api/merchant/v1/files/order-batch-imports/{batchNo}/error-reports/{fileId}:")
|
||||
.contains("需要merchant:order:create")
|
||||
.contains("ownerScope: {type: string, enum: [CUSTOMER]}")
|
||||
.contains("pattern: '^OBI-[0-9A-F]{20}$'")
|
||||
.contains("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package com.baiyue.file.controller;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.file.service.InternalBusinessFileService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class MerchantOrderBatchImportFileControllerTest {
|
||||
@Test
|
||||
void downloadsOnlyTheAuthenticatedCustomersErrorReportAsNoStoreXlsx() {
|
||||
var files = mock(InternalBusinessFileService.class);
|
||||
var controller = new MerchantOrderBatchImportFileController();
|
||||
ReflectionTestUtils.setField(controller, "files", files);
|
||||
String batchNo = "OBI-0123456789ABCDEF0123";
|
||||
byte[] content = {0x50, 0x4b, 0x03, 0x04};
|
||||
when(files.download(10001L, "ORDER_BATCH_IMPORT_ERROR_REPORT", batchNo, 81L))
|
||||
.thenReturn(new InternalBusinessFileService.Download("order-batch-errors.xlsx",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content));
|
||||
|
||||
var response = controller.downloadErrorReport(merchant(), batchNo, 81L);
|
||||
|
||||
assertThat(response.getBody()).containsExactly(content);
|
||||
assertThat(response.getHeaders().getContentType()).isEqualTo(MediaType.parseMediaType(
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"));
|
||||
assertThat(response.getHeaders().getCacheControl()).contains("no-store");
|
||||
assertThat(response.getHeaders().getContentDisposition().getType()).isEqualTo("attachment");
|
||||
assertThat(response.getHeaders().getContentDisposition().getFilename()).isEqualTo("order-batch-errors.xlsx");
|
||||
verify(files).download(10001L, "ORDER_BATCH_IMPORT_ERROR_REPORT", batchNo, 81L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void routeRequiresOrderCreateAndRejectsPlatformIdentity() throws Exception {
|
||||
var method = MerchantOrderBatchImportFileController.class.getMethod("downloadErrorReport",
|
||||
AuthenticatedUser.class, String.class, long.class);
|
||||
assertThat(method.getAnnotation(PreAuthorize.class).value())
|
||||
.isEqualTo("hasAuthority('merchant:order:create')");
|
||||
|
||||
var files = mock(InternalBusinessFileService.class);
|
||||
var controller = new MerchantOrderBatchImportFileController();
|
||||
ReflectionTestUtils.setField(controller, "files", files);
|
||||
var platform = new AuthenticatedUser(1L, 0L, 0L, 1L, "admin", "PLATFORM", "WEB", Set.of());
|
||||
|
||||
assertThatThrownBy(() -> controller.downloadErrorReport(platform,
|
||||
"OBI-0123456789ABCDEF0123", 81L)).isInstanceOf(BizException.class);
|
||||
verifyNoInteractions(files);
|
||||
}
|
||||
|
||||
private static AuthenticatedUser merchant() {
|
||||
return new AuthenticatedUser(2L, 10001L, 10001L, 2L, "merchant", "MERCHANT", "WEB", Set.of());
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ class QualificationFileContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void requiredPackageKeepsBusinessLicenseRequiredAndMakesLegalPersonIdOptional() {
|
||||
void requiredPackageLetsServiceReturnControlledValidationForMissingParts() {
|
||||
assertRequiredPackageParts(QualificationFileController.class);
|
||||
assertRequiredPackageParts(PlatformQualificationFileController.class);
|
||||
}
|
||||
@@ -316,7 +316,7 @@ class QualificationFileContractTest {
|
||||
.map(parameter -> parameter.getAnnotation(RequestPart.class))
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.collect(java.util.stream.Collectors.toMap(RequestPart::value, RequestPart::required));
|
||||
assertEquals(Boolean.TRUE, parts.get("businessLicense"));
|
||||
assertEquals(Boolean.FALSE, parts.get("businessLicense"));
|
||||
assertEquals(Boolean.FALSE, parts.get("legalPersonId"));
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@
|
||||
| 财务费用来源快照 | `controller/InternalFinanceFeeSourceController.java`;入库费用与柜次费用分别按来源 ID、版本向 Finance 提供不可变快照 |
|
||||
| 柜次费用明细 | `controller/PlatformContainerFeeDetailController.java`、`service/ContainerFeeDetailService.java`、`dto/ContainerFeeDetailDtos.java`;Admin 消费端见 `../../apps/admin-web/src/modules/loading-lists/api/containerFees.ts` |
|
||||
| 财务整柜成本分摊快照 | `loading/controller/InternalFinanceCostSnapshotController.java`、`loading/service/FinanceCostSnapshotService.java` |
|
||||
| 分柜、预装、真实柜号后补与正式装柜清单生命周期 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java`、`service/LoadingListService.java`;契约 `src/main/resources/openapi/loading-lists-v1.yaml`,详情阶段先查 `/loading-plans/{planId}/lifecycle`。同一 `planId` 在 `EDITING/LOADING` 返回 `PRELOAD`,`COMPLETED` 返回 `LOADING_LIST`。订舱关联、预装确认和 PDA task 创建不依赖真实集装箱号;已 push 后可在 `EDITING/LOADING` 首次补录,最终 PDA 完成前必须存在;已有值纠正仅限 `EDITING` 且任务未领取、无物理作业事实 |
|
||||
| 分柜、预装、真实柜号后补与正式装柜清单生命周期 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java`、`service/LoadingListService.java`;契约 `src/main/resources/openapi/loading-lists-v1.yaml`,详情阶段先查 `/loading-plans/{planId}/lifecycle`。创建柜次时原子生成唯一预装计划,加入入仓码明细时同步创建入库预装聚合与关联,不设推送步骤;同一 `planId` 在 `EDITING/LOADING` 返回 `PRELOAD`,`COMPLETED` 返回 `LOADING_LIST`。订舱关联、预装确认和 PDA task 创建不依赖真实集装箱号;可在 `EDITING/LOADING` 首次补录,最终 PDA 完成前必须存在;已有值纠正仅限 `EDITING` 且任务未领取、无物理作业事实 |
|
||||
| 同柜不可拆组 | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`;契约 `src/main/resources/openapi/coload-groups-v1.yaml`。请求按订单 ID 建组,服务端冻结每票全部 `WAIT_SPLIT` 可用入仓片段与满箱数;仅要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均保持整组原子 |
|
||||
| 货柜与智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`loading/`;按入仓码候选与箱数分配分别从 `/inbound-candidates`、`/plans/{planId}/inbound-assignments` 进入,明细规格拆分留在服务端 |
|
||||
| 货柜资料、PDA 证据与业务总览 | `controller/PlatformContainerController.java` 的 `/{id}/overview`、`/{id}/documents/{documentType}`,聚合 `mapper/ContainerOverviewMapper.java`;契约 `src/main/resources/openapi/phase3-container-overview-v1.yaml`,四类资料固定为目的港清关、国内出口报关、DO、PU 的当前唯一版本 |
|
||||
|
||||
+2
-2
@@ -72,7 +72,7 @@ public class PlatformLoadingListController {
|
||||
return ApiResponse.success(service.lifecycle(planId, warehouseId));
|
||||
}
|
||||
|
||||
@Operation(summary = "修改装柜运输字段", description = "已推送后可在装柜完成前首次补录集装箱号;已有柜号的纠正仅限 EDITING 且 PDA 尚未领取或产生物理作业事实,并必须提供原因和版本;柜号不能清空。")
|
||||
@Operation(summary = "修改装柜运输字段", description = "可在装柜完成前首次补录集装箱号;已有柜号的纠正仅限 EDITING 且 PDA 尚未领取或产生物理作业事实,并必须提供原因和版本;柜号不能清空。")
|
||||
@PatchMapping("/loading-plans/{planId}/transport")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:preload:edit')")
|
||||
public ApiResponse<PreloadDetail> updateTransport(
|
||||
@@ -86,7 +86,7 @@ public class PlatformLoadingListController {
|
||||
planId, warehouseId, user.userId(), user.username(), idempotencyKey, request));
|
||||
}
|
||||
|
||||
@Operation(summary = "确认整份预装计划", description = "按 loadingPlanId 原子确认全部关联入库预装票据;要求已推送且关联订舱已放舱,真实集装箱号可在PDA确认装柜完成前补录。成功后唯一创建待领取 PDA 任务,claim 后才进入 LOADING。")
|
||||
@Operation(summary = "确认整份预装计划", description = "按 loadingPlanId 原子确认全部已分配入库预装票据;要求关联订舱已放舱,真实集装箱号可在PDA确认装柜完成前补录。成功后唯一创建待领取 PDA 任务,claim 后才进入 LOADING。")
|
||||
@PostMapping("/loading-plans/{planId}/confirm")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:preload:edit')")
|
||||
public ApiResponse<PreloadConfirmationResponse> confirmPreload(
|
||||
|
||||
-4
@@ -18,8 +18,6 @@ public class PlatformSmartLoadingController {
|
||||
public ApiResponse<Plan> draftByBooking(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long bookingId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.draftByBooking(bookingId));}
|
||||
@GetMapping("/plans/{planId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
|
||||
public ApiResponse<Plan> detail(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.detail(planId));}
|
||||
@PostMapping("/plans") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:create')")
|
||||
public ApiResponse<Plan> create(@AuthenticationPrincipal AuthenticatedUser u,@Valid @RequestBody CreateRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.create(r.bookingId(),r.remark(),u.userId(),u.username()));}
|
||||
@PostMapping("/plans/{planId}/assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
|
||||
public ApiResponse<Plan> assign(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.assign(planId,r,u.userId(),u.username()));}
|
||||
@PostMapping("/plans/{planId}/inbound-assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
|
||||
@@ -36,6 +34,4 @@ public class PlatformSmartLoadingController {
|
||||
public ApiResponse<Plan> warehouseRemark(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody WarehouseRemarkRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.updateWarehouseRemark(planId,itemId,r,u.userId(),u.username()));}
|
||||
@DeleteMapping("/plans/{planId}/items/{itemId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:delete')")
|
||||
public ApiResponse<Plan> delete(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@PathVariable @Positive long itemId,@Valid @RequestBody DeleteRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.delete(planId,itemId,r,u.userId(),u.username()));}
|
||||
@PostMapping("/plans/{planId}/push-preload") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:push')")
|
||||
public ApiResponse<Plan> push(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@RequestHeader("Idempotency-Key") String key,@Valid @RequestBody PushRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.pushPreload(planId,key,r,u.userId(),u.username()));}
|
||||
}
|
||||
|
||||
@@ -85,7 +85,6 @@ public final class LoadingListDtos {
|
||||
}
|
||||
|
||||
public record PreloadReadiness(
|
||||
boolean preloadPushed,
|
||||
boolean containerNoReady,
|
||||
int preloadLinkCount,
|
||||
int confirmedPreloadCount,
|
||||
|
||||
@@ -15,7 +15,6 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
|
||||
BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg, BigDecimal unitVolumeCbm,
|
||||
Long coLoadGroupId, String groupNo, Integer memberCount, Integer groupTotalBoxes,
|
||||
Boolean locked, String blockReason) {}
|
||||
public record CreateRequest(@NotNull @Positive Long bookingId, @Size(max=500) String remark) {}
|
||||
public record AssignRequest(@NotNull @Positive Long inboundId,@NotNull @Positive Long inboundDetailId,
|
||||
@NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedVersion,@NotBlank @Size(max=128) String operationId) {}
|
||||
public record AssignInboundRequest(@NotNull @Positive Long inboundId,
|
||||
@@ -35,7 +34,6 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
|
||||
public record WarehouseRemarkRequest(@Size(max=1000) String warehouseRemark,
|
||||
@NotNull @Min(0) Integer expectedPlanVersion, @NotNull @Min(0) Integer expectedItemVersion,
|
||||
@NotBlank @Size(max=128) String operationId) {}
|
||||
public record PushRequest(@NotNull @Min(0) Integer expectedVersion) {}
|
||||
public record Plan(Long id,String planNo,String status,Long bookingId,String containerNo,String voyageNo,
|
||||
Integer version,Boolean canAssign,Boolean canEdit,String blockedReason,String finalCompletionStatus,
|
||||
Long pdaTaskId,String pdaTaskStatus,Integer pdaTaskVersion,List<LoadingPlanItemEntity> items) {}
|
||||
|
||||
@@ -3,7 +3,6 @@ import com.baomidou.mybatisplus.annotation.*; import lombok.Getter; import lombo
|
||||
@Getter @Setter @TableName("fl_loading_plan")
|
||||
public class LoadingPlanEntity {
|
||||
@TableId(type=IdType.ASSIGN_ID) private Long id; private String planNo; private Long containerBookingId; private String planStatus;
|
||||
private String pushIdempotencyKey; private Long preloadPushedBy; private String preloadPushedByName; private LocalDateTime preloadPushedAt;
|
||||
private Long preloadCompletedBy; private String preloadCompletedByName; private LocalDateTime preloadCompletedAt; private Long confirmedBy;
|
||||
private String confirmedByName; private LocalDateTime confirmedAt; private String remark; private Long createdBy; private String createdByName;
|
||||
private LocalDateTime createdAt; private Long updatedBy; private String updatedByName; private LocalDateTime updatedAt; @Version private Integer version;
|
||||
|
||||
+2
-7
@@ -17,7 +17,7 @@ public interface LoadingModelBridgeMapper extends BaseMapper<PreloadLoadingPlanL
|
||||
PreloadSnapshot preload(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
|
||||
|
||||
@Select("SELECT p.id,p.container_booking_id containerBookingId,b.container_no containerNo,"
|
||||
+ "p.plan_status status,p.version,p.preload_pushed_at preloadPushedAt FROM fl_loading_plan p JOIN fl_container_booking b "
|
||||
+ "p.plan_status status,p.version FROM fl_loading_plan p JOIN fl_container_booking b "
|
||||
+ "ON b.id=p.container_booking_id WHERE p.id=#{loadingPlanId}")
|
||||
LoadingPlanSnapshot loadingPlan(@Param("loadingPlanId") long loadingPlanId);
|
||||
|
||||
@@ -86,11 +86,6 @@ public interface LoadingModelBridgeMapper extends BaseMapper<PreloadLoadingPlanL
|
||||
|
||||
record PreloadSnapshot(Long id, Long tenantId, Long inboundId, String status, Integer version) {}
|
||||
record LoadingPlanSnapshot(Long id, Long containerBookingId, String containerNo,
|
||||
String status, Integer version, java.time.LocalDateTime preloadPushedAt) {
|
||||
public LoadingPlanSnapshot(Long id, Long containerBookingId, String containerNo,
|
||||
String status, Integer version) {
|
||||
this(id, containerBookingId, containerNo, status, version, null);
|
||||
}
|
||||
}
|
||||
String status, Integer version) {}
|
||||
record AllocationScope(Long tenantId, Long inboundId, Integer allocatedPackageCount) {}
|
||||
}
|
||||
|
||||
+3
-4
@@ -109,14 +109,13 @@ public class LoadingModelBridgeService {
|
||||
return repository.byLoadingPlan(loadingPlanId);
|
||||
}
|
||||
|
||||
/** Adds a newly allocated inbound to an already pushed EDITING/LOADING plan. */
|
||||
/** Adds a newly allocated inbound to the container's automatically created plan. */
|
||||
@Transactional
|
||||
public void bindIncrementalAllocation(long loadingPlanId, long tenantId, long inboundId,
|
||||
long operatorId, String operatorName, String operationId) {
|
||||
if (repository.byPair(tenantId, inboundId, loadingPlanId) != null) return;
|
||||
var plan = repository.loadingPlan(loadingPlanId);
|
||||
if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次装柜计划不存在");
|
||||
if (plan.preloadPushedAt() == null) return;
|
||||
if (!Set.of("EDITING", "LOADING").contains(plan.status())) {
|
||||
throw conflict("PDA任务正在最终确认或已经完成,不能新增预装关联");
|
||||
}
|
||||
@@ -168,8 +167,8 @@ public class LoadingModelBridgeService {
|
||||
public List<Link> requireAllocatedLinksForConfirmation(long loadingPlanId) {
|
||||
var loadingPlan = repository.loadingPlan(loadingPlanId);
|
||||
if (loadingPlan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次装柜计划不存在");
|
||||
if (loadingPlan.preloadPushedAt() == null || !"EDITING".equals(loadingPlan.status())) {
|
||||
throw conflict("仅已推送且仍为EDITING的预装计划可以确认");
|
||||
if (!"EDITING".equals(loadingPlan.status())) {
|
||||
throw conflict("仅编辑中的预装计划可以确认");
|
||||
}
|
||||
List<AllocationScope> scopes = repository.allocatedScopes(loadingPlanId);
|
||||
if (scopes.isEmpty()) throw conflict("装柜计划尚未分配入库单货物");
|
||||
|
||||
@@ -21,7 +21,7 @@ import java.util.List;
|
||||
public interface LoadingListQueryMapper {
|
||||
String AGGREGATE_COLUMNS = """
|
||||
p.id AS plan_id, p.plan_no, p.container_booking_id, p.plan_status, p.remark,
|
||||
p.created_by, p.created_by_name, p.created_at, p.version, p.preload_pushed_at,
|
||||
p.created_by, p.created_by_name, p.created_at, p.version,
|
||||
b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name,
|
||||
b.vessel_name, b.voyage_no, b.container_type,
|
||||
(SELECT COUNT(*) FROM fl_preload_loading_plan_link preload_link
|
||||
@@ -49,7 +49,7 @@ public interface LoadingListQueryMapper {
|
||||
|
||||
String AGGREGATE_GROUP = """
|
||||
p.id, p.plan_no, p.container_booking_id, p.plan_status, p.remark,
|
||||
p.created_by, p.created_by_name, p.created_at, p.version, p.preload_pushed_at,
|
||||
p.created_by, p.created_by_name, p.created_at, p.version,
|
||||
b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name,
|
||||
b.vessel_name, b.voyage_no, b.container_type
|
||||
""";
|
||||
@@ -61,10 +61,7 @@ public interface LoadingListQueryMapper {
|
||||
FROM fl_loading_plan p
|
||||
JOIN fl_container_booking b ON b.id = p.container_booking_id
|
||||
LEFT JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0
|
||||
WHERE p.preload_pushed_at IS NOT NULL
|
||||
AND p.plan_status IN ('EDITING', 'LOADING')
|
||||
AND EXISTS (SELECT 1 FROM fl_loading_plan_item active_item
|
||||
WHERE active_item.loading_plan_id = p.id AND active_item.deleted = 0)
|
||||
WHERE p.plan_status IN ('EDITING', 'LOADING')
|
||||
<if test="query.status != null and query.status != ''">AND p.plan_status = #{query.status}</if>
|
||||
<if test="query.customerId != null">AND EXISTS (SELECT 1 FROM fl_loading_plan_item customer_item WHERE customer_item.loading_plan_id = p.id AND customer_item.deleted = 0 AND customer_item.account_id = #{query.customerId})</if>
|
||||
<if test="query.keyword != null and query.keyword != ''">
|
||||
@@ -227,7 +224,7 @@ public interface LoadingListQueryMapper {
|
||||
UPDATE fl_loading_plan
|
||||
SET updated_by = #{operatorId}, updated_by_name = #{operatorName},
|
||||
updated_at = CURRENT_TIMESTAMP(3), version = version + 1
|
||||
WHERE id = #{planId} AND plan_status = 'EDITING' AND preload_pushed_at IS NOT NULL
|
||||
WHERE id = #{planId} AND plan_status = 'EDITING'
|
||||
AND version = #{expectedVersion}
|
||||
AND NOT EXISTS (SELECT 1 FROM fl_loading_pick_task task
|
||||
JOIN fl_loading_pick_assignment assignment ON assignment.pick_task_id=task.id
|
||||
@@ -249,7 +246,7 @@ public interface LoadingListQueryMapper {
|
||||
SET b.container_no = #{containerNo}, b.voyage_no = #{voyageNo},
|
||||
b.updated_by = #{operatorId}, b.updated_by_name = #{operatorName},
|
||||
b.updated_at = CURRENT_TIMESTAMP(3), b.version = b.version + 1
|
||||
WHERE p.id = #{planId} AND p.plan_status='EDITING' AND p.preload_pushed_at IS NOT NULL
|
||||
WHERE p.id = #{planId} AND p.plan_status='EDITING'
|
||||
AND NOT EXISTS (SELECT 1 FROM fl_loading_pick_task task
|
||||
JOIN fl_loading_pick_assignment assignment ON assignment.pick_task_id=task.id
|
||||
WHERE task.loading_plan_id=p.id)
|
||||
@@ -271,7 +268,7 @@ public interface LoadingListQueryMapper {
|
||||
SET p.updated_by=#{operatorId}, p.updated_by_name=#{operatorName},
|
||||
p.updated_at=CURRENT_TIMESTAMP(3), p.version=p.version+1
|
||||
WHERE p.id=#{planId} AND p.plan_status IN ('EDITING','LOADING')
|
||||
AND p.preload_pushed_at IS NOT NULL AND p.version=#{expectedVersion}
|
||||
AND p.version=#{expectedVersion}
|
||||
AND b.container_no IS NULL
|
||||
""")
|
||||
int touchForInitialContainerFill(@Param("planId") long planId,
|
||||
@@ -286,7 +283,7 @@ public interface LoadingListQueryMapper {
|
||||
b.updated_by=#{operatorId}, b.updated_by_name=#{operatorName},
|
||||
b.updated_at=CURRENT_TIMESTAMP(3), b.version=b.version+1
|
||||
WHERE p.id=#{planId} AND p.plan_status IN ('EDITING','LOADING')
|
||||
AND p.preload_pushed_at IS NOT NULL AND b.container_no IS NULL
|
||||
AND b.container_no IS NULL
|
||||
AND #{containerNo} IS NOT NULL
|
||||
""")
|
||||
int fillInitialContainerNo(@Param("planId") long planId,
|
||||
@@ -331,7 +328,7 @@ public interface LoadingListQueryMapper {
|
||||
UPDATE fl_loading_plan
|
||||
SET updated_by = #{operatorId}, updated_by_name = #{operatorName},
|
||||
updated_at = CURRENT_TIMESTAMP(3), version = version + 1
|
||||
WHERE id = #{planId} AND plan_status = 'EDITING' AND preload_pushed_at IS NOT NULL
|
||||
WHERE id = #{planId} AND plan_status = 'EDITING'
|
||||
AND version = #{expectedVersion}
|
||||
""")
|
||||
int touchForPreloadConfirmation(@Param("planId") long planId,
|
||||
@@ -385,7 +382,6 @@ public interface LoadingListQueryMapper {
|
||||
private String createdByName;
|
||||
private LocalDateTime createdAt;
|
||||
private Integer version;
|
||||
private LocalDateTime preloadPushedAt;
|
||||
private String containerNo;
|
||||
private String cabinetNo;
|
||||
private Long warehouseId;
|
||||
|
||||
@@ -7,17 +7,10 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstl
|
||||
WHERE id=#{planId} AND plan_status IN ('EDITING','LOADING') AND version=#{expectedVersion}
|
||||
""")
|
||||
int touchEditing(@Param("planId") long planId,@Param("expectedVersion") int expectedVersion,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
|
||||
@Update("""
|
||||
UPDATE fl_loading_plan SET push_idempotency_key=#{key},
|
||||
preload_pushed_by=#{operatorId},preload_pushed_by_name=#{operatorName},preload_pushed_at=CURRENT_TIMESTAMP(3),
|
||||
updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1
|
||||
WHERE id=#{planId} AND plan_status='EDITING' AND push_idempotency_key IS NULL AND version=#{expectedVersion}
|
||||
""")
|
||||
int pushPreload(@Param("planId") long planId,@Param("key") String key,@Param("expectedVersion") int expectedVersion,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
|
||||
@Update("""
|
||||
UPDATE fl_loading_plan SET plan_status='LOADING',updated_by=#{operatorId},updated_by_name=#{operatorName},
|
||||
updated_at=CURRENT_TIMESTAMP(3),version=version+1
|
||||
WHERE id=#{planId} AND plan_status='EDITING' AND preload_pushed_at IS NOT NULL
|
||||
WHERE id=#{planId} AND plan_status='EDITING'
|
||||
""")
|
||||
int startPicking(@Param("planId") long planId,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName);
|
||||
}
|
||||
|
||||
@@ -148,7 +148,6 @@ public class LoadingListRepository {
|
||||
}
|
||||
|
||||
private PreloadReadiness readiness(AggregateRow row) {
|
||||
boolean pushed = row.getPreloadPushedAt() != null;
|
||||
boolean containerReady = org.springframework.util.StringUtils.hasText(row.getContainerNo());
|
||||
int linkCount = Math.toIntExact(value(row.getPreloadLinkCount()));
|
||||
int confirmedCount = Math.toIntExact(value(row.getConfirmedPreloadCount()));
|
||||
@@ -157,19 +156,18 @@ public class LoadingListRepository {
|
||||
boolean editing = "EDITING".equals(row.getPlanStatus());
|
||||
boolean taskCreated = row.getPdaTaskId() != null;
|
||||
java.util.ArrayList<String> blockers = new java.util.ArrayList<>();
|
||||
if (!pushed) blockers.add("PRELOAD_NOT_PUSHED");
|
||||
if (!editing) blockers.add("LOADING_PLAN_NOT_EDITING");
|
||||
if (linkCount == 0) blockers.add("PRELOAD_LINK_MISSING");
|
||||
if (!oceanLinked) blockers.add("OCEAN_BOOKING_NOT_LINKED");
|
||||
else if (!released) blockers.add("OCEAN_BOOKING_NOT_RELEASED");
|
||||
if (taskCreated) blockers.add("PDA_TASK_ALREADY_CREATED");
|
||||
boolean allConfirmed = linkCount > 0 && confirmedCount == linkCount;
|
||||
boolean prerequisitesReady = pushed && editing && linkCount > 0 && released;
|
||||
boolean prerequisitesReady = editing && linkCount > 0 && released;
|
||||
boolean canConfirm = prerequisitesReady && !taskCreated && !allConfirmed;
|
||||
if (allConfirmed) blockers.add("PRELOAD_ALREADY_CONFIRMED");
|
||||
String confirmationStatus = allConfirmed ? "CONFIRMED"
|
||||
: confirmedCount > 0 ? "PARTIALLY_CONFIRMED" : "PENDING";
|
||||
return new PreloadReadiness(pushed, containerReady, linkCount, confirmedCount,
|
||||
return new PreloadReadiness(containerReady, linkCount, confirmedCount,
|
||||
row.getOceanBookingId(), row.getOceanBookingStatus(), row.getPdaTaskId(),
|
||||
row.getPdaTaskStatus(), confirmationStatus, canConfirm,
|
||||
prerequisitesReady && allConfirmed, blockers);
|
||||
|
||||
@@ -99,9 +99,6 @@ public class SmartLoadingRepository {
|
||||
return items.updateWarehouseRemark(planId, itemId, touchedPlanVersion, expectedItemVersion,
|
||||
remark, operatorId, operatorName);
|
||||
}
|
||||
public int push(long planId, String key, int version, long operatorId, String operatorName) {
|
||||
return plans.pushPreload(planId, key, version, operatorId, operatorName);
|
||||
}
|
||||
public int startPicking(long planId, long operatorId, String operatorName) {
|
||||
return plans.startPicking(planId, operatorId, operatorName);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ public class ContainerService {
|
||||
@Resource private OceanBookingRepository oceanBookings;
|
||||
@Resource private WarehouseSupplierSnapshotService suppliers;
|
||||
@Resource private FirstlegDocumentNumberGenerator documentNumbers;
|
||||
@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) {
|
||||
@@ -63,7 +64,9 @@ public class ContainerService {
|
||||
value.setContainerType(specification.containerType()); value.setCapacityCbm(specification.capacityCbm());
|
||||
value.setMaxWeightKg(specification.maxWeightKg()); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
|
||||
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); repository.insert(value);
|
||||
value.setPlanStatus(SmartLoadingStatus.WAIT_LOADING.name()); return value;
|
||||
var plan = smartLoadingService.createForContainer(value.getId(), operatorId, operatorName);
|
||||
value.setPlanId(plan.id()); value.setPlanNo(plan.planNo()); value.setPlanStatus(plan.status());
|
||||
return value;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
||||
@@ -593,9 +593,8 @@ public class LoadingListService {
|
||||
throw conflict("仅装柜完成前允许首次补录真实集装箱号");
|
||||
}
|
||||
if (!initialContainerFill && !"EDITING".equals(row.getPlanStatus())) {
|
||||
throw conflict("仅已推送且PDA尚未开始的预装计划可纠错真实集装箱号");
|
||||
throw conflict("仅PDA尚未开始的编辑中预装计划可纠错真实集装箱号");
|
||||
}
|
||||
if (row.getPreloadPushedAt() == null) throw conflict("仅已推送的预装清单可维护运输字段");
|
||||
if (!initialContainerFill && repository.hasPdaWorkStarted(row.getPlanId())) {
|
||||
throw conflict("PDA任务已领取、已开始作业或已有物理作业事实,不能修改真实集装箱号");
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ public class LoadingPickService {
|
||||
var booking = smartLoadingRepository.booking(containerBookingId);
|
||||
if (booking == null) return;
|
||||
var plan = smartLoadingRepository.planByBooking(containerBookingId);
|
||||
if (plan == null || plan.getPreloadPushedAt() == null || !"EDITING".equals(plan.getPlanStatus())) return;
|
||||
if (plan == null || !"EDITING".equals(plan.getPlanStatus())) return;
|
||||
var links = loadingModelBridge.byLoadingPlan(plan.getId());
|
||||
if (links.isEmpty() || links.stream().anyMatch(link -> !"CONFIRMED".equals(link.preloadStatus()))) return;
|
||||
var allocation = oceanBookingRepository.allocationByContainer(containerBookingId);
|
||||
|
||||
@@ -34,7 +34,7 @@ import java.sql.SQLException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
|
||||
/** 智能装柜的候选、推荐/人工分配、删除和不可逆推送编排。 */
|
||||
/** 智能装柜的自动计划、候选、推荐、人工分配和删除编排。 */
|
||||
@Service
|
||||
public class SmartLoadingService {
|
||||
private static final BigDecimal CAPACITY_LIMIT_RATIO = new BigDecimal("1.10");
|
||||
@@ -129,15 +129,13 @@ public class SmartLoadingService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "SMART_LOADING_PLAN_CREATE", resourceType = "LOADING_PLAN",
|
||||
description = "创建智能装柜计划", captureBefore = false)
|
||||
public Plan create(long bookingId, String remark, long operatorId, String operatorName) {
|
||||
public Plan createForContainer(long bookingId, long operatorId, String operatorName) {
|
||||
ContainerBookingEntity booking = requireBooking(bookingId);
|
||||
LoadingPlanEntity existing = repository.planByBooking(bookingId);
|
||||
if (existing != null) return draftView(existing, booking);
|
||||
LoadingPlanEntity plan = new LoadingPlanEntity();
|
||||
plan.setPlanNo(documentNumbers.next("LP"));
|
||||
plan.setContainerBookingId(bookingId); plan.setPlanStatus("EDITING"); plan.setRemark(trim(remark));
|
||||
plan.setContainerBookingId(bookingId); plan.setPlanStatus("EDITING");
|
||||
plan.setCreatedBy(operatorId); plan.setCreatedByName(operatorName); plan.setUpdatedBy(operatorId);
|
||||
plan.setUpdatedByName(operatorName); plan.setVersion(0);
|
||||
try {
|
||||
@@ -542,69 +540,6 @@ public class SmartLoadingService {
|
||||
return view(repository.plan(planId), requireBooking(plan.getContainerBookingId()));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "SMART_LOADING_PUSH_PRELOAD", resourceType = "LOADING_PLAN",
|
||||
resourceId = "#planId", description = "推送智能装柜计划到预装清单")
|
||||
public Plan pushPreload(long planId, String idempotencyKey, PushRequest request, long operatorId, String operatorName) {
|
||||
String key = trim(idempotencyKey);
|
||||
if (!StringUtils.hasText(key) || key.length() > 128) throw new BizException(ResultCode.BAD_REQUEST, "必须提供有效 Idempotency-Key");
|
||||
String requestHash = sha256("PUSH_PRELOAD|" + planId + '|' + request.expectedVersion());
|
||||
LoadingPlanEntity plan = repository.plan(planId); if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在");
|
||||
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
|
||||
if (key.equals(plan.getPushIdempotencyKey())) {
|
||||
requirePushReplay(planId, key, requestHash);
|
||||
return view(plan, booking);
|
||||
}
|
||||
if (StringUtils.hasText(plan.getPushIdempotencyKey())) throw conflict("预装清单已推送,不能使用新的幂等键重复推送");
|
||||
if (repository.operation(key) != null) throw conflict("Idempotency-Key已用于不同推送请求");
|
||||
if (!"EDITING".equals(plan.getPlanStatus()) || !Objects.equals(plan.getVersion(), request.expectedVersion())) throw conflict("仅编辑中的计划可开始装柜且版本必须匹配");
|
||||
List<LoadingPlanItemEntity> items = repository.items(planId);
|
||||
List<GroupSnapshot> groups = requireCompleteColoadAllocations(planId, items);
|
||||
ensurePendingPreloads(items, operatorId, operatorName);
|
||||
checkPlanCapacity(booking, items);
|
||||
try {
|
||||
if (repository.push(planId, key, request.expectedVersion(), operatorId, operatorName) != 1) throw conflict("装柜计划已被并发修改");
|
||||
} catch (DuplicateKeyException e) { throw conflict("幂等键已用于其他推送"); }
|
||||
try {
|
||||
history(planId, null, "PUSH_PRELOAD", "EDITING", "EDITING", key,
|
||||
json(values("version", request.expectedVersion())),
|
||||
json(values("requestHash", requestHash)), operatorId, operatorName);
|
||||
} catch (DuplicateKeyException exception) {
|
||||
throw conflict("Idempotency-Key已用于不同推送请求");
|
||||
}
|
||||
LoadingPlanEntity pushed = repository.plan(planId);
|
||||
loadingModelBridge.bindAllocatedBeforeLoading(planId, operatorId, operatorName, key);
|
||||
for (GroupSnapshot group : groups) {
|
||||
// The physical group remains LOCKED through PDA work; it is consumed only after the
|
||||
// full group has been picked and the cabinet completion worker reaches its final stage.
|
||||
requireSameGroup(group, requiredGroup(group.members().get(0).tenantId(),
|
||||
group.members().get(0).inboundId()), planId);
|
||||
}
|
||||
return view(pushed, requireBooking(pushed.getContainerBookingId()));
|
||||
}
|
||||
|
||||
private void requirePushReplay(long planId, String key, String requestHash) {
|
||||
LoadingPlanHistoryEntity history = repository.operation(key);
|
||||
if (history == null
|
||||
|| !Objects.equals(history.getLoadingPlanId(), planId)
|
||||
|| !Objects.equals(history.getActionType(), "PUSH_PRELOAD")
|
||||
|| history.getAfterSnapshot() == null
|
||||
|| !sameRequestHash(history.getAfterSnapshot(), requestHash)) {
|
||||
throw conflict("Idempotency-Key已用于不同推送请求");
|
||||
}
|
||||
}
|
||||
|
||||
private void ensurePendingPreloads(List<LoadingPlanItemEntity> items, long operatorId, String operatorName) {
|
||||
if (items.isEmpty()) throw validation("装柜明细不能为空");
|
||||
if (items.stream().anyMatch(item -> item.getAccountId() == null || item.getInboundId() == null)) {
|
||||
throw conflict("装柜明细缺少客户或入库单稳定归属,不能推送预装计划");
|
||||
}
|
||||
items.stream().collect(java.util.stream.Collectors.toMap(
|
||||
item -> item.getAccountId() + "|" + item.getInboundId(), item -> item, (left, right) -> left))
|
||||
.values().forEach(item -> splitPreloadService.ensurePendingForLoadingAllocation(
|
||||
item.getAccountId(), item.getInboundId(), operatorId, operatorName));
|
||||
}
|
||||
|
||||
private List<Candidate> enrichColoadCandidates(ContainerBookingEntity booking, LoadingPlanEntity plan,
|
||||
List<Candidate> values) {
|
||||
Map<String, GroupSnapshot> snapshots = new HashMap<>();
|
||||
|
||||
@@ -223,10 +223,6 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan (
|
||||
plan_no VARCHAR(40) NOT NULL COMMENT '平台唯一装柜计划号',
|
||||
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID',
|
||||
plan_status VARCHAR(24) NOT NULL DEFAULT 'EDITING' COMMENT '装柜计划状态:EDITING编辑中、LOADING装柜中、COMPLETED装柜完成;建议须经人工确认',
|
||||
push_idempotency_key VARCHAR(128) NULL COMMENT '推送预装幂等键',
|
||||
preload_pushed_by BIGINT UNSIGNED NULL COMMENT '推送预装操作人ID',
|
||||
preload_pushed_by_name VARCHAR(64) NULL COMMENT '推送预装操作人名称快照',
|
||||
preload_pushed_at DATETIME(3) NULL COMMENT '开始装柜并进入LOADING的时间',
|
||||
preload_completed_by BIGINT UNSIGNED NULL COMMENT '预装完成操作人ID',
|
||||
preload_completed_by_name VARCHAR(64) NULL COMMENT '预装完成操作人名称快照',
|
||||
preload_completed_at DATETIME(3) NULL COMMENT '预装完成时间',
|
||||
@@ -244,7 +240,6 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan (
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_loading_plan_no (plan_no),
|
||||
UNIQUE KEY uk_loading_plan_booking (container_booking_id),
|
||||
UNIQUE KEY uk_loading_plan_push_key (push_idempotency_key),
|
||||
KEY idx_loading_plan_status (plan_status, updated_at, id),
|
||||
CONSTRAINT fk_loading_plan_booking FOREIGN KEY (container_booking_id)
|
||||
REFERENCES fl_container_booking (id),
|
||||
@@ -893,10 +888,6 @@ BEGIN
|
||||
IF OLD.plan_status = 'LOADING' AND (
|
||||
NOT (OLD.id <=> NEW.id) OR NOT (OLD.plan_no <=> NEW.plan_no)
|
||||
OR NOT (OLD.container_booking_id <=> NEW.container_booking_id)
|
||||
OR NOT (OLD.push_idempotency_key <=> NEW.push_idempotency_key)
|
||||
OR NOT (OLD.preload_pushed_by <=> NEW.preload_pushed_by)
|
||||
OR NOT (OLD.preload_pushed_by_name <=> NEW.preload_pushed_by_name)
|
||||
OR NOT (OLD.preload_pushed_at <=> NEW.preload_pushed_at)
|
||||
OR NOT (OLD.remark <=> NEW.remark)
|
||||
OR NOT (OLD.created_by <=> NEW.created_by)
|
||||
OR NOT (OLD.created_by_name <=> NEW.created_by_name)
|
||||
@@ -916,12 +907,7 @@ BEGIN
|
||||
) THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '同状态乐观锁触碰不得修改阶段审计字段';
|
||||
END IF;
|
||||
IF OLD.plan_status = 'EDITING' AND NEW.plan_status = 'LOADING' THEN
|
||||
IF NEW.push_idempotency_key IS NULL OR CHAR_LENGTH(TRIM(NEW.push_idempotency_key)) = 0 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '开始装柜必须提供幂等键';
|
||||
END IF;
|
||||
SET NEW.preload_pushed_at = COALESCE(NEW.preload_pushed_at, CURRENT_TIMESTAMP(3));
|
||||
ELSEIF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN
|
||||
IF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN
|
||||
IF EXISTS (SELECT 1 FROM fl_loading_plan_item item
|
||||
WHERE item.loading_plan_id = OLD.id AND item.deleted = 0
|
||||
AND NOT EXISTS (
|
||||
@@ -1384,6 +1370,13 @@ DELIMITER ;
|
||||
CALL relocate_legacy_inbound_loading_permissions();
|
||||
DROP PROCEDURE relocate_legacy_inbound_loading_permissions;
|
||||
|
||||
DELETE role_permission FROM iam_role_permission role_permission
|
||||
JOIN iam_permission permission ON permission.id = role_permission.permission_id
|
||||
WHERE permission.tenant_id = 0 AND permission.permission_code IN (
|
||||
'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:push');
|
||||
DELETE FROM iam_permission WHERE tenant_id = 0 AND permission_code IN (
|
||||
'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:push');
|
||||
|
||||
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)
|
||||
@@ -1398,14 +1391,10 @@ VALUES
|
||||
'编辑货柜', NULL, 'PUT', 30, 'ACTIVE', 1, 0),
|
||||
(5102094, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:smart-loading:list',
|
||||
'智能装柜', '/first-leg/smart-loading', 'GET', 50, 'ACTIVE', 1, 0),
|
||||
(5102096, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:create',
|
||||
'创建智能装柜计划', NULL, 'POST', 20, 'ACTIVE', 1, 0),
|
||||
(5102097, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:assign',
|
||||
'分配装柜明细', NULL, 'POST', 30, 'ACTIVE', 1, 0),
|
||||
(5102098, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:delete',
|
||||
'删除装柜明细', NULL, 'DELETE', 40, 'ACTIVE', 1, 0),
|
||||
(5102099, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:push',
|
||||
'推送预装', NULL, 'POST', 50, 'ACTIVE', 1, 0),
|
||||
(5102100, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:preload:list',
|
||||
'预装清单', '/first-leg/preload-list', 'GET', 60, 'ACTIVE', 1, 0),
|
||||
(5102102, 0, 'PLATFORM', 'BUTTON', 5102100, 'platform:firstleg:preload:view',
|
||||
@@ -1435,8 +1424,7 @@ FROM iam_role role
|
||||
'platform:firstleg:container:list', 'platform:firstleg:container:view',
|
||||
'platform:firstleg:container:create',
|
||||
'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list',
|
||||
'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:assign',
|
||||
'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:push',
|
||||
'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete',
|
||||
'platform:firstleg:preload:list', 'platform:firstleg:preload:view',
|
||||
'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete',
|
||||
'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list',
|
||||
@@ -1566,12 +1554,11 @@ BEGIN
|
||||
AND permission_code IN (
|
||||
'platform:firstleg:container:list', 'platform:firstleg:container:view',
|
||||
'platform:firstleg:container:create', 'platform:firstleg:container:edit',
|
||||
'platform:firstleg:smart-loading:list', 'platform:firstleg:smart-loading:create',
|
||||
'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete',
|
||||
'platform:firstleg:smart-loading:push', 'platform:firstleg:preload:list',
|
||||
'platform:firstleg:smart-loading:list', 'platform:firstleg:smart-loading:assign',
|
||||
'platform:firstleg:smart-loading:delete', 'platform:firstleg:preload:list',
|
||||
'platform:firstleg:preload:view', 'platform:firstleg:preload:edit',
|
||||
'platform:firstleg:preload:complete', 'platform:firstleg:preload:confirm',
|
||||
'platform:firstleg:loading-list:list', 'platform:firstleg:loading-list:view')) <> 16 THEN
|
||||
'platform:firstleg:loading-list:list', 'platform:firstleg:loading-list:view')) <> 14 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台权限资源未完整创建';
|
||||
END IF;
|
||||
IF (SELECT COUNT(DISTINCT CONCAT(role.role_code, ':', permission.permission_code))
|
||||
@@ -1584,12 +1571,11 @@ BEGIN
|
||||
'platform:firstleg:container:list', 'platform:firstleg:container:view',
|
||||
'platform:firstleg:container:create',
|
||||
'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list',
|
||||
'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:assign',
|
||||
'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:push',
|
||||
'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete',
|
||||
'platform:firstleg:preload:list', 'platform:firstleg:preload:view',
|
||||
'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete',
|
||||
'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list',
|
||||
'platform:firstleg:loading-list:view')) <> 32 THEN
|
||||
'platform:firstleg:loading-list:view')) <> 28 THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台角色授权未完整写入';
|
||||
END IF;
|
||||
END$$
|
||||
|
||||
@@ -523,26 +523,6 @@ paths:
|
||||
content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingWarehouseRemarkRequest'}}}
|
||||
responses:
|
||||
'200': {description: 成功或业务失败,data 为最新 SmartLoadingPlan}
|
||||
/api/platform/v1/first-leg/smart-loading/plans/{planId}/push-preload:
|
||||
post:
|
||||
summary: 推送智能装柜计划到预装清单
|
||||
description: >-
|
||||
需要 platform:firstleg:smart-loading:push 权限。push-preload 不要求真实柜号,
|
||||
只冻结 EDITING 柜次计划的明细并建立稳定预装关联,不创建 PDA 作业任务;计划必须处于 EDITING 且版本匹配。
|
||||
预装页面随后补录真实柜号;只有预装 CONFIRMED、真实柜号存在、已关联 RELEASED 订舱时才创建待领取 PDA 任务,领取后才进入 LOADING。
|
||||
智能装柜首次分配会创建入库单级 PENDING 预装聚合;push 会在同一事务内对全部实际分配再次确保该聚合存在且仍为 PENDING,
|
||||
但不会覆盖 CONFIRMING/CONFIRMED 记录,也不会在 LOADING 后补建。
|
||||
推送事务会先为所有已分配入库单建立 PENDING 预装计划与 EDITING 柜次计划的正式关联,
|
||||
任一关联缺失、版本冲突或分配数量为零都会整体失败并禁止 PDA/LOADING;不得在 LOADING 后补关联。
|
||||
parameters:
|
||||
- {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {$ref: '#/components/parameters/IdempotencyKey'}
|
||||
requestBody:
|
||||
required: true
|
||||
content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingPushRequest'}}}
|
||||
responses:
|
||||
'200': {description: 推送成功或业务失败,按响应体 code 判断}
|
||||
'403': {description: 无智能装柜推送权限}
|
||||
/openapi/v1/inbound-measurements:
|
||||
post:
|
||||
summary: 提交一条第三方箱规测量结果和一张图片
|
||||
@@ -1651,11 +1631,6 @@ components:
|
||||
properties:
|
||||
version: {type: integer, minimum: 0}
|
||||
data: {$ref: '#/components/schemas/ContainerSaveRequest'}
|
||||
SmartLoadingPushRequest:
|
||||
type: object
|
||||
required: [expectedVersion]
|
||||
properties:
|
||||
expectedVersion: {type: integer, minimum: 0}
|
||||
SmartLoadingAssignRequest:
|
||||
type: object
|
||||
required: [inboundId, inboundDetailId, packageCount, expectedVersion, operationId]
|
||||
|
||||
@@ -49,7 +49,7 @@ paths:
|
||||
patch:
|
||||
summary: 修改预装运输信息
|
||||
description: >-
|
||||
已推送后可在 EDITING 或 LOADING 阶段首次补录真实集装箱号;已有柜号的纠正仅限仍为 EDITING、PDA任务尚未领取且无任何物理作业事实时,并必须提供原因和当前版本;
|
||||
可在 EDITING 或 LOADING 阶段首次补录真实集装箱号;已有柜号的纠正仅限仍为 EDITING、PDA任务尚未领取且无任何物理作业事实时,并必须提供原因和当前版本;
|
||||
containerNo 为 null 表示保持当前值,非空时服务端移除格式空白、转大写并校验ISO 6346结构、设备类别和校验位,空白字符串非法且禁止清空。
|
||||
x-permission: platform:firstleg:preload:edit
|
||||
parameters:
|
||||
@@ -301,9 +301,8 @@ components:
|
||||
readiness: {$ref: '#/components/schemas/PreloadReadiness'}
|
||||
PreloadReadiness:
|
||||
type: object
|
||||
required: [preloadPushed, containerNoReady, preloadLinkCount, confirmedPreloadCount, confirmationStatus, canConfirm, readyForPda, blockingReasons]
|
||||
required: [containerNoReady, preloadLinkCount, confirmedPreloadCount, confirmationStatus, canConfirm, readyForPda, blockingReasons]
|
||||
properties:
|
||||
preloadPushed: {type: boolean, description: 是否已完成 push-preload}
|
||||
containerNoReady: {type: boolean, description: 是否已录入真实集装箱号 containerNo}
|
||||
preloadLinkCount: {type: integer, format: int32, minimum: 0, description: 计划关联的入库预装票据数}
|
||||
confirmedPreloadCount: {type: integer, format: int32, minimum: 0, description: 已确认预装票据数}
|
||||
@@ -317,7 +316,7 @@ components:
|
||||
blockingReasons:
|
||||
type: array
|
||||
description: 阻止计划确认的稳定原因码
|
||||
items: {type: string, enum: [PRELOAD_NOT_PUSHED, LOADING_PLAN_NOT_EDITING, PRELOAD_LINK_MISSING, OCEAN_BOOKING_NOT_LINKED, OCEAN_BOOKING_NOT_RELEASED, PDA_TASK_ALREADY_CREATED, PRELOAD_ALREADY_CONFIRMED]}
|
||||
items: {type: string, enum: [LOADING_PLAN_NOT_EDITING, PRELOAD_LINK_MISSING, OCEAN_BOOKING_NOT_LINKED, OCEAN_BOOKING_NOT_RELEASED, PDA_TASK_ALREADY_CREATED, PRELOAD_ALREADY_CONFIRMED]}
|
||||
ConfirmPreloadRequest:
|
||||
type: object
|
||||
required: [expectedVersion, operationReason]
|
||||
|
||||
@@ -2,7 +2,7 @@ openapi: 3.0.3
|
||||
info:
|
||||
title: Phase 3 Preload and Container Loading Bridge API
|
||||
version: 1.0.0
|
||||
description: Links must be created while preload=PENDING and loadingPlan=EDITING. Smart-loading push creates all allocated links atomically before any PDA task creation; push-preload never requires containerNo and never advances LOADING. LOADING plans cannot be repaired through post-hoc binding.
|
||||
description: The container loading plan is created atomically with the container. Adding an inbound allocation creates its preload aggregate and link in the same transaction while loadingPlan=EDITING or LOADING; plan confirmation remains the gate before PDA task creation.
|
||||
paths:
|
||||
/api/platform/v1/inbounds/{inboundId}/preload/loading-plans:
|
||||
get:
|
||||
|
||||
+3
-9
@@ -32,17 +32,11 @@ class PlatformSmartLoadingControllerContractTest {
|
||||
authority("adjust","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,long.class,AdjustRequest.class);
|
||||
authority("warehouseRemark","platform:firstleg:preload:edit",AuthenticatedUser.class,long.class,long.class,WarehouseRemarkRequest.class);
|
||||
authority("delete","platform:firstleg:smart-loading:delete",AuthenticatedUser.class,long.class,long.class,DeleteRequest.class);
|
||||
authority("push","platform:firstleg:smart-loading:push",AuthenticatedUser.class,long.class,String.class,PushRequest.class);
|
||||
}
|
||||
@Test void openApiPushDoesNotRequireContainerNumberOrCreatePda() throws Exception {
|
||||
@Test void openApiHasNoManualCreateOrPushStep() throws Exception {
|
||||
String yaml=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/openapi/firstleg-v1.yaml"));
|
||||
assertThat(yaml).contains("/api/platform/v1/first-leg/smart-loading/plans/{planId}/push-preload:")
|
||||
.contains("不要求真实柜号")
|
||||
.contains("不创建 PDA 作业任务")
|
||||
.contains("已关联 RELEASED 订舱时才创建待领取 PDA 任务")
|
||||
.contains("领取后才进入 LOADING")
|
||||
.doesNotContain("已关联 CONFIRMED/RELEASED 订舱")
|
||||
.doesNotContain("真实柜号必须已录入");
|
||||
assertThat(yaml).doesNotContain("/push-preload:")
|
||||
.doesNotContain("SmartLoadingPushRequest:");
|
||||
}
|
||||
@Test void openApiFreezesIncrementalMutationAndCapabilityContract() throws Exception {
|
||||
String yaml=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/openapi/firstleg-v1.yaml"));
|
||||
|
||||
+2
-2
@@ -169,7 +169,7 @@ class LoadingModelBridgeServiceTest {
|
||||
@Test
|
||||
void confirmationGateRejectsIncompleteAllocatedLinks() {
|
||||
when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(
|
||||
40L, 50L, "C1", "EDITING", 3, LocalDateTime.now()));
|
||||
40L, 50L, "C1", "EDITING", 3));
|
||||
when(repository.allocatedScopes(40)).thenReturn(List.of(
|
||||
new AllocationScope(10L, 20L, 8), new AllocationScope(11L, 21L, 4)));
|
||||
when(repository.byLoadingPlan(40)).thenReturn(List.of(
|
||||
@@ -193,7 +193,7 @@ class LoadingModelBridgeServiceTest {
|
||||
@Test
|
||||
void bindsConfirmingPreloadToRunningPlanAndConfirmsIt() {
|
||||
when(repository.byPair(10, 20, 40)).thenReturn(null);
|
||||
when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "LOADING", 3, LocalDateTime.now()));
|
||||
when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "LOADING", 3));
|
||||
when(repository.allocatedPackages(10, 20, 40)).thenReturn(2);
|
||||
when(repository.preload(10, 20)).thenReturn(new PreloadSnapshot(30L, 10L, 20L, "CONFIRMING", 4));
|
||||
when(repository.confirmIncrementalPreload(30L, 10L, 20L, 4, 7L, "admin")).thenReturn(1);
|
||||
|
||||
+5
-5
@@ -34,9 +34,9 @@ class LoadingListQueryMapperContractTest {
|
||||
.contains("FROM fl_loading_plan p")
|
||||
.contains("JOIN fl_container_booking b")
|
||||
.contains("LEFT JOIN fl_loading_plan_item i")
|
||||
.contains("p.preload_pushed_at IS NOT NULL")
|
||||
.doesNotContain("preload_pushed_at")
|
||||
.contains("p.plan_status IN ('EDITING', 'LOADING')")
|
||||
.contains("active_item.deleted = 0")
|
||||
.doesNotContain("active_item.deleted = 0")
|
||||
.contains("fl_preload_loading_plan_link")
|
||||
.contains("fl_ocean_booking_container_allocation")
|
||||
.contains("fl_loading_pick_task")
|
||||
@@ -88,14 +88,14 @@ class LoadingListQueryMapperContractTest {
|
||||
|
||||
assertThat(touch)
|
||||
.contains("plan_status = 'EDITING'")
|
||||
.contains("preload_pushed_at IS NOT NULL")
|
||||
.doesNotContain("preload_pushed_at")
|
||||
.contains("fl_loading_pick_assignment", "item.item_status<>'PENDING'")
|
||||
.contains("version = #{expectedVersion}")
|
||||
.contains("version = version + 1");
|
||||
assertThat(transport)
|
||||
.contains("SET b.container_no = #{containerNo}, b.voyage_no = #{voyageNo}")
|
||||
.contains("p.plan_status='EDITING'")
|
||||
.contains("preload_pushed_at IS NOT NULL")
|
||||
.doesNotContain("preload_pushed_at")
|
||||
.doesNotContain("#{containerNo} IS NOT NULL")
|
||||
.contains("fl_loading_pick_task", "fl_loading_pick_assignment")
|
||||
.doesNotContain("DELETE")
|
||||
@@ -122,7 +122,7 @@ class LoadingListQueryMapperContractTest {
|
||||
.contains("version = version + 1");
|
||||
assertThat(confirmPlan)
|
||||
.contains("plan_status = 'EDITING'")
|
||||
.contains("preload_pushed_at IS NOT NULL")
|
||||
.doesNotContain("preload_pushed_at")
|
||||
.contains("version = #{expectedVersion}")
|
||||
.contains("version = version + 1");
|
||||
}
|
||||
|
||||
+6
-9
@@ -20,9 +20,9 @@ class SmartLoadingSqlContractTest {
|
||||
private static final Set<String> PHASE3_PERMISSIONS = Set.of(
|
||||
"platform:firstleg:container:list", "platform:firstleg:container:view",
|
||||
"platform:firstleg:container:create", "platform:firstleg:container:edit",
|
||||
"platform:firstleg:smart-loading:list", "platform:firstleg:smart-loading:create",
|
||||
"platform:firstleg:smart-loading:list",
|
||||
"platform:firstleg:smart-loading:assign", "platform:firstleg:smart-loading:delete",
|
||||
"platform:firstleg:smart-loading:push", "platform:firstleg:preload:list",
|
||||
"platform:firstleg:preload:list",
|
||||
"platform:firstleg:preload:view", "platform:firstleg:preload:edit",
|
||||
"platform:firstleg:preload:complete", "platform:firstleg:preload:confirm",
|
||||
"platform:firstleg:loading-list:list", "platform:firstleg:loading-list:view");
|
||||
@@ -60,7 +60,7 @@ class SmartLoadingSqlContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("活动明细唯一锁、乐观锁和双层幂等字段完整")
|
||||
@DisplayName("活动明细唯一锁、乐观锁和操作幂等字段完整")
|
||||
void shouldProvideConcurrencyAndIdempotencyBoundaries() throws Exception {
|
||||
assertContainerLifecycleSchema(migration());
|
||||
assertContainerLifecycleSchema(unifiedMigration());
|
||||
@@ -71,7 +71,6 @@ class SmartLoadingSqlContractTest {
|
||||
.contains("KEY idx_loading_item_active_detail (account_id, active_inbound_detail_id)")
|
||||
.contains("UNIQUE KEY uk_container_booking_no (booking_no)")
|
||||
.contains("UNIQUE KEY uk_container_booking_cabinet (cabinet_no)")
|
||||
.contains("UNIQUE KEY uk_loading_plan_push_key (push_idempotency_key)")
|
||||
.contains("UNIQUE KEY uk_loading_history_operation (operation_id)")
|
||||
.contains("version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'");
|
||||
}
|
||||
@@ -156,7 +155,7 @@ class SmartLoadingSqlContractTest {
|
||||
void shouldProvidePlatformPermissions() throws Exception {
|
||||
assertThat(migration())
|
||||
.contains("platform:firstleg:container:view")
|
||||
.contains("platform:firstleg:smart-loading:push")
|
||||
.doesNotContain("'platform:firstleg:smart-loading:push', '推送预装'")
|
||||
.contains("platform:firstleg:preload:confirm")
|
||||
.contains("platform:firstleg:loading-list:view")
|
||||
.contains("role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')")
|
||||
@@ -165,7 +164,7 @@ class SmartLoadingSqlContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("模块脚本和统一脚本均校验16项权限与两个角色32项授权")
|
||||
@DisplayName("模块脚本和统一脚本均校验14项权限与两个角色28项授权")
|
||||
void shouldAssertCompletePermissionGrants() throws Exception {
|
||||
assertPermissionContract(migration());
|
||||
assertPermissionContract(unifiedMigration());
|
||||
@@ -192,8 +191,6 @@ class SmartLoadingSqlContractTest {
|
||||
.doesNotContain("旧版本在推送预装时即写入 LOADING")
|
||||
.doesNotContain("修复旧版本“推送预装即拣货中”的状态污染")
|
||||
.contains("SET MESSAGE_TEXT = '装柜状态只允许单向推进'")
|
||||
.contains("IF OLD.plan_status = 'EDITING' AND NEW.plan_status = 'LOADING' THEN")
|
||||
.contains("ELSEIF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN")
|
||||
.contains("IF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN")
|
||||
.contains("SET frozen_at = COALESCE(frozen_at, NEW.confirmed_at, CURRENT_TIMESTAMP(3))")
|
||||
.contains("已有PDA拣货记录的装柜明细仅允许收敛部分拣货实际箱数")
|
||||
@@ -345,7 +342,7 @@ class SmartLoadingSqlContractTest {
|
||||
int assertionStart = sql.lastIndexOf("IF (SELECT COUNT(DISTINCT CONCAT", message);
|
||||
String assertion = sql.substring(assertionStart, message);
|
||||
assertThat(permissions(assertion)).isEqualTo(PHASE3_PERMISSIONS);
|
||||
assertThat(assertion).contains("<> 32 THEN");
|
||||
assertThat(assertion).contains("<> 28 THEN");
|
||||
}
|
||||
|
||||
private static Set<String> permissions(String sql) {
|
||||
|
||||
@@ -45,12 +45,17 @@ class ContainerServiceTest {
|
||||
@MockBean private OceanBookingRepository oceanBookings;
|
||||
@MockBean private WarehouseSupplierSnapshotService suppliers;
|
||||
@MockBean private FirstlegDocumentNumberGenerator documentNumbers;
|
||||
@MockBean private SmartLoadingService smartLoadingService;
|
||||
@Resource private ContainerService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUpWarehouse() {
|
||||
when(documentNumbers.next("CB")).thenReturn("CB-260818-0001");
|
||||
when(documentNumbers.next("CAB")).thenReturn("CAB-260818-0001");
|
||||
when(smartLoadingService.createForContainer(anyLong(), anyLong(), anyString())).thenAnswer(invocation ->
|
||||
new com.baiyue.firstleg.dto.SmartLoadingDtos.Plan(501L, "LP-260818-0001", "EDITING",
|
||||
invocation.getArgument(0, Long.class), null, null, 0, true, true, null,
|
||||
"NOT_STARTED", null, null, null, List.of()));
|
||||
when(warehouses.active(anyString(), anyLong(), anyLong())).thenAnswer(invocation ->
|
||||
activeWarehouse(invocation.getArgument(2, Long.class), "DOMESTIC", "上海中转仓"));
|
||||
when(suppliers.trucking(202L, "拖车公司A"))
|
||||
@@ -77,7 +82,10 @@ class ContainerServiceTest {
|
||||
assertThat(saved.getWarehouseId()).isEqualTo(101L);
|
||||
assertThat(saved.getWarehouseName()).isEqualTo("上海中转仓");
|
||||
assertThat(saved.getCapacityCbm()).isEqualByComparingTo("76.0000");
|
||||
assertThat(saved.getPlanStatus()).isEqualTo("WAIT_LOADING");
|
||||
assertThat(saved.getPlanId()).isEqualTo(501L);
|
||||
assertThat(saved.getPlanNo()).isEqualTo("LP-260818-0001");
|
||||
assertThat(saved.getPlanStatus()).isEqualTo("EDITING");
|
||||
verify(smartLoadingService).createForContainer(saved.getId(), 9L, "admin");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -85,11 +85,9 @@ class LoadingListServiceTest {
|
||||
@Test
|
||||
void shouldOnlyUpdateTransportFieldsWithOptimisticLockAndHistory() {
|
||||
AggregateRow before = row("EDITING", 3);
|
||||
before.setPreloadPushedAt(java.time.LocalDateTime.now());
|
||||
before.setContainerNo("MSCU6639870");
|
||||
before.setVoyageNo("001E");
|
||||
AggregateRow after = row("EDITING", 4);
|
||||
after.setPreloadPushedAt(before.getPreloadPushedAt());
|
||||
after.setContainerNo("CSQU3054383");
|
||||
after.setVoyageNo("023E");
|
||||
when(repository.history(anyString())).thenReturn(null);
|
||||
@@ -112,7 +110,6 @@ class LoadingListServiceTest {
|
||||
@Test
|
||||
void shouldNotWritePlanOrHistoryWhenTransportFieldsAreUnchanged() {
|
||||
AggregateRow before = row("EDITING", 3);
|
||||
before.setPreloadPushedAt(java.time.LocalDateTime.now());
|
||||
before.setContainerNo("MSCU6639870");
|
||||
before.setVoyageNo("023E");
|
||||
when(repository.aggregate(10L)).thenReturn(before);
|
||||
@@ -132,10 +129,8 @@ class LoadingListServiceTest {
|
||||
@Test
|
||||
void pushedEditingPreloadCanCorrectExistingContainerNumberBeforePdaClaim() {
|
||||
AggregateRow before = row("EDITING", 3);
|
||||
before.setPreloadPushedAt(java.time.LocalDateTime.now());
|
||||
before.setContainerNo("MSCU6639870");
|
||||
AggregateRow after = row("EDITING", 4);
|
||||
after.setPreloadPushedAt(before.getPreloadPushedAt());
|
||||
after.setContainerNo("CSQU3054383");
|
||||
when(repository.aggregate(10L)).thenReturn(before, after);
|
||||
when(repository.updateTransport(10L, 3, "CSQU3054383", null, 9L, "operator")).thenReturn(1);
|
||||
@@ -149,18 +144,18 @@ class LoadingListServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void editingPlanCannotReceiveTransportBeforePush() {
|
||||
void editingPlanCanCorrectTransportWithoutPushStep() {
|
||||
AggregateRow before = row("EDITING", 3);
|
||||
before.setContainerNo("MSCU6639870");
|
||||
when(repository.aggregate(10L)).thenReturn(before);
|
||||
AggregateRow after = row("EDITING", 4);
|
||||
after.setContainerNo("CSQU3054383");
|
||||
when(repository.aggregate(10L)).thenReturn(before, after);
|
||||
when(repository.updateTransport(10L, 3, "CSQU3054383", null, 9L, "operator")).thenReturn(1);
|
||||
|
||||
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "editing-key",
|
||||
new TransportUpdateRequest("CSQU3054383", null, 3, "纠错柜号")))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("已推送");
|
||||
service.updateTransport(10L, 100L, 9L, "operator", "editing-key",
|
||||
new TransportUpdateRequest("CSQU3054383", null, 3, "纠错柜号"));
|
||||
|
||||
verify(repository, never()).updateTransport(eq(10L), eq(3), anyString(),
|
||||
org.mockito.ArgumentMatchers.isNull(), eq(9L), anyString());
|
||||
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
|
||||
verify(repository).updateTransport(10L, 3, "CSQU3054383", null, 9L, "operator");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -179,7 +174,6 @@ class LoadingListServiceTest {
|
||||
@Test
|
||||
void shouldRejectBlankContainerNumberInsteadOfClearingAuthorityField() {
|
||||
AggregateRow before = row("EDITING", 3);
|
||||
before.setPreloadPushedAt(java.time.LocalDateTime.now());
|
||||
before.setContainerNo("MSCU6639870");
|
||||
when(repository.aggregate(10L)).thenReturn(before);
|
||||
|
||||
@@ -195,7 +189,6 @@ class LoadingListServiceTest {
|
||||
@Test
|
||||
void unclaimedPdaTaskStillAllowsCorrectionButClaimOrPhysicalWorkRejectsIt() {
|
||||
AggregateRow before = row("EDITING", 3);
|
||||
before.setPreloadPushedAt(java.time.LocalDateTime.now());
|
||||
before.setContainerNo("MSCU6639870");
|
||||
when(repository.aggregate(10L)).thenReturn(before);
|
||||
when(repository.hasPdaWorkStarted(10L)).thenReturn(true);
|
||||
@@ -210,12 +203,9 @@ class LoadingListServiceTest {
|
||||
@Test
|
||||
void loadingPlanRejectsTransportUpdateButPushedEditingPlanAllowsFirstContainerFill() {
|
||||
AggregateRow loading = row("LOADING", 3);
|
||||
loading.setPreloadPushedAt(java.time.LocalDateTime.now());
|
||||
loading.setContainerNo("MSCU6639870");
|
||||
AggregateRow missing = row("EDITING", 3);
|
||||
missing.setPreloadPushedAt(java.time.LocalDateTime.now());
|
||||
AggregateRow afterFill = row("EDITING", 4);
|
||||
afterFill.setPreloadPushedAt(missing.getPreloadPushedAt());
|
||||
afterFill.setContainerNo("CSQU3054383");
|
||||
when(repository.aggregate(10L)).thenReturn(loading, missing, afterFill);
|
||||
when(repository.fillInitialContainerNo(10L, 3, "CSQU3054383", null, 9L, "operator"))
|
||||
@@ -236,9 +226,7 @@ class LoadingListServiceTest {
|
||||
@Test
|
||||
void loadingPlanWithClaimedAndPickedPdaTaskStillAllowsInitialContainerFill() {
|
||||
AggregateRow before = row("LOADING", 7);
|
||||
before.setPreloadPushedAt(java.time.LocalDateTime.now());
|
||||
AggregateRow after = row("LOADING", 8);
|
||||
after.setPreloadPushedAt(before.getPreloadPushedAt());
|
||||
after.setContainerNo("CSQU3054383");
|
||||
when(repository.aggregate(10L)).thenReturn(before, after);
|
||||
when(repository.hasPdaWorkStarted(10L)).thenReturn(true);
|
||||
@@ -260,7 +248,6 @@ class LoadingListServiceTest {
|
||||
@Test
|
||||
void correctingExistingContainerNumberStillRequiresReason() {
|
||||
AggregateRow before = row("EDITING", 3);
|
||||
before.setPreloadPushedAt(java.time.LocalDateTime.now());
|
||||
before.setContainerNo("MSCU6639870");
|
||||
when(repository.aggregate(10L)).thenReturn(before);
|
||||
|
||||
@@ -274,7 +261,6 @@ class LoadingListServiceTest {
|
||||
@Test
|
||||
void rejectsGlobalUniqueConflictIncludingConcurrentConstraintRace() {
|
||||
AggregateRow before = row("EDITING", 3);
|
||||
before.setPreloadPushedAt(java.time.LocalDateTime.now());
|
||||
before.setContainerNo("MSCU6639870");
|
||||
when(repository.aggregate(10L)).thenReturn(before);
|
||||
when(containerRepository.containerNoExists("CSQU3054383", 30L)).thenReturn(true);
|
||||
@@ -329,10 +315,8 @@ class LoadingListServiceTest {
|
||||
@Test
|
||||
void shouldReplaySameTransportRequestWithoutSecondMutation() {
|
||||
AggregateRow before = row("EDITING", 3);
|
||||
before.setPreloadPushedAt(java.time.LocalDateTime.now());
|
||||
before.setContainerNo("MSCU6639870");
|
||||
AggregateRow after = row("EDITING", 4);
|
||||
after.setPreloadPushedAt(before.getPreloadPushedAt());
|
||||
after.setContainerNo("CSQU3054383");
|
||||
after.setVoyageNo("023E");
|
||||
when(repository.history(anyString())).thenReturn(null);
|
||||
@@ -361,7 +345,6 @@ class LoadingListServiceTest {
|
||||
@Test
|
||||
void reusedTransportKeyWithDifferentPayloadFailsBeforeMutation() {
|
||||
AggregateRow before = row("EDITING", 3);
|
||||
before.setPreloadPushedAt(java.time.LocalDateTime.now());
|
||||
before.setContainerNo("MSCU6639870");
|
||||
HistoryRow replay = new HistoryRow();
|
||||
replay.setAfterSnapshot("{\"requestHash\":\"different\"}");
|
||||
@@ -765,9 +748,7 @@ class LoadingListServiceTest {
|
||||
@Test
|
||||
void confirmsEveryLinkedPreloadAndCreatesSinglePdaTaskAtomically() {
|
||||
AggregateRow before = row("EDITING", 3);
|
||||
before.setPreloadPushedAt(java.time.LocalDateTime.now());
|
||||
AggregateRow after = row("EDITING", 4);
|
||||
after.setPreloadPushedAt(before.getPreloadPushedAt());
|
||||
List<Link> links = List.of(
|
||||
new Link(1L, 101L, 201L, 301L, "PENDING", 2, 10L, 30L,
|
||||
"MSCU-001", "EDITING", 3, 4, null),
|
||||
@@ -850,7 +831,6 @@ class LoadingListServiceTest {
|
||||
@Test
|
||||
void rejectsNewConfirmationKeyAfterPlanWasAlreadyConfirmed() {
|
||||
AggregateRow confirmed = row("EDITING", 4);
|
||||
confirmed.setPreloadPushedAt(java.time.LocalDateTime.now());
|
||||
when(repository.history(anyString())).thenReturn(null);
|
||||
when(repository.aggregate(10L)).thenReturn(confirmed);
|
||||
when(loadingPickService.requirePreloadConfirmationReady(10L))
|
||||
@@ -872,7 +852,6 @@ class LoadingListServiceTest {
|
||||
@Test
|
||||
void confirmationGateFailureLeavesPlanAndEveryPreloadUntouched() {
|
||||
AggregateRow before = row("EDITING", 3);
|
||||
before.setPreloadPushedAt(java.time.LocalDateTime.now());
|
||||
when(repository.history(anyString())).thenReturn(null);
|
||||
when(repository.aggregate(10L)).thenReturn(before);
|
||||
when(loadingPickService.requirePreloadConfirmationReady(10L))
|
||||
@@ -904,7 +883,7 @@ class LoadingListServiceTest {
|
||||
|
||||
private static PreloadReadiness readiness(String confirmationStatus, boolean canConfirm,
|
||||
boolean readyForPda, Long taskId) {
|
||||
return new PreloadReadiness(true, true, 2, 2, 90L, "RELEASED", taskId,
|
||||
return new PreloadReadiness(true, 2, 2, 90L, "RELEASED", taskId,
|
||||
taskId == null ? null : "LOADING", confirmationStatus, canConfirm,
|
||||
readyForPda, List.of());
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.AdjustRequest;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.DeleteRequest;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.Plan;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.PushRequest;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.RecommendRequest;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.WarehouseRemarkRequest;
|
||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||
@@ -403,29 +402,6 @@ class SmartLoadingServiceTest {
|
||||
verify(loadingPickService, never()).remove(anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pushPreloadRejectsIncompleteGroupBeforeAnyLifecycleOrPlanWrite() {
|
||||
LoadingPlanEntity plan = plan("EDITING", 0);
|
||||
LoadingPlanItemEntity first = item(8L, 9L, 20L, 4);
|
||||
GroupSnapshot group = group("LOCKED", 1L,
|
||||
new Member(9L, 30L, 10L, "IN-10", 4), new Member(19L, 31L, 11L, "IN-11", 6));
|
||||
when(repository.plan(1L)).thenReturn(plan);
|
||||
when(repository.booking(99L)).thenReturn(booking());
|
||||
when(repository.items(1L)).thenReturn(List.of(first));
|
||||
when(repository.activeItemsByInbound(9L, 10L)).thenReturn(List.of(first));
|
||||
when(coloadGroups.findByInbound(9L, 10L)).thenReturn(group);
|
||||
|
||||
assertThatThrownBy(() -> service.pushPreload(1L, "push-incomplete-group",
|
||||
new PushRequest(0), 7L, "u"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("全部在同一柜");
|
||||
|
||||
verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString());
|
||||
verify(splitPreloadService, never()).ensurePendingForLoadingAllocation(
|
||||
anyLong(), anyLong(), anyLong(), anyString());
|
||||
verify(loadingModelBridge, never()).bindAllocatedBeforeLoading(
|
||||
anyLong(), anyLong(), anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void removesEveryGroupMemberAndUnlocksOnlyAfterAllRowsAreRemoved() {
|
||||
LoadingPlanEntity plan = plan("EDITING", 0);
|
||||
@@ -548,7 +524,7 @@ class SmartLoadingServiceTest {
|
||||
when(repository.planByBooking(99L)).thenReturn(plan);
|
||||
when(repository.items(1L)).thenReturn(List.of());
|
||||
|
||||
Plan result = service.create(99L, "备注", 7L, "u");
|
||||
Plan result = service.createForContainer(99L, 7L, "u");
|
||||
|
||||
assertThat(result.id()).isEqualTo(1L);
|
||||
verify(repository, never()).insert(any(LoadingPlanEntity.class));
|
||||
@@ -565,7 +541,7 @@ class SmartLoadingServiceTest {
|
||||
}).when(repository).insert(any(LoadingPlanEntity.class));
|
||||
when(repository.items(1L)).thenReturn(List.of());
|
||||
|
||||
Plan result = service.create(99L, "备注", 7L, "u");
|
||||
Plan result = service.createForContainer(99L, 7L, "u");
|
||||
|
||||
assertThat(result.planNo()).isEqualTo("LP-260818-0001");
|
||||
}
|
||||
@@ -1076,205 +1052,6 @@ class SmartLoadingServiceTest {
|
||||
verify(repository).remove(1, 8, 0, 7, "u");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pushesIdempotently() {
|
||||
LoadingPlanEntity editing = plan("EDITING", 3);
|
||||
LoadingPlanEntity pushed = plan("EDITING", 4);
|
||||
pushed.setPushIdempotencyKey("key");
|
||||
when(repository.plan(1)).thenReturn(editing, pushed);
|
||||
when(repository.items(1)).thenReturn(List.of(item(8, 9, 20, 1)));
|
||||
when(repository.booking(99)).thenReturn(booking());
|
||||
when(repository.push(1, "key", 3, 7, "u")).thenReturn(1);
|
||||
|
||||
Plan result = service.pushPreload(1, "key", new PushRequest(3), 7, "u");
|
||||
|
||||
assertThat(result.status()).isEqualTo("EDITING");
|
||||
verify(repository).push(1, "key", 3, 7, "u");
|
||||
ArgumentCaptor<LoadingPlanHistoryEntity> history = ArgumentCaptor.forClass(LoadingPlanHistoryEntity.class);
|
||||
verify(repository).insert(history.capture());
|
||||
assertThat(history.getValue().getActionType()).isEqualTo("PUSH_PRELOAD");
|
||||
assertThat(history.getValue().getAfterSnapshot())
|
||||
.contains(sha256("PUSH_PRELOAD|1|3"));
|
||||
var ordered = inOrder(splitPreloadService, loadingModelBridge);
|
||||
ordered.verify(splitPreloadService).ensurePendingForLoadingAllocation(9L, 10L, 7L, "u");
|
||||
ordered.verify(loadingModelBridge).bindAllocatedBeforeLoading(1, 7, "u", "key");
|
||||
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void exactPushReplayIsReadOnly() {
|
||||
LoadingPlanEntity pushed = plan("EDITING", 4);
|
||||
pushed.setPushIdempotencyKey("key");
|
||||
when(repository.plan(1)).thenReturn(pushed);
|
||||
when(repository.booking(99)).thenReturn(booking());
|
||||
when(repository.operation("key"))
|
||||
.thenReturn(history(1, null, "PUSH_PRELOAD", sha256("PUSH_PRELOAD|1|3")));
|
||||
|
||||
Plan replay = service.pushPreload(1, "key", new PushRequest(3), 7, "u");
|
||||
|
||||
assertThat(replay.version()).isEqualTo(4);
|
||||
verify(splitPreloadService, never()).ensurePendingForLoadingAllocation(anyLong(), anyLong(), anyLong(), anyString());
|
||||
verify(loadingModelBridge, never()).bindAllocatedBeforeLoading(anyLong(), anyLong(), anyString(), anyString());
|
||||
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
|
||||
verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class));
|
||||
verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void samePushKeyWithDifferentRequestIsRejectedWithoutWrites() {
|
||||
LoadingPlanEntity pushed = plan("EDITING", 4);
|
||||
pushed.setPushIdempotencyKey("key");
|
||||
when(repository.plan(1)).thenReturn(pushed);
|
||||
when(repository.booking(99)).thenReturn(booking());
|
||||
when(repository.operation("key"))
|
||||
.thenReturn(history(1, null, "PUSH_PRELOAD", sha256("PUSH_PRELOAD|1|3")));
|
||||
|
||||
BizException error = catchThrowableOfType(
|
||||
() -> service.pushPreload(1, "key", new PushRequest(4), 7, "u"), BizException.class);
|
||||
|
||||
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
|
||||
assertThat(error.getMessage()).contains("不同推送请求");
|
||||
verify(repository, never()).items(anyLong());
|
||||
verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString());
|
||||
verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class));
|
||||
verifyNoPushSideEffects();
|
||||
}
|
||||
|
||||
@Test
|
||||
void newPushKeyAfterSuccessfulPushIsRejectedWithoutWrites() {
|
||||
LoadingPlanEntity pushed = plan("EDITING", 4);
|
||||
pushed.setPushIdempotencyKey("first-key");
|
||||
when(repository.plan(1)).thenReturn(pushed);
|
||||
when(repository.booking(99)).thenReturn(booking());
|
||||
|
||||
BizException error = catchThrowableOfType(
|
||||
() -> service.pushPreload(1, "new-key", new PushRequest(3), 7, "u"), BizException.class);
|
||||
|
||||
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
|
||||
assertThat(error.getMessage()).contains("新的幂等键");
|
||||
verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString());
|
||||
verifyNoPushSideEffects();
|
||||
}
|
||||
|
||||
@Test
|
||||
void pushKeyUsedByAnotherPlanOperationIsRejectedBeforeWrites() {
|
||||
LoadingPlanEntity editing = plan("EDITING", 3);
|
||||
when(repository.plan(1)).thenReturn(editing);
|
||||
when(repository.booking(99)).thenReturn(booking());
|
||||
when(repository.operation("used-key"))
|
||||
.thenReturn(history(1, 8L, "REMOVE_ITEM", sha256("DELETE|1|8|3|0")));
|
||||
|
||||
BizException error = catchThrowableOfType(
|
||||
() -> service.pushPreload(1, "used-key", new PushRequest(3), 7, "u"), BizException.class);
|
||||
|
||||
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
|
||||
assertThat(error.getMessage()).contains("不同推送请求");
|
||||
verify(repository, never()).items(anyLong());
|
||||
verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString());
|
||||
verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class));
|
||||
verifyNoPushSideEffects();
|
||||
}
|
||||
|
||||
@Test
|
||||
void bindingFailureStopsBeforePdaAndPropagatesForTransactionRollback() {
|
||||
LoadingPlanEntity editing = plan("EDITING", 3);
|
||||
LoadingPlanEntity pushed = plan("EDITING", 4);
|
||||
pushed.setPushIdempotencyKey("key");
|
||||
when(repository.plan(1)).thenReturn(editing, pushed);
|
||||
when(repository.items(1)).thenReturn(List.of(item(8, 9, 20, 1)));
|
||||
when(repository.booking(99)).thenReturn(booking());
|
||||
when(repository.push(1, "key", 3, 7, "u")).thenReturn(1);
|
||||
doThrow(new BizException(ResultCode.CONFLICT, "预装计划不存在"))
|
||||
.when(loadingModelBridge).bindAllocatedBeforeLoading(1, 7, "u", "key");
|
||||
|
||||
BizException error = catchThrowableOfType(
|
||||
() -> service.pushPreload(1, "key", new PushRequest(3), 7, "u"), BizException.class);
|
||||
|
||||
assertThat(error.getMessage()).contains("预装计划不存在");
|
||||
verify(repository).push(1, "key", 3, 7, "u");
|
||||
verify(repository).insert(any(LoadingPlanHistoryEntity.class));
|
||||
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentPushKeyClaimReturnsConflictBeforeBridgeAndPda() {
|
||||
LoadingPlanEntity editing = plan("EDITING", 3);
|
||||
when(repository.plan(1)).thenReturn(editing);
|
||||
when(repository.items(1)).thenReturn(List.of(item(8, 9, 20, 1)));
|
||||
when(repository.booking(99)).thenReturn(booking());
|
||||
when(repository.push(1, "race-key", 3, 7, "u")).thenReturn(1);
|
||||
doThrow(new DuplicateKeyException("duplicate operation_id"))
|
||||
.when(repository).insert(any(LoadingPlanHistoryEntity.class));
|
||||
|
||||
BizException error = catchThrowableOfType(
|
||||
() -> service.pushPreload(1, "race-key", new PushRequest(3), 7, "u"), BizException.class);
|
||||
|
||||
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
|
||||
assertThat(error.getMessage()).contains("不同推送请求");
|
||||
verify(loadingModelBridge, never()).bindAllocatedBeforeLoading(anyLong(), anyLong(), anyString(), anyString());
|
||||
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsOverweightPlanBeforePush() {
|
||||
LoadingPlanEntity editing = plan("EDITING", 1);
|
||||
ContainerBookingEntity booking = booking();
|
||||
booking.setMaxWeightKg(new BigDecimal("1"));
|
||||
LoadingPlanItemEntity overweight = item(8, 9, 20, 1);
|
||||
overweight.setGrossWeightKg(new BigDecimal("2"));
|
||||
when(repository.plan(1)).thenReturn(editing);
|
||||
when(repository.booking(99)).thenReturn(booking);
|
||||
when(repository.items(1)).thenReturn(List.of(overweight));
|
||||
|
||||
BizException error = catchThrowableOfType(
|
||||
() -> service.pushPreload(1, "weight-key", new PushRequest(1), 7, "u"),
|
||||
BizException.class);
|
||||
|
||||
assertThat(error.getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED);
|
||||
assertThat(error.getMessage()).contains("重量").contains("110%");
|
||||
verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString());
|
||||
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pushesWithoutContainerNumberAndDoesNotOpenPda() {
|
||||
LoadingPlanEntity editing = plan("EDITING", 1);
|
||||
ContainerBookingEntity booking = booking();
|
||||
booking.setContainerNo(null);
|
||||
when(repository.plan(1)).thenReturn(editing, plan("EDITING", 2));
|
||||
when(repository.booking(99)).thenReturn(booking);
|
||||
when(repository.items(1)).thenReturn(List.of(item(8, 9, 20, 1)));
|
||||
when(repository.push(1, "no-container-key", 1, 7, "u")).thenReturn(1);
|
||||
|
||||
service.pushPreload(1, "no-container-key", new PushRequest(1), 7, "u");
|
||||
|
||||
verify(repository).push(1, "no-container-key", 1, 7, "u");
|
||||
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void exactReplayWhenContainerNumberIsStillMissingIsReadOnly() {
|
||||
LoadingPlanEntity pushed = plan("EDITING", 2);
|
||||
pushed.setPushIdempotencyKey("replay-key");
|
||||
ContainerBookingEntity booking = booking();
|
||||
booking.setContainerNo(" ");
|
||||
when(repository.plan(1)).thenReturn(pushed);
|
||||
when(repository.booking(99)).thenReturn(booking);
|
||||
when(repository.operation("replay-key"))
|
||||
.thenReturn(history(1, null, "PUSH_PRELOAD", sha256("PUSH_PRELOAD|1|2")));
|
||||
|
||||
service.pushPreload(1, "replay-key", new PushRequest(2), 7, "u");
|
||||
|
||||
verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString());
|
||||
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
private void verifyNoPushSideEffects() {
|
||||
verify(splitPreloadService, never()).ensurePendingForLoadingAllocation(anyLong(), anyLong(), anyLong(), anyString());
|
||||
verify(loadingModelBridge, never()).bindAllocatedBeforeLoading(anyLong(), anyLong(), anyString(), anyString());
|
||||
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
private void stubAssignment() {
|
||||
LoadingPlanEntity plan = plan("EDITING", 0);
|
||||
when(repository.plan(1)).thenReturn(plan, plan("EDITING", 1));
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
| 排车编辑、PDA 出库、派送与交接 | `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`MerchantDeliveryController.java`、`OutboundPdaController.java` |
|
||||
| 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` |
|
||||
| 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java`、`transfer/service/WarehouseTransferService.java` |
|
||||
| 一件代发接收、SKU/箱唛库存 | 内部接收入口 `dropship/controller/DropshipOrderController.java` 的 `/internal/v1/lastleg/dropship/receive`,必须经 `InternalServiceTokenGuard`;处理 `dropship/service/DropshipOrderService.java`、`dropship/entity/`、`dropship/mapper/` |
|
||||
| 一件代发接收、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`;异常订单建档、关联解析、派送退回候选与标记 |
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Lastleg Internal Dropship API
|
||||
version: 1.0.0
|
||||
description: 国内头程事件落地后向海外 Lastleg 冻结一件代发托盘、SKU 与箱唛快照的内部同步契约。
|
||||
paths:
|
||||
/internal/v1/lastleg/dropship/receive:
|
||||
post:
|
||||
summary: 接收一件代发托盘与 SKU 快照
|
||||
description: 仅服务间调用;必须携带内部服务令牌。相同 customerId、orderId 与 idempotencyKey 重放返回既有结果。
|
||||
security:
|
||||
- internalServiceToken: []
|
||||
parameters:
|
||||
- name: X-Internal-Service-Token
|
||||
in: header
|
||||
required: true
|
||||
schema: { type: string, minLength: 1 }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/DropshipReceiveRequest' }
|
||||
responses:
|
||||
'200':
|
||||
description: 统一 ApiResponse,data 为接收的箱唛数量
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/IntegerApiResponse' }
|
||||
'401': { description: 内部服务令牌缺失或无效 }
|
||||
components:
|
||||
securitySchemes:
|
||||
internalServiceToken:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: X-Internal-Service-Token
|
||||
schemas:
|
||||
DropshipReceiveRequest:
|
||||
type: object
|
||||
required: [customerId, orderId, warehouseId, idempotencyKey, pallets]
|
||||
properties:
|
||||
customerId: { type: string, minLength: 1, description: 客户数据隔离键 }
|
||||
orderId: { type: integer, format: int64, description: Lastleg 尾程订单 ID }
|
||||
warehouseId: { type: integer, format: int64, description: 海外仓 ID 快照 }
|
||||
idempotencyKey: { type: string, minLength: 1, description: 跨区域调用幂等键 }
|
||||
pallets:
|
||||
type: array
|
||||
minItems: 1
|
||||
items: { $ref: '#/components/schemas/DropshipPallet' }
|
||||
DropshipPallet:
|
||||
type: object
|
||||
required: [palletNo, skuPackages]
|
||||
properties:
|
||||
palletNo: { type: string, minLength: 1, description: 来源头程托盘号快照 }
|
||||
skuPackages:
|
||||
type: array
|
||||
minItems: 1
|
||||
items: { $ref: '#/components/schemas/DropshipSkuPackages' }
|
||||
DropshipSkuPackages:
|
||||
type: object
|
||||
required: [skuCode, packageCount, productQuantity, cartonMarks]
|
||||
properties:
|
||||
skuCode: { type: string, minLength: 1 }
|
||||
productName: { type: string, nullable: true }
|
||||
packageCount: { type: integer, format: int64, minimum: 1 }
|
||||
productQuantity: { type: integer, format: int64, minimum: 1 }
|
||||
cartonMarks:
|
||||
type: array
|
||||
minItems: 1
|
||||
items: { type: string, minLength: 1 }
|
||||
IntegerApiResponse:
|
||||
type: object
|
||||
required: [code, message, data, timestamp]
|
||||
properties:
|
||||
code: { type: string, example: '200' }
|
||||
message: { type: string }
|
||||
data: { type: integer, format: int32, minimum: 0 }
|
||||
requestId: { type: string, nullable: true }
|
||||
traceId: { type: string, nullable: true }
|
||||
timestamp: { type: string, format: date-time }
|
||||
+3
-2
@@ -65,8 +65,9 @@ class DeliverySchemaContractTest {
|
||||
String overseas = Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"),
|
||||
StandardCharsets.UTF_8).toLowerCase();
|
||||
assertTrue(overseas.contains("空库先执行 lastleg-service/src/main/resources/db/lastleg.sql 基线"));
|
||||
assertFalse(overseas.contains(createToken));
|
||||
assertFalse(overseas.contains("alter table ll_delivery_order"));
|
||||
assertTrue(overseas.contains(createToken));
|
||||
assertTrue(overseas.indexOf(createToken) < overseas.indexOf("assert_phase5_lastleg_identifiers_migratable"));
|
||||
assertTrue(overseas.contains("alter table ll_delivery_order add column overseas_warehouse_name"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package com.baiyue.lastleg.dropship.controller;
|
||||
|
||||
import com.baiyue.lastleg.dropship.dto.DropshipRequests;
|
||||
import com.baiyue.lastleg.dropship.service.DropshipOrderService;
|
||||
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InOrder;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
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;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class DropshipOrderControllerContractTest {
|
||||
@Test
|
||||
void routeRequiresTheFrozenInternalTokenHeaderBeforeCallingTheService() throws Exception {
|
||||
RequestMapping base = DropshipOrderController.class.getAnnotation(RequestMapping.class);
|
||||
Method receive = DropshipOrderController.class.getMethod("receive", String.class,
|
||||
DropshipRequests.Receive.class);
|
||||
RequestHeader token = receive.getParameters()[0].getAnnotation(RequestHeader.class);
|
||||
|
||||
assertThat(base.value()).containsExactly("/internal/v1/lastleg/dropship");
|
||||
assertThat(receive.getAnnotation(PostMapping.class).value()).containsExactly("/receive");
|
||||
assertThat(token.value()).isEqualTo("X-Internal-Service-Token");
|
||||
|
||||
InternalServiceTokenGuard guard = mock(InternalServiceTokenGuard.class);
|
||||
DropshipOrderService service = mock(DropshipOrderService.class);
|
||||
DropshipOrderController controller = new DropshipOrderController();
|
||||
ReflectionTestUtils.setField(controller, "tokenGuard", guard);
|
||||
ReflectionTestUtils.setField(controller, "service", service);
|
||||
DropshipRequests.Receive request = request();
|
||||
when(service.receive(request)).thenReturn(1);
|
||||
|
||||
assertThat(controller.receive("internal-token", request).data()).isEqualTo(1);
|
||||
InOrder order = inOrder(guard, service);
|
||||
order.verify(guard).verify("internal-token");
|
||||
order.verify(service).receive(request);
|
||||
}
|
||||
|
||||
@Test
|
||||
void versionedOpenApiMatchesTheControllerAndDtoContract() throws Exception {
|
||||
String yaml = Files.readString(
|
||||
Path.of("src/main/resources/openapi/lastleg-internal-dropship-v1.yaml"),
|
||||
StandardCharsets.UTF_8);
|
||||
|
||||
assertThat(yaml).contains(
|
||||
"version: 1.0.0",
|
||||
"/internal/v1/lastleg/dropship/receive:",
|
||||
"name: X-Internal-Service-Token",
|
||||
"required: [customerId, orderId, warehouseId, idempotencyKey, pallets]",
|
||||
"required: [palletNo, skuPackages]",
|
||||
"required: [skuCode, packageCount, productQuantity, cartonMarks]",
|
||||
"data: { type: integer, format: int32, minimum: 0 }");
|
||||
assertThat(yaml.indexOf("name: X-Internal-Service-Token"))
|
||||
.isLessThan(yaml.indexOf("requestBody:"));
|
||||
}
|
||||
|
||||
private static DropshipRequests.Receive request() {
|
||||
return new DropshipRequests.Receive("100", 10L, 20L, "event-1",
|
||||
List.of(new DropshipRequests.Pallet("PALLET-1",
|
||||
List.of(new DropshipRequests.SkuPackages("SKU-1", "Product", 1, 2,
|
||||
List.of("CARTON-1"))))));
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.baiyue.lastleg.schema;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class Phase5ExceptionVisitSchemaContractTest {
|
||||
@Test
|
||||
void baselineAndOfficialOverseasEntryDefineExceptionAndVisitPersistence() throws Exception {
|
||||
String baseline = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8);
|
||||
String overseas = Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"),
|
||||
StandardCharsets.UTF_8);
|
||||
|
||||
for (String sql : new String[] { baseline, overseas }) {
|
||||
assertThat(sql).contains(
|
||||
"CREATE TABLE IF NOT EXISTS ll_exception_order",
|
||||
"UNIQUE KEY uk_ll_exception_no(customer_id,exception_no)",
|
||||
"UNIQUE KEY uk_ll_exception_delivery_return(customer_id,delivery_order_id,exception_type)",
|
||||
"KEY idx_ll_exception_inbound(customer_id,inbound_code)",
|
||||
"CREATE TABLE IF NOT EXISTS ll_customer_visit",
|
||||
"UNIQUE KEY uk_ll_customer_visit_delivery(customer_id,delivery_order_id)",
|
||||
"KEY idx_ll_customer_visit_list(customer_id,status,delivery_completed_at)",
|
||||
"COMMENT='海外仓异常订单'",
|
||||
"COMMENT='派送单维度客户回访记录'");
|
||||
}
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package com.baiyue.lastleg.schema;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class Phase5OverseasUpgradeSqlContractTest {
|
||||
private static final List<String> PHASE5_TABLES = List.of(
|
||||
"ll_dispatch_pda_task", "ll_dispatch_pda_pick", "ll_dispatch_pda_operation",
|
||||
"ll_dispatch_pda_pick_order", "ll_outbound_order", "ll_outbound_pallet_item",
|
||||
"ll_outbound_pda_task", "ll_outbound_pda_pick", "ll_delivery_order", "ll_delivery_fee",
|
||||
"ll_delivery_operation", "ll_dropship_order_profile", "ll_dropship_carton",
|
||||
"ll_dropship_sku_inventory", "ll_dropship_carton_ledger", "ll_exception_order",
|
||||
"ll_customer_visit");
|
||||
|
||||
@Test
|
||||
void overseasFixedEntryCreatesEveryPhase5TableBeforeAnyPhase5AlterOrAssertion() throws Exception {
|
||||
String overseas = repositoryFile("deploy/sql/海外区域统一增量-MySQL8.sql");
|
||||
int phase5Gate = overseas.indexOf("-- 五期排车、出库与海外PDA正式业务号/幂等约束");
|
||||
assertThat(phase5Gate).isPositive();
|
||||
|
||||
for (String table : PHASE5_TABLES) {
|
||||
String create = "CREATE TABLE IF NOT EXISTS " + table + " (";
|
||||
assertThat(overseas.indexOf(create)).as(table).isBetween(0, phase5Gate - 1);
|
||||
assertThat(count(overseas, create)).as(table).isEqualTo(1);
|
||||
assertThat(overseas.indexOf(table)).as(table + " first reference")
|
||||
.isEqualTo(overseas.indexOf(create) + "CREATE TABLE IF NOT EXISTS ".length());
|
||||
}
|
||||
assertThat(overseas).contains("<> 22", "五期完整业务表集合未部署");
|
||||
}
|
||||
|
||||
@Test
|
||||
void overseasFixedEntryConvergesKnownIntermediatePhase5ShapesWithoutBackfill() throws Exception {
|
||||
String overseas = repositoryFile("deploy/sql/海外区域统一增量-MySQL8.sql");
|
||||
|
||||
assertThat(overseas).contains(
|
||||
"ALTER TABLE ll_outbound_order RENAME COLUMN tenant_id TO customer_id",
|
||||
"ALTER TABLE ll_outbound_pallet_item RENAME COLUMN tenant_id TO customer_id",
|
||||
"ALTER TABLE ll_delivery_order RENAME COLUMN tenant_id TO customer_id",
|
||||
"ALTER TABLE ll_outbound_order ADD COLUMN outbound_type",
|
||||
"ALTER TABLE ll_outbound_pallet_item ADD COLUMN picked_package_count",
|
||||
"ALTER TABLE ll_delivery_order ADD COLUMN overseas_warehouse_name",
|
||||
"ALTER TABLE ll_delivery_order ADD COLUMN waybill_no",
|
||||
"ALTER TABLE ll_dropship_order_profile ADD COLUMN source_pallet_no",
|
||||
"ADD UNIQUE KEY uk_ll_dropship_sku_inventory(profile_id,sku_code)",
|
||||
"MODIFY COLUMN source_pallet_no VARCHAR(128) NOT NULL");
|
||||
int start = overseas.indexOf("-- 五期正式结构必须由海外固定入口自行补齐");
|
||||
int end = overseas.indexOf("USE baiyue_lastleg;", start);
|
||||
String phase5 = overseas.substring(start, end);
|
||||
assertThat(phase5).doesNotContain("\nUPDATE ", "\nDELETE ", "\nINSERT ");
|
||||
}
|
||||
|
||||
@Test
|
||||
void overseasFixedEntryUsesTheCurrentBaselineDefinitions() throws Exception {
|
||||
String baseline = repositoryFile("lastleg-service/src/main/resources/db/lastleg.sql");
|
||||
String overseas = repositoryFile("deploy/sql/海外区域统一增量-MySQL8.sql");
|
||||
|
||||
for (String table : PHASE5_TABLES) {
|
||||
assertThat(normalize(createStatement(overseas, table)))
|
||||
.as(table)
|
||||
.isEqualTo(normalize(createStatement(baseline, table)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void domesticFixedEntryKeepsIamPermissionsButOwnsNoLastlegBusinessSchema() throws Exception {
|
||||
String domestic = repositoryFile("deploy/sql/头程数据库统一增量-MySQL8.sql");
|
||||
|
||||
assertThat(domestic).contains(
|
||||
"platform:overseas-warehouse:dispatch:list",
|
||||
"platform:overseas-warehouse:exception-order:list",
|
||||
"platform:overseas-warehouse:customer-visit:list");
|
||||
assertThat(domestic).doesNotContain(
|
||||
"USE baiyue_lastleg",
|
||||
"CREATE DATABASE IF NOT EXISTS baiyue_lastleg",
|
||||
"CREATE TABLE IF NOT EXISTS ll_",
|
||||
"ALTER TABLE ll_");
|
||||
}
|
||||
|
||||
private static String createStatement(String sql, String table) {
|
||||
String token = "CREATE TABLE IF NOT EXISTS " + table + " (";
|
||||
int start = sql.indexOf(token);
|
||||
int end = sql.indexOf(';', start);
|
||||
assertThat(start).as(table).isGreaterThanOrEqualTo(0);
|
||||
assertThat(end).as(table).isGreaterThan(start);
|
||||
return sql.substring(start, end + 1);
|
||||
}
|
||||
|
||||
private static String normalize(String sql) {
|
||||
return sql.toLowerCase(Locale.ROOT).replaceAll("\\s+", "");
|
||||
}
|
||||
|
||||
private static int count(String source, String token) {
|
||||
return source.split(java.util.regex.Pattern.quote(token), -1).length - 1;
|
||||
}
|
||||
|
||||
private static String repositoryFile(String relative) throws Exception {
|
||||
Path path = Path.of(relative);
|
||||
if (!Files.exists(path)) path = Path.of("..").resolve(relative);
|
||||
return Files.readString(path, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
@@ -187,7 +187,8 @@ Assert-Contains $truckOpenApi 'Each booking may have at most one non-terminal ap
|
||||
Assert-Contains $truckOpenApi 'PDA loading completion does not change a booking-level truck appointment' '拖车与 PDA 生命周期解耦'
|
||||
Assert-Contains $truckOpenApi 'excluding bookings that already have a non-terminal truck appointment' '拖车创建候选过滤'
|
||||
Assert-Contains $loadingListsOpenApi '真实集装箱号可在PDA确认装柜完成前补录' '预装真实柜号晚录门禁'
|
||||
Assert-Contains $loadingListsOpenApi '已推送后可在 EDITING 或 LOADING 阶段首次补录真实集装箱号' '预装真实柜号首次补录阶段'
|
||||
Assert-Contains $loadingListsOpenApi '可在 EDITING 或 LOADING 阶段首次补录真实集装箱号' '预装真实柜号首次补录阶段'
|
||||
Assert-Contains $adminAgents '创建柜次时自动生成唯一预装计划' 'Admin 自动预装计划流程'
|
||||
Assert-Contains $loadingListsOpenApi '不依赖真实集装箱号,task 已创建后仍为 true' 'PDA task 不依赖真实柜号'
|
||||
Assert-Contains $adminAgents 'PDA 最终完成前必须补齐' 'Admin 真实柜号最终门禁'
|
||||
Assert-Contains $pdaAgents '当前 `taskId + warehouseId` 走任务专用 API' '国内 PDA 柜级证据入口'
|
||||
|
||||
Reference in New Issue
Block a user