diff --git a/AGENTS.md b/AGENTS.md index 82e3decc3..97558f874 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ |---|---|---| | 登录、Token、密码、注册与顶层授权快照 | `auth/` | `auth/AGENTS.md`;海外 Token 只携带稳定身份与版本,角色、权限和冻结海外仓从登录/刷新响应顶层快照读取,海外 Gateway 再按 IAM 安全投影失败关闭校验 | | 客户档案、成员、审核、RBAC、授权快照 | `iam/` | `iam/AGENTS.md` | -| 客户建档默认尺寸初始化与补偿 | `iam/`、`firstleg/` | IAM `CustomerDimensionRuleInitializationService` 持久化意图、`CustomerDimensionInitializationRelay` 后台投递;Firstleg 内部入口 `/internal/firstleg/v1/customer-dimension-configs/defaults`,未 `READY` 不得审核通过 | +| 客户建档默认箱规与权重系数初始化及补偿 | `iam/`、`firstleg/` | IAM `CustomerDimensionRuleInitializationService` 持久化意图、`CustomerDimensionInitializationRelay` 后台投递;Firstleg 内部入口 `/internal/firstleg/v1/customer-dimension-configs/defaults` 在同一事务生成四档尺寸和默认权重系数规则,未全部完成不得标记 `READY` 或审核通过 | | 消息中心、站内通知、消息模板、收件箱 | `iam/` | `iam/AGENTS.md`,再按 `src/main/java/com/baiyue/iam/notification/` 定位;业务事件生产者回到所属服务 | | 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md`;统一列表由 `UnifiedOrderQueryService` 组合交易与履约投影,仍固定显示 `PENDING_REVIEW` 并提供审核动作:Order 为 `SUBMITTED` 时必须优先返回 `PENDING_REVIEW/APPROVE_ORDER`,不得被 Firstleg 准备中状态覆盖 | | 客户头程订单中文批量导入 | `order/` | `MerchantBatchImportService.java`、`MerchantWarehouseCatalogClient.java`、`MerchantPricingCatalogClient.java`、`order-v1.yaml`;模板和导入都按当前客户读取动态地址、全部有效仓库与渠道,最多 50 行,只创建草稿,支持 `PARTIAL/ATOMIC` | @@ -95,6 +95,8 @@ ## 契约、数据库与验证入口 +- 对外 Controller/API 新增、改名或删除时,同时核对调用端、区域 Gateway、OpenAPI 和权限;提交前运行 `node --test scripts/gateway-route-contract.test.mjs`,自动发现生产 Controller 并校验网关目标服务与内部路径隔离。失败不得提交或标记完成;网关配置变化必须列入部署清单,不以业务服务单测代替入口检查。 + - 跨服务修改先检查调用方 `client/`、被调用方 Controller/DTO、`src/main/resources/openapi/` 和双方契约测试;禁止共享领域 Entity。 - 修改 Java 方法、构造器、Controller、Client、DTO 或 Mapper 参数签名时,先用 `rg` 定向查找符号及旧参数形态,并同步更新 `src/main/`、`src/test/`、Mock、Fixture、契约测试和脚本中的全部调用点。新增可选参数也必须补齐旧测试调用,不能因生产调用已更新就视为完成。 - 所有生产 MyBatis Mapper 接口必须显式标记 `@Mapper`;每个持久化服务只允许在启动类使用一次服务根包 `@MapperScan(..., annotationClass = Mapper.class)`,不得新增子域包白名单或分散的 `*MapperConfiguration`。新增或移动 Mapper 后先运行 `node scripts/mapper-scan-contract.mjs`,全量后端回归也会在 Maven 前执行该门禁。 diff --git a/Jenkinsfile b/Jenkinsfile index 726108088..078ce6bab 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -316,6 +316,16 @@ pipeline { } } + stage('Gateway 路由静态门禁') { + when { + expression { env.NO_SERVICE_CHANGE != 'true' } + } + agent { label 'java-builder' } + steps { + sh 'node --test scripts/gateway-route-contract.test.mjs' + } + } + stage('统一 Maven 测试门禁') { when { expression { env.NO_SERVICE_CHANGE != 'true' && !params.SKIP_TESTS } diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java index 9a3734ce8..d7389536f 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java @@ -28,8 +28,13 @@ public record FirstLegContainerShippingNodeRecordedV1( "EMPTY_RETURNED", 100); public FirstLegContainerShippingNodeRecordedV1 { - if (queryId == null || queryId.isBlank() || oceanBookingId == null || oceanBookingId.isBlank() - || containerBookingId == null || containerBookingId.isBlank() + if (queryId != null && queryId.isBlank()) { + throw new IllegalArgumentException("船节点跟踪任务标识不能为空字符串"); + } + if (oceanBookingId != null && oceanBookingId.isBlank()) { + throw new IllegalArgumentException("船节点订舱标识不能为空字符串"); + } + if (containerBookingId == null || containerBookingId.isBlank() || containerNo == null || containerNo.isBlank()) { throw new IllegalArgumentException("船节点交接业务标识不能为空"); } diff --git a/common-event-contract/src/main/resources/openapi/cross-region-events-v1.yaml b/common-event-contract/src/main/resources/openapi/cross-region-events-v1.yaml index ce70219d5..510436c55 100644 --- a/common-event-contract/src/main/resources/openapi/cross-region-events-v1.yaml +++ b/common-event-contract/src/main/resources/openapi/cross-region-events-v1.yaml @@ -42,11 +42,10 @@ components: destinationType: {type: string, enum: [FBA, OVERSEAS]} FirstLegContainerShippingNodeRecordedV1: type: object - required: [queryId, customerId, oceanBookingId, sourceContainerBookingId, containerNo, standardNode, nodeSequence, observedAt, actualAt, providerCode, providerEventId, providerNodeCode, rawPayloadHash] + required: [sourceContainerBookingId, containerNo, standardNode, nodeSequence, observedAt, actualAt, providerCode, providerEventId, providerNodeCode, rawPayloadHash] properties: - queryId: {type: string} - customerId: {type: string} - oceanBookingId: {type: string} + queryId: {type: [string, 'null'], description: Automatic tracking task ID; null for a direct platform manual node.} + oceanBookingId: {type: [string, 'null'], description: Ocean booking ID when the physical container has already been linked; null is valid for a direct manual node.} sourceContainerBookingId: {type: string, minLength: 1} containerNo: {type: string, minLength: 1, description: Uppercase normalized physical container number; never cabinetNo or a technical identity} standardNode: {type: string} diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1Test.java b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1Test.java index ceed06186..c69cea8be 100644 --- a/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1Test.java +++ b/common-event-contract/src/test/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1Test.java @@ -35,6 +35,19 @@ class FirstLegContainerShippingNodeRecordedV1Test { .hasMessageContaining("normalized"); } + @Test + void acceptsDirectManualNodeWithoutTrackingQuery() { + var payload = payload(); + var manual = new FirstLegContainerShippingNodeRecordedV1( + null, null, payload.containerBookingId(), payload.containerNo(), + payload.standardNode(), payload.nodeSequence(), payload.observedAt(), payload.actualAt(), + payload.locationCode(), payload.locationName(), "MANUAL", "manual-event", payload.providerNodeCode(), + "MANUAL:7", payload.rawPayloadHash()); + + assertThat(manual.queryId()).isNull(); + assertThat(manual.oceanBookingId()).isNull(); + } + @Test void rejectsEstimatedOnlyOrIncompleteProviderFacts() { assertThatThrownBy(() -> new FirstLegContainerShippingNodeRecordedV1( diff --git a/deploy/sql/README.md b/deploy/sql/README.md index e8d961513..91f69e6e5 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -1,8 +1,9 @@ # 测试环境 SQL 执行说明 -## 唯一数据库初始化与发布入口 +## 数据库初始化与权限发布入口 -- 国内区域固定入口:`头程数据库统一增量-MySQL8.sql`。文件名保留历史兼容,内容维护为当前完整数据库基线和存量环境兼容升级,不再按日期拆分发布脚本。 +- 国内区域全量入口:`头程数据库统一增量-MySQL8.sql`。文件名保留历史兼容,内容维护为当前完整数据库基线和存量环境升级,不再按日期拆分结构发布脚本。 +- 通用权限点独立入口:`权限点初始化-MySQL8.sql`。国内与海外业务的 Admin/Client 权限主数据均在 `baiyue_iam` 统一管理。当发布内容只新增或更新 IAM 权限资源及默认角色授权,且 IAM 基础表、父菜单和系统角色已存在时,可只执行该文件。它不建表,不创建账号、客户、仓库或业务数据。 - 海外区域固定入口:`海外区域统一增量-MySQL8.sql`。该文件已经内联 Lastleg 与 Warehouse Overseas 的完整空库基线,会自行创建并初始化 `baiyue_lastleg`、`baiyue_lastlag_warehouse` 和独立的 `baiyue_event_bridge_overseas`,清库后不得再额外执行模块基线。IAM 安全事件 Inbox 和可恢复投影位于 `baiyue_lastleg.ll_security_*`,Redis 只作可重建镜像。 - Lastleg 基线和海外固定增量仅确定性初始化国内固定 `platform_admin`(`authUserId=1000001`、`tenantId=0`)的 USER/AUTHORIZATION version=0 安全投影;AUTHORIZATION 同步冻结当前 `PLATFORM_ADMIN` 角色及 IAM 跨区前缀下的完整权限集合,保证空库重建后海外 Gateway 可立即鉴权。统一 SQL 重放只修正仍处于固定 baseline version=0 的该行,其他既有开发账号不建立兼容表或长期补投入口。 - 国内、海外 Compose 部署默认设置 `SPRING_SQL_INIT_MODE=never`,执行任一 SQL 均需用户单独授权。 @@ -25,6 +26,13 @@ 4. 每个增量区块末尾增加只读断言,并同步维护统一脚本末尾的总体验证。 5. MySQL DDL 会隐式提交,执行前仍应在目标环境确认连接和授权;不能把跨库脚本视为可整体回滚事务。 +### 权限点独立入口边界 + +- 新增权限时,同步维护 IAM 模块来源、`权限点初始化-MySQL8.sql`、对应区域的全量统一 SQL、前端按钮/路由、Controller `@PreAuthorize` 和 OpenAPI。 +- 权限点入口只用于既有 IAM 基线上的权限新增或元数据/默认授权更新。涉及建表、字段、索引、角色创建或其他业务数据时,仍执行全量统一 SQL。 +- 独立入口必须可重复执行,执行前校验稳定权限 ID/权限码没有被其他语义占用,执行后只在授权完整时返回 `permission_initialization_status = PASS`。 +- 权限写入后不修改 Redis 中已签发的会话快照;受影响用户必须退出并重新登录。权限撤销不通过该 SQL 直接处理,应走 IAM 角色授权命令以同步撤销会话。 + ## Finance 首期人工财务基线 - Finance 数据库为 `baiyue_finance`,模块溯源脚本为 `finance/src/main/resources/db/finance.sql`,测试和存量环境仍只执行统一入口。 @@ -53,7 +61,7 @@ 固定入口同时幂等补齐审核自动开户所需的全局 `MERCHANT_OWNER` 角色模板、全局商户菜单/按钮权限和角色授权;执行后应使用 `iam/src/test/resources/db/verify-phase1-admin-rbac.sql` 确认模板存在且权限完整。 -测试环境只执行当前固定入口;历史脚本和模块脚本不应在固定入口执行后再次串行执行。 +测试环境结构发布只执行全量固定入口;纯权限点发布可改用权限点独立入口。历史脚本和模块脚本不应在任一发布入口执行后再次串行执行。 ## 执行方式 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index a4d3ee7be..31f1e1a75 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -1345,18 +1345,19 @@ VALUES (5102019, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:create', '平台代客创建订单', NULL, NULL, 90, 'ACTIVE', 0), (5102024, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:edit', '平台编辑订单草稿', NULL, NULL, 100, 'ACTIVE', 0), (5102025, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:submit', '平台代客提交订单', NULL, NULL, 110, 'ACTIVE', 0), - (5102026, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:export', '导出订单列表', NULL, NULL, 120, 'ACTIVE', 0) + (5102026, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:export', '导出订单列表', NULL, NULL, 120, 'ACTIVE', 0), + (5102027, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:declaration:edit', '修改订单申报与运营预估', NULL, NULL, 130, 'ACTIVE', 0) ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), parent_id = VALUES(parent_id), status = 'ACTIVE', deleted = 0; INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000001, id, 0 FROM iam_permission -WHERE id IN (5102019, 5102024, 5102025, 5102026) +WHERE id IN (5102019, 5102024, 5102025, 5102026, 5102027) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000003, id, 0 FROM iam_permission -WHERE id IN (5102019, 5102024, 5102025, 5102026) +WHERE id IN (5102019, 5102024, 5102025, 5102026, 5102027) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); INSERT INTO iam_membership_role (membership_id, role_id, tenant_id) VALUES @@ -3053,6 +3054,7 @@ CREATE TABLE IF NOT EXISTS fl_customer_charge_rule ( effective_to DATETIME(3) NULL COMMENT '生效结束时间', status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '规则状态:ACTIVE启用、DISABLED停用', remark VARCHAR(500) NULL COMMENT '规则备注', + initialization_key VARCHAR(128) NULL COMMENT '客户创建执行器幂等键及补偿归属', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID', @@ -3061,6 +3063,7 @@ CREATE TABLE IF NOT EXISTS fl_customer_charge_rule ( version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (id), UNIQUE KEY uk_charge_rule_version (tenant_id, rule_code, rule_version), + KEY idx_charge_rule_initialization (tenant_id, initialization_key), KEY idx_charge_rule_match (tenant_id, customer_id, business_type, status, effective_from, effective_to) ) ENGINE=InnoDB COMMENT='客户头程计费规则版本'; @@ -3717,6 +3720,7 @@ CALL phase2_fba_add_column('fl_customer_charge_rule', 'length_factor', "DECIMAL( CALL phase2_fba_add_column('fl_customer_charge_rule', 'width_factor', "DECIMAL(18,6) NOT NULL DEFAULT 1 COMMENT '宽度计算系数' AFTER length_factor"); CALL phase2_fba_add_column('fl_customer_charge_rule', 'height_factor', "DECIMAL(18,6) NOT NULL DEFAULT 1 COMMENT '高度计算系数' AFTER width_factor"); CALL phase2_fba_add_column('fl_customer_charge_rule', 'default_rule', "TINYINT(1) NOT NULL DEFAULT 0 COMMENT '客户默认生效规则' AFTER status"); +CALL phase2_fba_add_column('fl_customer_charge_rule', 'initialization_key', "VARCHAR(128) NULL COMMENT '客户创建执行器幂等键及补偿归属' AFTER remark"); CALL phase2_fba_add_column('fl_carrier_fba_rate', 'version', "INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号' AFTER deleted"); CALL phase2_fba_add_column('fl_inbound_fee', 'rule_code', "VARCHAR(50) NOT NULL DEFAULT '' COMMENT '客户规则编码快照' AFTER carrier_rate_version"); CALL phase2_fba_add_column('fl_inbound_fee', 'measurement_id', "BIGINT UNSIGNED NULL COMMENT '费用绑定的测量批次ID快照' AFTER inbound_id"); @@ -3760,6 +3764,14 @@ PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; DROP PROCEDURE IF EXISTS phase2_fba_add_column; +SET @idx_exists = (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'fl_customer_charge_rule' + AND INDEX_NAME = 'idx_charge_rule_initialization'); +SET @sql = IF(@idx_exists = 0, + 'ALTER TABLE fl_customer_charge_rule ADD INDEX idx_charge_rule_initialization (tenant_id, initialization_key)', + 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + CREATE TABLE IF NOT EXISTS fl_price_channel_catalog ( id BIGINT UNSIGNED NOT NULL COMMENT '渠道目录ID', channel_code VARCHAR(64) NOT NULL COMMENT '稳定渠道编码', @@ -5005,7 +5017,6 @@ CREATE TABLE IF NOT EXISTS sys_audit_dead_letter ( -- ============================================================ -- 三期智能装柜、预装冻结与装柜清单 -- 来源:firstleg/src/main/resources/db/phase3-smart-loading-schema.sql --- ============================================================ SET NAMES utf8mb4; CREATE DATABASE IF NOT EXISTS baiyue_firstleg @@ -5060,7 +5071,7 @@ CREATE TABLE IF NOT EXISTS fl_container_booking ( bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号', vessel_name VARCHAR(128) NULL COMMENT '船名快照', voyage_no VARCHAR(64) NULL COMMENT '航次;确认前允许修订', - container_type VARCHAR(32) NOT NULL COMMENT '箱型,例如20GP、40GP、40HQ', + container_type VARCHAR(32) NOT NULL COMMENT '平台固定柜型:20GP、40GP、40HC、40NR、45HC', shipping_company_id BIGINT UNSIGNED NULL COMMENT '船司ID快照', shipping_company_name VARCHAR(128) NULL COMMENT '船司名称快照', departure_port_code VARCHAR(32) NULL COMMENT '起运港编码快照', @@ -5080,6 +5091,11 @@ CREATE TABLE IF NOT EXISTS fl_container_booking ( actual_port_arrival_at DATETIME(3) NULL COMMENT '实际到港时间', estimated_warehouse_arrival_at DATETIME(3) NULL COMMENT '预计到仓时间', actual_warehouse_arrival_at DATETIME(3) NULL COMMENT '实际到仓时间', + unloading_completed_at DATETIME(3) NULL COMMENT '海外仓卸柜完成时间(UTC)', + unloading_photo_uploaded_at DATETIME(3) NULL COMMENT '海外仓卸柜照片上传成功时间(UTC)', + estimated_empty_reported_at DATETIME(3) NULL COMMENT '预计还空时间(卸柜完成时间加72小时,UTC)', + actual_empty_reported_at DATETIME(3) NULL COMMENT '实际报空时间(UTC)', + empty_report_source_version BIGINT UNSIGNED NULL COMMENT '海外货柜事件聚合版本,用于阻止乱序覆盖', pickup_code VARCHAR(64) NULL COMMENT 'PU数字码;按字符串保存以保留前导零', pickup_code_updated_by BIGINT UNSIGNED NULL COMMENT 'PU码最后填写人ID', pickup_code_updated_by_name VARCHAR(64) NULL COMMENT 'PU码最后填写人名称快照', @@ -5089,8 +5105,7 @@ CREATE TABLE IF NOT EXISTS fl_container_booking ( created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID', updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) - COMMENT '货柜资料最后更新时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '货柜资料最后更新时间', version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (id), UNIQUE KEY uk_container_booking_no (booking_no), @@ -5099,17 +5114,31 @@ CREATE TABLE IF NOT EXISTS fl_container_booking ( KEY idx_container_booking_merge (record_status,merged_into_container_booking_id), KEY idx_container_booking_warehouse (warehouse_id, updated_at, id), KEY idx_container_booking_voyage (voyage_no, updated_at), + KEY idx_container_booking_transit_summary (actual_sailing_at, actual_warehouse_arrival_at, ocean_booking_id, container_type), CONSTRAINT chk_container_booking_source CHECK (source_type IN ('SIMULATION','OCEAN_BOOKING')), CONSTRAINT chk_container_booking_record_status CHECK (record_status IN ('ACTIVE','MERGED')), CONSTRAINT chk_container_booking_capacity CHECK (capacity_cbm IS NULL OR capacity_cbm > 0), CONSTRAINT chk_container_booking_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0), CONSTRAINT chk_container_booking_pickup_code CHECK (pickup_code IS NULL OR pickup_code REGEXP '^[0-9]{1,64}$'), CONSTRAINT chk_container_booking_iso6346 CHECK - (container_no IS NULL OR container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$'), - CONSTRAINT chk_container_booking_pickup_code CHECK - (pickup_code IS NULL OR pickup_code REGEXP '^[0-9]{1,64}$') + (container_no IS NULL OR container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$') ) ENGINE=InnoDB COMMENT='平台货柜预订资料;预装状态唯一来源为装柜计划'; +CREATE TABLE IF NOT EXISTS fl_container_event_inbox ( + event_id VARCHAR(64) NOT NULL COMMENT '跨区事件全局唯一ID', + event_type VARCHAR(64) NOT NULL COMMENT '事件类型', + event_version INT UNSIGNED NOT NULL COMMENT '事件契约版本', + aggregate_id VARCHAR(64) NOT NULL COMMENT '跨区业务聚合ID', + aggregate_version BIGINT UNSIGNED NOT NULL COMMENT '跨区业务聚合版本', + payload_hash VARCHAR(80) NOT NULL COMMENT '规范化负载SHA-256摘要', + payload_json JSON NOT NULL COMMENT '原始事件负载', + status VARCHAR(16) NOT NULL COMMENT 'RECEIVED或PROCESSED', + received_at DATETIME(3) NOT NULL COMMENT '接收时间(UTC)', + processed_at DATETIME(3) NULL COMMENT '处理完成时间(UTC)', + PRIMARY KEY (event_id), + KEY idx_container_event_aggregate (aggregate_id, aggregate_version) +) ENGINE=InnoDB COMMENT='海外到国内业务事件消费Inbox'; + DROP PROCEDURE IF EXISTS phase3_container_real_unit_columns; DELIMITER $$ CREATE PROCEDURE phase3_container_real_unit_columns() @@ -5139,10 +5168,40 @@ BEGIN ALTER TABLE fl_container_booking ADD COLUMN customer_name VARCHAR(128) NULL COMMENT '已关联真实订舱客户名称快照' AFTER customer_id; END IF; + UPDATE fl_container_booking container_booking + JOIN fl_ocean_booking ocean_booking ON ocean_booking.id=container_booking.ocean_booking_id + SET container_booking.customer_id=COALESCE(container_booking.customer_id,ocean_booking.customer_id), + container_booking.customer_name=COALESCE(container_booking.customer_name,ocean_booking.customer_name) + WHERE container_booking.customer_id IS NULL OR container_booking.customer_name IS NULL; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='fl_container_booking' AND column_name='unloading_completed_at') THEN + ALTER TABLE fl_container_booking ADD COLUMN unloading_completed_at DATETIME(3) NULL + COMMENT '海外仓卸柜完成时间(UTC)' AFTER actual_warehouse_arrival_at; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='fl_container_booking' AND column_name='unloading_photo_uploaded_at') THEN + ALTER TABLE fl_container_booking ADD COLUMN unloading_photo_uploaded_at DATETIME(3) NULL + COMMENT '海外仓卸柜照片上传成功时间(UTC)' AFTER unloading_completed_at; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='fl_container_booking' AND column_name='estimated_empty_reported_at') THEN + ALTER TABLE fl_container_booking ADD COLUMN estimated_empty_reported_at DATETIME(3) NULL + COMMENT '预计还空时间(卸柜完成时间加72小时,UTC)' AFTER unloading_photo_uploaded_at; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='fl_container_booking' AND column_name='actual_empty_reported_at') THEN + ALTER TABLE fl_container_booking ADD COLUMN actual_empty_reported_at DATETIME(3) NULL + COMMENT '实际报空时间(UTC)' AFTER estimated_empty_reported_at; + END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() + AND table_name='fl_container_booking' AND column_name='empty_report_source_version') THEN + ALTER TABLE fl_container_booking ADD COLUMN empty_report_source_version BIGINT UNSIGNED NULL + COMMENT '海外货柜事件聚合版本,用于阻止乱序覆盖' AFTER actual_empty_reported_at; + END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_container_booking' AND column_name='pickup_code') THEN ALTER TABLE fl_container_booking ADD COLUMN pickup_code VARCHAR(64) NULL - COMMENT 'PU数字码;按字符串保存以保留前导零' AFTER actual_warehouse_arrival_at; + COMMENT 'PU数字码;按字符串保存以保留前导零' AFTER empty_report_source_version; END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_container_booking' AND column_name='pickup_code_updated_by') THEN @@ -5159,14 +5218,6 @@ BEGIN ALTER TABLE fl_container_booking ADD COLUMN pickup_code_updated_at DATETIME(3) NULL COMMENT 'PU码最后填写时间' AFTER pickup_code_updated_by_name; END IF; - IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() - AND table_name='fl_ocean_booking') THEN - UPDATE fl_container_booking container_booking - JOIN fl_ocean_booking ocean_booking ON ocean_booking.id=container_booking.ocean_booking_id - SET container_booking.customer_id=COALESCE(container_booking.customer_id,ocean_booking.customer_id), - container_booking.customer_name=COALESCE(container_booking.customer_name,ocean_booking.customer_name) - WHERE container_booking.customer_id IS NULL OR container_booking.customer_name IS NULL; - END IF; IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='fl_container_booking' AND index_name='uk_container_booking_container_no') THEN ALTER TABLE fl_container_booking ADD UNIQUE KEY uk_container_booking_container_no (container_no); @@ -5220,8 +5271,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan ( id BIGINT UNSIGNED NOT NULL COMMENT '智能装柜计划ID(雪花ID)', 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装柜完成;建议须经人工确认', + plan_status VARCHAR(24) NOT NULL DEFAULT 'EDITING' COMMENT '装柜计划状态:EDITING编辑中、LOADING装柜中、COMPLETED装柜完成;建议须经人工确认', preload_completed_by BIGINT UNSIGNED NULL COMMENT '预装完成操作人ID', preload_completed_by_name VARCHAR(64) NULL COMMENT '预装完成操作人名称快照', preload_completed_at DATETIME(3) NULL COMMENT '预装完成时间', @@ -5234,8 +5284,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan ( created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID', updated_by_name VARCHAR(64) NULL COMMENT '最后修改人名称快照', - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) - COMMENT '装柜计划最后更新时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '装柜计划最后更新时间', version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (id), UNIQUE KEY uk_loading_plan_no (plan_no), @@ -5347,6 +5396,7 @@ CREATE TABLE IF NOT EXISTS fl_loading_pallet_label ( CONSTRAINT chk_loading_pallet_index CHECK (pallet_index BETWEEN 1 AND 999) ) ENGINE=InnoDB COMMENT='散货按柜号和FBA仓冻结的可打印托盘码'; +-- 柜次级简单文件仅承载目的港清关和提货单;PU资料作为数字码保存在货柜主表。 CREATE TABLE IF NOT EXISTS fl_container_document ( id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花ID)', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID', @@ -5361,8 +5411,7 @@ CREATE TABLE IF NOT EXISTS fl_container_document ( completed_by_name VARCHAR(64) 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 '货柜文件关联最后更新时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '货柜文件关联最后更新时间', version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (id), UNIQUE KEY uk_container_document_type (container_booking_id, document_type), @@ -5373,13 +5422,42 @@ CREATE TABLE IF NOT EXISTS fl_container_document ( CHECK (document_type IN ('DESTINATION_CLEARANCE', 'DELIVERY_ORDER')) ) ENGINE=InnoDB COMMENT='平台货柜清关与DO文件关联'; +SET @has_container_document_type_check=(SELECT COUNT(*) FROM information_schema.table_constraints + WHERE table_schema=DATABASE() AND table_name='fl_container_document' + AND constraint_name='chk_container_document_type' AND constraint_type='CHECK'); +SET @ddl=IF(@has_container_document_type_check=0,'SELECT 1', + 'ALTER TABLE fl_container_document DROP CHECK chk_container_document_type'); +PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement; +ALTER TABLE fl_container_document ADD CONSTRAINT chk_container_document_type + CHECK (document_type IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER')); + +CREATE TABLE IF NOT EXISTS fl_container_exception_history ( + id BIGINT UNSIGNED NOT NULL COMMENT '货柜异常历史ID', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '物理货柜ID', + action VARCHAR(16) NOT NULL COMMENT '操作类型:MARK标记异常、CANCEL取消异常', reason VARCHAR(500) NOT NULL COMMENT '异常原因', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', + operation_id VARCHAR(128) NOT NULL COMMENT '同一操作人幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求摘要', + occurred_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作发生时间', PRIMARY KEY(id), + UNIQUE KEY uk_container_exception_operation(container_booking_id,operator_id,operation_id), + KEY idx_container_exception_history(container_booking_id,occurred_at,id), + CONSTRAINT fk_container_exception_booking FOREIGN KEY(container_booking_id) REFERENCES fl_container_booking(id), + CONSTRAINT chk_container_exception_action CHECK(action IN ('MARK','CANCEL')) +) ENGINE=InnoDB COMMENT='货柜异常标记及取消原因历史'; + +DROP TRIGGER IF EXISTS trg_container_exception_history_before_update; +DROP TRIGGER IF EXISTS trg_container_exception_history_before_delete; +DELIMITER $$ +CREATE TRIGGER trg_container_exception_history_before_update BEFORE UPDATE ON fl_container_exception_history FOR EACH ROW +BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜异常历史不可修改'; END$$ +CREATE TRIGGER trg_container_exception_history_before_delete BEFORE DELETE ON fl_container_exception_history FOR EACH ROW +BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='货柜异常历史不可删除'; END$$ +DELIMITER ; + CREATE TABLE IF NOT EXISTS fl_loading_plan_history ( id BIGINT UNSIGNED NOT NULL COMMENT '装柜操作历史ID(雪花ID)', loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '智能装柜计划ID', loading_plan_item_id BIGINT UNSIGNED NULL COMMENT '装柜计划明细ID', operation_id VARCHAR(128) NOT NULL COMMENT '操作幂等ID', - action_type VARCHAR(32) NOT NULL - COMMENT '动作类型:CREATE_PLAN创建计划、ADD_ITEM新增明细、ADJUST_ITEM调整明细、REMOVE_ITEM移除明细、START_LOADING开始装柜、COMPLETE_LOADING完成装柜、PICK_ITEM拣货', + action_type VARCHAR(32) NOT NULL COMMENT '动作类型:CREATE_PLAN创建计划、ADD_ITEM新增明细、ADJUST_ITEM调整明细、REMOVE_ITEM移除明细、START_LOADING开始装柜、COMPLETE_LOADING完成装柜、PICK_ITEM拣货', from_status VARCHAR(24) NULL COMMENT '变更前状态', to_status VARCHAR(24) NULL COMMENT '变更后状态', before_snapshot JSON NULL COMMENT '变更前快照', @@ -5399,26 +5477,27 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_history ( -- fl_loading_pick_task 曾被另一套入库汇总模型复用。先按字段签名识别并迁走, -- 再创建智能装柜任务表;未知或混合结构必须人工处理,禁止静默套用错误表型。 -DROP PROCEDURE IF EXISTS assert_loading_pick_task_shapes; +DROP PROCEDURE IF EXISTS migrate_legacy_loading_pick_task; DELIMITER $$ -CREATE PROCEDURE assert_loading_pick_task_shapes(IN require_both TINYINT) +CREATE PROCEDURE migrate_legacy_loading_pick_task() BEGIN DECLARE source_exists INT DEFAULT 0; + DECLARE source_total_columns INT DEFAULT 0; + DECLARE source_smart_columns INT DEFAULT 0; + DECLARE source_inbound_columns INT DEFAULT 0; DECLARE target_exists INT DEFAULT 0; - DECLARE source_total INT DEFAULT 0; - DECLARE source_smart INT DEFAULT 0; - DECLARE source_stable_smart INT DEFAULT 0; - DECLARE source_inbound INT DEFAULT 0; - DECLARE target_total INT DEFAULT 0; - DECLARE target_inbound INT DEFAULT 0; - SELECT COUNT(*) INTO source_exists FROM information_schema.tables + DECLARE target_total_columns INT DEFAULT 0; + DECLARE target_inbound_columns INT DEFAULT 0; + + SELECT COUNT(*) INTO source_exists + FROM information_schema.tables WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'; - SELECT COUNT(*) INTO target_exists FROM information_schema.tables - WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_loading_pick_task'; IF source_exists = 1 THEN - SELECT COUNT(*) INTO source_total FROM information_schema.columns + SELECT COUNT(*) INTO source_total_columns + FROM information_schema.columns WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'; - SELECT COUNT(*) INTO source_smart FROM information_schema.columns + SELECT COUNT(*) INTO source_smart_columns + FROM information_schema.columns WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND ( (column_name = 'id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO') OR (column_name = 'loading_plan_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO') @@ -5430,113 +5509,57 @@ BEGIN OR (column_name = 'completed_by' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'YES') OR (column_name = 'completed_by_name' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES') OR (column_name = 'completed_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'YES') - OR (column_name = 'version' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'NO' AND CAST(column_default AS CHAR) = '0')); - SELECT COUNT(*) INTO source_inbound FROM information_schema.columns - WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND ( - (column_name = 'id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO') - OR (column_name = 'tenant_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO') - OR (column_name = 'inbound_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO') - OR (column_name = 'status' AND LOWER(column_type) = 'varchar(24)' AND is_nullable = 'NO' AND column_default = 'PENDING') - OR (column_name = 'version' AND LOWER(column_type) = 'int' AND is_nullable = 'NO' AND CAST(column_default AS CHAR) = '0') - OR (column_name = 'expected_count' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'YES') - OR (column_name = 'picked_count' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'YES') - OR (column_name = 'remark' AND LOWER(column_type) = 'varchar(1000)' AND is_nullable = 'YES') - OR (column_name = 'updated_by' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO') - OR (column_name = 'updated_by_name' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES') - OR (column_name = 'created_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%') - OR (column_name = 'updated_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%' AND LOWER(extra) LIKE '%on update current_timestamp%')); - SELECT COUNT(*) INTO source_stable_smart FROM information_schema.columns - WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND ( - (column_name = 'id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO') - OR (column_name = 'loading_plan_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO') - OR (column_name = 'container_booking_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO') - OR (column_name = 'cabinet_no' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'NO') - OR (column_name = 'container_no' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES') - OR (column_name = 'task_status' AND LOWER(column_type) = 'varchar(24)' AND is_nullable = 'NO' AND column_default = 'LOADING') - OR (column_name = 'started_by' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO') - OR (column_name = 'started_by_name' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES') - OR (column_name = 'started_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%') - OR (column_name = 'completed_by' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'YES') - OR (column_name = 'completed_by_name' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES') - OR (column_name = 'completed_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'YES') - OR (column_name = 'version' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'NO' AND CAST(column_default AS CHAR) = '0')); - IF NOT ( - (source_total = 11 AND source_smart = 11 - AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND index_name = 'uk_loading_pick_task_plan' AND non_unique = 0 AND seq_in_index = 1 AND column_name = 'loading_plan_id') = 1 - AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND index_name = 'idx_loading_pick_container' AND ((seq_in_index = 1 AND column_name = 'container_no') OR (seq_in_index = 2 AND column_name = 'task_status') OR (seq_in_index = 3 AND column_name = 'started_at'))) = 3 - AND (SELECT COUNT(*) FROM information_schema.key_column_usage WHERE constraint_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND column_name = 'loading_plan_id' AND referenced_table_name = 'fl_loading_plan' AND referenced_column_name = 'id') = 1) - OR - (source_total = 12 AND source_inbound = 12 - AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND index_name = 'uk_loading_pick_tenant_inbound' AND non_unique = 0 AND ((seq_in_index = 1 AND column_name = 'tenant_id') OR (seq_in_index = 2 AND column_name = 'inbound_id'))) = 2 - AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND index_name = 'idx_loading_pick_status' AND ((seq_in_index = 1 AND column_name = 'tenant_id') OR (seq_in_index = 2 AND column_name = 'status') OR (seq_in_index = 3 AND column_name = 'updated_at'))) = 3 - AND (SELECT COUNT(*) FROM information_schema.key_column_usage WHERE constraint_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND column_name = 'inbound_id' AND referenced_table_name = 'fl_inbound_order' AND referenced_column_name = 'id') = 1) - OR - (source_total = 13 AND source_stable_smart = 13 - AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND index_name = 'uk_loading_pick_task_plan' AND non_unique = 0 AND seq_in_index = 1 AND column_name = 'loading_plan_id') = 1 - AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND index_name = 'uk_loading_pick_task_booking' AND non_unique = 0 AND seq_in_index = 1 AND column_name = 'container_booking_id') = 1 - AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND index_name = 'idx_loading_pick_container' AND ((seq_in_index = 1 AND column_name = 'container_no') OR (seq_in_index = 2 AND column_name = 'task_status') OR (seq_in_index = 3 AND column_name = 'started_at'))) = 3 - AND (SELECT COUNT(*) FROM information_schema.key_column_usage WHERE constraint_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND column_name = 'loading_plan_id' AND referenced_table_name = 'fl_loading_plan' AND referenced_column_name = 'id') = 1) - ) THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拣货任务迁移失败:fl_loading_pick_task 不是完整 canonical 表型'; - END IF; - END IF; - IF target_exists = 1 THEN - SELECT COUNT(*) INTO target_total FROM information_schema.columns - WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_loading_pick_task'; - SELECT COUNT(*) INTO target_inbound FROM information_schema.columns - WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_loading_pick_task' AND ( - (column_name = 'id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO') - OR (column_name = 'tenant_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO') - OR (column_name = 'inbound_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO') - OR (column_name = 'status' AND LOWER(column_type) = 'varchar(24)' AND is_nullable = 'NO' AND column_default = 'PENDING') - OR (column_name = 'version' AND LOWER(column_type) = 'int' AND is_nullable = 'NO' AND CAST(column_default AS CHAR) = '0') - OR (column_name = 'expected_count' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'YES') - OR (column_name = 'picked_count' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'YES') - OR (column_name = 'remark' AND LOWER(column_type) = 'varchar(1000)' AND is_nullable = 'YES') - OR (column_name = 'updated_by' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO') - OR (column_name = 'updated_by_name' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES') - OR (column_name = 'created_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%') - OR (column_name = 'updated_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%' AND LOWER(extra) LIKE '%on update current_timestamp%')); - IF target_total <> 12 OR target_inbound <> 12 - OR (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_loading_pick_task' AND index_name = 'uk_loading_pick_tenant_inbound' AND non_unique = 0 AND ((seq_in_index = 1 AND column_name = 'tenant_id') OR (seq_in_index = 2 AND column_name = 'inbound_id'))) <> 2 - OR (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_loading_pick_task' AND index_name = 'idx_loading_pick_status' AND ((seq_in_index = 1 AND column_name = 'tenant_id') OR (seq_in_index = 2 AND column_name = 'status') OR (seq_in_index = 3 AND column_name = 'updated_at'))) <> 3 - OR (SELECT COUNT(*) FROM information_schema.key_column_usage WHERE constraint_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_loading_pick_task' AND column_name = 'inbound_id' AND referenced_table_name = 'fl_inbound_order' AND referenced_column_name = 'id') <> 1 THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拣货任务迁移失败:fl_inbound_loading_pick_task 不是完整 canonical 表型'; - END IF; - END IF; - IF require_both = 1 AND (source_exists <> 1 OR target_exists <> 1 OR source_stable_smart <> 13) THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拣货任务终验失败:两张 canonical 表未同时就绪'; - END IF; -END$$ -DELIMITER ; -CALL assert_loading_pick_task_shapes(0); - -DROP PROCEDURE IF EXISTS migrate_legacy_loading_pick_task; -DELIMITER $$ -CREATE PROCEDURE migrate_legacy_loading_pick_task() -BEGIN - DECLARE source_exists INT DEFAULT 0; - DECLARE source_smart_columns INT DEFAULT 0; - DECLARE source_inbound_columns INT DEFAULT 0; - DECLARE target_exists INT DEFAULT 0; - DECLARE target_inbound_columns INT DEFAULT 0; - - SELECT COUNT(*) INTO source_exists - FROM information_schema.tables - WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'; - IF source_exists = 1 THEN - SELECT COUNT(*) INTO source_smart_columns - FROM information_schema.columns - WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' - AND column_name IN ('loading_plan_id', 'container_no', 'task_status'); + OR (column_name = 'version' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'NO' AND CAST(column_default AS CHAR) = '0') + ); SELECT COUNT(*) INTO source_inbound_columns FROM information_schema.columns - WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' - AND column_name IN ('tenant_id', 'inbound_id', 'status', 'expected_count', 'picked_count'); + WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND ( + (column_name = 'id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO') + OR (column_name = 'tenant_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO') + OR (column_name = 'inbound_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO') + OR (column_name = 'status' AND LOWER(column_type) = 'varchar(24)' AND is_nullable = 'NO' AND column_default = 'PENDING') + OR (column_name = 'version' AND LOWER(column_type) = 'int' AND is_nullable = 'NO' AND CAST(column_default AS CHAR) = '0') + OR (column_name = 'expected_count' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'YES') + OR (column_name = 'picked_count' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'YES') + OR (column_name = 'remark' AND LOWER(column_type) = 'varchar(1000)' AND is_nullable = 'YES') + OR (column_name = 'updated_by' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO') + OR (column_name = 'updated_by_name' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES') + OR (column_name = 'created_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%') + OR (column_name = 'updated_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%' AND LOWER(extra) LIKE '%on update current_timestamp%') + ); - IF source_smart_columns = 3 AND source_inbound_columns = 0 THEN + IF source_total_columns = 11 AND source_smart_columns = 11 AND source_inbound_columns < 12 + AND (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' + AND index_name = 'uk_loading_pick_task_plan' AND non_unique = 0 + AND seq_in_index = 1 AND column_name = 'loading_plan_id') = 1 + AND (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' + AND index_name = 'idx_loading_pick_container' + AND ((seq_in_index = 1 AND column_name = 'container_no') + OR (seq_in_index = 2 AND column_name = 'task_status') + OR (seq_in_index = 3 AND column_name = 'started_at'))) = 3 + AND (SELECT COUNT(*) FROM information_schema.key_column_usage + WHERE constraint_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' + AND column_name = 'loading_plan_id' AND referenced_table_name = 'fl_loading_plan' + AND referenced_column_name = 'id') = 1 THEN SET source_exists = 1; - ELSEIF source_smart_columns = 0 AND source_inbound_columns = 5 THEN + ELSEIF source_total_columns = 12 AND source_inbound_columns = 12 AND source_smart_columns < 11 + AND (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' + AND index_name = 'uk_loading_pick_tenant_inbound' AND non_unique = 0 + AND ((seq_in_index = 1 AND column_name = 'tenant_id') + OR (seq_in_index = 2 AND column_name = 'inbound_id'))) = 2 + AND (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' + AND index_name = 'idx_loading_pick_status' + AND ((seq_in_index = 1 AND column_name = 'tenant_id') + OR (seq_in_index = 2 AND column_name = 'status') + OR (seq_in_index = 3 AND column_name = 'updated_at'))) = 3 + AND (SELECT COUNT(*) FROM information_schema.key_column_usage + WHERE constraint_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' + AND column_name = 'inbound_id' AND referenced_table_name = 'fl_inbound_order' + AND referenced_column_name = 'id') = 1 THEN SELECT COUNT(*) INTO target_exists FROM information_schema.tables WHERE table_schema = 'baiyue_firstleg' @@ -5544,14 +5567,44 @@ BEGIN IF target_exists = 0 THEN RENAME TABLE fl_loading_pick_task TO fl_inbound_loading_pick_task; ELSE + SELECT COUNT(*) INTO target_total_columns + FROM information_schema.columns + WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_loading_pick_task'; SELECT COUNT(*) INTO target_inbound_columns FROM information_schema.columns WHERE table_schema = 'baiyue_firstleg' - AND table_name = 'fl_inbound_loading_pick_task' - AND column_name IN ('tenant_id', 'inbound_id', 'status', 'expected_count', 'picked_count'); - IF target_inbound_columns <> 5 THEN + AND table_name = 'fl_inbound_loading_pick_task' AND ( + (column_name = 'id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO') + OR (column_name = 'tenant_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO') + OR (column_name = 'inbound_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO') + OR (column_name = 'status' AND LOWER(column_type) = 'varchar(24)' AND is_nullable = 'NO' AND column_default = 'PENDING') + OR (column_name = 'version' AND LOWER(column_type) = 'int' AND is_nullable = 'NO' AND CAST(column_default AS CHAR) = '0') + OR (column_name = 'expected_count' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'YES') + OR (column_name = 'picked_count' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'YES') + OR (column_name = 'remark' AND LOWER(column_type) = 'varchar(1000)' AND is_nullable = 'YES') + OR (column_name = 'updated_by' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO') + OR (column_name = 'updated_by_name' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES') + OR (column_name = 'created_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%') + OR (column_name = 'updated_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%' AND LOWER(extra) LIKE '%on update current_timestamp%') + ); + IF target_total_columns <> 12 OR target_inbound_columns <> 12 + OR (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_loading_pick_task' + AND index_name = 'uk_loading_pick_tenant_inbound' AND non_unique = 0 + AND ((seq_in_index = 1 AND column_name = 'tenant_id') + OR (seq_in_index = 2 AND column_name = 'inbound_id'))) <> 2 + OR (SELECT COUNT(*) FROM information_schema.statistics + WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_loading_pick_task' + AND index_name = 'idx_loading_pick_status' + AND ((seq_in_index = 1 AND column_name = 'tenant_id') + OR (seq_in_index = 2 AND column_name = 'status') + OR (seq_in_index = 3 AND column_name = 'updated_at'))) <> 3 + OR (SELECT COUNT(*) FROM information_schema.key_column_usage + WHERE constraint_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_loading_pick_task' + AND column_name = 'inbound_id' AND referenced_table_name = 'fl_inbound_order' + AND referenced_column_name = 'id') <> 1 THEN SIGNAL SQLSTATE '45000' - SET MESSAGE_TEXT = '拣货任务迁移失败:fl_inbound_loading_pick_task 不是入库汇总结构'; + SET MESSAGE_TEXT = '拣货任务迁移失败:fl_inbound_loading_pick_task 不是完整入库汇总结构'; END IF; INSERT INTO fl_inbound_loading_pick_task (id, tenant_id, inbound_id, status, version, expected_count, picked_count, @@ -5617,6 +5670,49 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_task ( CONSTRAINT chk_loading_pick_task_status CHECK (task_status IN ('LOADING','COMPLETING','COMPLETION_FAILED','COMPLETED')) ) ENGINE=InnoDB COMMENT='PDA装柜拣货任务'; +CREATE TABLE IF NOT EXISTS fl_loading_completion_command ( + id BIGINT UNSIGNED NOT NULL COMMENT '可靠装柜完成命令ID', + pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA任务ID', + operation_id VARCHAR(128) NOT NULL COMMENT '客户端全局幂等操作ID', + request_hash CHAR(64) NOT NULL COMMENT '规范化请求摘要', + command_status VARCHAR(16) NOT NULL COMMENT '命令状态:PROCESSING处理中、RETRYABLE可重试、FAILED失败、COMPLETED已完成', + expected_task_version INT UNSIGNED NOT NULL COMMENT '受理时客户端任务版本', + remark VARCHAR(500) NULL COMMENT '完成备注', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID', + operator_name VARCHAR(64) NULL COMMENT '操作人名称快照', + attempt_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'worker认领次数', + next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次处理时间', + lease_owner VARCHAR(128) NULL COMMENT 'worker租约持有者', + lease_until DATETIME(3) NULL COMMENT 'worker租约截止时间', + failure_stage VARCHAR(40) NULL COMMENT '脱敏失败阶段', + failure_code VARCHAR(32) NULL COMMENT '下游或业务错误码', + failure_request_id VARCHAR(64) NULL COMMENT '下游请求ID', + failure_trace_id VARCHAR(64) 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 '命令更新时间', + completed_at DATETIME(3) NULL COMMENT '命令完成时间', + PRIMARY KEY (id), + UNIQUE KEY uk_loading_completion_operation (operation_id), + KEY idx_loading_completion_dispatch (command_status,next_retry_at,lease_until), + CONSTRAINT fk_loading_completion_task FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task(id), + CONSTRAINT chk_loading_completion_status CHECK (command_status IN ('PROCESSING','RETRYABLE','FAILED','COMPLETED')) +) ENGINE=InnoDB COMMENT='PDA装柜完成可靠异步命令'; + +CREATE TABLE IF NOT EXISTS fl_loading_completion_checkpoint ( + command_id BIGINT UNSIGNED NOT NULL COMMENT '可靠装柜完成命令ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '入仓单客户ID', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入仓单ID', + owner_sequence INT UNSIGNED NOT NULL COMMENT '稳定处理顺序', + checkpoint_stage VARCHAR(24) NOT NULL COMMENT '处理阶段:READY待处理、SETTLED资源已结算、INBOUND_COMPLETED入仓已完成', + 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 (command_id,tenant_id,inbound_id), + KEY idx_loading_completion_owner (command_id,owner_sequence), + CONSTRAINT fk_loading_completion_checkpoint_command FOREIGN KEY (command_id) + REFERENCES fl_loading_completion_command(id), + CONSTRAINT chk_loading_completion_checkpoint CHECK (checkpoint_stage IN ('READY','SETTLED','INBOUND_COMPLETED')) +) ENGINE=InnoDB COMMENT='PDA装柜完成按入库单恢复检查点'; + CREATE TABLE IF NOT EXISTS fl_loading_pick_item ( id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货明细ID', pick_task_id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货任务ID', @@ -5626,6 +5722,8 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_item ( planned_package_count INT UNSIGNED NOT NULL COMMENT '计划拣货箱数;上层拆分按业务规则向上取整', picked_package_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已拣货箱数', item_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '拣货明细状态:PENDING待拣、PICKING拣货中、PICKED已拣完、PARTIAL部分拣货、SKIPPED跳拣', + release_mode VARCHAR(10) NULL COMMENT 'AUTO按比例释放,MANUAL按人工选择释放', + release_location_ids JSON NULL COMMENT '人工选择释放库位ID;空数组表示明确释放0个', version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT 'PDA拣货明细创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT 'PDA拣货明细最后更新时间', @@ -5658,8 +5756,6 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_history ( CONSTRAINT fk_loading_pick_history_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id) ) ENGINE=InnoDB COMMENT='PDA拣货幂等和操作审计历史'; - - DROP PROCEDURE IF EXISTS repair_phase3_loading_pick_task_foreign_keys; DELIMITER $$ CREATE PROCEDURE repair_phase3_loading_pick_task_foreign_keys() @@ -6031,8 +6127,10 @@ BEGIN OR NOT (NEW.cabinet_no <=> OLD.cabinet_no) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务稳定归属不可修改'; END IF; - IF NOT (OLD.task_status = 'LOADING' AND NEW.task_status IN ('LOADING', 'COMPLETED')) THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务状态只允许完成'; + IF NOT ((OLD.task_status = 'LOADING' AND NEW.task_status IN ('LOADING','COMPLETING')) + OR (OLD.task_status = 'COMPLETING' AND NEW.task_status IN ('COMPLETING','COMPLETION_FAILED','COMPLETED')) + OR (OLD.task_status = 'COMPLETION_FAILED' AND NEW.task_status = 'COMPLETING')) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务状态流转非法'; END IF; END$$ DELIMITER ; @@ -6194,10 +6292,11 @@ DELIMITER ; -- 三期智能装柜、预装清单和装柜清单平台权限;与一期平台根菜单幂等合并。 USE baiyue_iam; -DROP PROCEDURE IF EXISTS assert_smart_loading_permission_ids_available; +DROP PROCEDURE IF EXISTS relocate_legacy_inbound_loading_permissions; DELIMITER $$ -CREATE PROCEDURE assert_smart_loading_permission_ids_available() +CREATE PROCEDURE relocate_legacy_inbound_loading_permissions() BEGIN + DECLARE legacy_count INT DEFAULT 0; IF EXISTS ( SELECT 1 FROM iam_permission WHERE id IN (5102091, 5102092, 5102093, 5102094, 5102096, 5102097, 5102098, 5102099) @@ -6215,10 +6314,89 @@ BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '三期权限迁移失败:5102091-5102099 存在未知权限占用'; END IF; + SELECT COUNT(*) INTO legacy_count + FROM iam_permission + WHERE tenant_id = 0 AND app_code = 'PLATFORM' + AND permission_code IN ( + 'firstleg:inbound:split', 'firstleg:inbound:preload', 'firstleg:inbound:preload-view', + 'platform:firstleg:inbound:loading:edit', 'platform:firstleg:inbound:loading:view', + 'platform:firstleg:inbound:loading:complete', 'platform:firstleg:inbound:delivery:complete', + 'platform:firstleg:inbound:picking:edit', 'platform:firstleg:inbound:picking:view' + ); + IF legacy_count > 0 THEN + IF EXISTS ( + SELECT 1 FROM iam_permission + WHERE id BETWEEN 5102121 AND 5102129 + AND permission_code <> CASE id + WHEN 5102121 THEN 'firstleg:inbound:split' + WHEN 5102122 THEN 'firstleg:inbound:preload' + WHEN 5102123 THEN 'firstleg:inbound:preload-view' + WHEN 5102124 THEN 'platform:firstleg:inbound:loading:edit' + WHEN 5102125 THEN 'platform:firstleg:inbound:loading:view' + WHEN 5102126 THEN 'platform:firstleg:inbound:loading:complete' + WHEN 5102127 THEN 'platform:firstleg:inbound:delivery:complete' + WHEN 5102128 THEN 'platform:firstleg:inbound:picking:edit' + WHEN 5102129 THEN 'platform:firstleg:inbound:picking:view' + END + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '三期权限迁移失败:5102121-5102129 已被其他权限占用'; + END IF; + DROP TEMPORARY TABLE IF EXISTS tmp_phase3_inbound_role_grant; + CREATE TEMPORARY TABLE tmp_phase3_inbound_role_grant ( + role_id BIGINT UNSIGNED NOT NULL, + tenant_id BIGINT UNSIGNED NOT NULL, + permission_code VARCHAR(128) NOT NULL, + PRIMARY KEY (role_id, tenant_id, permission_code) + ) ENGINE=MEMORY; + INSERT IGNORE INTO tmp_phase3_inbound_role_grant (role_id, tenant_id, permission_code) + SELECT role_permission.role_id, role_permission.tenant_id, permission.permission_code + FROM iam_role_permission role_permission + JOIN iam_permission permission ON permission.id = role_permission.permission_id + WHERE permission.permission_code IN ( + 'firstleg:inbound:split', 'firstleg:inbound:preload', 'firstleg:inbound:preload-view', + 'platform:firstleg:inbound:loading:edit', 'platform:firstleg:inbound:loading:view', + 'platform:firstleg:inbound:loading:complete', 'platform:firstleg:inbound:delivery:complete', + 'platform:firstleg:inbound:picking:edit', 'platform:firstleg:inbound:picking:view' + ); + DELETE role_permission FROM iam_role_permission role_permission + JOIN iam_permission permission ON permission.id = role_permission.permission_id + WHERE permission.permission_code IN ( + 'firstleg:inbound:split', 'firstleg:inbound:preload', 'firstleg:inbound:preload-view', + 'platform:firstleg:inbound:loading:edit', 'platform:firstleg:inbound:loading:view', + 'platform:firstleg:inbound:loading:complete', 'platform:firstleg:inbound:delivery:complete', + 'platform:firstleg:inbound:picking:edit', 'platform:firstleg:inbound:picking:view' + ); + DELETE FROM iam_permission WHERE permission_code IN ( + 'firstleg:inbound:split', 'firstleg:inbound:preload', 'firstleg:inbound:preload-view', + 'platform:firstleg:inbound:loading:edit', 'platform:firstleg:inbound:loading:view', + 'platform:firstleg:inbound:loading:complete', 'platform:firstleg:inbound:delivery:complete', + 'platform:firstleg:inbound:picking:edit', 'platform:firstleg:inbound:picking:view' + ); + INSERT INTO iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, + permission_name, route_path, http_method, sort_no, status, built_in, deleted) + VALUES + (5102121, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:inbound:split', '智能分柜', NULL, NULL, 140, 'ACTIVE', 1, 0), + (5102122, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:inbound:preload', '编辑并确认预装清单', NULL, NULL, 150, 'ACTIVE', 1, 0), + (5102123, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:inbound:preload-view', '查看预装清单', NULL, NULL, 160, 'ACTIVE', 1, 0), + (5102124, 0, 'PLATFORM', 'BUTTON', 5102030, 'platform:firstleg:inbound:loading:edit', '维护装柜证据', NULL, NULL, 170, 'ACTIVE', 1, 0), + (5102125, 0, 'PLATFORM', 'BUTTON', 5102030, 'platform:firstleg:inbound:loading:view', '查看装柜证据', NULL, NULL, 180, 'ACTIVE', 1, 0), + (5102126, 0, 'PLATFORM', 'BUTTON', 5102030, 'platform:firstleg:inbound:loading:complete', '完成装柜', NULL, NULL, 190, 'ACTIVE', 1, 0), + (5102127, 0, 'PLATFORM', 'BUTTON', 5102030, 'platform:firstleg:inbound:delivery:complete', '完成派送', NULL, NULL, 200, 'ACTIVE', 1, 0), + (5102128, 0, 'PLATFORM', 'BUTTON', 5102030, 'platform:firstleg:inbound:picking:edit', '执行拣货', NULL, NULL, 210, 'ACTIVE', 1, 0), + (5102129, 0, 'PLATFORM', 'BUTTON', 5102030, 'platform:firstleg:inbound:picking:view', '查看拣货任务', NULL, NULL, 220, 'ACTIVE', 1, 0); + INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id) + SELECT grant_row.role_id, permission.id, grant_row.tenant_id + FROM tmp_phase3_inbound_role_grant grant_row + JOIN iam_permission permission ON permission.permission_code = grant_row.permission_code + AND permission.tenant_id = 0 AND permission.app_code = 'PLATFORM'; + DROP TEMPORARY TABLE tmp_phase3_inbound_role_grant; + END IF; END$$ DELIMITER ; -CALL assert_smart_loading_permission_ids_available(); -DROP PROCEDURE assert_smart_loading_permission_ids_available; +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 @@ -6274,7 +6452,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:assign', 'platform:firstleg:smart-loading:delete', + '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', @@ -6285,6 +6463,12 @@ WHERE role.tenant_id = 0 USE baiyue_firstleg; DROP PROCEDURE IF EXISTS assert_phase3_smart_loading_schema; + +DELETE rp FROM baiyue_iam.iam_role_permission rp +JOIN baiyue_iam.iam_permission p ON p.tenant_id=rp.tenant_id AND p.id=rp.permission_id +WHERE p.permission_code IN ('platform:firstleg:container:view','platform:firstleg:preload:view','platform:firstleg:loading-list:view'); +UPDATE baiyue_iam.iam_permission SET status='INACTIVE',deleted=1 +WHERE permission_code IN ('platform:firstleg:container:view','platform:firstleg:preload:view','platform:firstleg:loading-list:view'); DELIMITER $$ CREATE PROCEDURE assert_phase3_smart_loading_schema() BEGIN @@ -6426,9 +6610,118 @@ BEGIN END$$ DELIMITER ; --- ============================================================ +DROP PROCEDURE IF EXISTS assert_loading_pick_task_shapes; +DELIMITER $$ +CREATE PROCEDURE assert_loading_pick_task_shapes(IN require_both TINYINT) +BEGIN + DECLARE source_exists INT DEFAULT 0; + DECLARE target_exists INT DEFAULT 0; + DECLARE source_total INT DEFAULT 0; + DECLARE source_smart INT DEFAULT 0; + DECLARE source_stable_smart INT DEFAULT 0; + DECLARE source_inbound INT DEFAULT 0; + DECLARE target_total INT DEFAULT 0; + DECLARE target_inbound INT DEFAULT 0; + SELECT COUNT(*) INTO source_exists FROM information_schema.tables + WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'; + SELECT COUNT(*) INTO target_exists FROM information_schema.tables + WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_loading_pick_task'; + IF source_exists = 1 THEN + SELECT COUNT(*) INTO source_total FROM information_schema.columns + WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'; + SELECT COUNT(*) INTO source_smart FROM information_schema.columns + WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND ( + (column_name = 'id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO') + OR (column_name = 'loading_plan_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO') + OR (column_name = 'container_no' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'NO') + OR (column_name = 'task_status' AND LOWER(column_type) = 'varchar(16)' AND is_nullable = 'NO' AND column_default = 'LOADING') + OR (column_name = 'started_by' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO') + OR (column_name = 'started_by_name' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES') + OR (column_name = 'started_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%') + OR (column_name = 'completed_by' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'YES') + OR (column_name = 'completed_by_name' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES') + OR (column_name = 'completed_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'YES') + OR (column_name = 'version' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'NO' AND CAST(column_default AS CHAR) = '0')); + SELECT COUNT(*) INTO source_inbound FROM information_schema.columns + WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND ( + (column_name = 'id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO') + OR (column_name = 'tenant_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO') + OR (column_name = 'inbound_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO') + OR (column_name = 'status' AND LOWER(column_type) = 'varchar(24)' AND is_nullable = 'NO' AND column_default = 'PENDING') + OR (column_name = 'version' AND LOWER(column_type) = 'int' AND is_nullable = 'NO' AND CAST(column_default AS CHAR) = '0') + OR (column_name = 'expected_count' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'YES') + OR (column_name = 'picked_count' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'YES') + OR (column_name = 'remark' AND LOWER(column_type) = 'varchar(1000)' AND is_nullable = 'YES') + OR (column_name = 'updated_by' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO') + OR (column_name = 'updated_by_name' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES') + OR (column_name = 'created_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%') + OR (column_name = 'updated_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%' AND LOWER(extra) LIKE '%on update current_timestamp%')); + SELECT COUNT(*) INTO source_stable_smart FROM information_schema.columns + WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND ( + (column_name = 'id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO') + OR (column_name = 'loading_plan_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO') + OR (column_name = 'container_booking_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO') + OR (column_name = 'cabinet_no' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'NO') + OR (column_name = 'container_no' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES') + OR (column_name = 'task_status' AND LOWER(column_type) = 'varchar(24)' AND is_nullable = 'NO' AND column_default = 'LOADING') + OR (column_name = 'started_by' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO') + OR (column_name = 'started_by_name' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES') + OR (column_name = 'started_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%') + OR (column_name = 'completed_by' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'YES') + OR (column_name = 'completed_by_name' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES') + OR (column_name = 'completed_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'YES') + OR (column_name = 'version' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'NO' AND CAST(column_default AS CHAR) = '0')); + IF NOT ( + (source_total = 11 AND source_smart = 11 + AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND index_name = 'uk_loading_pick_task_plan' AND non_unique = 0 AND seq_in_index = 1 AND column_name = 'loading_plan_id') = 1 + AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND index_name = 'idx_loading_pick_container' AND ((seq_in_index = 1 AND column_name = 'container_no') OR (seq_in_index = 2 AND column_name = 'task_status') OR (seq_in_index = 3 AND column_name = 'started_at'))) = 3 + AND (SELECT COUNT(*) FROM information_schema.key_column_usage WHERE constraint_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND column_name = 'loading_plan_id' AND referenced_table_name = 'fl_loading_plan' AND referenced_column_name = 'id') = 1) + OR + (source_total = 12 AND source_inbound = 12 + AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND index_name = 'uk_loading_pick_tenant_inbound' AND non_unique = 0 AND ((seq_in_index = 1 AND column_name = 'tenant_id') OR (seq_in_index = 2 AND column_name = 'inbound_id'))) = 2 + AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND index_name = 'idx_loading_pick_status' AND ((seq_in_index = 1 AND column_name = 'tenant_id') OR (seq_in_index = 2 AND column_name = 'status') OR (seq_in_index = 3 AND column_name = 'updated_at'))) = 3 + AND (SELECT COUNT(*) FROM information_schema.key_column_usage WHERE constraint_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND column_name = 'inbound_id' AND referenced_table_name = 'fl_inbound_order' AND referenced_column_name = 'id') = 1) + OR + (source_total = 13 AND source_stable_smart = 13 + AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND index_name = 'uk_loading_pick_task_plan' AND non_unique = 0 AND seq_in_index = 1 AND column_name = 'loading_plan_id') = 1 + AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND index_name = 'uk_loading_pick_task_booking' AND non_unique = 0 AND seq_in_index = 1 AND column_name = 'container_booking_id') = 1 + AND (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND index_name = 'idx_loading_pick_container' AND ((seq_in_index = 1 AND column_name = 'container_no') OR (seq_in_index = 2 AND column_name = 'task_status') OR (seq_in_index = 3 AND column_name = 'started_at'))) = 3 + AND (SELECT COUNT(*) FROM information_schema.key_column_usage WHERE constraint_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND column_name = 'loading_plan_id' AND referenced_table_name = 'fl_loading_plan' AND referenced_column_name = 'id') = 1) + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拣货任务迁移失败:fl_loading_pick_task 不是完整 canonical 表型'; + END IF; + END IF; + IF target_exists = 1 THEN + SELECT COUNT(*) INTO target_total FROM information_schema.columns + WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_loading_pick_task'; + SELECT COUNT(*) INTO target_inbound FROM information_schema.columns + WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_loading_pick_task' AND ( + (column_name = 'id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO') + OR (column_name = 'tenant_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO') + OR (column_name = 'inbound_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO') + OR (column_name = 'status' AND LOWER(column_type) = 'varchar(24)' AND is_nullable = 'NO' AND column_default = 'PENDING') + OR (column_name = 'version' AND LOWER(column_type) = 'int' AND is_nullable = 'NO' AND CAST(column_default AS CHAR) = '0') + OR (column_name = 'expected_count' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'YES') + OR (column_name = 'picked_count' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'YES') + OR (column_name = 'remark' AND LOWER(column_type) = 'varchar(1000)' AND is_nullable = 'YES') + OR (column_name = 'updated_by' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO') + OR (column_name = 'updated_by_name' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES') + OR (column_name = 'created_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%') + OR (column_name = 'updated_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%' AND LOWER(extra) LIKE '%on update current_timestamp%')); + IF target_total <> 12 OR target_inbound <> 12 + OR (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_loading_pick_task' AND index_name = 'uk_loading_pick_tenant_inbound' AND non_unique = 0 AND ((seq_in_index = 1 AND column_name = 'tenant_id') OR (seq_in_index = 2 AND column_name = 'inbound_id'))) <> 2 + OR (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_loading_pick_task' AND index_name = 'idx_loading_pick_status' AND ((seq_in_index = 1 AND column_name = 'tenant_id') OR (seq_in_index = 2 AND column_name = 'status') OR (seq_in_index = 3 AND column_name = 'updated_at'))) <> 3 + OR (SELECT COUNT(*) FROM information_schema.key_column_usage WHERE constraint_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_loading_pick_task' AND column_name = 'inbound_id' AND referenced_table_name = 'fl_inbound_order' AND referenced_column_name = 'id') <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拣货任务迁移失败:fl_inbound_loading_pick_task 不是完整 canonical 表型'; + END IF; + END IF; + IF require_both = 1 AND (source_exists <> 1 OR target_exists <> 1 OR source_stable_smart <> 13) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拣货任务终验失败:两张 canonical 表未同时就绪'; + END IF; +END$$ +DELIMITER ; +CALL assert_loading_pick_task_shapes(0); --- 2026-08-06 平台公共仓库正式主数据:固定作业仓 + FBA 地址库 -- 来源:warehouse/src/main/resources/db/warehouse-public-master-data.sql。必须位于最终 PASS 校验之前,可重复执行。 USE baiyue_warehouse; @@ -10833,7 +11126,7 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query ( CONSTRAINT fk_carrier_schedule_query_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id), CONSTRAINT fk_carrier_schedule_query_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id), CONSTRAINT fk_carrier_schedule_query_result FOREIGN KEY (result_port_schedule_id) REFERENCES fl_port_schedule(id), - CONSTRAINT chk_carrier_schedule_query_customer CHECK (customer_id > 0), + CONSTRAINT chk_carrier_schedule_query_customer CHECK (customer_id >= 0), CONSTRAINT chk_carrier_schedule_query_carrier CHECK (carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')), CONSTRAINT chk_carrier_schedule_query_tracking CHECK @@ -10895,7 +11188,8 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_history ( CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact ( id BIGINT UNSIGNED NOT NULL COMMENT '标准船节点事实ID', - query_id BIGINT UNSIGNED NOT NULL COMMENT '所属查询任务ID', + query_id BIGINT UNSIGNED NULL COMMENT '自动跟踪任务ID;人工补录时为空', + container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '所属真实货柜ID', event_key VARCHAR(192) NOT NULL COMMENT '供应商编码与供应商事件ID组成的幂等键', provider_code VARCHAR(32) NOT NULL COMMENT '轨迹供应商编码', provider_event_id VARCHAR(128) NOT NULL COMMENT '供应商稳定事件ID', @@ -10911,9 +11205,11 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_node_fact ( raw_payload_hash CHAR(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间', PRIMARY KEY (id), - UNIQUE KEY uk_carrier_schedule_node_event (query_id,event_key), + UNIQUE KEY uk_carrier_schedule_node_event (container_booking_id,event_key), KEY idx_carrier_schedule_node_timeline (query_id,node_sequence,observed_at,id), + KEY idx_container_shipping_node_timeline (container_booking_id,observed_at,id), CONSTRAINT fk_carrier_schedule_node_query FOREIGN KEY (query_id) REFERENCES fl_carrier_schedule_query(id), + CONSTRAINT fk_carrier_schedule_node_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id), CONSTRAINT chk_carrier_schedule_node_standard CHECK (standard_node IN ('DEPARTED_WAREHOUSE','AT_ORIGIN_PORT','LOADED_ON_VESSEL','DEPARTED_ORIGIN_PORT', 'IN_TRANSIT','ARRIVED_DESTINATION_PORT','DISCHARGED_FROM_VESSEL','PICKUP_READY','PICKED_UP','EMPTY_RETURNED')), @@ -11109,6 +11405,34 @@ END$$ DELIMITER ; CALL assert_carrier_schedule_query_schema(); DROP PROCEDURE IF EXISTS assert_carrier_schedule_query_schema; +DELIMITER $$ +CREATE PROCEDURE assert_carrier_schedule_query_schema() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema='baiyue_firstleg' AND table_name IN + ('fl_carrier_schedule_query','fl_carrier_schedule_query_attempt', + 'fl_carrier_schedule_query_history','fl_carrier_schedule_query_operation', + 'fl_carrier_schedule_node_fact','fl_carrier_schedule_exception_fact')) <> 6 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务表未完整创建'; + END IF; + IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission + WHERE tenant_id=0 AND id IN (5102184,5102186,5102188) + AND permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 3 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务权限未完整创建'; + END IF; + IF (SELECT COUNT(*) + FROM baiyue_iam.iam_role_permission role_permission + JOIN baiyue_iam.iam_role role ON role.id=role_permission.role_id + JOIN baiyue_iam.iam_permission permission ON permission.id=role_permission.permission_id + WHERE role.tenant_id=0 AND role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR') + AND permission.tenant_id=0 + AND permission.permission_code LIKE 'platform:firstleg:port-schedule-query:%') <> 6 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询任务角色授权未完整创建'; + END IF; +END$$ +DELIMITER ; +CALL assert_carrier_schedule_query_schema(); +DROP PROCEDURE IF EXISTS assert_carrier_schedule_query_schema; DROP PROCEDURE IF EXISTS migrate_carrier_schedule_retry_columns; DELIMITER $$ @@ -13564,6 +13888,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_customer_charge_rule', 'deleted CALL queue_column_comment('baiyue_firstleg', 'fl_customer_charge_rule', 'effective_from', '生效开始时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_customer_charge_rule', 'effective_to', '生效结束时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_customer_charge_rule', 'id', '客户计费规则ID'); +CALL queue_column_comment('baiyue_firstleg', 'fl_customer_charge_rule', 'initialization_key', '客户创建执行器幂等键及补偿归属'); CALL queue_column_comment('baiyue_firstleg', 'fl_customer_charge_rule', 'remark', '规则备注'); CALL queue_column_comment('baiyue_firstleg', 'fl_customer_charge_rule', 'rounding_mode', '舍入方式'); CALL queue_column_comment('baiyue_firstleg', 'fl_customer_charge_rule', 'rounding_scale', '结果保留小数位数'); @@ -15754,6 +16079,16 @@ BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '统一迁移失败:管理员或订单运营角色缺少订单导出与下载中心权限'; END IF; + IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission + WHERE id = 5102027 AND tenant_id = 0 AND app_code = 'PLATFORM' + AND permission_code = 'platform:order:declaration:edit' + AND parent_id = 5102010 AND status = 'ACTIVE' AND deleted = 0) <> 1 + OR (SELECT COUNT(DISTINCT role_id) FROM baiyue_iam.iam_role_permission + WHERE tenant_id = 0 AND permission_id = 5102027 + AND role_id IN (4000001, 4000003)) <> 2 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '统一迁移失败:订单申报与运营预估权限未正确授予平台管理员和订单运营角色'; + END IF; IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND id BETWEEN 5102121 AND 5102129 @@ -18867,6 +19202,17 @@ DROP PROCEDURE IF EXISTS add_finance_p0_column; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema='baiyue_finance' AND table_name='fin_fee_source' AND index_name='idx_fin_fee_source_wallet_hold'), 'SELECT 1', 'ALTER TABLE baiyue_finance.fin_fee_source ADD KEY idx_fin_fee_source_wallet_hold(wallet_hold_id)')); PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement; +-- 跨客户合柜的订舱属于平台技术作用域,船节点任务使用 customer_id=0。 +USE baiyue_firstleg; +SET @ddl = (SELECT IF(EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE constraint_schema=DATABASE() AND table_name='fl_carrier_schedule_query' + AND constraint_name='chk_carrier_schedule_query_customer' AND constraint_type='CHECK' +), 'ALTER TABLE fl_carrier_schedule_query DROP CHECK chk_carrier_schedule_query_customer', 'SELECT 1')); +PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement; +ALTER TABLE fl_carrier_schedule_query + ADD CONSTRAINT chk_carrier_schedule_query_customer CHECK (customer_id >= 0); + CALL baiyue_firstleg.assert_unified_final_schema(); DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema; SELECT 'PASS' AS migration_status, diff --git a/deploy/sql/权限点初始化-MySQL8.sql b/deploy/sql/权限点初始化-MySQL8.sql new file mode 100644 index 000000000..651f45d01 --- /dev/null +++ b/deploy/sql/权限点初始化-MySQL8.sql @@ -0,0 +1,66 @@ +-- 通用 IAM 权限点独立初始化入口(MySQL 8.0)。 +-- 国内与海外业务的 Admin/Client 权限主数据均在 baiyue_iam 统一管理。 +-- 只允许写入 baiyue_iam 的权限资源和角色授权; +-- 不建表、不创建账号/客户/仓库,不写业务测试数据。 +-- 前置:IAM 基础表、相关父菜单及系统角色已由全量基线创建。 +USE baiyue_iam; +SET NAMES utf8mb4; + +DROP PROCEDURE IF EXISTS apply_permission_points; +DELIMITER $$ +CREATE PROCEDURE apply_permission_points() +BEGIN + IF NOT EXISTS (SELECT 1 FROM iam_permission WHERE id = 5102010 AND tenant_id = 0 + AND permission_code = 'platform:order:list' AND permission_type = 'MENU') THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '权限初始化失败:订单列表父菜单不存在,请先执行全量基线'; + END IF; + IF (SELECT COUNT(*) FROM iam_role WHERE id IN (4000001, 4000003) AND tenant_id = 0 + AND app_code = 'PLATFORM' AND status = 'ACTIVE' AND deleted = 0) <> 2 THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '权限初始化失败:平台管理员或订单运营角色不存在'; + END IF; + IF EXISTS (SELECT 1 FROM iam_permission WHERE id = 5102027 + AND permission_code <> 'platform:order:declaration:edit') + OR EXISTS (SELECT 1 FROM iam_permission + WHERE permission_code = 'platform:order:declaration:edit' AND id <> 5102027) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '权限初始化失败:5102027 或权限码已被其他语义占用'; + END IF; + + START TRANSACTION; + INSERT INTO iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, + route_path, http_method, sort_no, status, deleted) + VALUES + (5102027, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:declaration:edit', + '修改订单申报与运营预估', NULL, NULL, 130, 'ACTIVE', 0) + ON DUPLICATE KEY UPDATE + permission_name = VALUES(permission_name), parent_id = VALUES(parent_id), + permission_type = VALUES(permission_type), app_code = VALUES(app_code), + sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0; + + INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) + SELECT role_id, 5102027, 0 + FROM (SELECT 4000001 AS role_id UNION ALL SELECT 4000003 AS role_id) roles + ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); + + IF (SELECT COUNT(*) FROM iam_permission WHERE id = 5102027 AND tenant_id = 0 + AND app_code = 'PLATFORM' AND permission_type = 'BUTTON' AND parent_id = 5102010 + AND permission_code = 'platform:order:declaration:edit' + AND status = 'ACTIVE' AND deleted = 0) <> 1 + OR (SELECT COUNT(DISTINCT role_id) FROM iam_role_permission + WHERE tenant_id = 0 AND permission_id = 5102027 + AND role_id IN (4000001, 4000003)) <> 2 THEN + ROLLBACK; + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '权限初始化失败:订单申报与运营预估权限未完整写入'; + END IF; + COMMIT; +END$$ +DELIMITER ; + +CALL apply_permission_points(); +DROP PROCEDURE IF EXISTS apply_permission_points; + +SELECT 'PASS' AS permission_initialization_status; diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index ee4c98be0..bd7e49348 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -1,6 +1,7 @@ -- 海外区域唯一数据库初始化入口;空库和存量测试库均只执行本文件。 -- 本文件内联 Lastleg 与海外仓空库基线,再执行统一增量和最终结构校验。 -- 本文件只由人工审核执行,服务部署默认不自动执行 SQL。 +-- 空库先执行 lastleg-service/src/main/resources/db/lastleg.sql 基线;本入口同时内联该基线用于固定部署。 SET NAMES utf8mb4; -- 海外 Event Bridge 空库基线必须先于业务服务表初始化。 @@ -112,7 +113,7 @@ CREATE TABLE IF NOT EXISTS ll_loading_event_inbox ( CREATE TABLE IF NOT EXISTS ll_container_shipping_gate ( container_booking_id VARCHAR(128) NOT NULL COMMENT '来源头程柜预订ID', - ocean_booking_id VARCHAR(128) NOT NULL COMMENT '来源海运订舱ID', container_no VARCHAR(64) NOT NULL COMMENT '实际集装箱号', + ocean_booking_id VARCHAR(128) NULL COMMENT '来源海运订舱ID;货柜未关联订舱时为空', container_no VARCHAR(64) NOT NULL COMMENT '实际集装箱号', status VARCHAR(32) NOT NULL DEFAULT 'AWAITING_PICKUP_READY' COMMENT '海外收货门禁状态:AWAITING_PICKUP_READY、RECEIVING_AVAILABLE', standard_node VARCHAR(64) NULL COMMENT '最新有效标准船节点', node_sequence INT NOT NULL DEFAULT 0 COMMENT '最新有效节点单调序号', observed_at DATETIME(3) NULL COMMENT '供应商节点观测时间', actual_at DATETIME(3) NULL COMMENT '节点实际发生时间', @@ -3321,7 +3322,7 @@ PREPARE phase4_inventory_stmt FROM @ddl; EXECUTE phase4_inventory_stmt; DEALLOCA -- 海运节点达到可提柜后才允许海外 PDA 认领;装柜完成只建立等待门禁快照。 CREATE TABLE IF NOT EXISTS ll_container_shipping_gate ( container_booking_id VARCHAR(128) NOT NULL COMMENT '来源头程柜预订ID', - ocean_booking_id VARCHAR(128) NOT NULL COMMENT '来源海运订舱ID', + ocean_booking_id VARCHAR(128) NULL COMMENT '来源海运订舱ID;货柜未关联订舱时为空', container_no VARCHAR(64) NOT NULL COMMENT '实际集装箱号', status VARCHAR(32) NOT NULL DEFAULT 'AWAITING_PICKUP_READY' COMMENT '海外收货门禁状态:AWAITING_PICKUP_READY、RECEIVING_AVAILABLE', standard_node VARCHAR(64) NULL COMMENT '最新有效标准船节点', @@ -4587,3 +4588,7 @@ CALL assert_overseas_finance_p0_ready(); DROP PROCEDURE IF EXISTS assert_overseas_finance_p0_ready; SELECT 'PASS' AS migration_status, '海外财务P0费用来源字段已就绪' AS message; + +-- 人工货柜节点独立于订舱:货柜尚未关联海运订舱时仍可同步当前节点。 +ALTER TABLE ll_container_shipping_gate + MODIFY ocean_booking_id VARCHAR(128) NULL COMMENT '来源海运订舱ID;货柜未关联订舱时为空'; diff --git a/file/AGENTS.md b/file/AGENTS.md index e432bb45e..fe8603673 100644 --- a/file/AGENTS.md +++ b/file/AGENTS.md @@ -15,7 +15,7 @@ | 测量图片/证据 | `controller/PlatformMeasurementImageController.java`、`InternalMeasurementImageController.java`、`service/MeasurementImageService.java` | | 平台订单商品图片读取 | `controller/PlatformProductImageController.java` 的 `/api/platform/v1/files/product-images/{tenantId}/{fileId}`、`service/ProductImageService.java`;仅平台 `platform:order:list` 可按明确客户与文件 ID 内联读取,响应 `no-store` | | 装柜与业务文件证据 | `controller/PlatformLoadingEvidenceController.java`、`InternalLoadingEvidenceController.java`、`service/LoadingEvidenceService.java`、`src/main/resources/openapi/loading-evidence-v1.yaml`;五类装柜证据固定为平台所有、`tenantId=0`、`businessId=containerBookingId`,不得绑定客户或入仓单。其他业务文件进入 `InternalBusinessFileController.java`、`service/InternalBusinessFileService.java` | -| 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;`FINANCE_VOUCHER` 按客户与业务 ID 精确校验;`OVERSEAS_UNLOAD_PHOTO` 是多客户共享柜的物理证据,固定平台范围 `tenantId=0` 并按收货会话 ID 精确校验 | +| 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;`FINANCE_VOUCHER` 按客户与业务 ID 精确校验;`OVERSEAS_UNLOAD_PHOTO` 是允许同柜多客户的柜级物理证据,固定平台范围 `tenantId=0` 并按接柜会话 ID 精确校验 | | 海外异常订单证据 | 通用内部业务文件入口;固定 `businessType=OVERSEAS_EXCEPTION_EVIDENCE`、`tenantId=异常订单所属客户ID`、`businessId=异常订单ID`,File 只校验文件复合归属,异常状态、完成门禁和证据引用归 Lastleg | | 海外换标 PDF 资料 | 通用内部业务文件入口,契约 `src/main/resources/openapi/overseas-relabel-document-v1.yaml`;`OVERSEAS_RELABEL_TOTAL_DOCUMENT` 按 `application.id`、`OVERSEAS_RELABEL_ITEM_DOCUMENT` 按 `item.id` 归属,必须指定明确客户与正数业务 ID;仅非空 PDF、最大 10MiB,校验 MIME 和文件签名。File 按客户、类型、业务 ID、文件 ID 复合校验,Lastleg 校验申请/明细关系、客户归属、权限和业务状态 | | 入库扣货凭证 | 通用内部业务文件入口;固定 `businessType=INBOUND_HOLD_EVIDENCE`、`tenantId=客户ID`、`businessId=入库单ID`,客户作用域单文件最多 10MiB;可见性与历史引用由 Firstleg 校验 | diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java index f308db828..f2603ec75 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -56,7 +56,8 @@ enum BusinessFileCategory { void validateOwner(long tenantId) { if ((this == OCEAN_BOOKING_DOCUMENT || this == OVERSEAS_UNLOAD_PHOTO) && tenantId != 0) { - throw new BizException(ResultCode.BAD_REQUEST, "该附件类型必须归属平台"); + throw new BizException(ResultCode.BAD_REQUEST, + this == OCEAN_BOOKING_DOCUMENT ? "订舱附件必须归属平台" : "海外卸柜照片必须归属平台"); } if (customerOwned && tenantId <= 0) { throw new BizException(ResultCode.BAD_REQUEST, "该附件类型必须归属明确客户"); diff --git a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java index acc3bd48b..3f1c1fe34 100644 --- a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java @@ -44,6 +44,8 @@ class InternalBusinessFileServiceTest { when(storage.store(eq(0L), anyString(), same(file), any(BusinessFileCategory.class))).thenAnswer(invocation -> new BusinessFileStorage.Stored("business-files/0/" + invocation.getArgument(1) + "/customs.pdf", "application/pdf", file.getSize(), "b".repeat(64), "customs.pdf")); + when(storage.store(0L, "51", file, BusinessFileCategory.OVERSEAS_UNLOAD_PHOTO)).thenReturn(new BusinessFileStorage.Stored( + "business-files/0/51/unload.jpg", "image/jpeg", file.getSize(), "c".repeat(64), "unload.jpg")); when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> { ((FileObjectEntity) invocation.getArgument(0)).setId(81L); return 1; }); @@ -72,7 +74,7 @@ class InternalBusinessFileServiceTest { verify(storage).store(0L, "41", file, BusinessFileCategory.CONTAINER_PICKUP_ORDER); verify(storage).store(0L, "51", file, BusinessFileCategory.OVERSEAS_UNLOAD_PHOTO); assertThatThrownBy(() -> service.upload(11L, "OVERSEAS_UNLOAD_PHOTO", "51", 7L, file)) - .isInstanceOf(BizException.class).hasMessageContaining("必须归属平台"); + .isInstanceOf(BizException.class).hasMessageContaining("卸柜照片必须归属平台"); } @Test void oceanBookingDocumentUsesPlatformAndBookingOwnership() { diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 00a21d8f9..3e6de5460 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -86,8 +86,8 @@ ## 国内货柜履约装柜与 PDA 资源结算 - 智能装柜全局工作台由服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `WAIT_LOADING/EDITING/LOADING` 柜次;候选支持按创建日期和预计到货日期闭区间筛选,状态筛选使用最多四项的 `orderStatuses`,未传表示 `PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING` 全部未完成状态。状态只用于搜索和展示,能否加入以响应 `canAssign` 为准,由有效柜次占用、剩余箱数、容量、组锁和版本等事实决定;人工加入允许订单国内仓及渠道与柜次不同,智能推荐允许跨国内仓但仍只选择柜次渠道。容积和重量均允许达到柜型额定值的 150%,超过即拒绝。待审核事实必须经 `PendingReviewOrderClient` 重新核验 Order 权威 `SUBMITTED` 订单,待审核或待测量可先占位,但确认预装和启动 PDA 前仍必须进入 `WAIT_SPLIT`。计划明细通过 `totalPackageCount/remainingPackageCount` 返回可调总量和余量:测量前取 Order 原始申报箱数,进入 `WAIT_SPLIT/LOADING` 后取最新已审核测量箱数;只读补充失败时保守回退当前已分配数,调整写入仍重新查询并校验权威数量。同柜不可拆组只有全部成员命中状态集合时才返回,且必须成员完整、满箱并作为单个分页与分配单元,不能跨页或拆分。一次性移除未拣明细时,普通未拣项直接移除;同柜组仅全组未拣时整组移除,任一成员已拣则整组保留,单事务只递增一次计划版本。推荐顺序按入库时间先进先出。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。 -- 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间并将该费用版本永久锁定,平台不得再调整;旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。 -- PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。 +- 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间;客户未确认或该票尚未装柜完成时,平台仍可修改箱规规则、生成新费用版本并重新发送,新版本必须清空旧版客户确认快照。仅当“客户已确认且该票已装柜完成”同时成立时最终冻结;装柜完成不得代替客户自动确认。旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。 +- PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。装柜允许跨国内仓,仓库与渠道限制统一由上游装柜编排负责;PDA 只按已生成任务读取各入仓单自身的 `CONFIRMED` 库位资源,不得再用柜次仓库过滤任务明细、库存快照或拣货写入。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。 - PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。 - 首张称重图片确认时按 `max(已确认称重图片数, ceil(有效体积 / 默认托盘容量))` 尽力预留并确认整票库位块;有效体积先取入库单已落地的正数实测体积,否则对最新测量批次的正数 `totalVolumeCbm/originalVolumeCbm` 取较大值,再无有效值才查询 Order 最新客户申报体积,全部无效则拒绝。后续确认绑定最小空槽,所需槽数增长时通过 Warehouse `/blocks/expand` 原子追加,既有 `blockId/slotNo/reservationId/locationId/sharedOverflow` 不得变化。库位指引失败只记录警告,不阻断称重确认;测量提交后以最新已确认图片调用 `ensureForSubmission` 再次补齐和校验,失败同样不回滚已提交的测量主流程。Firstleg 必须显式保存 `weightResultId -> blockId/slotNo/reservationId/locationId/locationCode`,按 `confirmedAt + weightResultId` 稳定顺序绑定,禁止按列表下标推断;普通连续位不足的新增槽由 Warehouse 放入共享 A0,不阻断确认。 - 删除称重图片只解除图片和托盘快照绑定,将槽位恢复为 `RESERVED_EMPTY`;槽位和整票块继续归当前入仓码。解绑失败时不得调用文件删除,文件删除失败时本地解绑和重量结果删除必须回滚。 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalCustomerDimensionConfigController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalCustomerDimensionConfigController.java index 70df7e8aa..86281d867 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalCustomerDimensionConfigController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalCustomerDimensionConfigController.java @@ -41,7 +41,7 @@ public class InternalCustomerDimensionConfigController { @Valid @RequestBody InitializeRequest request) { requireToken(token); return ApiResponse.success(service.initializeDefault( - request.tenantId(), request.operatorId(), request.initializationKey())); + request.tenantId(), request.operatorId(), request.initializationKey(), request.customerName())); } @DeleteMapping("/defaults") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java index e4e43d904..d7bdccc2c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java @@ -92,7 +92,6 @@ public class PlatformContainerController { @PathVariable @Positive long id, @PathVariable @Positive long fileId) { FirstlegScopeGuard.platform(user); - service.requireDomestic(id); var value = overview.downloadPhoto(id, fileId); return ResponseEntity.ok().cacheControl(CacheControl.noStore()) .header(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.inline() diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java index a7196bb7b..9d5d3a901 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundMeasurementController.java @@ -93,6 +93,40 @@ public class PlatformInboundMeasurementController { return ApiResponse.success(service.saveDraft(tenantId, inboundId, user.userId(), request)); } + @BusinessAudit(event = "INBOUND_MEASUREMENT_CALCULATION_STRATEGY_UPDATE", resourceType = "INBOUND", + resourceId = "#inboundId", description = "更新入库测量箱规计算规则") + @Operation(summary = "更新箱规计算规则", + description = "独立更新当前测量批次全部明细的箱规计算规则并重算体积;不改变测量提交或审核状态。" + + "仅当客户已确认费用且该票已装柜完成时冻结。") + @PutMapping("/measurement/calculation-strategy") + @PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:edit')") + public ApiResponse updateCalculationStrategy( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam long tenantId, + @PathVariable long inboundId, + @Valid @RequestBody InboundMeasurementRequests.UpdateCalculationStrategyRequest request) { + requirePlatformTenant(user, tenantId); + return ApiResponse.success(service.updateCalculationStrategy(tenantId, inboundId, request)); + } + + @BusinessAudit(event = "INBOUND_MEASUREMENT_OUTER_DIMENSIONS_UPDATE", + resourceType = "INBOUND_MEASUREMENT_ITEM", resourceId = "#itemId", + description = "更新仓库外箱长宽高") + @Operation(summary = "更新仓库外箱长宽高", + description = "独立更新当前测量批次指定明细的仓库外箱尺寸并重算体积;不改变测量提交或审核状态。" + + "仅当客户已确认费用且该票已装柜完成时冻结。") + @PutMapping("/measurement/items/{itemId}/outer-dimensions") + @PreAuthorize("hasAuthority('platform:firstleg:inbound:measure:edit')") + public ApiResponse updateOuterDimensions( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam long tenantId, + @PathVariable long inboundId, + @PathVariable long itemId, + @Valid @RequestBody InboundMeasurementRequests.UpdateOuterDimensionsRequest request) { + requirePlatformTenant(user, tenantId); + return ApiResponse.success(service.updateOuterDimensions(tenantId, inboundId, itemId, request)); + } + @BusinessAudit(event = "INBOUND_MEASUREMENT_SUBMIT", resourceType = "INBOUND", resourceId = "#inboundId", description = "确认测量并提交审核") @Operation(summary = "确认测量并提交审核", diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundOrderController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundOrderController.java index c53158e70..f055a4160 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundOrderController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundOrderController.java @@ -10,6 +10,7 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.dto.InboundOrderDetail; import com.baiyue.firstleg.dto.InboundOrderQuery; import com.baiyue.firstleg.dto.InboundOrderSummary; +import com.baiyue.firstleg.dto.InboundCartonLabels; import com.baiyue.firstleg.dto.InboundHoldDtos.CommandRequest; import com.baiyue.firstleg.dto.InboundHoldDtos.Detail; import com.baiyue.firstleg.service.FirstlegScopeGuard; @@ -22,6 +23,7 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Size; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; @@ -29,6 +31,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RequestParam; @@ -160,6 +163,18 @@ public class PlatformInboundOrderController { return cartonLabelPrintService.generate(tenantId, inboundId, cartonIndex, requestId); } + @Operation(summary = "批量生成入仓箱唛 PDF", + description = "按所选客户和入库单聚合生成一个 PDF;首次调用冻结各入库单整单箱号。") + @PostMapping("/carton-label-pdf") + @PreAuthorize("hasAuthority('platform:firstleg:inbound:view')") + public ResponseEntity printCartonLabelsBatch( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestBody @Size(min = 1, max = 200) List items, + @RequestHeader(value = "X-Request-Id", required = false) String requestId) { + requirePlatform(user); + return cartonLabelPrintService.generateForPlatformInbounds(items, requestId); + } + private static void requirePlatformTenant(AuthenticatedUser user, long tenantId) { FirstlegScopeGuard.platform(user, tenantId); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java index 3f812aa8f..ffa5959db 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java @@ -22,7 +22,8 @@ public final class ContainerCustomsDtos { boolean frozen, FileView completion) {} public record ContainerView(long aggregateId, long loadingPlanId, long containerBookingId, - String planNo, String cabinetNo, String containerNo, String status, String merchantStatus, + String planNo, String cabinetNo, String containerNo, String status, boolean hasActualCargo, + String merchantStatus, int version, int taxRefundCount, int coveredTaxRefundCount, List inbounds, List documents, LocalDateTime startedAt, LocalDateTime completedAt) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java index 8778d08e3..f15db80a4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java @@ -34,6 +34,15 @@ public interface ContainerCustomsMapper { """) List planInbounds(@Param("planId") long planId); + @Select(""" + SELECT p.id loadingPlanId,p.plan_no planNo,p.container_booking_id containerBookingId, + p.plan_status loadingPlanStatus,b.cabinet_no cabinetNo,b.container_no containerNo + FROM fl_loading_plan p + JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE' + WHERE p.id=#{planId} + """) + PlanRow plan(@Param("planId") long planId); + @Select(""" SELECT p.id FROM fl_loading_plan p JOIN fl_loading_plan_item item ON item.loading_plan_id=p.id AND item.deleted=0 @@ -331,6 +340,8 @@ public interface ContainerCustomsMapper { record InboundRow(Long loadingPlanId,String planNo,Long containerBookingId,String loadingPlanStatus, String cabinetNo,String containerNo,Long inboundId,Long orderId,String orderNo,String inboundCode, Long tenantId,String customerName,String taxMode) {} + record PlanRow(Long loadingPlanId,String planNo,Long containerBookingId,String loadingPlanStatus, + String cabinetNo,String containerNo) {} record ContainerRow(Long id,Long loadingPlanId,Long containerBookingId,String status,Long startedBy, String startedByName,LocalDateTime startedAt,Long completedBy,String completedByName, LocalDateTime completedAt,LocalDateTime createdAt,LocalDateTime updatedAt,Integer version) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java index 140fed70c..a55395fd6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java @@ -44,6 +44,9 @@ public class ContainerCustomsService { @Transactional public ContainerView platformView(long planId) { + List rows = actualPlanRows(planId); + PlanRow plan = rows.isEmpty() ? requiredPlan(planId) : plan(rows.get(0)); + if (!"COMPLETED".equals(plan.loadingPlanStatus())) return null; ContainerRow container = ensure(planId); return view(container, null, false); } @@ -155,6 +158,7 @@ public class ContainerCustomsService { CommandRow replay = claim(operatorId, action, key, requestHash); if (replay != null) return platformView(planId); ensure(planId); + if (actualPlanRows(planId).isEmpty()) throw conflict("当前装柜计划没有实际货物,无需报关"); ContainerRow locked = mapper.lockContainer(planId); if (locked == null || !READY.equals(locked.status())) throw conflict("当前柜报关资料尚未齐全或已开始报关"); if (!Objects.equals(locked.version(), request.expectedVersion())) throw concurrent(); @@ -219,7 +223,8 @@ public class ContainerCustomsService { ContainerRow container = mapper.lockContainer(planId); if (container == null || !DECLARING.equals(container.status())) throw conflict("当前柜不在报关中"); if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent(); - List actual=planRows(planId); + List actual=actualPlanRows(planId); + if(actual.isEmpty())throw conflict("当前装柜计划没有实际货物,无需报关"); if(actual.stream().anyMatch(row->!"BUY_DECLARATION".equals(row.taxMode())))throw conflict("全买单柜完成只允许全部实际行均为BUY_DECLARATION"); completeContainer(container, operatorId, operatorName, request.remark()); completeCommand(operatorId, action, key, container.id()); @@ -245,11 +250,11 @@ public class ContainerCustomsService { } private ContainerRow ensure(long planId) { - List rows = planRows(planId); - InboundRow first = rows.get(0); - if (!"COMPLETED".equals(first.loadingPlanStatus())) throw conflict("装柜完成后才能处理报关资料"); + List rows = actualPlanRows(planId); + PlanRow plan = rows.isEmpty() ? requiredPlan(planId) : plan(rows.get(0)); + if (!"COMPLETED".equals(plan.loadingPlanStatus())) throw conflict("装柜完成后才能处理报关资料"); boolean refund = rows.stream().anyMatch(row -> "TAX_REFUND".equals(row.taxMode())); - mapper.insertContainer(IdWorker.getId(), planId, first.containerBookingId(), refund ? WAITING : READY); + mapper.insertContainer(IdWorker.getId(), planId, plan.containerBookingId(), refund ? WAITING : READY); return refresh(planId); } @@ -283,11 +288,27 @@ public class ContainerCustomsService { } private List planRows(long planId) { - List rows = mapper.planInbounds(planId); + List rows = actualPlanRows(planId); if (rows == null || rows.isEmpty()) throw notFound("未找到当前装柜计划的实际货物明细"); return rows; } + private List actualPlanRows(long planId) { + List rows = mapper.planInbounds(planId); + return rows == null ? List.of() : rows; + } + + private PlanRow requiredPlan(long planId) { + PlanRow plan = mapper.plan(planId); + if (plan == null) throw notFound("装柜计划不存在或不可用"); + return plan; + } + + private PlanRow plan(InboundRow row) { + return new PlanRow(row.loadingPlanId(), row.planNo(), row.containerBookingId(), row.loadingPlanStatus(), + row.cabinetNo(), row.containerNo()); + } + private List validateScope(long planId, long tenantId, List inboundIds) { if (inboundIds == null || inboundIds.isEmpty() || inboundIds.size() > 200) throw bad("至少选择一个且最多200个入仓码"); Set requested = new LinkedHashSet<>(inboundIds); @@ -304,7 +325,8 @@ public class ContainerCustomsService { } private ContainerView view(ContainerRow container, Long tenantId, boolean merchant) { - List rows = planRows(container.loadingPlanId()); + List rows = actualPlanRows(container.loadingPlanId()); + PlanRow plan = rows.isEmpty() ? requiredPlan(container.loadingPlanId()) : plan(rows.get(0)); List documents = mapper.documents(container.id(), tenantId); Set covered = documents.stream().flatMap(doc -> mapper.scopes(doc.id()).stream()).map(ScopeRow::inboundId).collect(Collectors.toSet()); List visibleRows=rows.stream().filter(row -> tenantId == null || (row.tenantId().equals(tenantId)&&"TAX_REFUND".equals(row.taxMode()))).toList(); @@ -315,9 +337,8 @@ public class ContainerCustomsService { int refund = (int) visibleRows.stream().filter(row -> "TAX_REFUND".equals(row.taxMode())).count(); int coveredRefund = (int) visibleRows.stream().filter(row -> covered.contains(row.inboundId())).count(); String merchantStatus=tenantId==null?null:merchantStatus(container.status(),refund,coveredRefund); - InboundRow first = rows.get(0); - return new ContainerView(container.id(), container.loadingPlanId(), container.containerBookingId(), first.planNo(), - first.cabinetNo(), first.containerNo(), container.status(), merchantStatus, container.version(), refund, coveredRefund, + return new ContainerView(container.id(), container.loadingPlanId(), container.containerBookingId(), plan.planNo(), + plan.cabinetNo(), plan.containerNo(), container.status(), !rows.isEmpty(), merchantStatus, container.version(), refund, coveredRefund, inbounds, documentViews, container.startedAt(), container.completedAt()); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/CustomerDimensionConfigDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/CustomerDimensionConfigDtos.java index 1d8e12098..7a9f1f838 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/CustomerDimensionConfigDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/CustomerDimensionConfigDtos.java @@ -60,8 +60,10 @@ public final class CustomerDimensionConfigDtos { public record InitializeRequest( @NotNull @Min(1) Long tenantId, @NotNull @Min(0) Long operatorId, - @NotBlank @Size(max = 128) String initializationKey + @NotBlank @Size(max = 128) String initializationKey, + @NotBlank @Size(max = 128) String customerName ) { } - public record InitializeResponse(Long configId, boolean createdByThisInitialization) { } + public record InitializeResponse(Long configId, Long chargeRuleId, + boolean createdByThisInitialization) { } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundCartonLabels.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundCartonLabels.java index 937a23367..cec38faa7 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundCartonLabels.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundCartonLabels.java @@ -3,6 +3,8 @@ package com.baiyue.firstleg.dto; import java.math.BigDecimal; import java.util.List; +import jakarta.validation.constraints.Min; + /** 稳定箱号分配、补打和测量解析契约。 */ public final class InboundCartonLabels { private InboundCartonLabels() { @@ -12,6 +14,9 @@ public final class InboundCartonLabels { List