200 lines
12 KiB
JavaScript
200 lines
12 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
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 overseasFeeStructurePath = 'deploy/sql/overseas-fees-storage-rent-structure-add.sql'
|
|
const readSql = relative => readFile(path.join(repositoryRoot, relative), 'utf8')
|
|
const withoutSqlComments = source => source.replace(/^\s*--.*$/gm, '')
|
|
|
|
test('海外费用结构补齐只修改已核准的表字段,不包含业务数据写入', async () => {
|
|
const source = withoutSqlComments(await readSql(overseasFeeStructurePath))
|
|
assert.match(source, /USE baiyue_lastleg;/)
|
|
assert.doesNotMatch(source, /\b(?:INSERT|UPDATE|DELETE|REPLACE|TRUNCATE)\b/i)
|
|
assert.doesNotMatch(source, /\bDROP\s+(?:TABLE|DATABASE|COLUMN|INDEX)\b/i)
|
|
assert.deepEqual([...source.matchAll(/^\s*ALTER TABLE\s+(\w+)/gim)].map(match => match[1]), ['ll_finance_fee_source'])
|
|
assert.deepEqual([...source.matchAll(/^\s*CREATE TABLE\s+(\w+)/gim)].map(match => match[1]), ['ll_storage_rent_inbound'])
|
|
assert.match(source, /ADD COLUMN reviewer_name VARCHAR\(255\) NULL COMMENT '审核人名称快照' AFTER reviewer_id;/)
|
|
})
|
|
|
|
test('海外费用计租实收表与两份正式基线完全一致', async () => {
|
|
const files = [overseasFeeStructurePath, 'lastleg-service/src/main/resources/db/lastleg.sql', 'deploy/sql/海外区域统一增量-MySQL8.sql']
|
|
const definitions = await Promise.all(files.map(async file => {
|
|
const source = await readSql(file)
|
|
const definition = source.match(/CREATE TABLE(?: IF NOT EXISTS)? ll_storage_rent_inbound\s*\([\s\S]*?\) ENGINE=[^;]+;/)?.[0]
|
|
assert.ok(definition, file)
|
|
assert.ok(/reviewer_name\s+VARCHAR\(255\)\s+(?:COLLATE\s+\w+\s+)?(?:DEFAULT\s+)?NULL\s+COMMENT '审核人名称快照'/i.test(source), `${file} 审核人名称须为可空 VARCHAR(255)`)
|
|
return definition.replace(' IF NOT EXISTS', '').replace(/\s+/g, ' ').toLowerCase()
|
|
}))
|
|
assert.equal(definitions[0], definitions[1])
|
|
assert.equal(definitions[0], definitions[2])
|
|
})
|
|
|
|
test('海外费用结构补齐先检查同名结构再修改,支持匹配结构重跑', async () => {
|
|
const source = withoutSqlComments(await readSql(overseasFeeStructurePath))
|
|
const firstDdl = source.indexOf('ALTER TABLE ll_finance_fee_source')
|
|
assert.ok(firstDdl > 0)
|
|
const guards = source.slice(0, firstDdl)
|
|
for (const marker of ["DATABASE()<>'baiyue_lastleg'", 'reviewer_exists>0', 'inbound_exists>0', "TABLE_COLLATION='utf8mb4_unicode_ci'", "COLUMN_TYPE='decimal(24,12)'", "COLUMN_TYPE='datetime(3)'", "INDEX_NAME='PRIMARY'", "INDEX_NAME='idx_storage_rent_inbound_lot'"]) {
|
|
assert.ok(guards.includes(marker), marker)
|
|
}
|
|
assert.match(guards, /SIGNAL SQLSTATE '45000'/)
|
|
assert.match(source, /IF reviewer_exists=0 THEN\s+ALTER TABLE/)
|
|
assert.match(source, /IF inbound_exists=0 THEN\s+CREATE TABLE/)
|
|
assert.match(source, /CALL overseas_fee_rent_structure_add_20261026\(\);[\s\S]+SHOW CREATE TABLE ll_storage_rent_inbound;[\s\S]+'TABLE_COLUMN_READY'/)
|
|
})
|
|
|
|
test('海外费用免费仓租修复只放行自动应收双零,保留其他费用及汇率边界', async () => {
|
|
const repair = await readSql('deploy/sql/overseas-storage-rent-zero-fee-repair.sql')
|
|
const normalized = repair.replaceAll("''", "'")
|
|
const autoZero = "amount=0 AND original_amount=0 AND created_by='STORAGE_RENT' AND fee_item_code='FEE_TYPE_077' AND direction='RECEIVABLE'"
|
|
assert.ok(normalized.includes(`CHECK(amount>0 OR (${autoZero}))`))
|
|
assert.ok(normalized.includes(`CHECK((exchange_rate IS NOT NULL AND exchange_rate>0) OR (exchange_rate IS NULL AND ${autoZero}))`))
|
|
assert.match(repair, /SELECT COLUMN_TYPE,COLUMN_COMMENT,COLUMN_DEFAULT INTO rate_type,rate_comment,rate_default/)
|
|
assert.match(repair, /rate_default IS NOT NULL AND rate_default NOT REGEXP/)
|
|
assert.doesNotMatch(withoutSqlComments(repair), /^\s*(?:INSERT|UPDATE|DELETE|REPLACE|TRUNCATE)\b/im)
|
|
})
|
|
|
|
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() {
|
|
return Object.fromEntries(await Promise.all(Object.entries(migrations).map(async ([name, relative]) => (
|
|
[name, await readFile(path.join(repositoryRoot, relative), 'utf8')]
|
|
))))
|
|
}
|
|
|
|
test('schema alignment migrations contain no destructive statements', async () => {
|
|
for (const [name, source] of Object.entries(await sources())) {
|
|
assert.doesNotMatch(source, /^\s*(?:DROP|DELETE|UPDATE|TRUNCATE)\b/im, `${name} 迁移不得删除、清理或回填数据`)
|
|
assert.doesNotMatch(source, /\bSIGNAL\s+SQLSTATE\b/i, `${name} 迁移不得使用 MySQL 不支持的动态 SIGNAL`)
|
|
}
|
|
})
|
|
|
|
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 [
|
|
'actual_discard_packages', 'pda_status', 'pda_operator_id', 'pda_completed_at',
|
|
'quantity', 'pallet_code', 'discrepancy_reason', 'manually_completed_at',
|
|
]) assert.match(source.lastleg, new RegExp(`\\b${field}\\b`))
|
|
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('墨家迁移保留未知重量,不因已有收件记录阻断', async () => {
|
|
const { firstlegMeasurement: source } = await sources()
|
|
assert.match(source, /ADD COLUMN weight_kg DECIMAL\(18,3\) NULL/i)
|
|
assert.match(source, /MODIFY COLUMN weight_kg DECIMAL\(18,3\) NULL/i)
|
|
assert.doesNotMatch(source, /weight_kg DECIMAL\(18,3\) NOT NULL/i)
|
|
assert.doesNotMatch(source, /FROM\s+fl_mojia_measurement_receipt\b/i)
|
|
assert.match(source, /migration_blocked_firstleg_mojia_receipt_table_missing/)
|
|
const module = await readFile(path.join(repositoryRoot, 'firstleg/src/main/resources/db/phase2-inbound-schema.sql'), 'utf8')
|
|
const receipt = module.match(/CREATE TABLE IF NOT EXISTS fl_mojia_measurement_receipt\s*\(([\s\S]*?)\n\) ENGINE=/i)?.[1]
|
|
assert.ok(receipt, '必须保留墨家收件表定义')
|
|
assert.match(receipt, /weight_kg DECIMAL\(18,3\) NULL/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' || name === 'firstlegMeasurement' || name === 'firstlegCustoms') {
|
|
assert.ok(firstGuard >= 0 && firstGuard < firstAlter, `${name} 必须先验证存量数据再执行 DDL`)
|
|
}
|
|
}
|
|
})
|
|
|
|
test('数据库迁移计划包含授权版本和事件可靠投递的正式目标版本', 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', 'finance', '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, '6')
|
|
assert.equal(targets.baiyue_iam.migrations.at(-1).path,
|
|
'migrations/cn/baiyue_iam/V6__cargo_discard_customer_confirmation_permission.sql')
|
|
assert.equal(targets.baiyue_firstleg.target, '9')
|
|
assert.equal(targets.baiyue_firstleg.migrations.at(-1).path,
|
|
'migrations/cn/baiyue_firstleg/V9__drop_chargeable_weight_confirmation.sql')
|
|
assert.equal(targets.baiyue_finance.target, '2')
|
|
assert.equal(targets.baiyue_lastleg.target, '33')
|
|
assert.equal(targets.baiyue_lastleg.migrations.at(-1).path,
|
|
'migrations/overseas/baiyue_lastleg/V33__looseboard_pool_demand.sql')
|
|
assert.deepEqual(targets.baiyue_lastleg.migrations.filter(migration => /\/V1[0-3]__/.test(migration.path)).map(migration => migration.path), [
|
|
'migrations/overseas/baiyue_lastleg/V10__fleet_capacity_pool.sql',
|
|
'migrations/overseas/baiyue_lastleg/V11__align_capacity_pool_collation.sql',
|
|
'migrations/overseas/baiyue_lastleg/V12__preserve_unknown_pool_order_fields.sql',
|
|
'migrations/overseas/baiyue_lastleg/V13__widen_inbound_plan_creation_mode.sql',
|
|
])
|
|
assert.equal(targets.baiyue_lastlag_warehouse.target, '2')
|
|
assert.deepEqual(targets.baiyue_firstleg.migrations.filter(migration => /\/V[5-7]__/.test(migration.path)).map((migration) => migration.path), [
|
|
'migrations/cn/baiyue_firstleg/V5__event_delivery_retry_audit.sql',
|
|
'migrations/cn/baiyue_firstleg/V6__event_delivery_attempt.sql',
|
|
'migrations/cn/baiyue_firstleg/V7__settle_partial_loaded_cargo.sql',
|
|
])
|
|
})
|