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`;价格簿查询和客户统计按端口/适配器切片,不改变服务与数据库边界 | | 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`,不得等部署启动发现 | | 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` | | 脚本管理、全量验证、测试造数与诊断 | 仓库根 `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/` | `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/` | `controller/PlatformSmartLoadingController.java` 的 `/inbound-candidates`、`/plans/{planId}/inbound-assignments`,业务编排 `service/SmartLoadingService.java` |
| 订舱资料/打印、船期、订舱级拖车、报关 | `firstleg/` | `oceanbooking/`、`portschedule/`、`TruckAppointmentService.java`、`outbound/dispatch/`、`customs/`;拖车只绑定 `RELEASED` 订舱,不携带客户、柜次、装柜计划或真实柜号,每订舱最多一张非终态预约,订舱文件联查 `file/AGENTS.md` | | 订舱资料/打印、船期、订舱级拖车、报关 | `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`。 - 五期数据库部署仍只执行 `海外区域统一增量-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`;应重新造数,重置或清理仍需用户另行授权。 - 固定入口只补目标字段并做 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)` 完成命令唯一约束;旧客户复合索引不保留。 - 数据门禁通过后,脚本原位把 `dispatch_no`、`outbound_no`、排车 PDA `task_no`、出库 PDA `task_no` 收敛为全局唯一,并建立 `(customer_id, completion_idempotency_key)` 完成命令唯一约束;旧客户复合索引不保留。
- 必须在启动或重启 Lastleg Service 前完成固定入口;不得单独执行模块基线补丁、日期脚本或手工兼容 SQL。 - 必须在启动或重启 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', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID',
plan_status VARCHAR(24) NOT NULL DEFAULT 'EDITING' plan_status VARCHAR(24) NOT NULL DEFAULT 'EDITING'
COMMENT '装柜计划状态:EDITING编辑中、LOADING装柜中、COMPLETED装柜完成;建议须经人工确认', 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 BIGINT UNSIGNED NULL COMMENT '预装完成操作人ID',
preload_completed_by_name VARCHAR(64) NULL COMMENT '预装完成操作人名称快照', preload_completed_by_name VARCHAR(64) NULL COMMENT '预装完成操作人名称快照',
preload_completed_at DATETIME(3) NULL COMMENT '预装完成时间', preload_completed_at DATETIME(3) NULL COMMENT '预装完成时间',
@@ -5040,7 +5036,6 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan (
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_loading_plan_no (plan_no), UNIQUE KEY uk_loading_plan_no (plan_no),
UNIQUE KEY uk_loading_plan_booking (container_booking_id), 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), KEY idx_loading_plan_status (plan_status, updated_at, id),
CONSTRAINT fk_loading_plan_booking FOREIGN KEY (container_booking_id) CONSTRAINT fk_loading_plan_booking FOREIGN KEY (container_booking_id)
REFERENCES fl_container_booking (id), REFERENCES fl_container_booking (id),
@@ -5652,10 +5647,6 @@ BEGIN
IF OLD.plan_status = 'LOADING' AND ( IF OLD.plan_status = 'LOADING' AND (
NOT (OLD.id <=> NEW.id) OR NOT (OLD.plan_no <=> NEW.plan_no) 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.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.remark <=> NEW.remark)
OR NOT (OLD.created_by <=> NEW.created_by) OR NOT (OLD.created_by <=> NEW.created_by)
OR NOT (OLD.created_by_name <=> NEW.created_by_name) OR NOT (OLD.created_by_name <=> NEW.created_by_name)
@@ -5675,12 +5666,7 @@ BEGIN
) THEN ) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '同状态乐观锁触碰不得修改阶段审计字段'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '同状态乐观锁触碰不得修改阶段审计字段';
END IF; END IF;
IF OLD.plan_status = 'EDITING' AND NEW.plan_status = 'LOADING' THEN IF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' 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 EXISTS (SELECT 1 FROM fl_loading_plan_item item IF EXISTS (SELECT 1 FROM fl_loading_plan_item item
WHERE item.loading_plan_id = OLD.id AND item.deleted = 0 WHERE item.loading_plan_id = OLD.id AND item.deleted = 0
AND NOT EXISTS ( AND NOT EXISTS (
@@ -6061,6 +6047,13 @@ DELIMITER ;
CALL assert_smart_loading_permission_ids_available(); CALL assert_smart_loading_permission_ids_available();
DROP PROCEDURE 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 INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code, (id, tenant_id, app_code, permission_type, parent_id, permission_code,
permission_name, route_path, http_method, sort_no, status, built_in, deleted) permission_name, route_path, http_method, sort_no, status, built_in, deleted)
@@ -6075,14 +6068,10 @@ VALUES
'编辑货柜', NULL, 'PUT', 30, 'ACTIVE', 1, 0), '编辑货柜', NULL, 'PUT', 30, 'ACTIVE', 1, 0),
(5102094, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:smart-loading:list', (5102094, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:smart-loading:list',
'智能装柜', '/first-leg/smart-loading', 'GET', 50, 'ACTIVE', 1, 0), '智能装柜', '/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', (5102097, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:assign',
'分配装柜明细', NULL, 'POST', 30, 'ACTIVE', 1, 0), '分配装柜明细', NULL, 'POST', 30, 'ACTIVE', 1, 0),
(5102098, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:delete', (5102098, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:delete',
'删除装柜明细', NULL, 'DELETE', 40, 'ACTIVE', 1, 0), '删除装柜明细', 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', (5102100, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:preload:list',
'预装清单', '/first-leg/preload-list', 'GET', 60, 'ACTIVE', 1, 0), '预装清单', '/first-leg/preload-list', 'GET', 60, 'ACTIVE', 1, 0),
(5102102, 0, 'PLATFORM', 'BUTTON', 5102100, 'platform:firstleg:preload:view', (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:list', 'platform:firstleg:container:view',
'platform:firstleg:container:create', 'platform:firstleg:container:create',
'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list', 'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list',
'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete',
'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:push',
'platform:firstleg:preload:list', 'platform:firstleg:preload:view', 'platform:firstleg:preload:list', 'platform:firstleg:preload:view',
'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete', 'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete',
'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list', 'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list',
@@ -6237,12 +6225,11 @@ BEGIN
AND permission_code IN ( AND permission_code IN (
'platform:firstleg:container:list', 'platform:firstleg:container:view', 'platform:firstleg:container:list', 'platform:firstleg:container:view',
'platform:firstleg:container:create', 'platform:firstleg:container:edit', '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:assign', 'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:delete', 'platform:firstleg:preload:list',
'platform:firstleg:smart-loading:push', 'platform:firstleg:preload:list',
'platform:firstleg:preload:view', 'platform:firstleg:preload:edit', 'platform:firstleg:preload:view', 'platform:firstleg:preload:edit',
'platform:firstleg:preload:complete', 'platform:firstleg:preload:confirm', '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 = '智能装柜迁移失败:平台权限资源未完整创建'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台权限资源未完整创建';
END IF; END IF;
IF (SELECT COUNT(DISTINCT CONCAT(role.role_code, ':', permission.permission_code)) 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:list', 'platform:firstleg:container:view',
'platform:firstleg:container:create', 'platform:firstleg:container:create',
'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list', 'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list',
'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete',
'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:push',
'platform:firstleg:preload:list', 'platform:firstleg:preload:view', 'platform:firstleg:preload:list', 'platform:firstleg:preload:view',
'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete', 'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete',
'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list', '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 = '智能装柜迁移失败:平台角色授权未完整写入'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台角色授权未完整写入';
END IF; END IF;
END$$ END$$
@@ -7858,10 +7844,8 @@ VALUES
(5102092, 0, 'PLATFORM', 'BUTTON', 5102090, 'platform:firstleg:container:create', '建立货柜', NULL, 'POST', 20, 'ACTIVE', 1, 0), (5102092, 0, 'PLATFORM', 'BUTTON', 5102090, 'platform:firstleg:container:create', '建立货柜', NULL, 'POST', 20, 'ACTIVE', 1, 0),
(5102093, 0, 'PLATFORM', 'BUTTON', 5102090, 'platform:firstleg:container:edit', '编辑货柜', NULL, 'PUT', 30, 'ACTIVE', 1, 0), (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), (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), (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), (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)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), permission_type = VALUES(permission_type), parent_id = VALUES(parent_id),
permission_name = VALUES(permission_name), route_path = VALUES(route_path), 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 ( AND permission.permission_code IN (
'platform:firstleg:container:view', 'platform:firstleg:container:create', 'platform:firstleg:container:view', 'platform:firstleg:container:create',
'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list', 'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list',
'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete'
'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:push'
) )
WHERE role.tenant_id = 0 AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR'); 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_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', '预装完成操作人ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan', 'preload_completed_by_name', '预装完成操作人名称快照'); 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', 'remark', '平台装柜计划备注');
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan', 'updated_at', '装柜计划最后更新时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan', 'updated_at', '装柜计划最后更新时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_loading_plan', 'updated_by', '最后修改人ID'); 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 = 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 = 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 = 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 = 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 = 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' SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:智能装柜权限 ID 或父子关系不正确'; SET MESSAGE_TEXT = '统一迁移失败:智能装柜权限 ID 或父子关系不正确';
END IF; END IF;
@@ -16799,124 +16777,6 @@ DROP PROCEDURE assert_phase5_delivery_permissions;
DROP TEMPORARY TABLE tmp_phase5_delivery_role_permission; 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。 -- IAM 客户提交即成功,默认尺寸通过持久化任务后台初始化;已有客户按历史事实标记 READY。
USE baiyue_iam; USE baiyue_iam;
SET @customer_dimension_status_ddl = (SELECT IF( 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) PRIMARY KEY(id), KEY idx_ll_order_operation_customer_order(customer_id,order_id,created_at)
) ENGINE=InnoDB COMMENT='尾程订单操作记录'; ) 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 ( 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', 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 '任务状态', task_no VARCHAR(64) NOT NULL COMMENT 'PDA任务号', status VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '任务状态:待拣货、拣货中、已完成',
completion_idempotency_key VARCHAR(255) NULL COMMENT '完成命令幂等键', completion_idempotency_key VARCHAR(255) NULL COMMENT '完成命令幂等键', pushed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '推送时间',
pushed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '推送时间', completed_at DATETIME(3) NULL COMMENT '完成时间', completed_at DATETIME(3) NULL COMMENT '完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) 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 '更新时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY(id),
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_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) UNIQUE KEY uk_ll_outbound_pda_complete_key(customer_id,completion_idempotency_key)
) ENGINE=InnoDB COMMENT='一件代发出库PDA拣货任务'; ) ENGINE=InnoDB COMMENT='一件代发出库PDA拣货任务';
CREATE TABLE IF NOT EXISTS ll_outbound_pda_pick ( 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', 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 '拣货箱数', outbound_pallet_item_id BIGINT NOT NULL COMMENT '出库托盘明细ID', inventory_position_id BIGINT NOT NULL COMMENT '库存仓位ID',
idempotency_key VARCHAR(255) NOT NULL COMMENT '拣货幂等键', picked_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '拣货时间', packages BIGINT NOT NULL COMMENT '拣货箱数', idempotency_key VARCHAR(255) NOT NULL COMMENT '拣货幂等键',
PRIMARY KEY(id), UNIQUE KEY uk_ll_outbound_pda_pick_key(customer_id,idempotency_key) 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托盘拣货记录'; ) 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 校验开发数据,不回填或修复旧行。 -- 五期排车、出库与海外PDA正式业务号/幂等约束。只补结构并 fail-fast 校验开发数据,不回填或修复旧行。
SET @ddl = (SELECT IF(EXISTS ( SET @ddl = (SELECT IF(EXISTS (
SELECT 1 FROM information_schema.columns SELECT 1 FROM information_schema.columns
@@ -1132,8 +1353,13 @@ CREATE PROCEDURE assert_phase5_lastleg_identifiers_migratable()
BEGIN BEGIN
IF (SELECT COUNT(*) FROM information_schema.tables IF (SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema='baiyue_lastleg' AND table_name IN ( 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 'll_dispatch_order','ll_dispatch_item','ll_dispatch_pallet_lock','ll_dispatch_draft','ll_dispatch_draft_container',
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期排车、出库或PDA任务表未完整部署'; '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; END IF;
IF EXISTS (SELECT dispatch_no FROM ll_dispatch_order GROUP BY dispatch_no HAVING COUNT(*)>1) THEN 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,无法收敛全局唯一'; 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 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仓库范围;请重新造数后重跑,脚本不回填旧行'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='ll_dispatch_pda_task存在空或0仓库范围;请重新造数后重跑,脚本不回填旧行';
END IF; 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$$ END$$
DELIMITER ; DELIMITER ;
CALL assert_phase5_lastleg_identifiers_migratable(); 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''')); '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; 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 SET @index_ready = (SELECT COUNT(*)=1 AND MIN(non_unique)=0
AND MAX(seq_in_index=1 AND column_name='dispatch_no')=1 AND MAX(seq_in_index=1 AND column_name='dispatch_no')=1
FROM information_schema.statistics WHERE table_schema=DATABASE() FROM information_schema.statistics WHERE table_schema=DATABASE()
@@ -1229,6 +1467,16 @@ DROP PROCEDURE IF EXISTS assert_phase5_lastleg_identifiers_ready;
DELIMITER $$ DELIMITER $$
CREATE PROCEDURE assert_phase5_lastleg_identifiers_ready() CREATE PROCEDURE assert_phase5_lastleg_identifiers_ready()
BEGIN 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' 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 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未收敛为非空'; 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 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仓库范围'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='ll_dispatch_pda_task仍存在空或0仓库范围';
END IF; 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' 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 table_name='ll_outbound_pda_task' AND column_name='completion_idempotency_key'
AND column_type='varchar(255)' AND is_nullable='YES') <> 1 THEN 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( public ApiResponse<QualificationPackageService.PackageUploadResult> uploadRequiredPackage(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long tenantId, @PathVariable long tenantId,
@RequestPart("businessLicense") MultipartFile businessLicense, @RequestPart(value = "businessLicense", required = false) MultipartFile businessLicense,
@RequestPart(value = "legalPersonId", required = false) MultipartFile legalPersonId, @RequestPart(value = "legalPersonId", required = false) MultipartFile legalPersonId,
@RequestParam String unifiedSocialCreditCode, @RequestParam String unifiedSocialCreditCode,
@RequestParam(required = false) LocalDate businessLicenseValidTo, @RequestParam(required = false) LocalDate businessLicenseValidTo,
@@ -78,7 +78,7 @@ public class QualificationFileController {
@PreAuthorize("hasAuthority('merchant:qualification:upload')") @PreAuthorize("hasAuthority('merchant:qualification:upload')")
public ApiResponse<QualificationPackageService.PackageUploadResult> uploadRequiredPackage( public ApiResponse<QualificationPackageService.PackageUploadResult> uploadRequiredPackage(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@RequestPart("businessLicense") MultipartFile businessLicense, @RequestPart(value = "businessLicense", required = false) MultipartFile businessLicense,
@RequestPart(value = "legalPersonId", required = false) MultipartFile legalPersonId, @RequestPart(value = "legalPersonId", required = false) MultipartFile legalPersonId,
@RequestParam String unifiedSocialCreditCode, @RequestParam String unifiedSocialCreditCode,
@RequestParam(required = false) LocalDate businessLicenseValidTo, @RequestParam(required = false) LocalDate businessLicenseValidTo,
@@ -8,6 +8,23 @@ info:
x-lifecycle-owner: ORDER x-lifecycle-owner: ORDER
x-file-object-semantics: IMMUTABLE_CREATE_ONLY x-file-object-semantics: IMMUTABLE_CREATE_ONLY
paths: 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}: /internal/file/v1/business-files/{tenantId}/{businessType}/{batchNo}:
post: post:
summary: Order上传批量导入源文件或错误报告 summary: Order上传批量导入源文件或错误报告
@@ -62,6 +79,7 @@ paths:
schema: {type: string, format: binary} schema: {type: string, format: binary}
components: components:
securitySchemes: securitySchemes:
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
internalServiceToken: {type: apiKey, in: header, name: X-Internal-Service-Token} internalServiceToken: {type: apiKey, in: header, name: X-Internal-Service-Token}
parameters: parameters:
OperatorId: OperatorId:
@@ -27,6 +27,8 @@ class QualificationAndOrderFileOpenApiContractTest {
assertThat(yaml) assertThat(yaml)
.contains("ORDER_BATCH_IMPORT_SOURCE", "ORDER_BATCH_IMPORT_ERROR_REPORT") .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("ownerScope: {type: string, enum: [CUSTOMER]}")
.contains("pattern: '^OBI-[0-9A-F]{20}$'") .contains("pattern: '^OBI-[0-9A-F]{20}$'")
.contains("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") .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 @Test
void requiredPackageKeepsBusinessLicenseRequiredAndMakesLegalPersonIdOptional() { void requiredPackageLetsServiceReturnControlledValidationForMissingParts() {
assertRequiredPackageParts(QualificationFileController.class); assertRequiredPackageParts(QualificationFileController.class);
assertRequiredPackageParts(PlatformQualificationFileController.class); assertRequiredPackageParts(PlatformQualificationFileController.class);
} }
@@ -316,7 +316,7 @@ class QualificationFileContractTest {
.map(parameter -> parameter.getAnnotation(RequestPart.class)) .map(parameter -> parameter.getAnnotation(RequestPart.class))
.filter(java.util.Objects::nonNull) .filter(java.util.Objects::nonNull)
.collect(java.util.stream.Collectors.toMap(RequestPart::value, RequestPart::required)); .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")); assertEquals(Boolean.FALSE, parts.get("legalPersonId"));
} }
+1 -1
View File
@@ -29,7 +29,7 @@
| 财务费用来源快照 | `controller/InternalFinanceFeeSourceController.java`;入库费用与柜次费用分别按来源 ID、版本向 Finance 提供不可变快照 | | 财务费用来源快照 | `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` | | 柜次费用明细 | `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` | | 财务整柜成本分摊快照 | `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` 可用入仓片段与满箱数;仅要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均保持整组原子 | | 同柜不可拆组 | `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` 进入,明细规格拆分留在服务端 | | 货柜与智能排柜 | `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 的当前唯一版本 | | 货柜资料、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)); return ApiResponse.success(service.lifecycle(planId, warehouseId));
} }
@Operation(summary = "修改装柜运输字段", description = "已推送后可在装柜完成前首次补录集装箱号;已有柜号的纠正仅限 EDITING 且 PDA 尚未领取或产生物理作业事实,并必须提供原因和版本;柜号不能清空。") @Operation(summary = "修改装柜运输字段", description = "可在装柜完成前首次补录集装箱号;已有柜号的纠正仅限 EDITING 且 PDA 尚未领取或产生物理作业事实,并必须提供原因和版本;柜号不能清空。")
@PatchMapping("/loading-plans/{planId}/transport") @PatchMapping("/loading-plans/{planId}/transport")
@PreAuthorize("hasAuthority('platform:firstleg:preload:edit')") @PreAuthorize("hasAuthority('platform:firstleg:preload:edit')")
public ApiResponse<PreloadDetail> updateTransport( public ApiResponse<PreloadDetail> updateTransport(
@@ -86,7 +86,7 @@ public class PlatformLoadingListController {
planId, warehouseId, user.userId(), user.username(), idempotencyKey, request)); 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") @PostMapping("/loading-plans/{planId}/confirm")
@PreAuthorize("hasAuthority('platform:firstleg:preload:edit')") @PreAuthorize("hasAuthority('platform:firstleg:preload:edit')")
public ApiResponse<PreloadConfirmationResponse> confirmPreload( 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));} 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')") @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));} 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')") @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()));} 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')") @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()));} 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')") @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()));} 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( public record PreloadReadiness(
boolean preloadPushed,
boolean containerNoReady, boolean containerNoReady,
int preloadLinkCount, int preloadLinkCount,
int confirmedPreloadCount, int confirmedPreloadCount,
@@ -15,7 +15,6 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg, BigDecimal unitVolumeCbm, BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg, BigDecimal unitVolumeCbm,
Long coLoadGroupId, String groupNo, Integer memberCount, Integer groupTotalBoxes, Long coLoadGroupId, String groupNo, Integer memberCount, Integer groupTotalBoxes,
Boolean locked, String blockReason) {} 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, 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) {} @NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedVersion,@NotBlank @Size(max=128) String operationId) {}
public record AssignInboundRequest(@NotNull @Positive Long inboundId, 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, public record WarehouseRemarkRequest(@Size(max=1000) String warehouseRemark,
@NotNull @Min(0) Integer expectedPlanVersion, @NotNull @Min(0) Integer expectedItemVersion, @NotNull @Min(0) Integer expectedPlanVersion, @NotNull @Min(0) Integer expectedItemVersion,
@NotBlank @Size(max=128) String operationId) {} @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, 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, Integer version,Boolean canAssign,Boolean canEdit,String blockedReason,String finalCompletionStatus,
Long pdaTaskId,String pdaTaskStatus,Integer pdaTaskVersion,List<LoadingPlanItemEntity> items) {} 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") @Getter @Setter @TableName("fl_loading_plan")
public class LoadingPlanEntity { public class LoadingPlanEntity {
@TableId(type=IdType.ASSIGN_ID) private Long id; private String planNo; private Long containerBookingId; private String planStatus; @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 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 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; 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); PreloadSnapshot preload(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
@Select("SELECT p.id,p.container_booking_id containerBookingId,b.container_no containerNo," @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}") + "ON b.id=p.container_booking_id WHERE p.id=#{loadingPlanId}")
LoadingPlanSnapshot loadingPlan(@Param("loadingPlanId") long 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 PreloadSnapshot(Long id, Long tenantId, Long inboundId, String status, Integer version) {}
record LoadingPlanSnapshot(Long id, Long containerBookingId, String containerNo, record LoadingPlanSnapshot(Long id, Long containerBookingId, String containerNo,
String status, Integer version, java.time.LocalDateTime preloadPushedAt) { String status, Integer version) {}
public LoadingPlanSnapshot(Long id, Long containerBookingId, String containerNo,
String status, Integer version) {
this(id, containerBookingId, containerNo, status, version, null);
}
}
record AllocationScope(Long tenantId, Long inboundId, Integer allocatedPackageCount) {} record AllocationScope(Long tenantId, Long inboundId, Integer allocatedPackageCount) {}
} }
@@ -109,14 +109,13 @@ public class LoadingModelBridgeService {
return repository.byLoadingPlan(loadingPlanId); 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 @Transactional
public void bindIncrementalAllocation(long loadingPlanId, long tenantId, long inboundId, public void bindIncrementalAllocation(long loadingPlanId, long tenantId, long inboundId,
long operatorId, String operatorName, String operationId) { long operatorId, String operatorName, String operationId) {
if (repository.byPair(tenantId, inboundId, loadingPlanId) != null) return; if (repository.byPair(tenantId, inboundId, loadingPlanId) != null) return;
var plan = repository.loadingPlan(loadingPlanId); var plan = repository.loadingPlan(loadingPlanId);
if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次装柜计划不存在"); if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次装柜计划不存在");
if (plan.preloadPushedAt() == null) return;
if (!Set.of("EDITING", "LOADING").contains(plan.status())) { if (!Set.of("EDITING", "LOADING").contains(plan.status())) {
throw conflict("PDA任务正在最终确认或已经完成,不能新增预装关联"); throw conflict("PDA任务正在最终确认或已经完成,不能新增预装关联");
} }
@@ -168,8 +167,8 @@ public class LoadingModelBridgeService {
public List<Link> requireAllocatedLinksForConfirmation(long loadingPlanId) { public List<Link> requireAllocatedLinksForConfirmation(long loadingPlanId) {
var loadingPlan = repository.loadingPlan(loadingPlanId); var loadingPlan = repository.loadingPlan(loadingPlanId);
if (loadingPlan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次装柜计划不存在"); if (loadingPlan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次装柜计划不存在");
if (loadingPlan.preloadPushedAt() == null || !"EDITING".equals(loadingPlan.status())) { if (!"EDITING".equals(loadingPlan.status())) {
throw conflict("仅已推送且仍为EDITING的预装计划可以确认"); throw conflict("仅编辑中的预装计划可以确认");
} }
List<AllocationScope> scopes = repository.allocatedScopes(loadingPlanId); List<AllocationScope> scopes = repository.allocatedScopes(loadingPlanId);
if (scopes.isEmpty()) throw conflict("装柜计划尚未分配入库单货物"); if (scopes.isEmpty()) throw conflict("装柜计划尚未分配入库单货物");
@@ -21,7 +21,7 @@ import java.util.List;
public interface LoadingListQueryMapper { public interface LoadingListQueryMapper {
String AGGREGATE_COLUMNS = """ String AGGREGATE_COLUMNS = """
p.id AS plan_id, p.plan_no, p.container_booking_id, p.plan_status, p.remark, 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.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name,
b.vessel_name, b.voyage_no, b.container_type, b.vessel_name, b.voyage_no, b.container_type,
(SELECT COUNT(*) FROM fl_preload_loading_plan_link preload_link (SELECT COUNT(*) FROM fl_preload_loading_plan_link preload_link
@@ -49,7 +49,7 @@ public interface LoadingListQueryMapper {
String AGGREGATE_GROUP = """ String AGGREGATE_GROUP = """
p.id, p.plan_no, p.container_booking_id, p.plan_status, p.remark, 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.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name,
b.vessel_name, b.voyage_no, b.container_type b.vessel_name, b.voyage_no, b.container_type
"""; """;
@@ -61,10 +61,7 @@ public interface LoadingListQueryMapper {
FROM fl_loading_plan p FROM fl_loading_plan p
JOIN fl_container_booking b ON b.id = p.container_booking_id 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 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 WHERE p.plan_status IN ('EDITING', 'LOADING')
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)
<if test="query.status != null and query.status != ''">AND p.plan_status = #{query.status}</if> <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.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 != ''"> <if test="query.keyword != null and query.keyword != ''">
@@ -227,7 +224,7 @@ public interface LoadingListQueryMapper {
UPDATE fl_loading_plan UPDATE fl_loading_plan
SET updated_by = #{operatorId}, updated_by_name = #{operatorName}, SET updated_by = #{operatorId}, updated_by_name = #{operatorName},
updated_at = CURRENT_TIMESTAMP(3), version = version + 1 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 version = #{expectedVersion}
AND NOT EXISTS (SELECT 1 FROM fl_loading_pick_task task AND NOT EXISTS (SELECT 1 FROM fl_loading_pick_task task
JOIN fl_loading_pick_assignment assignment ON assignment.pick_task_id=task.id 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}, SET b.container_no = #{containerNo}, b.voyage_no = #{voyageNo},
b.updated_by = #{operatorId}, b.updated_by_name = #{operatorName}, b.updated_by = #{operatorId}, b.updated_by_name = #{operatorName},
b.updated_at = CURRENT_TIMESTAMP(3), b.version = b.version + 1 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 AND NOT EXISTS (SELECT 1 FROM fl_loading_pick_task task
JOIN fl_loading_pick_assignment assignment ON assignment.pick_task_id=task.id JOIN fl_loading_pick_assignment assignment ON assignment.pick_task_id=task.id
WHERE task.loading_plan_id=p.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}, SET p.updated_by=#{operatorId}, p.updated_by_name=#{operatorName},
p.updated_at=CURRENT_TIMESTAMP(3), p.version=p.version+1 p.updated_at=CURRENT_TIMESTAMP(3), p.version=p.version+1
WHERE p.id=#{planId} AND p.plan_status IN ('EDITING','LOADING') 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 AND b.container_no IS NULL
""") """)
int touchForInitialContainerFill(@Param("planId") long planId, int touchForInitialContainerFill(@Param("planId") long planId,
@@ -286,7 +283,7 @@ public interface LoadingListQueryMapper {
b.updated_by=#{operatorId}, b.updated_by_name=#{operatorName}, b.updated_by=#{operatorId}, b.updated_by_name=#{operatorName},
b.updated_at=CURRENT_TIMESTAMP(3), b.version=b.version+1 b.updated_at=CURRENT_TIMESTAMP(3), b.version=b.version+1
WHERE p.id=#{planId} AND p.plan_status IN ('EDITING','LOADING') 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 AND #{containerNo} IS NOT NULL
""") """)
int fillInitialContainerNo(@Param("planId") long planId, int fillInitialContainerNo(@Param("planId") long planId,
@@ -331,7 +328,7 @@ public interface LoadingListQueryMapper {
UPDATE fl_loading_plan UPDATE fl_loading_plan
SET updated_by = #{operatorId}, updated_by_name = #{operatorName}, SET updated_by = #{operatorId}, updated_by_name = #{operatorName},
updated_at = CURRENT_TIMESTAMP(3), version = version + 1 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 version = #{expectedVersion}
""") """)
int touchForPreloadConfirmation(@Param("planId") long planId, int touchForPreloadConfirmation(@Param("planId") long planId,
@@ -385,7 +382,6 @@ public interface LoadingListQueryMapper {
private String createdByName; private String createdByName;
private LocalDateTime createdAt; private LocalDateTime createdAt;
private Integer version; private Integer version;
private LocalDateTime preloadPushedAt;
private String containerNo; private String containerNo;
private String cabinetNo; private String cabinetNo;
private Long warehouseId; 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} 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); 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("""
UPDATE fl_loading_plan SET plan_status='LOADING',updated_by=#{operatorId},updated_by_name=#{operatorName}, UPDATE fl_loading_plan SET plan_status='LOADING',updated_by=#{operatorId},updated_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3),version=version+1 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); 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) { private PreloadReadiness readiness(AggregateRow row) {
boolean pushed = row.getPreloadPushedAt() != null;
boolean containerReady = org.springframework.util.StringUtils.hasText(row.getContainerNo()); boolean containerReady = org.springframework.util.StringUtils.hasText(row.getContainerNo());
int linkCount = Math.toIntExact(value(row.getPreloadLinkCount())); int linkCount = Math.toIntExact(value(row.getPreloadLinkCount()));
int confirmedCount = Math.toIntExact(value(row.getConfirmedPreloadCount())); int confirmedCount = Math.toIntExact(value(row.getConfirmedPreloadCount()));
@@ -157,19 +156,18 @@ public class LoadingListRepository {
boolean editing = "EDITING".equals(row.getPlanStatus()); boolean editing = "EDITING".equals(row.getPlanStatus());
boolean taskCreated = row.getPdaTaskId() != null; boolean taskCreated = row.getPdaTaskId() != null;
java.util.ArrayList<String> blockers = new java.util.ArrayList<>(); 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 (!editing) blockers.add("LOADING_PLAN_NOT_EDITING");
if (linkCount == 0) blockers.add("PRELOAD_LINK_MISSING"); if (linkCount == 0) blockers.add("PRELOAD_LINK_MISSING");
if (!oceanLinked) blockers.add("OCEAN_BOOKING_NOT_LINKED"); if (!oceanLinked) blockers.add("OCEAN_BOOKING_NOT_LINKED");
else if (!released) blockers.add("OCEAN_BOOKING_NOT_RELEASED"); else if (!released) blockers.add("OCEAN_BOOKING_NOT_RELEASED");
if (taskCreated) blockers.add("PDA_TASK_ALREADY_CREATED"); if (taskCreated) blockers.add("PDA_TASK_ALREADY_CREATED");
boolean allConfirmed = linkCount > 0 && confirmedCount == linkCount; boolean allConfirmed = linkCount > 0 && confirmedCount == linkCount;
boolean prerequisitesReady = pushed && editing && linkCount > 0 && released; boolean prerequisitesReady = editing && linkCount > 0 && released;
boolean canConfirm = prerequisitesReady && !taskCreated && !allConfirmed; boolean canConfirm = prerequisitesReady && !taskCreated && !allConfirmed;
if (allConfirmed) blockers.add("PRELOAD_ALREADY_CONFIRMED"); if (allConfirmed) blockers.add("PRELOAD_ALREADY_CONFIRMED");
String confirmationStatus = allConfirmed ? "CONFIRMED" String confirmationStatus = allConfirmed ? "CONFIRMED"
: confirmedCount > 0 ? "PARTIALLY_CONFIRMED" : "PENDING"; : 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.getOceanBookingId(), row.getOceanBookingStatus(), row.getPdaTaskId(),
row.getPdaTaskStatus(), confirmationStatus, canConfirm, row.getPdaTaskStatus(), confirmationStatus, canConfirm,
prerequisitesReady && allConfirmed, blockers); prerequisitesReady && allConfirmed, blockers);
@@ -99,9 +99,6 @@ public class SmartLoadingRepository {
return items.updateWarehouseRemark(planId, itemId, touchedPlanVersion, expectedItemVersion, return items.updateWarehouseRemark(planId, itemId, touchedPlanVersion, expectedItemVersion,
remark, operatorId, operatorName); 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) { public int startPicking(long planId, long operatorId, String operatorName) {
return plans.startPicking(planId, operatorId, operatorName); return plans.startPicking(planId, operatorId, operatorName);
} }
@@ -38,6 +38,7 @@ public class ContainerService {
@Resource private OceanBookingRepository oceanBookings; @Resource private OceanBookingRepository oceanBookings;
@Resource private WarehouseSupplierSnapshotService suppliers; @Resource private WarehouseSupplierSnapshotService suppliers;
@Resource private FirstlegDocumentNumberGenerator documentNumbers; @Resource private FirstlegDocumentNumberGenerator documentNumbers;
@Resource private SmartLoadingService smartLoadingService;
@Value("${baiyue.security.internal-service-token:}") private String internalToken; @Value("${baiyue.security.internal-service-token:}") private String internalToken;
@Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId; @Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId;
public PageResult<ContainerBookingEntity> page(Query query) { public PageResult<ContainerBookingEntity> page(Query query) {
@@ -63,7 +64,9 @@ public class ContainerService {
value.setContainerType(specification.containerType()); value.setCapacityCbm(specification.capacityCbm()); value.setContainerType(specification.containerType()); value.setCapacityCbm(specification.capacityCbm());
value.setMaxWeightKg(specification.maxWeightKg()); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setMaxWeightKg(specification.maxWeightKg()); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); repository.insert(value); 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 @Transactional
@@ -593,9 +593,8 @@ public class LoadingListService {
throw conflict("仅装柜完成前允许首次补录真实集装箱号"); throw conflict("仅装柜完成前允许首次补录真实集装箱号");
} }
if (!initialContainerFill && !"EDITING".equals(row.getPlanStatus())) { if (!initialContainerFill && !"EDITING".equals(row.getPlanStatus())) {
throw conflict("仅已推送且PDA尚未开始的预装计划可纠错真实集装箱号"); throw conflict("仅PDA尚未开始的编辑中预装计划可纠错真实集装箱号");
} }
if (row.getPreloadPushedAt() == null) throw conflict("仅已推送的预装清单可维护运输字段");
if (!initialContainerFill && repository.hasPdaWorkStarted(row.getPlanId())) { if (!initialContainerFill && repository.hasPdaWorkStarted(row.getPlanId())) {
throw conflict("PDA任务已领取、已开始作业或已有物理作业事实,不能修改真实集装箱号"); throw conflict("PDA任务已领取、已开始作业或已有物理作业事实,不能修改真实集装箱号");
} }
@@ -117,7 +117,7 @@ public class LoadingPickService {
var booking = smartLoadingRepository.booking(containerBookingId); var booking = smartLoadingRepository.booking(containerBookingId);
if (booking == null) return; if (booking == null) return;
var plan = smartLoadingRepository.planByBooking(containerBookingId); 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()); var links = loadingModelBridge.byLoadingPlan(plan.getId());
if (links.isEmpty() || links.stream().anyMatch(link -> !"CONFIRMED".equals(link.preloadStatus()))) return; if (links.isEmpty() || links.stream().anyMatch(link -> !"CONFIRMED".equals(link.preloadStatus()))) return;
var allocation = oceanBookingRepository.allocationByContainer(containerBookingId); var allocation = oceanBookingRepository.allocationByContainer(containerBookingId);
@@ -34,7 +34,7 @@ import java.sql.SQLException;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.*; import java.util.*;
/** 智能装柜的候选、推荐/人工分配、删除和不可逆推送编排。 */ /** 智能装柜的自动计划、候选、推荐、人工分配和删除编排。 */
@Service @Service
public class SmartLoadingService { public class SmartLoadingService {
private static final BigDecimal CAPACITY_LIMIT_RATIO = new BigDecimal("1.10"); private static final BigDecimal CAPACITY_LIMIT_RATIO = new BigDecimal("1.10");
@@ -129,15 +129,13 @@ public class SmartLoadingService {
} }
@Transactional @Transactional
@BusinessAudit(event = "SMART_LOADING_PLAN_CREATE", resourceType = "LOADING_PLAN", public Plan createForContainer(long bookingId, long operatorId, String operatorName) {
description = "创建智能装柜计划", captureBefore = false)
public Plan create(long bookingId, String remark, long operatorId, String operatorName) {
ContainerBookingEntity booking = requireBooking(bookingId); ContainerBookingEntity booking = requireBooking(bookingId);
LoadingPlanEntity existing = repository.planByBooking(bookingId); LoadingPlanEntity existing = repository.planByBooking(bookingId);
if (existing != null) return draftView(existing, booking); if (existing != null) return draftView(existing, booking);
LoadingPlanEntity plan = new LoadingPlanEntity(); LoadingPlanEntity plan = new LoadingPlanEntity();
plan.setPlanNo(documentNumbers.next("LP")); 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.setCreatedBy(operatorId); plan.setCreatedByName(operatorName); plan.setUpdatedBy(operatorId);
plan.setUpdatedByName(operatorName); plan.setVersion(0); plan.setUpdatedByName(operatorName); plan.setVersion(0);
try { try {
@@ -542,69 +540,6 @@ public class SmartLoadingService {
return view(repository.plan(planId), requireBooking(plan.getContainerBookingId())); 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, private List<Candidate> enrichColoadCandidates(ContainerBookingEntity booking, LoadingPlanEntity plan,
List<Candidate> values) { List<Candidate> values) {
Map<String, GroupSnapshot> snapshots = new HashMap<>(); 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 '平台唯一装柜计划号', plan_no VARCHAR(40) NOT NULL COMMENT '平台唯一装柜计划号',
container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID',
plan_status VARCHAR(24) NOT NULL DEFAULT 'EDITING' COMMENT '装柜计划状态:EDITING编辑中、LOADING装柜中、COMPLETED装柜完成;建议须经人工确认', 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 BIGINT UNSIGNED NULL COMMENT '预装完成操作人ID',
preload_completed_by_name VARCHAR(64) NULL COMMENT '预装完成操作人名称快照', preload_completed_by_name VARCHAR(64) NULL COMMENT '预装完成操作人名称快照',
preload_completed_at DATETIME(3) NULL COMMENT '预装完成时间', preload_completed_at DATETIME(3) NULL COMMENT '预装完成时间',
@@ -244,7 +240,6 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan (
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_loading_plan_no (plan_no), UNIQUE KEY uk_loading_plan_no (plan_no),
UNIQUE KEY uk_loading_plan_booking (container_booking_id), 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), KEY idx_loading_plan_status (plan_status, updated_at, id),
CONSTRAINT fk_loading_plan_booking FOREIGN KEY (container_booking_id) CONSTRAINT fk_loading_plan_booking FOREIGN KEY (container_booking_id)
REFERENCES fl_container_booking (id), REFERENCES fl_container_booking (id),
@@ -893,10 +888,6 @@ BEGIN
IF OLD.plan_status = 'LOADING' AND ( IF OLD.plan_status = 'LOADING' AND (
NOT (OLD.id <=> NEW.id) OR NOT (OLD.plan_no <=> NEW.plan_no) 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.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.remark <=> NEW.remark)
OR NOT (OLD.created_by <=> NEW.created_by) OR NOT (OLD.created_by <=> NEW.created_by)
OR NOT (OLD.created_by_name <=> NEW.created_by_name) OR NOT (OLD.created_by_name <=> NEW.created_by_name)
@@ -916,12 +907,7 @@ BEGIN
) THEN ) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '同状态乐观锁触碰不得修改阶段审计字段'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '同状态乐观锁触碰不得修改阶段审计字段';
END IF; END IF;
IF OLD.plan_status = 'EDITING' AND NEW.plan_status = 'LOADING' THEN IF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' 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 EXISTS (SELECT 1 FROM fl_loading_plan_item item IF EXISTS (SELECT 1 FROM fl_loading_plan_item item
WHERE item.loading_plan_id = OLD.id AND item.deleted = 0 WHERE item.loading_plan_id = OLD.id AND item.deleted = 0
AND NOT EXISTS ( AND NOT EXISTS (
@@ -1384,6 +1370,13 @@ DELIMITER ;
CALL relocate_legacy_inbound_loading_permissions(); CALL relocate_legacy_inbound_loading_permissions();
DROP PROCEDURE 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 INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code, (id, tenant_id, app_code, permission_type, parent_id, permission_code,
permission_name, route_path, http_method, sort_no, status, built_in, deleted) permission_name, route_path, http_method, sort_no, status, built_in, deleted)
@@ -1398,14 +1391,10 @@ VALUES
'编辑货柜', NULL, 'PUT', 30, 'ACTIVE', 1, 0), '编辑货柜', NULL, 'PUT', 30, 'ACTIVE', 1, 0),
(5102094, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:smart-loading:list', (5102094, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:smart-loading:list',
'智能装柜', '/first-leg/smart-loading', 'GET', 50, 'ACTIVE', 1, 0), '智能装柜', '/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', (5102097, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:assign',
'分配装柜明细', NULL, 'POST', 30, 'ACTIVE', 1, 0), '分配装柜明细', NULL, 'POST', 30, 'ACTIVE', 1, 0),
(5102098, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:delete', (5102098, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:delete',
'删除装柜明细', NULL, 'DELETE', 40, 'ACTIVE', 1, 0), '删除装柜明细', 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', (5102100, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:preload:list',
'预装清单', '/first-leg/preload-list', 'GET', 60, 'ACTIVE', 1, 0), '预装清单', '/first-leg/preload-list', 'GET', 60, 'ACTIVE', 1, 0),
(5102102, 0, 'PLATFORM', 'BUTTON', 5102100, 'platform:firstleg:preload:view', (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:list', 'platform:firstleg:container:view',
'platform:firstleg:container:create', 'platform:firstleg:container:create',
'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list', 'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list',
'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete',
'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:push',
'platform:firstleg:preload:list', 'platform:firstleg:preload:view', 'platform:firstleg:preload:list', 'platform:firstleg:preload:view',
'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete', 'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete',
'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list', 'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list',
@@ -1566,12 +1554,11 @@ BEGIN
AND permission_code IN ( AND permission_code IN (
'platform:firstleg:container:list', 'platform:firstleg:container:view', 'platform:firstleg:container:list', 'platform:firstleg:container:view',
'platform:firstleg:container:create', 'platform:firstleg:container:edit', '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:assign', 'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:delete', 'platform:firstleg:preload:list',
'platform:firstleg:smart-loading:push', 'platform:firstleg:preload:list',
'platform:firstleg:preload:view', 'platform:firstleg:preload:edit', 'platform:firstleg:preload:view', 'platform:firstleg:preload:edit',
'platform:firstleg:preload:complete', 'platform:firstleg:preload:confirm', '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 = '智能装柜迁移失败:平台权限资源未完整创建'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台权限资源未完整创建';
END IF; END IF;
IF (SELECT COUNT(DISTINCT CONCAT(role.role_code, ':', permission.permission_code)) 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:list', 'platform:firstleg:container:view',
'platform:firstleg:container:create', 'platform:firstleg:container:create',
'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list', 'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list',
'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete',
'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:push',
'platform:firstleg:preload:list', 'platform:firstleg:preload:view', 'platform:firstleg:preload:list', 'platform:firstleg:preload:view',
'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete', 'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete',
'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list', '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 = '智能装柜迁移失败:平台角色授权未完整写入'; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台角色授权未完整写入';
END IF; END IF;
END$$ END$$
@@ -523,26 +523,6 @@ paths:
content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingWarehouseRemarkRequest'}}} content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingWarehouseRemarkRequest'}}}
responses: responses:
'200': {description: 成功或业务失败,data 为最新 SmartLoadingPlan} '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: /openapi/v1/inbound-measurements:
post: post:
summary: 提交一条第三方箱规测量结果和一张图片 summary: 提交一条第三方箱规测量结果和一张图片
@@ -1651,11 +1631,6 @@ components:
properties: properties:
version: {type: integer, minimum: 0} version: {type: integer, minimum: 0}
data: {$ref: '#/components/schemas/ContainerSaveRequest'} data: {$ref: '#/components/schemas/ContainerSaveRequest'}
SmartLoadingPushRequest:
type: object
required: [expectedVersion]
properties:
expectedVersion: {type: integer, minimum: 0}
SmartLoadingAssignRequest: SmartLoadingAssignRequest:
type: object type: object
required: [inboundId, inboundDetailId, packageCount, expectedVersion, operationId] required: [inboundId, inboundDetailId, packageCount, expectedVersion, operationId]
@@ -49,7 +49,7 @@ paths:
patch: patch:
summary: 修改预装运输信息 summary: 修改预装运输信息
description: >- description: >-
已推送后可在 EDITING 或 LOADING 阶段首次补录真实集装箱号;已有柜号的纠正仅限仍为 EDITING、PDA任务尚未领取且无任何物理作业事实时,并必须提供原因和当前版本; 可在 EDITING 或 LOADING 阶段首次补录真实集装箱号;已有柜号的纠正仅限仍为 EDITING、PDA任务尚未领取且无任何物理作业事实时,并必须提供原因和当前版本;
containerNo 为 null 表示保持当前值,非空时服务端移除格式空白、转大写并校验ISO 6346结构、设备类别和校验位,空白字符串非法且禁止清空。 containerNo 为 null 表示保持当前值,非空时服务端移除格式空白、转大写并校验ISO 6346结构、设备类别和校验位,空白字符串非法且禁止清空。
x-permission: platform:firstleg:preload:edit x-permission: platform:firstleg:preload:edit
parameters: parameters:
@@ -301,9 +301,8 @@ components:
readiness: {$ref: '#/components/schemas/PreloadReadiness'} readiness: {$ref: '#/components/schemas/PreloadReadiness'}
PreloadReadiness: PreloadReadiness:
type: object type: object
required: [preloadPushed, containerNoReady, preloadLinkCount, confirmedPreloadCount, confirmationStatus, canConfirm, readyForPda, blockingReasons] required: [containerNoReady, preloadLinkCount, confirmedPreloadCount, confirmationStatus, canConfirm, readyForPda, blockingReasons]
properties: properties:
preloadPushed: {type: boolean, description: 是否已完成 push-preload}
containerNoReady: {type: boolean, description: 是否已录入真实集装箱号 containerNo} containerNoReady: {type: boolean, description: 是否已录入真实集装箱号 containerNo}
preloadLinkCount: {type: integer, format: int32, minimum: 0, description: 计划关联的入库预装票据数} preloadLinkCount: {type: integer, format: int32, minimum: 0, description: 计划关联的入库预装票据数}
confirmedPreloadCount: {type: integer, format: int32, minimum: 0, description: 已确认预装票据数} confirmedPreloadCount: {type: integer, format: int32, minimum: 0, description: 已确认预装票据数}
@@ -317,7 +316,7 @@ components:
blockingReasons: blockingReasons:
type: array type: array
description: 阻止计划确认的稳定原因码 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: ConfirmPreloadRequest:
type: object type: object
required: [expectedVersion, operationReason] required: [expectedVersion, operationReason]
@@ -2,7 +2,7 @@ openapi: 3.0.3
info: info:
title: Phase 3 Preload and Container Loading Bridge API title: Phase 3 Preload and Container Loading Bridge API
version: 1.0.0 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: paths:
/api/platform/v1/inbounds/{inboundId}/preload/loading-plans: /api/platform/v1/inbounds/{inboundId}/preload/loading-plans:
get: get:
@@ -32,17 +32,11 @@ class PlatformSmartLoadingControllerContractTest {
authority("adjust","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,long.class,AdjustRequest.class); 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("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("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")); 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:") assertThat(yaml).doesNotContain("/push-preload:")
.contains("不要求真实柜号") .doesNotContain("SmartLoadingPushRequest:");
.contains("不创建 PDA 作业任务")
.contains("已关联 RELEASED 订舱时才创建待领取 PDA 任务")
.contains("领取后才进入 LOADING")
.doesNotContain("已关联 CONFIRMED/RELEASED 订舱")
.doesNotContain("真实柜号必须已录入");
} }
@Test void openApiFreezesIncrementalMutationAndCapabilityContract() throws Exception { @Test void openApiFreezesIncrementalMutationAndCapabilityContract() throws Exception {
String yaml=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/openapi/firstleg-v1.yaml")); 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 @Test
void confirmationGateRejectsIncompleteAllocatedLinks() { void confirmationGateRejectsIncompleteAllocatedLinks() {
when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot( 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( when(repository.allocatedScopes(40)).thenReturn(List.of(
new AllocationScope(10L, 20L, 8), new AllocationScope(11L, 21L, 4))); new AllocationScope(10L, 20L, 8), new AllocationScope(11L, 21L, 4)));
when(repository.byLoadingPlan(40)).thenReturn(List.of( when(repository.byLoadingPlan(40)).thenReturn(List.of(
@@ -193,7 +193,7 @@ class LoadingModelBridgeServiceTest {
@Test @Test
void bindsConfirmingPreloadToRunningPlanAndConfirmsIt() { void bindsConfirmingPreloadToRunningPlanAndConfirmsIt() {
when(repository.byPair(10, 20, 40)).thenReturn(null); 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.allocatedPackages(10, 20, 40)).thenReturn(2);
when(repository.preload(10, 20)).thenReturn(new PreloadSnapshot(30L, 10L, 20L, "CONFIRMING", 4)); when(repository.preload(10, 20)).thenReturn(new PreloadSnapshot(30L, 10L, 20L, "CONFIRMING", 4));
when(repository.confirmIncrementalPreload(30L, 10L, 20L, 4, 7L, "admin")).thenReturn(1); when(repository.confirmIncrementalPreload(30L, 10L, 20L, 4, 7L, "admin")).thenReturn(1);
@@ -34,9 +34,9 @@ class LoadingListQueryMapperContractTest {
.contains("FROM fl_loading_plan p") .contains("FROM fl_loading_plan p")
.contains("JOIN fl_container_booking b") .contains("JOIN fl_container_booking b")
.contains("LEFT JOIN fl_loading_plan_item i") .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("p.plan_status IN ('EDITING', 'LOADING')")
.contains("active_item.deleted = 0") .doesNotContain("active_item.deleted = 0")
.contains("fl_preload_loading_plan_link") .contains("fl_preload_loading_plan_link")
.contains("fl_ocean_booking_container_allocation") .contains("fl_ocean_booking_container_allocation")
.contains("fl_loading_pick_task") .contains("fl_loading_pick_task")
@@ -88,14 +88,14 @@ class LoadingListQueryMapperContractTest {
assertThat(touch) assertThat(touch)
.contains("plan_status = 'EDITING'") .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("fl_loading_pick_assignment", "item.item_status<>'PENDING'")
.contains("version = #{expectedVersion}") .contains("version = #{expectedVersion}")
.contains("version = version + 1"); .contains("version = version + 1");
assertThat(transport) assertThat(transport)
.contains("SET b.container_no = #{containerNo}, b.voyage_no = #{voyageNo}") .contains("SET b.container_no = #{containerNo}, b.voyage_no = #{voyageNo}")
.contains("p.plan_status='EDITING'") .contains("p.plan_status='EDITING'")
.contains("preload_pushed_at IS NOT NULL") .doesNotContain("preload_pushed_at")
.doesNotContain("#{containerNo} IS NOT NULL") .doesNotContain("#{containerNo} IS NOT NULL")
.contains("fl_loading_pick_task", "fl_loading_pick_assignment") .contains("fl_loading_pick_task", "fl_loading_pick_assignment")
.doesNotContain("DELETE") .doesNotContain("DELETE")
@@ -122,7 +122,7 @@ class LoadingListQueryMapperContractTest {
.contains("version = version + 1"); .contains("version = version + 1");
assertThat(confirmPlan) assertThat(confirmPlan)
.contains("plan_status = 'EDITING'") .contains("plan_status = 'EDITING'")
.contains("preload_pushed_at IS NOT NULL") .doesNotContain("preload_pushed_at")
.contains("version = #{expectedVersion}") .contains("version = #{expectedVersion}")
.contains("version = version + 1"); .contains("version = version + 1");
} }
@@ -20,9 +20,9 @@ class SmartLoadingSqlContractTest {
private static final Set<String> PHASE3_PERMISSIONS = Set.of( private static final Set<String> PHASE3_PERMISSIONS = Set.of(
"platform:firstleg:container:list", "platform:firstleg:container:view", "platform:firstleg:container:list", "platform:firstleg:container:view",
"platform:firstleg:container:create", "platform:firstleg:container:edit", "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: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:view", "platform:firstleg:preload:edit",
"platform:firstleg:preload:complete", "platform:firstleg:preload:confirm", "platform:firstleg:preload:complete", "platform:firstleg:preload:confirm",
"platform:firstleg:loading-list:list", "platform:firstleg:loading-list:view"); "platform:firstleg:loading-list:list", "platform:firstleg:loading-list:view");
@@ -60,7 +60,7 @@ class SmartLoadingSqlContractTest {
} }
@Test @Test
@DisplayName("活动明细唯一锁、乐观锁和双层幂等字段完整") @DisplayName("活动明细唯一锁、乐观锁和操作幂等字段完整")
void shouldProvideConcurrencyAndIdempotencyBoundaries() throws Exception { void shouldProvideConcurrencyAndIdempotencyBoundaries() throws Exception {
assertContainerLifecycleSchema(migration()); assertContainerLifecycleSchema(migration());
assertContainerLifecycleSchema(unifiedMigration()); assertContainerLifecycleSchema(unifiedMigration());
@@ -71,7 +71,6 @@ class SmartLoadingSqlContractTest {
.contains("KEY idx_loading_item_active_detail (account_id, active_inbound_detail_id)") .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_no (booking_no)")
.contains("UNIQUE KEY uk_container_booking_cabinet (cabinet_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("UNIQUE KEY uk_loading_history_operation (operation_id)")
.contains("version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'"); .contains("version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'");
} }
@@ -156,7 +155,7 @@ class SmartLoadingSqlContractTest {
void shouldProvidePlatformPermissions() throws Exception { void shouldProvidePlatformPermissions() throws Exception {
assertThat(migration()) assertThat(migration())
.contains("platform:firstleg:container:view") .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:preload:confirm")
.contains("platform:firstleg:loading-list:view") .contains("platform:firstleg:loading-list:view")
.contains("role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')") .contains("role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')")
@@ -165,7 +164,7 @@ class SmartLoadingSqlContractTest {
} }
@Test @Test
@DisplayName("模块脚本和统一脚本均校验16项权限与两个角色32项授权") @DisplayName("模块脚本和统一脚本均校验14项权限与两个角色28项授权")
void shouldAssertCompletePermissionGrants() throws Exception { void shouldAssertCompletePermissionGrants() throws Exception {
assertPermissionContract(migration()); assertPermissionContract(migration());
assertPermissionContract(unifiedMigration()); assertPermissionContract(unifiedMigration());
@@ -192,8 +191,6 @@ class SmartLoadingSqlContractTest {
.doesNotContain("旧版本在推送预装时即写入 LOADING") .doesNotContain("旧版本在推送预装时即写入 LOADING")
.doesNotContain("修复旧版本“推送预装即拣货中”的状态污染") .doesNotContain("修复旧版本“推送预装即拣货中”的状态污染")
.contains("SET MESSAGE_TEXT = '装柜状态只允许单向推进'") .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("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("SET frozen_at = COALESCE(frozen_at, NEW.confirmed_at, CURRENT_TIMESTAMP(3))")
.contains("已有PDA拣货记录的装柜明细仅允许收敛部分拣货实际箱数") .contains("已有PDA拣货记录的装柜明细仅允许收敛部分拣货实际箱数")
@@ -345,7 +342,7 @@ class SmartLoadingSqlContractTest {
int assertionStart = sql.lastIndexOf("IF (SELECT COUNT(DISTINCT CONCAT", message); int assertionStart = sql.lastIndexOf("IF (SELECT COUNT(DISTINCT CONCAT", message);
String assertion = sql.substring(assertionStart, message); String assertion = sql.substring(assertionStart, message);
assertThat(permissions(assertion)).isEqualTo(PHASE3_PERMISSIONS); assertThat(permissions(assertion)).isEqualTo(PHASE3_PERMISSIONS);
assertThat(assertion).contains("<> 32 THEN"); assertThat(assertion).contains("<> 28 THEN");
} }
private static Set<String> permissions(String sql) { private static Set<String> permissions(String sql) {
@@ -45,12 +45,17 @@ class ContainerServiceTest {
@MockBean private OceanBookingRepository oceanBookings; @MockBean private OceanBookingRepository oceanBookings;
@MockBean private WarehouseSupplierSnapshotService suppliers; @MockBean private WarehouseSupplierSnapshotService suppliers;
@MockBean private FirstlegDocumentNumberGenerator documentNumbers; @MockBean private FirstlegDocumentNumberGenerator documentNumbers;
@MockBean private SmartLoadingService smartLoadingService;
@Resource private ContainerService service; @Resource private ContainerService service;
@BeforeEach @BeforeEach
void setUpWarehouse() { void setUpWarehouse() {
when(documentNumbers.next("CB")).thenReturn("CB-260818-0001"); when(documentNumbers.next("CB")).thenReturn("CB-260818-0001");
when(documentNumbers.next("CAB")).thenReturn("CAB-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 -> when(warehouses.active(anyString(), anyLong(), anyLong())).thenAnswer(invocation ->
activeWarehouse(invocation.getArgument(2, Long.class), "DOMESTIC", "上海中转仓")); activeWarehouse(invocation.getArgument(2, Long.class), "DOMESTIC", "上海中转仓"));
when(suppliers.trucking(202L, "拖车公司A")) when(suppliers.trucking(202L, "拖车公司A"))
@@ -77,7 +82,10 @@ class ContainerServiceTest {
assertThat(saved.getWarehouseId()).isEqualTo(101L); assertThat(saved.getWarehouseId()).isEqualTo(101L);
assertThat(saved.getWarehouseName()).isEqualTo("上海中转仓"); assertThat(saved.getWarehouseName()).isEqualTo("上海中转仓");
assertThat(saved.getCapacityCbm()).isEqualByComparingTo("76.0000"); 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 @Test
@@ -85,11 +85,9 @@ class LoadingListServiceTest {
@Test @Test
void shouldOnlyUpdateTransportFieldsWithOptimisticLockAndHistory() { void shouldOnlyUpdateTransportFieldsWithOptimisticLockAndHistory() {
AggregateRow before = row("EDITING", 3); AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
before.setContainerNo("MSCU6639870"); before.setContainerNo("MSCU6639870");
before.setVoyageNo("001E"); before.setVoyageNo("001E");
AggregateRow after = row("EDITING", 4); AggregateRow after = row("EDITING", 4);
after.setPreloadPushedAt(before.getPreloadPushedAt());
after.setContainerNo("CSQU3054383"); after.setContainerNo("CSQU3054383");
after.setVoyageNo("023E"); after.setVoyageNo("023E");
when(repository.history(anyString())).thenReturn(null); when(repository.history(anyString())).thenReturn(null);
@@ -112,7 +110,6 @@ class LoadingListServiceTest {
@Test @Test
void shouldNotWritePlanOrHistoryWhenTransportFieldsAreUnchanged() { void shouldNotWritePlanOrHistoryWhenTransportFieldsAreUnchanged() {
AggregateRow before = row("EDITING", 3); AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
before.setContainerNo("MSCU6639870"); before.setContainerNo("MSCU6639870");
before.setVoyageNo("023E"); before.setVoyageNo("023E");
when(repository.aggregate(10L)).thenReturn(before); when(repository.aggregate(10L)).thenReturn(before);
@@ -132,10 +129,8 @@ class LoadingListServiceTest {
@Test @Test
void pushedEditingPreloadCanCorrectExistingContainerNumberBeforePdaClaim() { void pushedEditingPreloadCanCorrectExistingContainerNumberBeforePdaClaim() {
AggregateRow before = row("EDITING", 3); AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
before.setContainerNo("MSCU6639870"); before.setContainerNo("MSCU6639870");
AggregateRow after = row("EDITING", 4); AggregateRow after = row("EDITING", 4);
after.setPreloadPushedAt(before.getPreloadPushedAt());
after.setContainerNo("CSQU3054383"); after.setContainerNo("CSQU3054383");
when(repository.aggregate(10L)).thenReturn(before, after); when(repository.aggregate(10L)).thenReturn(before, after);
when(repository.updateTransport(10L, 3, "CSQU3054383", null, 9L, "operator")).thenReturn(1); when(repository.updateTransport(10L, 3, "CSQU3054383", null, 9L, "operator")).thenReturn(1);
@@ -149,18 +144,18 @@ class LoadingListServiceTest {
} }
@Test @Test
void editingPlanCannotReceiveTransportBeforePush() { void editingPlanCanCorrectTransportWithoutPushStep() {
AggregateRow before = row("EDITING", 3); AggregateRow before = row("EDITING", 3);
before.setContainerNo("MSCU6639870"); 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", service.updateTransport(10L, 100L, 9L, "operator", "editing-key",
new TransportUpdateRequest("CSQU3054383", null, 3, "纠错柜号"))) new TransportUpdateRequest("CSQU3054383", null, 3, "纠错柜号"));
.isInstanceOf(BizException.class).hasMessageContaining("已推送");
verify(repository, never()).updateTransport(eq(10L), eq(3), anyString(), verify(repository).updateTransport(10L, 3, "CSQU3054383", null, 9L, "operator");
org.mockito.ArgumentMatchers.isNull(), eq(9L), anyString());
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
} }
@Test @Test
@@ -179,7 +174,6 @@ class LoadingListServiceTest {
@Test @Test
void shouldRejectBlankContainerNumberInsteadOfClearingAuthorityField() { void shouldRejectBlankContainerNumberInsteadOfClearingAuthorityField() {
AggregateRow before = row("EDITING", 3); AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
before.setContainerNo("MSCU6639870"); before.setContainerNo("MSCU6639870");
when(repository.aggregate(10L)).thenReturn(before); when(repository.aggregate(10L)).thenReturn(before);
@@ -195,7 +189,6 @@ class LoadingListServiceTest {
@Test @Test
void unclaimedPdaTaskStillAllowsCorrectionButClaimOrPhysicalWorkRejectsIt() { void unclaimedPdaTaskStillAllowsCorrectionButClaimOrPhysicalWorkRejectsIt() {
AggregateRow before = row("EDITING", 3); AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
before.setContainerNo("MSCU6639870"); before.setContainerNo("MSCU6639870");
when(repository.aggregate(10L)).thenReturn(before); when(repository.aggregate(10L)).thenReturn(before);
when(repository.hasPdaWorkStarted(10L)).thenReturn(true); when(repository.hasPdaWorkStarted(10L)).thenReturn(true);
@@ -210,12 +203,9 @@ class LoadingListServiceTest {
@Test @Test
void loadingPlanRejectsTransportUpdateButPushedEditingPlanAllowsFirstContainerFill() { void loadingPlanRejectsTransportUpdateButPushedEditingPlanAllowsFirstContainerFill() {
AggregateRow loading = row("LOADING", 3); AggregateRow loading = row("LOADING", 3);
loading.setPreloadPushedAt(java.time.LocalDateTime.now());
loading.setContainerNo("MSCU6639870"); loading.setContainerNo("MSCU6639870");
AggregateRow missing = row("EDITING", 3); AggregateRow missing = row("EDITING", 3);
missing.setPreloadPushedAt(java.time.LocalDateTime.now());
AggregateRow afterFill = row("EDITING", 4); AggregateRow afterFill = row("EDITING", 4);
afterFill.setPreloadPushedAt(missing.getPreloadPushedAt());
afterFill.setContainerNo("CSQU3054383"); afterFill.setContainerNo("CSQU3054383");
when(repository.aggregate(10L)).thenReturn(loading, missing, afterFill); when(repository.aggregate(10L)).thenReturn(loading, missing, afterFill);
when(repository.fillInitialContainerNo(10L, 3, "CSQU3054383", null, 9L, "operator")) when(repository.fillInitialContainerNo(10L, 3, "CSQU3054383", null, 9L, "operator"))
@@ -236,9 +226,7 @@ class LoadingListServiceTest {
@Test @Test
void loadingPlanWithClaimedAndPickedPdaTaskStillAllowsInitialContainerFill() { void loadingPlanWithClaimedAndPickedPdaTaskStillAllowsInitialContainerFill() {
AggregateRow before = row("LOADING", 7); AggregateRow before = row("LOADING", 7);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
AggregateRow after = row("LOADING", 8); AggregateRow after = row("LOADING", 8);
after.setPreloadPushedAt(before.getPreloadPushedAt());
after.setContainerNo("CSQU3054383"); after.setContainerNo("CSQU3054383");
when(repository.aggregate(10L)).thenReturn(before, after); when(repository.aggregate(10L)).thenReturn(before, after);
when(repository.hasPdaWorkStarted(10L)).thenReturn(true); when(repository.hasPdaWorkStarted(10L)).thenReturn(true);
@@ -260,7 +248,6 @@ class LoadingListServiceTest {
@Test @Test
void correctingExistingContainerNumberStillRequiresReason() { void correctingExistingContainerNumberStillRequiresReason() {
AggregateRow before = row("EDITING", 3); AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
before.setContainerNo("MSCU6639870"); before.setContainerNo("MSCU6639870");
when(repository.aggregate(10L)).thenReturn(before); when(repository.aggregate(10L)).thenReturn(before);
@@ -274,7 +261,6 @@ class LoadingListServiceTest {
@Test @Test
void rejectsGlobalUniqueConflictIncludingConcurrentConstraintRace() { void rejectsGlobalUniqueConflictIncludingConcurrentConstraintRace() {
AggregateRow before = row("EDITING", 3); AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
before.setContainerNo("MSCU6639870"); before.setContainerNo("MSCU6639870");
when(repository.aggregate(10L)).thenReturn(before); when(repository.aggregate(10L)).thenReturn(before);
when(containerRepository.containerNoExists("CSQU3054383", 30L)).thenReturn(true); when(containerRepository.containerNoExists("CSQU3054383", 30L)).thenReturn(true);
@@ -329,10 +315,8 @@ class LoadingListServiceTest {
@Test @Test
void shouldReplaySameTransportRequestWithoutSecondMutation() { void shouldReplaySameTransportRequestWithoutSecondMutation() {
AggregateRow before = row("EDITING", 3); AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
before.setContainerNo("MSCU6639870"); before.setContainerNo("MSCU6639870");
AggregateRow after = row("EDITING", 4); AggregateRow after = row("EDITING", 4);
after.setPreloadPushedAt(before.getPreloadPushedAt());
after.setContainerNo("CSQU3054383"); after.setContainerNo("CSQU3054383");
after.setVoyageNo("023E"); after.setVoyageNo("023E");
when(repository.history(anyString())).thenReturn(null); when(repository.history(anyString())).thenReturn(null);
@@ -361,7 +345,6 @@ class LoadingListServiceTest {
@Test @Test
void reusedTransportKeyWithDifferentPayloadFailsBeforeMutation() { void reusedTransportKeyWithDifferentPayloadFailsBeforeMutation() {
AggregateRow before = row("EDITING", 3); AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
before.setContainerNo("MSCU6639870"); before.setContainerNo("MSCU6639870");
HistoryRow replay = new HistoryRow(); HistoryRow replay = new HistoryRow();
replay.setAfterSnapshot("{\"requestHash\":\"different\"}"); replay.setAfterSnapshot("{\"requestHash\":\"different\"}");
@@ -765,9 +748,7 @@ class LoadingListServiceTest {
@Test @Test
void confirmsEveryLinkedPreloadAndCreatesSinglePdaTaskAtomically() { void confirmsEveryLinkedPreloadAndCreatesSinglePdaTaskAtomically() {
AggregateRow before = row("EDITING", 3); AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
AggregateRow after = row("EDITING", 4); AggregateRow after = row("EDITING", 4);
after.setPreloadPushedAt(before.getPreloadPushedAt());
List<Link> links = List.of( List<Link> links = List.of(
new Link(1L, 101L, 201L, 301L, "PENDING", 2, 10L, 30L, new Link(1L, 101L, 201L, 301L, "PENDING", 2, 10L, 30L,
"MSCU-001", "EDITING", 3, 4, null), "MSCU-001", "EDITING", 3, 4, null),
@@ -850,7 +831,6 @@ class LoadingListServiceTest {
@Test @Test
void rejectsNewConfirmationKeyAfterPlanWasAlreadyConfirmed() { void rejectsNewConfirmationKeyAfterPlanWasAlreadyConfirmed() {
AggregateRow confirmed = row("EDITING", 4); AggregateRow confirmed = row("EDITING", 4);
confirmed.setPreloadPushedAt(java.time.LocalDateTime.now());
when(repository.history(anyString())).thenReturn(null); when(repository.history(anyString())).thenReturn(null);
when(repository.aggregate(10L)).thenReturn(confirmed); when(repository.aggregate(10L)).thenReturn(confirmed);
when(loadingPickService.requirePreloadConfirmationReady(10L)) when(loadingPickService.requirePreloadConfirmationReady(10L))
@@ -872,7 +852,6 @@ class LoadingListServiceTest {
@Test @Test
void confirmationGateFailureLeavesPlanAndEveryPreloadUntouched() { void confirmationGateFailureLeavesPlanAndEveryPreloadUntouched() {
AggregateRow before = row("EDITING", 3); AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
when(repository.history(anyString())).thenReturn(null); when(repository.history(anyString())).thenReturn(null);
when(repository.aggregate(10L)).thenReturn(before); when(repository.aggregate(10L)).thenReturn(before);
when(loadingPickService.requirePreloadConfirmationReady(10L)) when(loadingPickService.requirePreloadConfirmationReady(10L))
@@ -904,7 +883,7 @@ class LoadingListServiceTest {
private static PreloadReadiness readiness(String confirmationStatus, boolean canConfirm, private static PreloadReadiness readiness(String confirmationStatus, boolean canConfirm,
boolean readyForPda, Long taskId) { 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, taskId == null ? null : "LOADING", confirmationStatus, canConfirm,
readyForPda, List.of()); 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.Candidate;
import com.baiyue.firstleg.dto.SmartLoadingDtos.DeleteRequest; import com.baiyue.firstleg.dto.SmartLoadingDtos.DeleteRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.Plan; 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.RecommendRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.WarehouseRemarkRequest; import com.baiyue.firstleg.dto.SmartLoadingDtos.WarehouseRemarkRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.entity.ContainerBookingEntity;
@@ -403,29 +402,6 @@ class SmartLoadingServiceTest {
verify(loadingPickService, never()).remove(anyLong()); 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 @Test
void removesEveryGroupMemberAndUnlocksOnlyAfterAllRowsAreRemoved() { void removesEveryGroupMemberAndUnlocksOnlyAfterAllRowsAreRemoved() {
LoadingPlanEntity plan = plan("EDITING", 0); LoadingPlanEntity plan = plan("EDITING", 0);
@@ -548,7 +524,7 @@ class SmartLoadingServiceTest {
when(repository.planByBooking(99L)).thenReturn(plan); when(repository.planByBooking(99L)).thenReturn(plan);
when(repository.items(1L)).thenReturn(List.of()); 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); assertThat(result.id()).isEqualTo(1L);
verify(repository, never()).insert(any(LoadingPlanEntity.class)); verify(repository, never()).insert(any(LoadingPlanEntity.class));
@@ -565,7 +541,7 @@ class SmartLoadingServiceTest {
}).when(repository).insert(any(LoadingPlanEntity.class)); }).when(repository).insert(any(LoadingPlanEntity.class));
when(repository.items(1L)).thenReturn(List.of()); 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"); assertThat(result.planNo()).isEqualTo("LP-260818-0001");
} }
@@ -1076,205 +1052,6 @@ class SmartLoadingServiceTest {
verify(repository).remove(1, 8, 0, 7, "u"); 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() { private void stubAssignment() {
LoadingPlanEntity plan = plan("EDITING", 0); LoadingPlanEntity plan = plan("EDITING", 0);
when(repository.plan(1)).thenReturn(plan, plan("EDITING", 1)); 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` | | 排车编辑、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` | | 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java`、`HttpWarehouseResourceGateway.java` |
| 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java`、`transfer/service/WarehouseTransferService.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` | | 货物废弃、待审锁箱、审批扣减 | `discard/controller/CargoDiscardController.java`、`discard/service/CargoDiscardService.java` |
| 客户拜访、派送状态与签收附件 | `customervisit/controller/CustomerVisitController.java`、`customervisit/service/CustomerVisitService.java`;平台维护拜访、附件上传、派送完成/状态 | | 客户拜访、派送状态与签收附件 | `customervisit/controller/CustomerVisitController.java`、`customervisit/service/CustomerVisitService.java`;平台维护拜访、附件上传、派送完成/状态 |
| 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java`、`exceptionorder/service/ExceptionOrderService.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"), String overseas = Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"),
StandardCharsets.UTF_8).toLowerCase(); StandardCharsets.UTF_8).toLowerCase();
assertTrue(overseas.contains("空库先执行 lastleg-service/src/main/resources/db/lastleg.sql 基线")); assertTrue(overseas.contains("空库先执行 lastleg-service/src/main/resources/db/lastleg.sql 基线"));
assertFalse(overseas.contains(createToken)); assertTrue(overseas.contains(createToken));
assertFalse(overseas.contains("alter table ll_delivery_order")); 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 @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 '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 $truckOpenApi 'excluding bookings that already have a non-terminal truck appointment' '拖车创建候选过滤'
Assert-Contains $loadingListsOpenApi '真实集装箱号可在PDA确认装柜完成前补录' '预装真实柜号晚录门禁' 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 $loadingListsOpenApi '不依赖真实集装箱号,task 已创建后仍为 true' 'PDA task 不依赖真实柜号'
Assert-Contains $adminAgents 'PDA 最终完成前必须补齐' 'Admin 真实柜号最终门禁' Assert-Contains $adminAgents 'PDA 最终完成前必须补齐' 'Admin 真实柜号最终门禁'
Assert-Contains $pdaAgents '当前 `taskId + warehouseId` 走任务专用 API' '国内 PDA 柜级证据入口' Assert-Contains $pdaAgents '当前 `taskId + warehouseId` 走任务专用 API' '国内 PDA 柜级证据入口'