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