fix: complete phase5 overseas deployment contract

This commit is contained in:
2026-09-01 20:20:25 +08:00
45 changed files with 810 additions and 638 deletions
+1 -1
View File
@@ -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` |
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
@@ -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();
}
}
@@ -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:
@@ -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")
@@ -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
View File
@@ -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 的当前唯一版本 |
@@ -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(
@@ -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;
@@ -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) {}
}
@@ -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:
@@ -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"));
@@ -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);
@@ -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");
}
@@ -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));
+1 -1
View File
@@ -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 }
@@ -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
@@ -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"))))));
}
}
@@ -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='派送单维度客户回访记录'");
}
}
}
@@ -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);
}
}
+2 -1
View File
@@ -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 柜级证据入口'