diff --git a/deploy/sql/README.md b/deploy/sql/README.md index f82584788..5cc03e831 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -62,7 +62,16 @@ ## 后续迁移工具(已开始实施) -机制启用前,以上两份统一 SQL 仍是空库初始化入口;本节工具不会把当前可丢弃的开发/测试存量转换成迁移链。当前已登记一份生产尾程结构补齐迁移 `migrations/overseas/baiyue_lastleg/V1__align_lastleg_production_schema.sql`,由 `scripts/prod-database-schema-acceptance.mjs` 默认只读核对,只有显式确认参数才执行;它只新增字段、放宽生成后才确定字段的可空性并创建缺失表,不执行 DML。 +机制启用前,以上两份统一 SQL 仍是空库初始化入口;本节工具不会把当前可丢弃的开发/测试存量自动转换成迁移链。现有候选迁移包括: + +| 区域 / 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_file` | `migrations/cn/baiyue_file/V1__align_schema_baseline.sql` | 补齐 PDA 发布表和固定渠道,收敛文件业务类型长度 | +| OVERSEAS / `baiyue_lastleg` | `migrations/overseas/baiyue_lastleg/V1__align_lastleg_production_schema.sql`、`V2__align_schema_baseline.sql` | 补齐尾程字段并收敛字段定义和排序规则;收紧前检查存量数据 | + +上述新增候选当前只完成编写和离线检查,尚未在测试或生产执行。测试通过后必须复用同一份 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_file/V1__align_schema_baseline.sql b/deploy/sql/migrations/cn/baiyue_file/V1__align_schema_baseline.sql new file mode 100644 index 000000000..f80f99e07 --- /dev/null +++ b/deploy/sql/migrations/cn/baiyue_file/V1__align_schema_baseline.sql @@ -0,0 +1,55 @@ +-- Region: CN +-- Schema: baiyue_file +-- Purpose: 补齐 PDA 发布表,并将文件业务类型长度收敛到当前空库基线。 +-- Preconditions: 连接必须指向 baiyue_file;既有 business_type 不超过 32 字符。 +-- Postconditions: PDA 发布表及固定渠道存在,file_object.business_type 为 varchar(32)。 + +SET NAMES utf8mb4; + +SET @guard = IF( + EXISTS (SELECT 1 FROM file_object WHERE CHAR_LENGTH(business_type) > 32 LIMIT 1), + 'SIGNAL SQLSTATE ''45000'' SET MESSAGE_TEXT = ''file_object.business_type exceeds 32 characters''', + 'SELECT 1' +); +PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +ALTER TABLE file_object + MODIFY COLUMN business_type varchar(32) DEFAULT NULL COMMENT '关联业务类型'; + +CREATE TABLE IF NOT EXISTS file_pda_release_channel ( + app_code varchar(16) NOT NULL, + app_id varchar(64) NOT NULL, + current_apk_id char(36) DEFAULT NULL, + current_wgt_id char(36) DEFAULT NULL, + PRIMARY KEY (app_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='国内/海外PDA当前发布指针'; + +CREATE TABLE IF NOT EXISTS file_pda_release ( + id char(36) NOT NULL, + app_code varchar(16) NOT NULL, + app_id varchar(64) NOT NULL, + package_type varchar(8) NOT NULL, + version_name varchar(32) NOT NULL, + version_code int unsigned NOT NULL, + min_app_version_code int unsigned NOT NULL, + mandatory tinyint(1) NOT NULL DEFAULT '0', + release_notes varchar(1000) NOT NULL DEFAULT '', + status varchar(16) NOT NULL DEFAULT 'DRAFT', + object_key varchar(128) NOT NULL, + sha256 char(64) NOT NULL, + size_bytes bigint unsigned NOT NULL, + created_by bigint unsigned NOT NULL, + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + published_at datetime(3) DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_pda_release_version (app_code,package_type,version_name), + KEY idx_pda_release_created (app_code,created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='PDA安装包与热更新发布历史'; + +INSERT INTO file_pda_release_channel (app_code,app_id) +SELECT 'domestic','__UNI__9BFFAF2' +WHERE NOT EXISTS (SELECT 1 FROM file_pda_release_channel WHERE app_code = 'domestic'); + +INSERT INTO file_pda_release_channel (app_code,app_id) +SELECT 'overseas','__UNI__936D079' +WHERE NOT EXISTS (SELECT 1 FROM file_pda_release_channel WHERE app_code = 'overseas'); diff --git a/deploy/sql/migrations/cn/baiyue_firstleg/V1__align_schema_baseline.sql b/deploy/sql/migrations/cn/baiyue_firstleg/V1__align_schema_baseline.sql new file mode 100644 index 000000000..f63d365d5 --- /dev/null +++ b/deploy/sql/migrations/cn/baiyue_firstleg/V1__align_schema_baseline.sql @@ -0,0 +1,17 @@ +-- Region: CN +-- Schema: baiyue_firstleg +-- Purpose: 将一件代发作业托盘码可空性收敛到当前空库基线。 +-- Preconditions: 连接必须指向 baiyue_firstleg;fl_dropship_pallet.pallet_no 不存在 NULL。 +-- Postconditions: pallet_no 为 varchar(32) NOT NULL。 + +SET NAMES utf8mb4; + +SET @guard = IF( + EXISTS (SELECT 1 FROM fl_dropship_pallet WHERE pallet_no IS NULL LIMIT 1), + 'SIGNAL SQLSTATE ''45000'' SET MESSAGE_TEXT = ''fl_dropship_pallet.pallet_no contains NULL''', + 'SELECT 1' +); +PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +ALTER TABLE fl_dropship_pallet + MODIFY COLUMN pallet_no varchar(32) NOT NULL COMMENT '现场扫描的系统DP托盘码'; diff --git a/deploy/sql/migrations/cn/baiyue_iam/V1__align_schema_baseline.sql b/deploy/sql/migrations/cn/baiyue_iam/V1__align_schema_baseline.sql new file mode 100644 index 000000000..eee539c55 --- /dev/null +++ b/deploy/sql/migrations/cn/baiyue_iam/V1__align_schema_baseline.sql @@ -0,0 +1,35 @@ +-- Region: CN +-- Schema: baiyue_iam +-- Purpose: 将客户编号、昵称和资质原文件名定义收敛到当前空库基线。 +-- Preconditions: 连接必须指向 baiyue_iam;待收紧字段不存在 NULL,tenant_code 不超过 8 字符。 +-- Postconditions: 三个字段的类型和可空性与头程统一初始化 SQL 一致。 + +SET NAMES utf8mb4; + +SET @guard = IF( + EXISTS (SELECT 1 FROM iam_tenant WHERE customer_nickname IS NULL LIMIT 1), + 'SIGNAL SQLSTATE ''45000'' SET MESSAGE_TEXT = ''iam_tenant.customer_nickname contains NULL''', + 'SELECT 1' +); +PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @guard = IF( + EXISTS (SELECT 1 FROM iam_tenant WHERE CHAR_LENGTH(tenant_code) > 8 LIMIT 1), + 'SIGNAL SQLSTATE ''45000'' SET MESSAGE_TEXT = ''iam_tenant.tenant_code exceeds 8 characters''', + 'SELECT 1' +); +PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @guard = IF( + EXISTS (SELECT 1 FROM iam_tenant_qualification WHERE original_file_name IS NULL LIMIT 1), + 'SIGNAL SQLSTATE ''45000'' SET MESSAGE_TEXT = ''iam_tenant_qualification.original_file_name contains NULL''', + 'SELECT 1' +); +PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +ALTER TABLE iam_tenant + MODIFY COLUMN tenant_code varchar(8) NOT NULL COMMENT '客户编号:企业客户固定五位数字,平台技术主体为PLATFORM', + MODIFY COLUMN customer_nickname varchar(32) NOT NULL COMMENT '客户昵称:创建时默认取公司名称中文拼音大写首字母,可修改'; + +ALTER TABLE iam_tenant_qualification + MODIFY COLUMN original_file_name varchar(255) NOT NULL COMMENT '上传时原始文件名'; diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V2__align_schema_baseline.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V2__align_schema_baseline.sql new file mode 100644 index 000000000..610f04578 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V2__align_schema_baseline.sql @@ -0,0 +1,66 @@ +-- Region: OVERSEAS +-- Schema: baiyue_lastleg +-- Purpose: 补齐测试与生产各自缺失的尾程字段,并将字段定义收敛到当前空库基线。 +-- Preconditions: 连接必须指向 baiyue_lastleg;待收紧字段不存在 NULL,review_decision 不超过 16 字符。 +-- Postconditions: 本文件涉及的字段集合、类型、默认值、可空性和测量照片表排序规则与海外统一初始化 SQL 一致。 + +SET NAMES utf8mb4; + +-- 测试环境当前缺失、生产已存在的 PDA 废弃作业字段。 +SET @ddl = IF(NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_cargo_discard_item' AND column_name = 'actual_discard_packages'), 'ALTER TABLE ll_cargo_discard_item ADD COLUMN actual_discard_packages int NULL COMMENT ''PDA确认实际废弃箱数'' AFTER discard_packages', '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 = 'll_cargo_discard_item' AND column_name = 'pda_status'), 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT ''PENDING'' COMMENT ''PDA处理状态:待处理PENDING、已完成COMPLETED'' AFTER actual_discard_packages', '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 = 'll_cargo_discard_item' AND column_name = 'pda_operator_id'), 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_operator_id varchar(128) COLLATE utf8mb4_unicode_ci NULL COMMENT ''PDA操作人'' AFTER pda_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 = 'll_cargo_discard_item' AND column_name = 'pda_completed_at'), 'ALTER TABLE ll_cargo_discard_item ADD COLUMN pda_completed_at datetime(3) NULL COMMENT ''PDA完成时间'' AFTER pda_operator_id', '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 = 'll_relabel_pda_unit_fact' AND column_name = 'quantity'), 'ALTER TABLE ll_relabel_pda_unit_fact ADD COLUMN quantity int NOT NULL DEFAULT 1 COMMENT ''本次事实处理件数,箱级作业固定为1'' AFTER 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 = 'll_return_inventory_carton' AND column_name = 'pallet_code'), 'ALTER TABLE ll_return_inventory_carton ADD COLUMN pallet_code varchar(128) COLLATE utf8mb4_unicode_ci NULL COMMENT ''签收时绑定的托盘码'' AFTER source_return_no', '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 = 'll_return_order' AND column_name = 'discrepancy_reason'), 'ALTER TABLE ll_return_order ADD COLUMN discrepancy_reason varchar(500) COLLATE utf8mb4_unicode_ci NULL COMMENT ''手动完结差异原因'' 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 = 'll_return_order' AND column_name = 'manually_completed_at'), 'ALTER TABLE ll_return_order ADD COLUMN manually_completed_at datetime(3) NULL COMMENT ''手动差异完结时间'' AFTER discrepancy_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 = 'll_return_receipt' AND column_name = 'pallet_code'), 'ALTER TABLE ll_return_receipt ADD COLUMN pallet_code varchar(128) COLLATE utf8mb4_unicode_ci NULL COMMENT ''本次签收绑定的托盘码'' AFTER carton_mark', 'SELECT 1'); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 收紧测试库漂移字段前先阻断不满足目标定义的数据,不执行自动回填或清理。 +SET @guard = IF(EXISTS (SELECT 1 FROM ll_delivery_order WHERE delivery_group_key IS NULL LIMIT 1), 'SIGNAL SQLSTATE ''45000'' SET MESSAGE_TEXT = ''ll_delivery_order.delivery_group_key contains NULL''', 'SELECT 1'); +PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @guard = IF(EXISTS (SELECT 1 FROM ll_local_looseboard_order WHERE container_type IS NULL LIMIT 1), 'SIGNAL SQLSTATE ''45000'' SET MESSAGE_TEXT = ''ll_local_looseboard_order.container_type contains NULL''', 'SELECT 1'); +PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @guard = IF(EXISTS (SELECT 1 FROM ll_us_truck_order WHERE container_type IS NULL LIMIT 1), 'SIGNAL SQLSTATE ''45000'' SET MESSAGE_TEXT = ''ll_us_truck_order.container_type contains NULL''', 'SELECT 1'); +PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @guard = IF(EXISTS (SELECT 1 FROM ll_outbound_pda_pick WHERE inventory_position_id IS NULL LIMIT 1), 'SIGNAL SQLSTATE ''45000'' SET MESSAGE_TEXT = ''ll_outbound_pda_pick.inventory_position_id contains NULL''', 'SELECT 1'); +PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @guard = IF(EXISTS (SELECT 1 FROM ll_relabel_item WHERE new_sku_code IS NULL LIMIT 1), 'SIGNAL SQLSTATE ''45000'' SET MESSAGE_TEXT = ''ll_relabel_item.new_sku_code contains NULL''', 'SELECT 1'); +PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @guard = IF(EXISTS (SELECT 1 FROM ll_relabel_item WHERE source_warehouse_id IS NULL LIMIT 1), 'SIGNAL SQLSTATE ''45000'' SET MESSAGE_TEXT = ''ll_relabel_item.source_warehouse_id contains NULL''', 'SELECT 1'); +PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt; +SET @guard = IF(EXISTS (SELECT 1 FROM ll_relabel_pda_task WHERE CHAR_LENGTH(review_decision) > 16 LIMIT 1), 'SIGNAL SQLSTATE ''45000'' SET MESSAGE_TEXT = ''ll_relabel_pda_task.review_decision exceeds 16 characters''', 'SELECT 1'); +PREPARE stmt FROM @guard; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +ALTER TABLE ll_delivery_order + MODIFY COLUMN delivery_group_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '同一出库单内按目的FBA仓拆单的稳定分组键'; +ALTER TABLE ll_local_looseboard_order + MODIFY COLUMN container_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '40HQ' COMMENT '平台统一柜型:40GP、40HQ、45HQ'; +ALTER TABLE ll_us_truck_order + MODIFY COLUMN container_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '40HQ' COMMENT '平台统一柜型:40GP、40HQ、45HQ'; +ALTER TABLE ll_outbound_pda_pick + MODIFY COLUMN inventory_position_id bigint NOT NULL COMMENT '库存仓位ID'; +ALTER TABLE ll_relabel_item + MODIFY COLUMN new_sku_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标后SKU编码', + MODIFY COLUMN source_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原海外中转仓ID'; +ALTER TABLE ll_relabel_pda_task + MODIFY COLUMN review_decision varchar(16) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最近复核结论:APPROVE或REWORK'; + +SET @ddl = IF( + (SELECT table_collation FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'll_warehouse_transfer_measurement_photo') <> 'utf8mb4_unicode_ci', + 'ALTER TABLE ll_warehouse_transfer_measurement_photo CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/scripts/README.md b/scripts/README.md index 3a3a8258d..d7a7d7d60 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,7 @@ # 后端脚本管理与全量 HTTP 验收索引 +`db/test-return-relabel-checks.sql`:测试库退货转换标检查约束定向修复,显式目标 `baiyue_lastleg.ll_return_inventory_carton`。与 Lastleg 基线保持一致,允许正数量的 `RELABEL_PENDING`,保留可用/已出库正数量与已合箱零数量限制。一条原子 ALTER 同时替换两个既有 CHECK,不修改业务行、不删除数据、不关闭约束;重复执行得到相同约束,旧约束不存在则报错停止。凭据由外部测试数据库连接提供,执行前需测试库操作授权;末尾只读输出约束定义供回查。不是生产迁移入口,不能替代完整 HTTP 换标回归。 + `db/relabel-wait-status-incremental.sql`:存量换标主单状态修正,显式使用 `baiyue_lastleg`。由操作者选择已授权数据库连接执行,无内置凭据;只更新未生成入库单、仍为待分配的 `RELABEL_APPLICATION` 主单为待换标/待排车/待出库,不修改库存或删除单据。事务执行、可重复运行,执行后按换标号回查订单状态;无单独报告文件。 `db/warehouse-transfer-outbound-incremental.sql`:固定指向 `baiyue_lastleg`,一次补齐仓储转运审核、提货、PDA 测量打托、现场照片、换标、FBA/私人仓目的地、入库计划、智能排车和派送所需表、租户字段及索引,并将换标新 SKU 和转运托盘生命周期字段修正为本地要求的可空结构。无内置凭据;表使用 `CREATE TABLE IF NOT EXISTS`,字段和索引通过 `information_schema` 独立判断,可重复执行,不删除或改写业务单据。完整成功时返回 `WAREHOUSE_TRANSFER_LATEST_SCHEMA_READY`;若历史数据存在同客户重复托盘号,则返回 `WAREHOUSE_TRANSFER_SCHEMA_APPLIED_WITH_INDEX_BLOCKER`,且 `inbound_pallet_unique_index` 为 `BLOCKED_BY_DUPLICATE_CUSTOMER_PALLET_NO`。其余结构仍正常补齐,脚本不会擅自修改历史托盘号。 @@ -127,6 +129,7 @@ Client 全量、Client 入库测量与计费、待审核订单和批量待分柜 | 本地质量 | `frontend-full-regression.ps1` | 编排 Admin/Client 本地测试、类型检查及国内/海外 PDA Node 全量测试,不访问 Gateway | | 本地质量 | `database-comment-audit.ps1` | 离线扫描数据库中文注释,不连接数据库 | | Jenkins 只读数据库结构 | `database-schema-contract.mjs` | 只查询 `information_schema` 核对区域初始化 SQL 的表和字段;不执行 SQL、不造业务数据、不执行迁移 | +| 环境间只读数据库结构 | `database-schema-diff.mjs` | 通过显式 `--left-env`、`--right-env` 双向比较两套 MySQL 的 schema、表和字段契约;默认忽略字段顺序,只查询 `information_schema`,差异时退出码为 1,不生成或执行修复 SQL | | 本地质量 | `local-db-full-acceptance.ps1` | 只允许本机 MySQL 的受控 SQL 验收;非 dry-run 会写本地测试库 | | 本地质量 | `mapper-scan-contract.mjs` | 离线检查 Mapper 注解与扫描入口,不启动 Spring、不连接数据库 | | 本地质量 | `java-quality-summary.mjs` | 聚合 Maven 拓扑、OpenAPI、JaCoCo 和 SpotBugs 已有报告,不启动服务 | diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 34babb109..9d32effcc 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -16,10 +16,14 @@ 扩展离线检查:`node --test scripts/admin-test-env-modules/return-boundary-scenarios-contract.test.mjs scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs scripts/script-documentation-contract.test.mjs`。这些校验验证脚本编排和断言,不代替真实 HTTP 验收;运行报告中的每个失败均保留阻断。 +边界扩展创建的每张退货单也必须先审核,并由绑定该仓的 PDA 经正式接口生成本轮退货实收托盘、上传合成打托测试图片;客户身份通过本轮登录会话映射,禁止用主客户替代第二客户。签收请求传 `palletCode`,成功响应核对同一托盘而非已移除的 `cartonMark` 字段;流水分两批时分别使用两个正式托盘。混合明细场景先验证两种 SKU 同批拒绝且不落账,再分别签收为两个来源库存箱以继续合箱回滚验证。三轮双请求并发范围保持不变。 + 同一退货扩展还覆盖五组完整性检查:混合明细中前项合法、后项超量/不存在时签收与合箱整笔回滚;同一操作员同键并发合箱及后续重试只生成一个目标箱;不同客户及仓库使用同一 SKU 时的候选、列表和库存隔离;客户/平台/PDA/无登录身份不得进入未授权退货入口;两批签收流水逐行核对客户、仓库、SKU、签收业务号及增量,并在重放和超收失败后回查不变。流水由现有接口从签收明细重建,不代表独立持久审计账本,也不验证后续换标或出库。并发合箱固定三轮、每轮两个请求,其余造数和校验顺序执行。 `phase4-overseas-return-followups.mjs` 是独立可选后续模块,只在显式 `--modules phase4-overseas-return-followups` 时依赖并运行退货前置,再关联换标单及执行退货出库;不计入退货专项通过结论。仅复用同次运行、同 marker 的退货资源,凭据只在内存中,finally 停用其独立临时 PDA。合同检查复用 `phase4-overseas-return-receipt-contract.test.mjs`;HTTP 必须有目标环境写入授权。 +退货转换标请求按当前契约传 `items[{skuCode, quantity}]`、`fileScope` 和真实 FBA 目的仓 ID/编码,返回 `PENDING_REVIEW` 与 `skuCodes`。分页查找启用的 SCK8,缺失则阻断;总箱标文件回查 `cartonTotalFileId`。独立退货出库候选使用查询参数 `warehouseId`。同模块的 `completeReturnRelabel` 在独立出库之后继续审核本轮换标申请、PDA 认领与数量完成、来源托盘解析、正式生成目标托盘、组托及原键重放、上传合成完工图片、提交完工、客户确认及原键重放,最终回查原退货单仍为签收完成且换标关联一致。它不执行其他客户库存操作;目标托盘是本轮换标业务资源,不是国内基础托盘。退货 SKU 换标客户确认后为 `COMPLETED`,还需另验该换标库存的后续出库;不能把此前未冻结库存独立出库视为换标后出库。 + 退货专项复跑命令(在后端根目录执行,每次使用新的报告名;不要与其他环境混用): ```powershell diff --git a/scripts/admin-test-env-modules/phase4-overseas-return-followups.mjs b/scripts/admin-test-env-modules/phase4-overseas-return-followups.mjs index bf43e5537..3778f0f7b 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-return-followups.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-return-followups.mjs @@ -102,22 +102,35 @@ export async function run(context) { const relabelCandidates = await overseas(context, merchantToken, 'GET', '客户查询本轮退货换标候选', `${RETURN_ORDERS}/${encodeURIComponent(returnNo)}/relabel-candidates`) const relabelCandidate = (Array.isArray(relabelCandidates.data) ? relabelCandidates.data : []) - .find(row => nonBlank(row?.cartonMark) && row?.status === 'AVAILABLE') + .find(row => source.cartonMarks?.includes(row?.cartonMark) && row?.status === 'AVAILABLE') context.assert(MODULE, '退货换标候选来自本轮已签收可用箱', relabelCandidates.response, Boolean(relabelCandidate) && relabelCandidate.sourceReturnNo === returnNo, `cartonMark=${relabelCandidate?.cartonMark || 'missing'}`, OPTIONS) if (relabelCandidate) { + let destination + for (let page = 1; page <= 20; page += 1) { + const destinations = await context.request('GET', `/api/platform/v1/warehouse-admin?page=${page}&size=200&warehouseType=FBA&status=ACTIVE`) + if (!success(destinations)) throw new Error('读取FBA目的仓失败') + const data = destinations.body.data + destination = records(data).find(row => row.warehouseCode === 'SCK8' && row.status === 'ACTIVE') + if (destination || page * 200 >= Number(data.total)) break + if (!records(data).length) throw new Error('FBA目的仓分页不完整') + } + if (!destination?.id) throw new Error('完整分页未找到启用的SCK8目的仓,停止退货转换标') + const sku = relabelCandidate.items?.[0]?.skuCode + if (!sku) throw new Error('本轮候选缺少SKU') const relabelBody = { - cartonMarks: [relabelCandidate.cartonMark], idempotencyKey: key(context, 'RELABEL'), - contactName: '退货换标验收', contactPhone: '13800000008', deliveryMethod: 'TRUCK', remark: context.marker, + items: [{ skuCode: sku, quantity: 1 }], idempotencyKey: key(context, 'RELABEL'), + fileScope: 'CARTON_TOTAL', destinationFbaWarehouseId: id(destination.id), + destinationFbaWarehouseCode: destination.warehouseCode, cartonRemark: context.marker, } const relabel = await overseas(context, merchantToken, 'POST', '客户从退货箱创建换标单', `${RETURN_ORDERS}/${encodeURIComponent(returnNo)}/relabel`, relabelBody) const relabelNo = id(relabel.data?.relabelNo) - context.assert(MODULE, '退货换标单绑定原退货单与选中箱', relabel.response, + context.assert(MODULE, '退货换标单绑定原退货单与选中SKU', relabel.response, nonBlank(relabelNo) && relabel.data?.returnNo === returnNo - && relabel.data?.status === 'PENDING_PROCESSING' - && relabel.data?.cartonMarks?.includes?.(relabelCandidate.cartonMark), + && relabel.data?.status === 'PENDING_REVIEW' + && relabel.data?.skuCodes?.includes?.(sku), `relabelNo=${relabelNo || 'missing'}`, OPTIONS) if (relabelNo) { const form = new FormData() @@ -125,21 +138,21 @@ export async function run(context) { { type: 'application/pdf' }), `${suffix}-return-relabel.pdf`) const uploaded = await context.requestOverseasMultipart('POST', `/api/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/files?scope=CARTON_TOTAL`, - form, {}, merchantToken) + form, merchantToken) add(context, '客户编辑退货换标总资料', uploaded, success(uploaded)) const relabelDetail = await overseas(context, merchantToken, 'GET', '客户回查退货换标资料修改', `/api/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}`) context.assert(MODULE, '退货换标详情持久化总资料且业务状态未漂移', relabelDetail.response, relabelDetail.data?.application?.relabelNo === relabelNo - && relabelDetail.data?.application?.status === 'PENDING_PROCESSING' - && nonBlank(relabelDetail.data?.application?.totalFileId), trace(relabelDetail.response), OPTIONS) + && relabelDetail.data?.application?.status === 'PENDING_REVIEW' + && nonBlank(relabelDetail.data?.application?.cartonTotalFileId), trace(relabelDetail.response), OPTIONS) context.createdResources.phase4OverseasReturnReceipt.relabelNo = relabelNo } } const outboundCandidates = await overseas(context, context.overseasToken, 'GET', '平台查询本轮退货出库候选', query('/api/platform/v1/last-leg/outbound-orders/return/candidates', { - customerId, overseasWarehouseId: warehouse.id, + customerId, warehouseId: warehouse.id, })) const outboundCandidate = (Array.isArray(outboundCandidates.data) ? outboundCandidates.data : []) .find(row => row?.returnNo === returnNo && nonBlank(row?.cartonMark) @@ -185,7 +198,72 @@ export async function run(context) { context.createdResources.phase4OverseasReturnReceipt.returnOutboundNo = outboundNo } } + if (source.relabelNo) await completeReturnRelabel(context, merchantToken, picker.token, source.relabelNo, returnNo) } finally { await disablePicker(context, picker, 92) } } + +export async function completeReturnRelabel(context, merchantToken, pdaToken, relabelNo, returnNo) { + const platform = `/api/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}` + const merchant = `/api/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}` + const work = `/api/pda/v1/last-leg/relabel-tasks/${encodeURIComponent(relabelNo)}` + const pallets = '/api/pda/v1/last-leg/pallet-operations' + const command = async (token, method, name, path, body) => { + const result = await overseas(context, token, method, name, path, body) + if (!success(result.response)) throw new Error(`${name}: ${trace(result.response)}`) + return result.data + } + const require = (name, passed) => { + context.assert(MODULE, name, null, Boolean(passed), `relabelNo=${relabelNo}`, OPTIONS) + if (!passed) throw new Error(name) + } + const reviewed = await command(context.overseasToken, 'PUT', '审核本轮退货换标申请', `${platform}/status`, + { status: 'PENDING_PROCESSING', remark: context.marker }) + require('换标审核后等待PDA作业', reviewed.application?.relabelNo === relabelNo && reviewed.application.status === 'PENDING_PROCESSING') + let task = await command(pdaToken, 'POST', 'PDA认领本轮退货换标', `${work}/claim`, { idempotencyKey: key(context, 'RETURN-CLAIM') }) + require('本轮任务全部为退货SKU换标', task.relabelNo === relabelNo && task.items?.length > 0 + && task.items.every(item => item.operationType === 'RETURN_BULK_SKU_LABEL')) + for (const item of task.items) { + task = await command(pdaToken, 'POST', 'PDA完成本轮退货SKU数量', `${work}/return-skus/complete`, { + itemId: item.itemId, quantity: Number(item.expectedPackages), expectedVersion: Number(task.version), + idempotencyKey: key(context, `RETURN-SKU-${item.itemId}`), + }) + } + const sources = await command(pdaToken, 'POST', '读取本轮换标实际来源托盘', `${pallets}/relabel-sources`, { relabelNo }) + require('退货换标具有明确来源托盘', Array.isArray(sources) && sources.length > 0) + for (const [index, source] of sources.entries()) { + const target = await command(pdaToken, 'POST', '生成本轮带目的仓换标托盘', `${pallets}/relabel-pallet-label`, + { relabelNo, idempotencyKey: key(context, `RETURN-PALLET-${index}`) }) + const from = await command(pdaToken, 'POST', '回查来源托盘版本', `${pallets}/resolve`, { code: source.palletCode }) + const to = await command(pdaToken, 'POST', '回查目标托盘版本', `${pallets}/resolve`, { code: target.palletCode }) + require('组托仅使用本轮任务箱唛', source.cartonMarks?.length > 0 + && source.cartonMarks.every(mark => task.items.some(item => item.cartonMark === mark))) + const body = { relabelNo, source: { palletCode: from.palletCode, expectedVersion: Number(from.palletVersion) }, + target: { palletCode: to.palletCode, expectedVersion: Number(to.palletVersion) }, + cartonMarks: source.cartonMarks, idempotencyKey: key(context, `RETURN-BIND-${index}`), reason: context.marker } + const bound = await command(pdaToken, 'POST', '退货换标按实际数量绑定目标托盘', `${pallets}/relabel-disassemble`, body) + const replay = await command(pdaToken, 'POST', '退货换标组托原键重放', `${pallets}/relabel-disassemble`, body) + require('换标组托重放不重复移动库存', bound.status === 'COMPLETED' && replay.replayed === true && id(bound.operationId) === id(replay.operationId)) + } + task = await command(pdaToken, 'GET', '组托后回查换标任务版本', work) + const form = new FormData() + form.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64')], { type: 'image/png' }), 'synthetic-return-relabel.png') + const photo = await context.requestOverseasMultipart('POST', `${work}/photos/files`, form, pdaToken) + add(context, '上传合成换标完工照片', photo, success(photo)) + if (!success(photo) || !photo.body.data?.fileId) throw new Error('换标完工照片失败') + task = await command(pdaToken, 'POST', 'PDA提交退货换标完工', `${work}/complete`, { + photoFileIds: [id(photo.body.data.fileId)], expectedVersion: Number(task.version), + idempotencyKey: key(context, 'RETURN-COMPLETE'), remark: context.marker, + }) + require('换标完工等待客户确认', task.applicationStatus === 'CUSTOMER_CONFIRM_PENDING') + const body = { idempotencyKey: key(context, 'RETURN-CONFIRM'), remark: context.marker } + const confirmed = await command(merchantToken, 'POST', '客户确认本轮退货换标完成', `${merchant}/confirm-completion`, body) + const replay = await command(merchantToken, 'POST', '客户确认换标原键重放', `${merchant}/confirm-completion`, body) + require('退货SKU换标客户确认完成且不重复建单', confirmed.application?.status === 'COMPLETED' + && replay.application?.relabelNo === relabelNo && replay.application?.status === 'COMPLETED') + const order = await command(merchantToken, 'GET', '回查原退货单换标状态', `${RETURN_ORDERS}/${encodeURIComponent(returnNo)}`) + require('原退货单保持签收完成且关联同一已完成换标', order.order?.status === 'COMPLETED' + && order.order?.relabelNo === relabelNo && order.order?.relabelStatus === 'COMPLETED') + context.createdResources.phase4OverseasReturnReceipt.relabelCompleted = true +} diff --git a/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs index 11ca11af5..cef04d8e8 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs @@ -3,10 +3,45 @@ import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import { validationFailure, inventorySnapshot } from './phase4-overseas-return-receipt.mjs' import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' +import { completeReturnRelabel } from './phase4-overseas-return-followups.mjs' const moduleUrl = new URL('./phase4-overseas-return-receipt.mjs', import.meta.url) const acceptanceUrl = new URL('../admin-test-env-acceptance.mjs', import.meta.url) +test('return relabel lifecycle requires review, quantities, binding, photo and customer confirmation', async () => { + const calls = [] + const task = { relabelNo: 'RL-1', version: 1, items: [{ itemId: '1', cartonMark: 'BOX', operationType: 'RETURN_BULK_SKU_LABEL', expectedPackages: 1 }] } + const context = { marker: 'fixture', overseasToken: 'admin', createdResources: { phase4OverseasReturnReceipt: {} }, + addStep() {}, assert(_m, name, _r, passed) { assert.ok(passed, name) }, + async requestOverseas(method, path, body, _headers, token) { + calls.push({ method, path, body, token }) + let data = task + if (path.endsWith('/status')) data = { application: { relabelNo: 'RL-1', status: 'PENDING_PROCESSING' } } + if (path.endsWith('/relabel-sources')) data = [{ palletCode: 'OLD', cartonMarks: ['BOX'] }] + if (path.endsWith('/relabel-pallet-label')) data = { palletCode: 'NEW' } + if (path.endsWith('/resolve')) data = { palletCode: body.code, palletVersion: 1 } + if (path.endsWith('/relabel-disassemble')) data = { status: 'COMPLETED', operationId: '9', replayed: true } + if (path.endsWith('/RL-1/complete')) data = { applicationStatus: 'CUSTOMER_CONFIRM_PENDING' } + if (path.endsWith('/confirm-completion')) data = { application: { relabelNo: 'RL-1', status: 'COMPLETED' } } + if (path.endsWith('/return-orders/RT-1')) data = { order: { status: 'COMPLETED', relabelNo: 'RL-1', relabelStatus: 'COMPLETED' } } + return { http: 200, body: { code: '200', data } } + }, + async requestOverseasMultipart(method, path, form, token) { + assert.equal(token, 'pda'); assert.ok(form instanceof FormData) + calls.push({ method, path }) + return { http: 200, body: { code: '200', data: { fileId: '7' } } } + } } + await completeReturnRelabel(context, 'merchant', 'pda', 'RL-1', 'RT-1') + assert.equal(context.createdResources.phase4OverseasReturnReceipt.relabelCompleted, true) + const index = suffix => calls.findIndex(c => c.path.endsWith(suffix)) + assert.ok(index('/status') < index('/claim')) + assert.ok(index('/return-skus/complete') < index('/relabel-disassemble')) + assert.ok(index('/relabel-disassemble') < index('/photos/files')) + assert.ok(index('/photos/files') < index('/RL-1/complete')) + assert.ok(index('/RL-1/complete') < index('/confirm-completion')) + assert.equal(calls.find(c => c.path.endsWith('/confirm-completion')).token, 'merchant') +}) + test('full acceptance registers return receipt as a configured overseas module with an isolated merchant fixture', async () => { const source = await readFile(acceptanceUrl, 'utf8') assert.match(source, /CONFIGURED_MODULES[^\n]+phase4-overseas-return-receipt/) @@ -62,6 +97,18 @@ test('receipt scope stops before relabel and outbound; followups are explicit an assert.ok(!normalizeModuleOrder(['phase4-overseas-return-receipt']).includes('phase4-overseas-return-followups')) }) +test('return followups use SKU quantities, explicit FBA destination and current file and candidate contracts', async () => { + const source = await readFile(new URL('./phase4-overseas-return-followups.mjs', import.meta.url), 'utf8') + assert.match(source, /items: \[\{ skuCode: sku, quantity: 1 \}\]/) + assert.match(source, /destinationFbaWarehouseId: id\(destination.id\)/) + assert.match(source, /destinationFbaWarehouseCode: destination.warehouseCode/) + assert.match(source, /status === 'PENDING_REVIEW'/) + assert.match(source, /skuCodes\?\.includes\?\.\(sku\)/) + assert.match(source, /cartonTotalFileId/) + assert.match(source, /customerId, warehouseId: warehouse.id/) + assert.doesNotMatch(source, /form, \{\}, merchantToken/) +}) + test('validation predicate rejects outages, permission failures, success and missing diagnostics', () => { const response = { http: 200, body: { code: 'A0422' }, requestId: 'request', traceId: 'trace' } assert.equal(validationFailure(response), true) diff --git a/scripts/admin-test-env-modules/return-boundary-scenarios-contract.test.mjs b/scripts/admin-test-env-modules/return-boundary-scenarios-contract.test.mjs index 18f427499..20dd73f1e 100644 --- a/scripts/admin-test-env-modules/return-boundary-scenarios-contract.test.mjs +++ b/scripts/admin-test-env-modules/return-boundary-scenarios-contract.test.mjs @@ -131,3 +131,15 @@ test('高价值扩展覆盖混合回滚、同键合箱、同码隔离、权限 assert.match(source, /snapshot: withLedger[\s\S]*签收重放不重复产生流水/) assert.match(source, /sameKeyResponses/) }) + +test('边界fixture遵守审核、单SKU托盘和完工照片新契约', async () => { + const source = await readFile(new URL('return-boundary-scenarios.mjs', import.meta.url), 'utf8') + assert.match(source, /customerByToken\.set\(tokenB, String\(fixtureB\.tenantId\)\)/) + assert.match(source, /'X-Customer-Id': customerId/) + assert.match(source, /status !== 'PENDING_REVIEW'/) + assert.match(source, /palletCode: order\.palletCode/) + assert.match(source, /requestOverseasMultipart\('POST',[^\n]+form, operator\.token\)/) + assert.match(source, /两个合法SKU同批签收仍整笔拒绝/) + assert.match(source, /palletCode: nextPallet\.palletCode/) + assert.doesNotMatch(source, /\['receiptNo', 'receiptBatchNo', 'cartonMark'\]/) +}) diff --git a/scripts/database-schema-contract.mjs b/scripts/database-schema-contract.mjs index f5728f720..f5577675d 100644 --- a/scripts/database-schema-contract.mjs +++ b/scripts/database-schema-contract.mjs @@ -69,7 +69,7 @@ function parseExpected(sql) { const table = start[2] const columns = new Set() for (const line of body) { - const field = line.match(/^\s+`([a-z][a-z0-9_]*)`\s+/i) + const field = line.match(/^\s+(?!(?:PRIMARY|UNIQUE|KEY|CONSTRAINT|CHECK|FOREIGN)\b)`?([a-z][a-z0-9_]*)`?\s+/i) if (field) columns.add(field[1]) } if (!columns.size) continue diff --git a/scripts/database-schema-diff.mjs b/scripts/database-schema-diff.mjs new file mode 100644 index 000000000..243b8185a --- /dev/null +++ b/scripts/database-schema-diff.mjs @@ -0,0 +1,161 @@ +#!/usr/bin/env node + +import { readFile } from 'node:fs/promises' +import { spawnSync } from 'node:child_process' +import path from 'node:path' +import process from 'node:process' + +const DEFAULT_SCHEMAS = [ + 'baiyue_auth', 'baiyue_iam', 'baiyue_order', 'baiyue_warehouse', + 'baiyue_firstleg', 'baiyue_file', 'baiyue_audit', 'baiyue_event_bridge', + 'baiyue_finance', 'baiyue_lastleg', 'baiyue_lastlag_warehouse', + 'baiyue_event_bridge_overseas', +] + +function usage() { + console.log('用法:node scripts/database-schema-diff.mjs --left-env --right-env [--schemas schema1,schema2] [--json]') + console.log('env 键:CHONGCHONG_ENV、CHONGCHONG_DB_HOST、CHONGCHONG_DB_PORT、CHONGCHONG_DB_USERNAME、CHONGCHONG_DB_PASSWORD') + console.log('只查询 information_schema;不读取业务数据,不执行 DDL/DML。差异退出码为 1,执行错误退出码为 2。') +} + +function argument(argv, name) { + const index = argv.indexOf(name) + return index >= 0 ? argv[index + 1] : undefined +} + +function parseArguments(argv) { + if (argv.includes('--help') || argv.includes('-h')) return { help: true } + const leftEnv = argument(argv, '--left-env') + const rightEnv = argument(argv, '--right-env') + if (!leftEnv || !rightEnv) throw new Error('必须同时指定 --left-env 和 --right-env') + const schemasValue = argument(argv, '--schemas') + const schemas = schemasValue ? schemasValue.split(',').map((item) => item.trim()).filter(Boolean) : DEFAULT_SCHEMAS + if (!schemas.length || schemas.some((schema) => !/^[a-z][a-z0-9_]*$/i.test(schema))) throw new Error('--schemas 包含无效 schema 名称') + return { leftEnv: path.resolve(leftEnv), rightEnv: path.resolve(rightEnv), schemas: [...new Set(schemas)], json: argv.includes('--json') } +} + +function unquote(value) { + const trimmed = value.trim() + if (trimmed.length >= 2 && trimmed[0] === trimmed.at(-1) && ['"', "'"].includes(trimmed[0])) return trimmed.slice(1, -1) + return trimmed +} + +async function readConfig(file) { + const values = {} + const source = await readFile(file, 'utf8') + for (const line of source.split(/\r?\n/)) { + const match = line.match(/^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=\s*(.*)$/) + if (match) values[match[1]] = unquote(match[2]) + } + const required = ['CHONGCHONG_DB_HOST', 'CHONGCHONG_DB_PORT', 'CHONGCHONG_DB_USERNAME', 'CHONGCHONG_DB_PASSWORD'] + const missing = required.filter((key) => !values[key]) + if (missing.length) throw new Error(`${path.basename(file)} 缺少:${missing.join(', ')}`) + return { ...values, label: values.CHONGCHONG_ENV || path.basename(file) } +} + +function sqlStrings(values) { + return values.map((value) => `'${value.replaceAll("'", "''")}'`).join(',') +} + +function runMysql(config, sql) { + const executable = process.env.MYSQL_BIN || 'mysql' + const result = spawnSync(executable, [ + '--protocol=TCP', '-h', config.CHONGCHONG_DB_HOST, '-P', config.CHONGCHONG_DB_PORT, + '-u', config.CHONGCHONG_DB_USERNAME, '--batch', '--raw', '--skip-column-names', + '--default-character-set=utf8mb4', '-e', sql, + ], { + encoding: 'utf8', + env: { ...process.env, MYSQL_PWD: config.CHONGCHONG_DB_PASSWORD }, + maxBuffer: 32 * 1024 * 1024, + }) + if (result.error) throw new Error(`${config.label} mysql 客户端执行失败:${result.error.message}`) + if (result.status !== 0) throw new Error(`${config.label} 元数据查询失败:${(result.stderr || result.stdout).trim()}`) + return result.stdout.trim().split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line)) +} + +function snapshot(config, schemas) { + const selected = sqlStrings(schemas) + const schemaRows = runMysql(config, `SELECT JSON_OBJECT('schema', SCHEMA_NAME) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME IN (${selected}) ORDER BY SCHEMA_NAME`) + const tableRows = runMysql(config, `SELECT JSON_OBJECT('schema', TABLE_SCHEMA, 'table', TABLE_NAME, 'type', TABLE_TYPE, 'engine', COALESCE(ENGINE, ''), 'collation', COALESCE(TABLE_COLLATION, '')) FROM information_schema.TABLES WHERE TABLE_SCHEMA IN (${selected}) ORDER BY TABLE_SCHEMA, TABLE_NAME`) + const columnRows = runMysql(config, `SELECT JSON_OBJECT('schema', TABLE_SCHEMA, 'table', TABLE_NAME, 'column', COLUMN_NAME, 'ordinal', ORDINAL_POSITION, 'type', COLUMN_TYPE, 'nullable', IS_NULLABLE, 'defaultValue', COLUMN_DEFAULT, 'extra', EXTRA, 'charset', CHARACTER_SET_NAME, 'collation', COLLATION_NAME, 'generation', GENERATION_EXPRESSION) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA IN (${selected}) ORDER BY TABLE_SCHEMA, TABLE_NAME, ORDINAL_POSITION`) + return { schemas: schemaRows, tables: tableRows, columns: columnRows } +} + +function keyed(rows, keyOf, normalized) { + return new Map(rows.map((row) => [keyOf(row), normalized(row)])) +} + +function compareMaps(left, right) { + const leftOnly = [...left.keys()].filter((key) => !right.has(key)).sort() + const rightOnly = [...right.keys()].filter((key) => !left.has(key)).sort() + const changed = [...left.keys()].filter((key) => right.has(key) && JSON.stringify(left.get(key)) !== JSON.stringify(right.get(key))).sort() + .map((key) => ({ key, left: left.get(key), right: right.get(key) })) + return { leftOnly, rightOnly, changed } +} + +export function compareSnapshots(left, right) { + const schemaKey = (row) => row.schema + const tableKey = (row) => `${row.schema}.${row.table}` + const columnKey = (row) => `${row.schema}.${row.table}.${row.column}` + const schemas = compareMaps(keyed(left.schemas, schemaKey, () => true), keyed(right.schemas, schemaKey, () => true)) + const tables = compareMaps( + keyed(left.tables, tableKey, ({ type, engine, collation }) => ({ type, engine, collation })), + keyed(right.tables, tableKey, ({ type, engine, collation }) => ({ type, engine, collation })), + ) + const normalizeColumn = ({ type, nullable, defaultValue, extra, charset, collation, generation }) => ( + { type, nullable, defaultValue, extra, charset, collation, generation } + ) + const columns = compareMaps(keyed(left.columns, columnKey, normalizeColumn), keyed(right.columns, columnKey, normalizeColumn)) + return { schemas, tables, columns } +} + +function count(diff) { + return diff.leftOnly.length + diff.rightOnly.length + diff.changed.length +} + +function printSection(title, diff, leftLabel, rightLabel) { + if (!count(diff)) return + console.log(`\n${title}`) + for (const key of diff.leftOnly) console.log(` 仅 ${leftLabel}: ${key}`) + for (const key of diff.rightOnly) console.log(` 仅 ${rightLabel}: ${key}`) + for (const item of diff.changed) { + console.log(` 定义不同: ${item.key}`) + console.log(` ${leftLabel}: ${JSON.stringify(item.left)}`) + console.log(` ${rightLabel}: ${JSON.stringify(item.right)}`) + } +} + +async function main() { + const options = parseArguments(process.argv.slice(2)) + if (options.help) return usage() + const [leftConfig, rightConfig] = await Promise.all([readConfig(options.leftEnv), readConfig(options.rightEnv)]) + if (leftConfig.label === rightConfig.label) throw new Error(`两侧环境标签相同:${leftConfig.label}`) + const [left, right] = [snapshot(leftConfig, options.schemas), snapshot(rightConfig, options.schemas)] + const differences = compareSnapshots(left, right) + const result = { + left: leftConfig.label, + right: rightConfig.label, + checkedSchemas: options.schemas, + summary: { + schemas: count(differences.schemas), + tables: count(differences.tables), + columns: count(differences.columns), + }, + differences, + } + if (options.json) console.log(JSON.stringify(result, null, 2)) + else { + console.log(`数据库结构比较:${result.left} <-> ${result.right}`) + console.log(`检查 schema=${options.schemas.length};schema 差异=${result.summary.schemas},表差异=${result.summary.tables},字段差异=${result.summary.columns}`) + printSection('SCHEMA 差异', differences.schemas, result.left, result.right) + printSection('表差异', differences.tables, result.left, result.right) + printSection('字段差异', differences.columns, result.left, result.right) + } + if (Object.values(result.summary).some((value) => value > 0)) process.exitCode = 1 +} + +const isEntryPoint = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname.replace(/^\/(?:[A-Za-z]:)/, (value) => value.slice(1))) +if (isEntryPoint) main().catch((error) => { + console.error(`DATABASE_SCHEMA_DIFF_ERROR: ${error.message}`) + process.exitCode = 2 +}) diff --git a/scripts/database-schema-diff.test.mjs b/scripts/database-schema-diff.test.mjs new file mode 100644 index 000000000..91cd6c281 --- /dev/null +++ b/scripts/database-schema-diff.test.mjs @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { compareSnapshots } from './database-schema-diff.mjs' + +test('database schema diff ignores column order but catches contract changes', () => { + const left = { + schemas: [{ schema: 'sample' }], + tables: [{ schema: 'sample', table: 'orders', type: 'BASE TABLE', engine: 'InnoDB', collation: 'utf8mb4_0900_ai_ci' }], + columns: [ + { schema: 'sample', table: 'orders', column: 'id', ordinal: 1, type: 'bigint', nullable: 'NO', defaultValue: null, extra: '', charset: null, collation: null, generation: '' }, + ], + } + const reordered = structuredClone(left) + reordered.columns[0].ordinal = 9 + assert.deepEqual(compareSnapshots(left, reordered).columns, { leftOnly: [], rightOnly: [], changed: [] }) + + reordered.columns[0].nullable = 'YES' + const changed = compareSnapshots(left, reordered).columns.changed + assert.equal(changed.length, 1) + assert.equal(changed[0].key, 'sample.orders.id') +}) + +test('database schema diff reports objects present on only one side', () => { + const left = { schemas: [{ schema: 'sample' }], tables: [], columns: [] } + const right = { + schemas: [{ schema: 'sample' }], + tables: [{ schema: 'sample', table: 'orders', type: 'BASE TABLE', engine: 'InnoDB', collation: 'utf8mb4_0900_ai_ci' }], + columns: [{ schema: 'sample', table: 'orders', column: 'id', type: 'bigint', nullable: 'NO', defaultValue: null, extra: '', charset: null, collation: null, generation: '' }], + } + const diff = compareSnapshots(left, right) + assert.deepEqual(diff.tables.rightOnly, ['sample.orders']) + assert.deepEqual(diff.columns.rightOnly, ['sample.orders.id']) +}) diff --git a/scripts/db/test-return-relabel-checks.sql b/scripts/db/test-return-relabel-checks.sql new file mode 100644 index 000000000..2d2db3b45 --- /dev/null +++ b/scripts/db/test-return-relabel-checks.sql @@ -0,0 +1,18 @@ +-- Test-only targeted alignment with the authoritative Lastleg initialization checks. +-- Execute only against the explicitly authorized test connection; no row updates. +-- Both old checks must exist. Failure must stop execution, not disable enforcement. +ALTER TABLE baiyue_lastleg.ll_return_inventory_carton + DROP CHECK chk_ll_return_inventory_carton_quantity, + DROP CHECK chk_ll_return_inventory_carton_status, + ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK ( + (status IN ('AVAILABLE', 'RELABEL_PENDING', 'OUTBOUNDED') AND sku_types > 0 AND total_quantity > 0) + OR (status = 'MERGED' AND sku_types = 0 AND total_quantity = 0) + ), + ADD CONSTRAINT chk_ll_return_inventory_carton_status CHECK ( + status IN ('AVAILABLE', 'RELABEL_PENDING', 'MERGED', 'OUTBOUNDED') + ); + +SELECT CONSTRAINT_NAME, CHECK_CLAUSE +FROM information_schema.CHECK_CONSTRAINTS +WHERE CONSTRAINT_SCHEMA = 'baiyue_lastleg' + AND CONSTRAINT_NAME IN ('chk_ll_return_inventory_carton_quantity', 'chk_ll_return_inventory_carton_status');