From 08bf8f9a5a740bcfed150c0e8b7bdcd2c99b4e45 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 28 Sep 2026 15:39:15 +0800 Subject: [PATCH] feat(db): add production migration chain --- deploy/sql/README.md | 9 +- .../V2__align_measurement_and_fee_schema.sql | 52 ++++ ...add_customs_receipt_declaration_schema.sql | 146 +++++++++++ .../V4__add_sailing_calendar_schema.sql | 243 ++++++++++++++++++ ...domestic_pda_mojia_sailing_permissions.sql | 85 ++++++ ..._location_zone_and_rebuild_query_index.sql | 18 ++ ...5__allow_multiple_relabel_applications.sql | 14 + ...ema-alignment-migrations-contract.test.mjs | 69 ++++- 8 files changed, 631 insertions(+), 5 deletions(-) create mode 100644 deploy/sql/migrations/cn/baiyue_firstleg/V2__align_measurement_and_fee_schema.sql create mode 100644 deploy/sql/migrations/cn/baiyue_firstleg/V3__add_customs_receipt_declaration_schema.sql create mode 100644 deploy/sql/migrations/cn/baiyue_firstleg/V4__add_sailing_calendar_schema.sql create mode 100644 deploy/sql/migrations/cn/baiyue_iam/V3__align_domestic_pda_mojia_sailing_permissions.sql create mode 100644 deploy/sql/migrations/overseas/baiyue_lastlag_warehouse/V2__retire_location_zone_and_rebuild_query_index.sql create mode 100644 deploy/sql/migrations/overseas/baiyue_lastleg/V5__allow_multiple_relabel_applications.sql diff --git a/deploy/sql/README.md b/deploy/sql/README.md index bb04b1612..05f4ae572 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -66,12 +66,13 @@ | 区域 / schema | 迁移 | 目的 | |---|---|---| -| CN / `baiyue_iam` | `migrations/cn/baiyue_iam/V1__align_schema_baseline.sql` | 收敛客户编号、昵称和资质原文件名定义;收紧前检查存量数据 | -| CN / `baiyue_firstleg` | `migrations/cn/baiyue_firstleg/V1__align_schema_baseline.sql` | 收敛一件代发托盘码非空定义;收紧前检查存量数据 | +| CN / `baiyue_iam` | `migrations/cn/baiyue_iam/V1__align_schema_baseline.sql`、`V2__add_pda_release_permissions.sql`、`V3__align_domestic_pda_mojia_sailing_permissions.sql` | 收敛客户编号、昵称和资质原文件名定义;补齐 PDA 发布、国内作业仓绑定、墨家 SKU 同步、仓管 PDA 和船期日历权限 | +| CN / `baiyue_firstleg` | `migrations/cn/baiyue_firstleg/V1__align_schema_baseline.sql`、`V2__align_measurement_and_fee_schema.sql`、`V3__add_customs_receipt_declaration_schema.sql`、`V4__add_sailing_calendar_schema.sql` | 收敛一件代发托盘码非空定义;补齐墨家测量和人工报价字段、报关行回执/申报事实、船期日历事实表;V2 对存量收件记录仅做阻断检查,不自动回填 | | CN / `baiyue_file` | `migrations/cn/baiyue_file/V1__align_schema_baseline.sql`、`V2__widen_business_type.sql` | 补齐 PDA 发布表和固定渠道,并支持正式长业务类型 | -| OVERSEAS / `baiyue_lastleg` | `migrations/overseas/baiyue_lastleg/V1__align_lastleg_production_schema.sql`、`V2__align_schema_baseline.sql`、`V3__relax_valid_nullable_fields.sql`、`V4__cargo_discard_return_inventory.sql` | 补齐尾程字段并收敛字段定义;放宽退货出库和预入仓批量 SKU 换标合法空值;货物废弃支持退货库存箱 | +| OVERSEAS / `baiyue_lastleg` | `migrations/overseas/baiyue_lastleg/V1__align_lastleg_production_schema.sql`、`V2__align_schema_baseline.sql`、`V3__relax_valid_nullable_fields.sql`、`V4__cargo_discard_return_inventory.sql`、`V5__allow_multiple_relabel_applications.sql` | 补齐尾程字段并收敛字段定义;放宽退货出库和预入仓批量 SKU 换标合法空值;货物废弃支持退货库存箱;允许同一尾程订单多个换标申请 | +| OVERSEAS / `baiyue_lastlag_warehouse` | `migrations/overseas/baiyue_lastlag_warehouse/V1__add_shared_overflow_location.sql`、`V2__retire_location_zone_and_rebuild_query_index.sql` | 补齐共享溢出库位并移除退役 `zone_code`;V2 含 `DROP COLUMN`,生产执行必须单独授权并确认无外部依赖 | -上述新增候选当前只完成编写和离线检查,尚未在测试或生产执行。测试通过后必须复用同一份 SQL 和 manifest 晋级生产,不得重新生成或修改候选。`scripts/prod-database-schema-acceptance.mjs` 仍只负责历史尾程 V1 的专用只读核对/受控执行,不代表本批跨 schema 发布入口;本批按迁移 manifest 管理,实际执行须等待环境执行器接入或当前轮明确授权后的受控人工执行。 +上述迁移候选只完成编写和离线检查,尚未在测试或生产执行。`db-migrations.mjs plan --services iam,firstleg,lastleg-service,warehouse-overseas-service --regions CN,OVERSEAS` 生成的 manifest 目标版本应为 `baiyue_iam=3`、`baiyue_firstleg=4`、`baiyue_lastleg=5`、`baiyue_lastlag_warehouse=2`。测试通过后必须复用同一份 SQL 和 manifest 晋级生产,不得重新生成或修改候选。`scripts/prod-database-schema-acceptance.mjs` 仍只负责历史尾程 V1 的专用只读核对/受控执行,不代表本批跨 schema 发布入口;本批按迁移 manifest 管理,实际执行须等待环境执行器接入或当前轮明确授权后的受控人工执行。 迁移范围映射位于 `migration-scopes.json`,按区域、schema、所属服务和顺序登记。迁移文件位于 `migrations/{cn|overseas}//`,一个文件只允许修改一个 schema。目录中的 `.gitkeep` 仅用于保留空目录,不是迁移文件。 diff --git a/deploy/sql/migrations/cn/baiyue_firstleg/V2__align_measurement_and_fee_schema.sql b/deploy/sql/migrations/cn/baiyue_firstleg/V2__align_measurement_and_fee_schema.sql new file mode 100644 index 000000000..b207b75e1 --- /dev/null +++ b/deploy/sql/migrations/cn/baiyue_firstleg/V2__align_measurement_and_fee_schema.sql @@ -0,0 +1,52 @@ +-- Region: CN +-- Schema: baiyue_firstleg +-- Purpose: 对齐墨家测量收件箱字段并允许人工报价费用快照缺少船司价格。 +-- Preconditions: 连接必须指向 baiyue_firstleg;若墨家收件箱已有记录,必须先完成经批准的重量回填方案;本文件不执行 UPDATE。 +-- Postconditions: 墨家收件箱字段、入库费用船司价格字段与当前 Firstleg 基线一致。 + +SET NAMES utf8mb4; + +SET @guard = IF( + EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt') + AND EXISTS (SELECT 1 FROM fl_mojia_measurement_receipt LIMIT 1), + 'SELECT 1 FROM migration_blocked_firstleg_mojia_receipt_requires_weight_review', + 'SELECT 1' +); +PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' AND column_name = 'inbound_code'), + 'ALTER TABLE fl_mojia_measurement_receipt ADD COLUMN inbound_code VARCHAR(64) NULL COMMENT ''箱码命中后的权威入仓码;SKU记录为空'' AFTER box_code', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' AND column_name = 'measurement_kind'), + 'ALTER TABLE fl_mojia_measurement_receipt ADD COLUMN measurement_kind VARCHAR(16) NOT NULL DEFAULT ''BOX_LABEL'' COMMENT ''测量分流:BOX_LABEL箱唛;SKU一件代发SKU'' AFTER inbound_code', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' AND column_name = 'weight_kg'), + 'ALTER TABLE fl_mojia_measurement_receipt ADD COLUMN weight_kg DECIMAL(18,3) NOT NULL COMMENT ''设备实测重量kg'' AFTER height_cm', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' AND column_name = 'sync_status'), + 'ALTER TABLE fl_mojia_measurement_receipt ADD COLUMN sync_status VARCHAR(24) NULL COMMENT ''SKU同步状态:PENDING/SYNCING/SUCCESS/FAILED'' AFTER status', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' AND column_name = 'sync_failure_code'), + 'ALTER TABLE fl_mojia_measurement_receipt ADD COLUMN sync_failure_code VARCHAR(64) NULL COMMENT ''SKU同步失败业务码'' AFTER sync_status', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' AND column_name = 'sync_failure_reason'), + 'ALTER TABLE fl_mojia_measurement_receipt ADD COLUMN sync_failure_reason VARCHAR(500) NULL COMMENT ''脱敏后的SKU同步失败原因'' AFTER sync_failure_code', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' AND column_name = 'lingxing_sku_id'), + 'ALTER TABLE fl_mojia_measurement_receipt ADD COLUMN lingxing_sku_id VARCHAR(128) NULL COMMENT ''领星SKU内部标识'' AFTER sync_failure_reason', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' AND column_name = 'revision'), + 'ALTER TABLE fl_mojia_measurement_receipt ADD COLUMN revision INT NOT NULL DEFAULT 1 COMMENT ''当前测量版本号'' AFTER lingxing_sku_id', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' AND column_name = 'match_failure_code' AND is_nullable = 'NO'), + 'ALTER TABLE fl_mojia_measurement_receipt MODIFY COLUMN match_failure_code VARCHAR(16) NULL COMMENT ''正式箱唛业务匹配失败码''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_mojia_measurement_receipt' AND column_name = 'match_failure_reason' AND is_nullable = 'NO'), + 'ALTER TABLE fl_mojia_measurement_receipt MODIFY COLUMN match_failure_reason VARCHAR(500) NULL COMMENT ''脱敏后的箱唛匹配失败原因''', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +ALTER TABLE fl_inbound_fee + MODIFY COLUMN carrier_rate_id BIGINT UNSIGNED NULL COMMENT '船司FBA价格ID;人工报价为空', + MODIFY COLUMN carrier_rate_version INT UNSIGNED NULL COMMENT '船司价格版本快照;人工报价为空', + MODIFY COLUMN rate_effective_from DATETIME(3) NULL COMMENT '价格生效开始快照;人工报价为空'; diff --git a/deploy/sql/migrations/cn/baiyue_firstleg/V3__add_customs_receipt_declaration_schema.sql b/deploy/sql/migrations/cn/baiyue_firstleg/V3__add_customs_receipt_declaration_schema.sql new file mode 100644 index 000000000..a4c74eb38 --- /dev/null +++ b/deploy/sql/migrations/cn/baiyue_firstleg/V3__add_customs_receipt_declaration_schema.sql @@ -0,0 +1,146 @@ +-- Region: CN +-- Schema: baiyue_firstleg +-- Purpose: 补齐报关行发送、客户回执动作、完税文件、正式申报批次和海关结果事实。 +-- Preconditions: 连接必须指向 baiyue_firstleg;现有报关主表和客户资料表已存在;不执行数据回填或清理。 +-- Postconditions: 报关增量字段与事实表和当前 Firstleg 报关基线一致。 + +SET NAMES utf8mb4; + +SET @guard = IF( + NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'fl_customs_container'), + 'SELECT 1 FROM migration_blocked_firstleg_customs_container_table_missing', + 'SELECT 1' +); +PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_customs_container' AND column_name = 'broker_submitted_by'), + 'ALTER TABLE fl_customs_container ADD COLUMN broker_submitted_by BIGINT UNSIGNED NULL COMMENT ''已将客户确认结果发送给报关行的操作人ID'' AFTER completed_at', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_customs_container' AND column_name = 'broker_submitted_by_name'), + 'ALTER TABLE fl_customs_container ADD COLUMN broker_submitted_by_name VARCHAR(64) NULL COMMENT ''发送报关行操作人名称快照'' AFTER broker_submitted_by', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_customs_container' AND column_name = 'broker_submitted_at'), + 'ALTER TABLE fl_customs_container ADD COLUMN broker_submitted_at DATETIME(3) NULL COMMENT ''发送报关行执行时间'' AFTER broker_submitted_by_name', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_customs_completion_document' AND column_name = 'customer_confirm_status'), + 'ALTER TABLE fl_customs_completion_document ADD COLUMN customer_confirm_status VARCHAR(20) NOT NULL DEFAULT ''PENDING'' COMMENT ''客户回执确认状态:PENDING/CONFIRMED/REJECTED'' AFTER version', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_customs_completion_document' AND column_name = 'customer_confirmed_by'), + 'ALTER TABLE fl_customs_completion_document ADD COLUMN customer_confirmed_by BIGINT UNSIGNED NULL COMMENT ''客户确认人ID'' AFTER customer_confirm_status', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_customs_completion_document' AND column_name = 'customer_confirmed_by_name'), + 'ALTER TABLE fl_customs_completion_document ADD COLUMN customer_confirmed_by_name VARCHAR(64) NULL COMMENT ''客户确认人名称快照'' AFTER customer_confirmed_by', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_customs_completion_document' AND column_name = 'customer_confirmed_at'), + 'ALTER TABLE fl_customs_completion_document ADD COLUMN customer_confirmed_at DATETIME(3) NULL COMMENT ''客户确认时间'' AFTER customer_confirmed_by_name', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'fl_customs_completion_document' AND column_name = 'customer_reject_reason'), + 'ALTER TABLE fl_customs_completion_document ADD COLUMN customer_reject_reason VARCHAR(500) NULL COMMENT ''客户打回原因'' AFTER customer_confirmed_at', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +CREATE TABLE IF NOT EXISTS fl_customs_broker_submission ( + id BIGINT UNSIGNED NOT NULL COMMENT '首次发送报关行批次ID', + customs_container_id BIGINT UNSIGNED NOT NULL COMMENT '所属整柜报关聚合ID', + loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID快照', + batch_no VARCHAR(64) NOT NULL COMMENT '首次发送报关行批次号', + status VARCHAR(16) NOT NULL COMMENT '发送状态:SENT已发送', + source_version INT UNSIGNED NOT NULL COMMENT '发送时整柜报关聚合版本', + source_snapshot_json JSON NOT NULL COMMENT '发送时冻结资料版本与覆盖范围快照', + sent_by BIGINT UNSIGNED NOT NULL COMMENT '首次发送操作人ID', + sent_by_name VARCHAR(64) NULL COMMENT '首次发送操作人名称快照', + sent_at DATETIME(3) NOT NULL COMMENT '首次发送时间', + version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观锁版本号', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '批次创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '批次更新时间', + PRIMARY KEY(id), + UNIQUE KEY uk_customs_broker_submission_container(customs_container_id), + UNIQUE KEY uk_customs_broker_submission_batch(batch_no), + KEY idx_customs_broker_submission_plan(loading_plan_id,sent_at), + CONSTRAINT fk_customs_broker_submission_container FOREIGN KEY(customs_container_id) REFERENCES fl_customs_container(id), + CONSTRAINT chk_customs_broker_submission_status CHECK(status IN('SENT')) +) ENGINE=InnoDB COMMENT='冻结退税报关资料首次发送报关行批次事实'; + +CREATE TABLE IF NOT EXISTS fl_customs_completion_action_history ( + id BIGINT UNSIGNED NOT NULL COMMENT '客户回执动作历史ID', + completion_document_id BIGINT UNSIGNED NOT NULL COMMENT '报关行回执当前版本逻辑根ID', + receipt_version INT UNSIGNED NOT NULL COMMENT '动作对应的回执文件版本', + action VARCHAR(16) NOT NULL COMMENT '客户动作:CONFIRM确认、REJECT打回', + reason VARCHAR(500) NULL COMMENT '客户打回原因;确认动作为空', + operator_id BIGINT UNSIGNED NOT NULL COMMENT '客户操作人ID', + operator_name VARCHAR(64) NULL COMMENT '客户操作人名称快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '客户动作时间', + PRIMARY KEY(id), + KEY idx_customs_completion_action_history(completion_document_id,receipt_version,created_at,id), + CONSTRAINT fk_customs_completion_action_history_document FOREIGN KEY(completion_document_id) REFERENCES fl_customs_completion_document(id), + CONSTRAINT chk_customs_completion_action_history_action CHECK(action IN('CONFIRM','REJECT')) +) ENGINE=InnoDB COMMENT='客户报关行回执确认与打回不可变历史'; + +CREATE TABLE IF NOT EXISTS fl_customs_final_document ( + id BIGINT UNSIGNED NOT NULL COMMENT '真实报关完税文件ID', + source_document_id BIGINT UNSIGNED NOT NULL COMMENT '对应冻结客户资料ID', + file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID', + file_name VARCHAR(255) NOT NULL COMMENT '真实报关完税文件名称快照', + file_hash CHAR(64) NOT NULL COMMENT '真实报关完税文件SHA-256摘要', + content_type VARCHAR(128) NULL COMMENT '真实报关完税文件媒体类型', + size_bytes BIGINT UNSIGNED NOT NULL COMMENT '真实报关完税文件大小(字节)', + uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传操作人ID', + uploaded_by_name VARCHAR(64) NULL COMMENT '上传操作人名称快照', + uploaded_at DATETIME(3) NOT NULL COMMENT '上传时间', + version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '完税文件版本号', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(id), + UNIQUE KEY uk_customs_final_source(source_document_id), + CONSTRAINT fk_customs_final_source FOREIGN KEY(source_document_id) REFERENCES fl_customs_customer_document(id) +) ENGINE=InnoDB COMMENT='真实报关完税文件当前版本'; + +CREATE TABLE IF NOT EXISTS fl_customs_declaration_batch ( + id BIGINT UNSIGNED NOT NULL COMMENT '正式申报批次ID', + customs_container_id BIGINT UNSIGNED NOT NULL COMMENT '所属整柜报关聚合ID', + loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID快照', + batch_no VARCHAR(64) NULL COMMENT '平台正式申报批次号', + status VARCHAR(32) NOT NULL COMMENT '批次状态:READY/SUBMITTED/ACKNOWLEDGED/RESULT_PENDING/CLOSED/CANCELLED', + source_version INT UNSIGNED NOT NULL COMMENT '创建时整柜报关聚合版本', + source_snapshot_json JSON NOT NULL COMMENT '提交时资料、回执和确认版本快照', + customs_entry_no VARCHAR(64) NULL COMMENT '报关单号', + submitted_by BIGINT UNSIGNED NULL COMMENT '正式申报提交人', + submitted_by_name VARCHAR(64) NULL COMMENT '正式申报提交人名称快照', + submitted_at DATETIME(3) NULL COMMENT '正式申报提交时间', + acknowledged_by BIGINT UNSIGNED NULL COMMENT '报关行确认受理人', + acknowledged_by_name VARCHAR(64) NULL COMMENT '报关行确认受理人名称快照', + acknowledged_at DATETIME(3) NULL COMMENT '报关行确认受理时间', + remark VARCHAR(500) NULL COMMENT '批次说明', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '批次创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '批次更新时间', + PRIMARY KEY(id), + UNIQUE KEY uk_customs_declaration_batch_container(customs_container_id, id), + KEY idx_customs_declaration_batch_plan(loading_plan_id,status,created_at), + CONSTRAINT fk_customs_declaration_batch_container FOREIGN KEY(customs_container_id) REFERENCES fl_customs_container(id), + CONSTRAINT chk_customs_declaration_batch_status CHECK(status IN ('READY','SUBMITTED','ACKNOWLEDGED','RESULT_PENDING','CLOSED','CANCELLED')) +) ENGINE=InnoDB COMMENT='整柜正式申报批次事实'; + +CREATE TABLE IF NOT EXISTS fl_customs_clearance_result ( + id BIGINT UNSIGNED NOT NULL COMMENT '海关结果事实ID', + declaration_batch_id BIGINT UNSIGNED NOT NULL COMMENT '所属正式申报批次ID', + customs_container_id BIGINT UNSIGNED NOT NULL COMMENT '所属整柜报关聚合ID', + result_type VARCHAR(32) NOT NULL COMMENT '结果类型:INSPECTION/SUPPLEMENT_REQUIRED/RELEASED/DETAINED/OTHER', + result_status VARCHAR(16) NOT NULL DEFAULT 'OPEN' COMMENT '结果状态:OPEN/RESOLVED/CANCELLED', + customs_entry_no VARCHAR(64) NULL COMMENT '报关单号快照', + occurred_at DATETIME(3) NOT NULL COMMENT '海关事实发生时间', + resolution VARCHAR(1000) NULL COMMENT '处理结果或异常说明', + evidence_file_id BIGINT UNSIGNED NULL COMMENT '结果凭证File文件ID', + recorded_by BIGINT UNSIGNED NOT NULL COMMENT '平台录入人', + recorded_by_name VARCHAR(64) NULL COMMENT '平台录入人名称快照', + recorded_at DATETIME(3) NOT NULL COMMENT '平台录入时间', + previous_result_id BIGINT UNSIGNED NULL COMMENT '前序结果事实ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '结果创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '结果更新时间', + PRIMARY KEY(id), + KEY idx_customs_clearance_result_batch(declaration_batch_id,occurred_at,id), + KEY idx_customs_clearance_result_container(customs_container_id, result_type, result_status), + CONSTRAINT fk_customs_clearance_result_batch FOREIGN KEY(declaration_batch_id) REFERENCES fl_customs_declaration_batch(id), + CONSTRAINT chk_customs_clearance_result_type CHECK(result_type IN ('INSPECTION','SUPPLEMENT_REQUIRED','RELEASED','DETAINED','OTHER')), + CONSTRAINT chk_customs_clearance_result_status CHECK(result_status IN ('OPEN','RESOLVED','CANCELLED')) +) ENGINE=InnoDB COMMENT='正式申报后的海关结果不可变事实'; + diff --git a/deploy/sql/migrations/cn/baiyue_firstleg/V4__add_sailing_calendar_schema.sql b/deploy/sql/migrations/cn/baiyue_firstleg/V4__add_sailing_calendar_schema.sql new file mode 100644 index 000000000..63cd59683 --- /dev/null +++ b/deploy/sql/migrations/cn/baiyue_firstleg/V4__add_sailing_calendar_schema.sql @@ -0,0 +1,243 @@ +-- Region: CN +-- Schema: baiyue_firstleg +-- Purpose: 补齐船期日历、周模板及收货/预报/归班/订舱关联事实表。 +-- Preconditions: 连接必须指向 baiyue_firstleg;不写入业务数据,不包含跨 schema 权限种子。 +-- Postconditions: 船期日历 8 张表与 Firstleg 当前 phase4 SQL 定义一致。 + +SET NAMES utf8mb4; +-- 船期计划日历首版基线。与 fl_port_schedule(港务船期观测)分离。 +CREATE TABLE IF NOT EXISTS fl_sailing_channel ( + id BIGINT NOT NULL, + channel_code VARCHAR(64) NOT NULL, + channel_name VARCHAR(128) NOT NULL, + source_type VARCHAR(24) NOT NULL, + mapped_channel_code VARCHAR(64) NULL, + default_departure_port_code VARCHAR(32) NULL, + default_destination_port_code VARCHAR(32) NULL, + default_cutoff_days TINYINT NOT NULL DEFAULT 3, + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + remark VARCHAR(1000) NULL, + created_by BIGINT NOT NULL, + created_by_name VARCHAR(128) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_by BIGINT NOT NULL, + updated_by_name VARCHAR(128) NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + version INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uk_fl_sailing_channel_code (channel_code), + KEY idx_fl_sailing_channel_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS fl_sailing_template ( + id BIGINT NOT NULL, + template_no VARCHAR(64) NOT NULL, + template_name VARCHAR(128) NOT NULL, + channel_id BIGINT NOT NULL, + channel_code_snapshot VARCHAR(64) NOT NULL, + channel_name_snapshot VARCHAR(128) NOT NULL, + warehouse_id BIGINT NOT NULL, + warehouse_name_snapshot VARCHAR(128) NULL, + departure_port_code VARCHAR(32) NOT NULL, + destination_port_code VARCHAR(32) NOT NULL, + weekday TINYINT NOT NULL, + weekday_mask TINYINT NOT NULL DEFAULT 1, + etd_local_time TIME NOT NULL, + cutoff_days TINYINT NOT NULL DEFAULT 3, + cutoff_local_time TIME NOT NULL DEFAULT '18:00:00', + valid_from DATE NOT NULL, + valid_to DATE NULL, + voyage_rule VARCHAR(64) NULL, + vessel_name VARCHAR(128) NULL, + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + remark VARCHAR(1000) NULL, + created_by BIGINT NOT NULL, + created_by_name VARCHAR(128) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_by BIGINT NOT NULL, + updated_by_name VARCHAR(128) NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + version INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uk_fl_sailing_template_no (template_no), + KEY idx_fl_sailing_template_filter (warehouse_id,channel_id,status,weekday), + CONSTRAINT ck_fl_sailing_template_weekday CHECK (weekday BETWEEN 1 AND 7), + CONSTRAINT ck_fl_sailing_template_cutoff_days CHECK (cutoff_days BETWEEN 0 AND 30) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS fl_sailing ( + id BIGINT NOT NULL, + sailing_no VARCHAR(64) NOT NULL, + template_id BIGINT NULL, + channel_id BIGINT NOT NULL, + channel_code_snapshot VARCHAR(64) NOT NULL, + channel_name_snapshot VARCHAR(128) NOT NULL, + warehouse_id BIGINT NOT NULL, + warehouse_name_snapshot VARCHAR(128) NULL, + departure_port_code VARCHAR(32) NOT NULL, + destination_port_code VARCHAR(32) NOT NULL, + vessel_name VARCHAR(128) NULL, + voyage_no VARCHAR(64) NULL, + etd_at DATETIME(3) NOT NULL, + warehouse_cutoff_at DATETIME(3) NOT NULL, + eta_at DATETIME(3) NULL, + status VARCHAR(16) NOT NULL DEFAULT 'OPEN', + source_type VARCHAR(24) NOT NULL DEFAULT 'MANUAL', + generated_key VARCHAR(192) NULL, + planned_capacity_cbm DECIMAL(14,3) NULL, + remark VARCHAR(1000) NULL, + created_by BIGINT NOT NULL, + created_by_name VARCHAR(128) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_by BIGINT NOT NULL, + updated_by_name VARCHAR(128) NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + version INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uk_fl_sailing_no (sailing_no), + UNIQUE KEY uk_fl_sailing_generated_key (generated_key), + UNIQUE KEY uk_fl_sailing_business (warehouse_id,channel_code_snapshot,departure_port_code,destination_port_code,etd_at,voyage_no), + KEY idx_fl_sailing_calendar (etd_at,warehouse_id,channel_id,status), + KEY idx_fl_sailing_route (departure_port_code,destination_port_code), + CONSTRAINT ck_fl_sailing_status CHECK (status IN ('DRAFT','OPEN','CLOSED','DEPARTED','CANCELLED','COMPLETED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS fl_sailing_history ( + id BIGINT NOT NULL, + sailing_id BIGINT NOT NULL, + action VARCHAR(32) NOT NULL, + from_status VARCHAR(16) NULL, + to_status VARCHAR(16) NULL, + reason VARCHAR(1000) NULL, + operator_id BIGINT NULL, + operator_name VARCHAR(128) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_fl_sailing_history_sailing (sailing_id,created_at,id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +/* + * 船期日历的事实契约。 + * + * 这些表不修改入库单或海运订舱聚合:入库单仍是履约主数据,订舱仍是 + * RELEASED/DRAFT 聚合。船期日历只消费这里的稳定事实和审计版本。 + */ + +CREATE TABLE IF NOT EXISTS fl_sailing_receipt_batch_fact ( + id BIGINT UNSIGNED NOT NULL COMMENT '稳定收货批次事实ID(receiptBatchId)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键,继承入库事实', + receipt_batch_key VARCHAR(64) NOT NULL COMMENT '同一物理收货批次的不可变业务键', + fact_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '同一批次复测/冲正版本', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', + measurement_id BIGINT UNSIGNED NULL COMMENT '最终审核测量批次ID', + receiving_warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '实际收货仓ID', + channel_code VARCHAR(64) NULL COMMENT '收货渠道编码快照', + channel_name VARCHAR(128) NULL COMMENT '收货渠道名称快照', + departure_port_code VARCHAR(32) NULL COMMENT '起运港编码快照', + destination_port_code VARCHAR(32) NULL COMMENT '目的港编码快照', + received_at DATETIME(3) NOT NULL COMMENT '实际收货完成时间,不使用入库单inbounded_at替代', + actual_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '最终审核实测体积m3', + fact_status VARCHAR(24) NOT NULL DEFAULT 'APPROVED' COMMENT '事实状态:APPROVED/REVERSED/VOIDED/SUPERSEDED', + reversal_of_fact_id BIGINT UNSIGNED NULL COMMENT '冲正所替代的事实ID', + source_type VARCHAR(32) NOT NULL DEFAULT 'FIRSTLEG_RECEIPT' COMMENT '事实来源', + created_by BIGINT UNSIGNED NULL COMMENT '写入人或系统主体', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_sailing_receipt_batch_version (tenant_id, receipt_batch_key, fact_version), + KEY idx_sailing_receipt_batch_current (tenant_id, receipt_batch_key, fact_status, fact_version), + KEY idx_sailing_receipt_match (tenant_id, receiving_warehouse_id, channel_code, received_at, id), + KEY idx_sailing_receipt_inbound (tenant_id, inbound_id, received_at, id), + CONSTRAINT chk_sailing_receipt_volume CHECK (actual_volume_cbm > 0), + CONSTRAINT chk_sailing_receipt_status CHECK (fact_status IN ('APPROVED','REVERSED','VOIDED','SUPERSEDED')) +) ENGINE=InnoDB COMMENT='船期日历稳定收货批次实测事实及版本历史'; + +CREATE TABLE IF NOT EXISTS fl_sailing_inbound_volume_forecast ( + id BIGINT UNSIGNED NOT NULL COMMENT '货量预报事实ID(forecastId)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键,继承入库事实', + forecast_key VARCHAR(64) NOT NULL COMMENT '同一未收货对象的不可变业务键', + fact_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '预报修订版本', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', + receiving_warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '预计收货仓ID', + channel_code VARCHAR(64) NULL COMMENT '预计渠道编码快照', + channel_name VARCHAR(128) NULL COMMENT '预计渠道名称快照', + departure_port_code VARCHAR(32) NULL COMMENT '预计起运港编码快照', + destination_port_code VARCHAR(32) NULL COMMENT '预计目的港编码快照', + expected_arrival_at DATETIME(3) NOT NULL COMMENT '预计到仓时间', + forecast_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '未收货预计体积m3,不得使用申报体积代替', + source_type VARCHAR(32) NOT NULL COMMENT '预报来源,例如OPERATIONS/UPSTREAM_MEASUREMENT;不得使用客户申报体积冒充预报', + fact_status VARCHAR(24) NOT NULL DEFAULT 'VALID' COMMENT '事实状态:VALID/SUPERSEDED/VOIDED/CONSUMED', + effective_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '本版本生效时间', + created_by BIGINT UNSIGNED NULL COMMENT '写入人或系统主体', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_sailing_forecast_version (tenant_id, forecast_key, fact_version), + KEY idx_sailing_forecast_current (tenant_id, forecast_key, fact_status, fact_version), + KEY idx_sailing_forecast_match (tenant_id, receiving_warehouse_id, channel_code, expected_arrival_at, id), + KEY idx_sailing_forecast_inbound (tenant_id, inbound_id, expected_arrival_at, id), + CONSTRAINT chk_sailing_forecast_volume CHECK (forecast_volume_cbm > 0), + CONSTRAINT chk_sailing_forecast_status CHECK (fact_status IN ('VALID','SUPERSEDED','VOIDED','CONSUMED')) +) ENGINE=InnoDB COMMENT='船期日历未收货量预报事实及版本历史'; + +CREATE TABLE IF NOT EXISTS fl_sailing_assignment ( + id BIGINT UNSIGNED NOT NULL COMMENT '归班事实ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键', + sailing_id BIGINT UNSIGNED NULL COMMENT '船期实例ID,UNMATCHED为空', + origin_sailing_id BIGINT UNSIGNED NULL COMMENT '异常重排/取消前的原船期实例ID', + inbound_id BIGINT UNSIGNED NULL COMMENT '归班对象入库单ID', + inbound_code VARCHAR(64) NULL COMMENT '入仓码快照,仅用于展示', + receipt_batch_id BIGINT UNSIGNED NULL COMMENT '收货批次事实ID', + forecast_id BIGINT UNSIGNED NULL COMMENT '货量预报事实ID', + assignment_source VARCHAR(24) NOT NULL DEFAULT 'AUTO' COMMENT '归班来源:AUTO/MANUAL/ROLLOVER', + assignment_version INT UNSIGNED NOT NULL COMMENT '同一对象的归班版本', + match_status VARCHAR(24) NOT NULL COMMENT 'FORECAST_MATCHED/RECEIVED/LATE/UNMATCHED/SUPERSEDED', + volume_cbm DECIMAL(18,4) NULL COMMENT '已收实测体积', + forecast_volume_cbm DECIMAL(18,4) NULL COMMENT '未收有效预计体积', + route_source VARCHAR(32) NULL COMMENT '航线来源:INBOUND_FACT/FORECAST_FACT/CHANNEL_DEFAULT', + volume_source VARCHAR(32) NULL COMMENT 'MEASURED_ACTUAL或预报来源', + rule_version VARCHAR(64) NOT NULL COMMENT '自动归班规则版本', + matched_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + superseded_at DATETIME(3) NULL, + locked_at DATETIME(3) NULL COMMENT '截止/订舱/关闭后冻结时间', + lock_reason VARCHAR(32) NULL COMMENT 'CUTOFF/BOOKING_CONFIRMED/CLOSED', + operator_id BIGINT UNSIGNED NULL, + reason VARCHAR(500) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_sailing_assignment_current (tenant_id, sailing_id, match_status, matched_at, id), + KEY idx_sailing_assignment_origin (tenant_id, origin_sailing_id, match_status, id), + KEY idx_sailing_assignment_receipt (tenant_id, receipt_batch_id, assignment_version, id), + KEY idx_sailing_assignment_forecast (tenant_id, forecast_id, assignment_version, id), + KEY idx_sailing_assignment_inbound (tenant_id, inbound_id, match_status, id), + CONSTRAINT chk_sailing_assignment_status CHECK (match_status IN ('FORECAST_MATCHED','RECEIVED','LATE','UNMATCHED','SUPERSEDED')), + CONSTRAINT chk_sailing_assignment_source CHECK (assignment_source IN ('AUTO','MANUAL','ROLLOVER')) +) ENGINE=InnoDB COMMENT='船期日历自动归班事实及不可变版本历史'; + +CREATE TABLE IF NOT EXISTS fl_sailing_booking_link ( + id BIGINT UNSIGNED NOT NULL COMMENT '船期-订舱关联ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台关联隔离键', + sailing_id BIGINT UNSIGNED NOT NULL COMMENT '计划船期实例ID', + ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '海运订舱ID', + link_status VARCHAR(24) NOT NULL DEFAULT 'ACTIVE' COMMENT '关联状态:ACTIVE/REVOKED', + active_booking_guard TINYINT NULL COMMENT 'ACTIVE为1,REVOKED为空,用于唯一约束', + booking_status_snapshot VARCHAR(24) NOT NULL COMMENT '关联时订舱状态快照;只有RELEASED可建立', + warehouse_id BIGINT UNSIGNED NULL COMMENT '关联仓库快照', + channel_code VARCHAR(64) NULL COMMENT '关联渠道快照', + departure_port_code VARCHAR(32) NULL COMMENT '关联起运港快照', + destination_port_code VARCHAR(32) NULL COMMENT '关联目的港快照', + linked_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + revoked_at DATETIME(3) NULL, + operator_id BIGINT UNSIGNED NULL, + operator_name VARCHAR(64) NULL, + reason VARCHAR(500) NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_sailing_booking_active (ocean_booking_id, active_booking_guard), + KEY idx_sailing_booking_link (sailing_id, link_status, ocean_booking_id), + KEY idx_sailing_booking_booking (ocean_booking_id, link_status, id), + CONSTRAINT chk_sailing_booking_link_status CHECK (link_status IN ('ACTIVE','REVOKED')), + CONSTRAINT chk_sailing_booking_link_guard CHECK ((link_status='ACTIVE' AND active_booking_guard=1) OR (link_status='REVOKED' AND active_booking_guard IS NULL)), + CONSTRAINT chk_sailing_booking_released CHECK (booking_status_snapshot='RELEASED') +) ENGINE=InnoDB COMMENT='船期计划与已放舱订舱的最小关联及历史'; + + diff --git a/deploy/sql/migrations/cn/baiyue_iam/V3__align_domestic_pda_mojia_sailing_permissions.sql b/deploy/sql/migrations/cn/baiyue_iam/V3__align_domestic_pda_mojia_sailing_permissions.sql new file mode 100644 index 000000000..4b0af7ba4 --- /dev/null +++ b/deploy/sql/migrations/cn/baiyue_iam/V3__align_domestic_pda_mojia_sailing_permissions.sql @@ -0,0 +1,85 @@ +-- Region: CN +-- Schema: baiyue_iam +-- Purpose: 补齐国内 PDA、墨家 SKU 同步和船期日历所需权限,并增加国内作业仓绑定索引。 +-- Preconditions: 连接必须指向 baiyue_iam;角色和权限基线已存在;不执行数据回填。 +-- Postconditions: 国内成员可绑定作业仓,新增权限和角色授权与当前 IAM 基线一致。 + +SET NAMES utf8mb4; + +SET @guard = IF( + NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'iam_membership'), + 'SELECT 1 FROM migration_blocked_iam_membership_table_missing', + 'SELECT 1' +); +PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF( + NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'iam_membership' AND column_name = 'domestic_warehouse_id'), + 'ALTER TABLE iam_membership ADD COLUMN domestic_warehouse_id BIGINT NULL COMMENT ''国内头程PDA操作员绑定的唯一国内作业仓ID'' AFTER overseas_warehouse_id', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @ddl = IF( + NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'iam_membership' AND index_name = 'idx_membership_domestic_warehouse'), + 'ALTER TABLE iam_membership ADD KEY idx_membership_domestic_warehouse (tenant_id, domestic_warehouse_id, status)', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +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 + (5102131, 0, 'PLATFORM', 'MENU', 5103000, 'platform:mojia-sku-sync:list', '称重机器', '/general/mojia-sku-sync', 'GET', 130, 'ACTIVE', 1, 0), + (5102132, 0, 'PLATFORM', 'BUTTON', 5102131, 'platform:mojia-sku-sync:edit', '编辑SKU测量数据', NULL, 'PUT', 10, 'ACTIVE', 1, 0), + (5102133, 0, 'PLATFORM', 'BUTTON', 5102131, 'platform:mojia-sku-sync:retry', '重新推送SKU测量', NULL, 'POST', 20, 'ACTIVE', 1, 0) +ON DUPLICATE KEY UPDATE + permission_type = VALUES(permission_type), permission_name = VALUES(permission_name), parent_id = VALUES(parent_id), + http_method = VALUES(http_method), status = 'ACTIVE', built_in = 1, deleted = 0; + +INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT r.id, p.id, 0 + FROM iam_role r + JOIN iam_permission p ON p.tenant_id = 0 AND p.app_code = 'PLATFORM' + WHERE r.tenant_id = 0 AND r.app_code = 'PLATFORM' + AND r.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR') + AND p.permission_code IN ('platform:mojia-sku-sync:list', 'platform:mojia-sku-sync:edit', 'platform:mojia-sku-sync:retry'); + +INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT r.id, p.id, 0 + FROM iam_role r + JOIN iam_permission p ON p.tenant_id = 0 AND p.app_code = 'PLATFORM' + WHERE r.tenant_id = 0 AND r.app_code = 'PLATFORM' + AND r.role_code = 'WAREHOUSE_OPERATOR' + AND p.permission_code IN ('platform:firstleg:preload:list', 'platform:firstleg:preload:edit', 'platform:firstleg:pda:assign'); + +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 + (5102206, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:sailing-calendar:list', '船期日历', '/sailing-calendar', 'GET', 125, 'ACTIVE', 1, 0), + (5102207, 0, 'PLATFORM', 'BUTTON', 5102206, 'platform:firstleg:sailing-calendar:create', '新增船期', '/sailing-calendar', 'POST', 10, 'ACTIVE', 1, 0), + (5102208, 0, 'PLATFORM', 'BUTTON', 5102206, 'platform:firstleg:sailing-calendar:edit', '编辑船期', '/sailing-calendar', 'PUT', 20, 'ACTIVE', 1, 0), + (5102209, 0, 'PLATFORM', 'BUTTON', 5102206, 'platform:firstleg:sailing-template:manage', '管理渠道与周模板', '/sailing-calendar', 'POST', 30, 'ACTIVE', 1, 0), + (5102210, 0, 'PLATFORM', 'BUTTON', 5102206, 'platform:firstleg:sailing-booking:link', '关联订舱', '/sailing-calendar', 'POST', 40, 'ACTIVE', 1, 0), + (5102211, 0, 'PLATFORM', 'BUTTON', 5102206, 'platform:firstleg:sailing-volume:forecast-manage', '维护货量预报', '/sailing-calendar', 'POST', 50, 'ACTIVE', 1, 0), + (5102212, 0, 'PLATFORM', 'BUTTON', 5102206, 'platform:firstleg:sailing-volume:exception-reassign', '异常重新安排', '/sailing-calendar', 'POST', 60, 'ACTIVE', 1, 0) +ON DUPLICATE KEY UPDATE + parent_id = VALUES(parent_id), permission_code = VALUES(permission_code), permission_name = VALUES(permission_name), + route_path = VALUES(route_path), http_method = VALUES(http_method), sort_no = VALUES(sort_no), + status = 'ACTIVE', built_in = 1, deleted = 0; + +INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id) +SELECT r.id, p.id, 0 + FROM iam_role r + JOIN iam_permission p ON p.tenant_id = 0 AND p.app_code = 'PLATFORM' + WHERE r.tenant_id = 0 AND r.app_code = 'PLATFORM' + AND r.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR') + AND p.permission_code IN ( + 'platform:firstleg:sailing-calendar:list', 'platform:firstleg:sailing-calendar:create', + 'platform:firstleg:sailing-calendar:edit', 'platform:firstleg:sailing-template:manage', + 'platform:firstleg:sailing-booking:link', 'platform:firstleg:sailing-volume:forecast-manage', + 'platform:firstleg:sailing-volume:exception-reassign'); diff --git a/deploy/sql/migrations/overseas/baiyue_lastlag_warehouse/V2__retire_location_zone_and_rebuild_query_index.sql b/deploy/sql/migrations/overseas/baiyue_lastlag_warehouse/V2__retire_location_zone_and_rebuild_query_index.sql new file mode 100644 index 000000000..edc137896 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastlag_warehouse/V2__retire_location_zone_and_rebuild_query_index.sql @@ -0,0 +1,18 @@ +-- Region: OVERSEAS +-- Schema: baiyue_lastlag_warehouse +-- Purpose: 移除已退役的库位 zone_code,并重建不含 zone_code 的查询索引。 +-- Preconditions: 连接必须指向 baiyue_lastlag_warehouse;生产执行前必须单独授权 DROP COLUMN,并确认 zone_code 无外部读写依赖。 +-- Postconditions: wh_location 不再有 zone_code,idx_location_query 为 (customer_id, warehouse_id, status, deleted)。 +-- Safety: DROP COLUMN zone_code 是有意的破坏性结构变更;本文件只声明迁移,不执行生产授权或数据清理。 + +SET NAMES utf8mb4; + +SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'wh_location' AND index_name = 'idx_location_query'), + 'ALTER TABLE wh_location DROP INDEX idx_location_query', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'wh_location' AND column_name = 'zone_code'), + 'ALTER TABLE wh_location DROP COLUMN zone_code', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'wh_location' AND index_name = 'idx_location_query'), + 'ALTER TABLE wh_location ADD KEY idx_location_query (customer_id, warehouse_id, status, deleted)', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V5__allow_multiple_relabel_applications.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V5__allow_multiple_relabel_applications.sql new file mode 100644 index 000000000..415312c6e --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V5__allow_multiple_relabel_applications.sql @@ -0,0 +1,14 @@ +-- Region: OVERSEAS +-- Schema: baiyue_lastleg +-- Purpose: 允许同一尾程订单建立多个换标申请,并保留订单查询索引。 +-- Preconditions: 连接必须指向 baiyue_lastleg;执行前确认旧唯一索引不再是业务约束。 +-- Postconditions: ll_relabel_application.lastleg_order_id 不再受唯一索引限制,普通查询索引存在。 + +SET NAMES utf8mb4; + +SET @ddl = IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'll_relabel_application' AND index_name = 'uk_ll_relabel_application_order'), + 'ALTER TABLE ll_relabel_application DROP INDEX uk_ll_relabel_application_order', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'll_relabel_application' AND index_name = 'idx_ll_relabel_application_order'), + 'ALTER TABLE ll_relabel_application ADD KEY idx_ll_relabel_application_order (lastleg_order_id)', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/scripts/database-schema-alignment-migrations-contract.test.mjs b/scripts/database-schema-alignment-migrations-contract.test.mjs index 6822655b5..09b02d27c 100644 --- a/scripts/database-schema-alignment-migrations-contract.test.mjs +++ b/scripts/database-schema-alignment-migrations-contract.test.mjs @@ -3,15 +3,24 @@ import { readFile } from 'node:fs/promises' import path from 'node:path' import test from 'node:test' import { fileURLToPath } from 'node:url' +import { buildPlan, loadScopes } from '../deploy/sql/scripts/db-migrations.mjs' const repositoryRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url))) const migrations = { iam: 'deploy/sql/migrations/cn/baiyue_iam/V1__align_schema_baseline.sql', + iamForwardFix: 'deploy/sql/migrations/cn/baiyue_iam/V3__align_domestic_pda_mojia_sailing_permissions.sql', firstleg: 'deploy/sql/migrations/cn/baiyue_firstleg/V1__align_schema_baseline.sql', + firstlegMeasurement: 'deploy/sql/migrations/cn/baiyue_firstleg/V2__align_measurement_and_fee_schema.sql', + firstlegCustoms: 'deploy/sql/migrations/cn/baiyue_firstleg/V3__add_customs_receipt_declaration_schema.sql', + firstlegSailing: 'deploy/sql/migrations/cn/baiyue_firstleg/V4__add_sailing_calendar_schema.sql', file: 'deploy/sql/migrations/cn/baiyue_file/V1__align_schema_baseline.sql', fileForwardFix: 'deploy/sql/migrations/cn/baiyue_file/V2__widen_business_type.sql', lastleg: 'deploy/sql/migrations/overseas/baiyue_lastleg/V2__align_schema_baseline.sql', lastlegForwardFix: 'deploy/sql/migrations/overseas/baiyue_lastleg/V3__relax_valid_nullable_fields.sql', + lastlegRelabel: 'deploy/sql/migrations/overseas/baiyue_lastleg/V5__allow_multiple_relabel_applications.sql', +} +const destructiveMigrations = { + overseasWarehouseLocation: 'deploy/sql/migrations/overseas/baiyue_lastlag_warehouse/V2__retire_location_zone_and_rebuild_query_index.sql', } async function sources() { @@ -27,10 +36,44 @@ test('schema alignment migrations contain no destructive statements', async () = } }) +test('warehouse location migration marks its production-only destructive step', async () => { + const source = await readFile(path.join(repositoryRoot, destructiveMigrations.overseasWarehouseLocation), 'utf8') + assert.match(source, /DROP COLUMN zone_code/i) + assert.match(source, /生产执行前必须单独授权 DROP COLUMN/i) + assert.match(source, /有意的破坏性结构变更/i) + assert.doesNotMatch(source, /^\s*(?:DROP|DELETE|UPDATE|TRUNCATE)\b/im) +}) + test('schema alignment migrations cover the verified drift', async () => { const source = await sources() for (const field of ['tenant_code', 'customer_nickname', 'original_file_name']) assert.match(source.iam, new RegExp(`\\b${field}\\b`)) + for (const field of [ + 'domestic_warehouse_id', 'idx_membership_domestic_warehouse', + 'platform:mojia-sku-sync:list', 'platform:firstleg:preload:list', + 'platform:firstleg:pda:assign', 'platform:firstleg:sailing-calendar:list', + 'platform:firstleg:sailing-volume:exception-reassign', + ]) assert.match(source.iamForwardFix, new RegExp(field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))) assert.match(source.firstleg, /\bpallet_no\b/) + for (const field of [ + 'inbound_code', 'measurement_kind', 'weight_kg', 'sync_status', + 'sync_failure_code', 'sync_failure_reason', 'lingxing_sku_id', 'revision', + 'match_failure_code', 'match_failure_reason', 'carrier_rate_id', + 'carrier_rate_version', 'rate_effective_from', + ]) assert.match(source.firstlegMeasurement, new RegExp(`\\b${field}\\b`)) + for (const table of [ + 'fl_customs_broker_submission', 'fl_customs_completion_action_history', + 'fl_customs_final_document', 'fl_customs_declaration_batch', 'fl_customs_clearance_result', + ]) assert.match(source.firstlegCustoms, new RegExp(`\\b${table}\\b`)) + for (const field of [ + 'broker_submitted_by', 'broker_submitted_by_name', 'broker_submitted_at', + 'customer_confirm_status', 'customer_confirmed_by', 'customer_confirmed_by_name', + 'customer_confirmed_at', 'customer_reject_reason', + ]) assert.match(source.firstlegCustoms, new RegExp(`\\b${field}\\b`)) + for (const table of [ + 'fl_sailing_channel', 'fl_sailing_template', 'fl_sailing', 'fl_sailing_history', + 'fl_sailing_receipt_batch_fact', 'fl_sailing_inbound_volume_forecast', + 'fl_sailing_assignment', 'fl_sailing_booking_link', + ]) assert.match(source.firstlegSailing, new RegExp(`\\b${table}\\b`)) for (const table of ['file_pda_release_channel', 'file_pda_release']) assert.match(source.file, new RegExp(`\\b${table}\\b`)) assert.match(source.fileForwardFix, /business_type\s+varchar\(64\)/i) for (const field of [ @@ -40,14 +83,38 @@ test('schema alignment migrations cover the verified drift', async () => { for (const field of ['inventory_position_id', 'new_sku_code', 'source_warehouse_id']) { assert.match(source.lastlegForwardFix, new RegExp(`\\b${field}\\b`)) } + assert.match(source.lastlegRelabel, /uk_ll_relabel_application_order/i) + assert.match(source.lastlegRelabel, /idx_ll_relabel_application_order/i) }) test('constraint tightening is guarded before any alter table', async () => { for (const [name, source] of Object.entries(await sources())) { const firstAlter = source.search(/\bALTER TABLE\b/i) const firstGuard = source.search(/SET @guard\s*=\s*IF/i) - if (name === 'file' || name === 'iam' || name === 'firstleg' || name === 'lastleg') { + if (name === 'file' || name === 'iam' || name === 'firstleg' || name === 'lastleg' || name === 'firstlegMeasurement' || name === 'firstlegCustoms') { assert.ok(firstGuard >= 0 && firstGuard < firstAlter, `${name} 必须先验证存量数据再执行 DDL`) } } }) + +test('数据库迁移计划包含本批四个 schema 的正式目标版本', async () => { + const sqlRoot = path.join(repositoryRoot, 'deploy', 'sql') + const scopes = await loadScopes(path.join(sqlRoot, 'migration-scopes.json')) + const plan = await buildPlan({ + sqlRoot, + scopes, + regions: ['CN', 'OVERSEAS'], + services: ['iam', 'firstleg', 'lastleg-service', 'warehouse-overseas-service'], + commit: null, + }) + const targets = Object.fromEntries(plan.regions.flatMap((region) => region.schemas.map((schema) => [schema.schema, schema]))) + assert.equal(targets.baiyue_iam.target, '3') + assert.equal(targets.baiyue_firstleg.target, '4') + assert.equal(targets.baiyue_lastleg.target, '5') + assert.equal(targets.baiyue_lastlag_warehouse.target, '2') + assert.deepEqual(targets.baiyue_firstleg.migrations.slice(-3).map((migration) => migration.path), [ + 'migrations/cn/baiyue_firstleg/V2__align_measurement_and_fee_schema.sql', + 'migrations/cn/baiyue_firstleg/V3__add_customs_receipt_declaration_schema.sql', + 'migrations/cn/baiyue_firstleg/V4__add_sailing_calendar_schema.sql', + ]) +})