2026-02-24 13:47:00 +08:00
|
|
|
/**
|
|
|
|
|
* IDC管理系统 - 数据库迁移汇总脚本
|
|
|
|
|
* 按顺序执行所有数据库迁移
|
|
|
|
|
* 支持幂等执行(重复执行不会出错)
|
2026-03-05 10:07:52 +08:00
|
|
|
* 支持 SQLite 和 MySQL
|
2026-02-24 13:47:00 +08:00
|
|
|
*/
|
|
|
|
|
|
2026-03-05 10:13:02 +08:00
|
|
|
// 必须在最前面加载环境变量
|
|
|
|
|
const path = require('path');
|
|
|
|
|
const envPath = path.join(__dirname, '../.env');
|
|
|
|
|
console.log(`加载环境变量文件: ${envPath}`);
|
|
|
|
|
require('dotenv').config({ path: envPath });
|
|
|
|
|
|
|
|
|
|
console.log('环境变量检查:');
|
|
|
|
|
console.log(` DB_TYPE: ${process.env.DB_TYPE}`);
|
|
|
|
|
console.log(` MYSQL_HOST: ${process.env.MYSQL_HOST}`);
|
|
|
|
|
console.log(` MYSQL_DATABASE: ${process.env.MYSQL_DATABASE}`);
|
|
|
|
|
console.log(` MYSQL_USERNAME: ${process.env.MYSQL_USERNAME}`);
|
|
|
|
|
|
|
|
|
|
const { sequelize, DB_TYPE, dbDialect } = require('../db');
|
|
|
|
|
|
|
|
|
|
console.log(`\n数据库连接信息:`);
|
|
|
|
|
console.log(` DB_TYPE (from env): ${DB_TYPE}`);
|
|
|
|
|
console.log(` dbDialect (actual): ${dbDialect}`);
|
|
|
|
|
console.log(` sequelize.getDialect(): ${sequelize.getDialect()}`);
|
2026-02-24 13:47:00 +08:00
|
|
|
|
|
|
|
|
const migrations = [
|
|
|
|
|
{
|
|
|
|
|
name: 'v2.0 - 网卡和端口表',
|
|
|
|
|
description: '创建 network_cards 表,为 device_ports 添加 nic_id 字段',
|
2026-03-27 19:12:16 +08:00
|
|
|
migrate: migrateV2,
|
2026-02-24 13:47:00 +08:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: '用户表 pending 状态',
|
|
|
|
|
description: '为用户表添加 pending 状态支持',
|
2026-03-27 19:12:16 +08:00
|
|
|
migrate: migratePendingStatus,
|
2026-02-24 13:47:00 +08:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: '耗材乐观锁',
|
|
|
|
|
description: '为 consumables 表添加 version 字段',
|
2026-03-27 19:12:16 +08:00
|
|
|
migrate: migrateConsumableVersion,
|
2026-02-24 13:47:00 +08:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: '耗材操作日志表结构',
|
|
|
|
|
description: '添加 isEditable、originalLogId 等修改记录字段',
|
2026-03-27 19:12:16 +08:00
|
|
|
migrate: migrateConsumableLogs,
|
2026-02-24 13:47:00 +08:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: '耗材日志解耦',
|
|
|
|
|
description: '添加 isConsumableDeleted 和 consumableSnapshot 字段',
|
2026-03-27 19:12:16 +08:00
|
|
|
migrate: migrateConsumableLogDecouple,
|
2026-02-24 13:47:00 +08:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: '移除日志外键约束',
|
|
|
|
|
description: '移除 consumable_logs 表的外键约束,防止级联删除',
|
2026-03-27 19:12:16 +08:00
|
|
|
migrate: removeConsumableLogFK,
|
2026-02-24 13:47:00 +08:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: '耗材日志归档表',
|
|
|
|
|
description: '创建 consumable_log_archives 归档表',
|
2026-03-27 19:12:16 +08:00
|
|
|
migrate: migrateConsumableLogArchive,
|
2026-03-05 09:57:44 +08:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: '耗材SN序列号字段',
|
|
|
|
|
description: '为 consumables、consumable_records、consumable_logs 添加 snList 字段',
|
2026-03-27 19:12:16 +08:00
|
|
|
migrate: migrateSnList,
|
2026-03-06 13:57:37 +08:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: '设备型号字段可空',
|
|
|
|
|
description: '将 devices 表 model 字段改为可空,支持非必填',
|
2026-03-27 19:12:16 +08:00
|
|
|
migrate: migrateDeviceModelField,
|
2026-03-06 13:57:37 +08:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: '设备字段配置同步',
|
|
|
|
|
description: '同步前后端字段必填配置',
|
2026-03-27 19:12:16 +08:00
|
|
|
migrate: migrateDeviceFieldsConfig,
|
2026-03-06 13:57:37 +08:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: '设备表字段可空',
|
|
|
|
|
description: '将设备表所有字段改为可空,由应用层验证控制',
|
2026-03-27 19:12:16 +08:00
|
|
|
migrate: migrateDeviceFieldsNullable,
|
2026-03-10 09:20:31 +08:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: '暂存设备自定义字段',
|
|
|
|
|
description: '为 pending_devices 表添加 customFields 字段,支持自定义字段存储',
|
2026-03-27 19:12:16 +08:00
|
|
|
migrate: migratePendingDeviceCustomFields,
|
2026-03-20 17:15:14 +08:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: '空闲设备与业务关联',
|
|
|
|
|
description: '创建 businesses、warehouses、device_business 表,为 devices 添加空闲设备字段',
|
2026-03-27 19:12:16 +08:00
|
|
|
migrate: migrateIdleDeviceAndBusiness,
|
2026-03-20 17:15:14 +08:00
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: '设备字段系统标记',
|
|
|
|
|
description: '为 deviceFields 表添加 isSystem 字段,标记系统字段不可删除',
|
2026-03-27 19:12:16 +08:00
|
|
|
migrate: migrateDeviceFieldsIsSystem,
|
|
|
|
|
},
|
2026-02-24 13:47:00 +08:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
async function runMigrations() {
|
|
|
|
|
console.log('========================================');
|
|
|
|
|
console.log(' IDC管理系统 - 数据库迁移汇总脚本 ');
|
|
|
|
|
console.log('========================================');
|
|
|
|
|
console.log(`数据库类型: ${DB_TYPE}`);
|
|
|
|
|
console.log(`迁移数量: ${migrations.length}`);
|
|
|
|
|
console.log('');
|
|
|
|
|
|
|
|
|
|
const results = [];
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < migrations.length; i++) {
|
|
|
|
|
const migration = migrations[i];
|
|
|
|
|
console.log(`\n[${i + 1}/${migrations.length}] ${migration.name}`);
|
|
|
|
|
console.log(` ${migration.description}`);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await migration.migrate();
|
|
|
|
|
results.push({ name: migration.name, status: '成功' });
|
|
|
|
|
console.log(` ✓ 完成`);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
results.push({ name: migration.name, status: '失败', error: error.message });
|
|
|
|
|
console.error(` ✗ 失败: ${error.message}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log('\n========================================');
|
|
|
|
|
console.log(' 迁移结果汇总 ');
|
|
|
|
|
console.log('========================================');
|
|
|
|
|
|
|
|
|
|
const successCount = results.filter(r => r.status === '成功').length;
|
|
|
|
|
const failCount = results.filter(r => r.status === '失败').length;
|
|
|
|
|
|
|
|
|
|
results.forEach((result, index) => {
|
|
|
|
|
const icon = result.status === '成功' ? '✓' : '✗';
|
|
|
|
|
console.log(`${icon} [${index + 1}] ${result.name}: ${result.status}`);
|
|
|
|
|
if (result.error) {
|
|
|
|
|
console.log(` 错误: ${result.error}`);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
console.log('\n----------------------------------------');
|
|
|
|
|
console.log(`总计: ${results.length} | 成功: ${successCount} | 失败: ${failCount}`);
|
|
|
|
|
console.log('========================================');
|
|
|
|
|
|
|
|
|
|
await sequelize.close();
|
|
|
|
|
process.exit(failCount > 0 ? 1 : 0);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 10:07:52 +08:00
|
|
|
// ==================== 工具函数 ====================
|
|
|
|
|
|
|
|
|
|
async function getTableColumns(tableName) {
|
|
|
|
|
const dialect = sequelize.getDialect();
|
2026-03-27 19:12:16 +08:00
|
|
|
|
2026-03-05 10:07:52 +08:00
|
|
|
if (dialect === 'sqlite') {
|
2026-03-27 19:12:16 +08:00
|
|
|
const tableInfo = await sequelize.query(`PRAGMA table_info(${tableName})`, {
|
|
|
|
|
type: sequelize.QueryTypes.SELECT,
|
|
|
|
|
});
|
2026-03-05 10:07:52 +08:00
|
|
|
return tableInfo.map(col => col.name);
|
|
|
|
|
} else {
|
2026-03-27 19:12:16 +08:00
|
|
|
const tableInfo = await sequelize.query(`SHOW COLUMNS FROM ${tableName}`, {
|
|
|
|
|
type: sequelize.QueryTypes.SELECT,
|
|
|
|
|
});
|
2026-03-05 10:07:52 +08:00
|
|
|
return tableInfo.map(col => col.Field);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function tableExists(tableName) {
|
|
|
|
|
const dialect = sequelize.getDialect();
|
2026-03-27 19:12:16 +08:00
|
|
|
|
2026-03-05 10:07:52 +08:00
|
|
|
if (dialect === 'sqlite') {
|
|
|
|
|
const tables = await sequelize.query(
|
|
|
|
|
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
|
|
|
|
|
{ replacements: [tableName], type: sequelize.QueryTypes.SELECT }
|
|
|
|
|
);
|
|
|
|
|
return tables.length > 0;
|
|
|
|
|
} else {
|
2026-03-27 19:12:16 +08:00
|
|
|
const tables = await sequelize.query('SHOW TABLES LIKE ?', {
|
|
|
|
|
replacements: [tableName],
|
|
|
|
|
type: sequelize.QueryTypes.SELECT,
|
|
|
|
|
});
|
2026-03-05 10:07:52 +08:00
|
|
|
return tables.length > 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function addColumnIfNotExists(tableName, columnName, columnDef) {
|
|
|
|
|
const columns = await getTableColumns(tableName);
|
2026-03-27 19:12:16 +08:00
|
|
|
|
2026-03-05 10:07:52 +08:00
|
|
|
if (!columns.includes(columnName)) {
|
|
|
|
|
const dialect = sequelize.getDialect();
|
2026-03-27 19:12:16 +08:00
|
|
|
const sql =
|
|
|
|
|
dialect === 'sqlite'
|
|
|
|
|
? `ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${columnDef}`
|
|
|
|
|
: `ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${columnDef}`;
|
2026-03-05 10:07:52 +08:00
|
|
|
await sequelize.query(sql);
|
|
|
|
|
console.log(` ${tableName} 表添加 ${columnName} 字段成功`);
|
|
|
|
|
} else {
|
|
|
|
|
console.log(` ${tableName} 表 ${columnName} 字段已存在,跳过`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-24 13:47:00 +08:00
|
|
|
// ==================== 迁移函数 ====================
|
|
|
|
|
|
|
|
|
|
async function migrateV2() {
|
|
|
|
|
const queryInterface = sequelize.getQueryInterface();
|
|
|
|
|
const dialect = sequelize.getDialect();
|
|
|
|
|
|
|
|
|
|
// 1. 创建 network_cards 表
|
2026-03-05 10:07:52 +08:00
|
|
|
if (!(await tableExists('network_cards'))) {
|
2026-02-24 13:47:00 +08:00
|
|
|
await queryInterface.createTable('network_cards', {
|
|
|
|
|
id: {
|
|
|
|
|
type: sequelize.Sequelize.INTEGER,
|
|
|
|
|
primaryKey: true,
|
2026-03-27 19:12:16 +08:00
|
|
|
autoIncrement: true,
|
2026-02-24 13:47:00 +08:00
|
|
|
},
|
|
|
|
|
nicId: {
|
|
|
|
|
type: sequelize.Sequelize.STRING,
|
|
|
|
|
allowNull: false,
|
2026-03-27 19:12:16 +08:00
|
|
|
unique: true,
|
2026-02-24 13:47:00 +08:00
|
|
|
},
|
|
|
|
|
name: {
|
|
|
|
|
type: sequelize.Sequelize.STRING,
|
2026-03-27 19:12:16 +08:00
|
|
|
allowNull: false,
|
2026-02-24 13:47:00 +08:00
|
|
|
},
|
|
|
|
|
macAddress: {
|
2026-03-27 19:12:16 +08:00
|
|
|
type: sequelize.Sequelize.STRING,
|
2026-02-24 13:47:00 +08:00
|
|
|
},
|
|
|
|
|
ipAddress: {
|
2026-03-27 19:12:16 +08:00
|
|
|
type: sequelize.Sequelize.STRING,
|
2026-02-24 13:47:00 +08:00
|
|
|
},
|
|
|
|
|
deviceId: {
|
2026-03-27 19:12:16 +08:00
|
|
|
type: sequelize.Sequelize.STRING,
|
2026-02-24 13:47:00 +08:00
|
|
|
},
|
|
|
|
|
status: {
|
|
|
|
|
type: sequelize.Sequelize.STRING,
|
2026-03-27 19:12:16 +08:00
|
|
|
defaultValue: 'active',
|
2026-02-24 13:47:00 +08:00
|
|
|
},
|
|
|
|
|
createdAt: sequelize.Sequelize.DATE,
|
2026-03-27 19:12:16 +08:00
|
|
|
updatedAt: sequelize.Sequelize.DATE,
|
2026-02-24 13:47:00 +08:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2. 为 device_ports 添加 nic_id 字段
|
2026-03-05 10:07:52 +08:00
|
|
|
if (await tableExists('device_ports')) {
|
|
|
|
|
await addColumnIfNotExists('device_ports', 'nic_id', 'INTEGER');
|
2026-02-24 13:47:00 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function migratePendingStatus() {
|
2026-03-05 10:07:52 +08:00
|
|
|
if (await tableExists('users')) {
|
|
|
|
|
await addColumnIfNotExists('users', 'status', "VARCHAR(255) DEFAULT 'active'");
|
2026-02-24 13:47:00 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function migrateConsumableVersion() {
|
2026-03-05 10:07:52 +08:00
|
|
|
if (await tableExists('consumables')) {
|
|
|
|
|
await addColumnIfNotExists('consumables', 'version', 'INTEGER DEFAULT 0');
|
2026-02-24 13:47:00 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function migrateConsumableLogs() {
|
2026-03-05 10:07:52 +08:00
|
|
|
if (await tableExists('consumable_logs')) {
|
|
|
|
|
await addColumnIfNotExists('consumable_logs', 'isEditable', 'BOOLEAN DEFAULT 1');
|
|
|
|
|
await addColumnIfNotExists('consumable_logs', 'originalLogId', 'INTEGER');
|
|
|
|
|
await addColumnIfNotExists('consumable_logs', 'modifiedBy', 'VARCHAR(255)');
|
|
|
|
|
await addColumnIfNotExists('consumable_logs', 'modifiedAt', 'DATETIME');
|
|
|
|
|
await addColumnIfNotExists('consumable_logs', 'modificationReason', 'TEXT');
|
2026-02-24 13:47:00 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function migrateConsumableLogDecouple() {
|
2026-03-05 10:07:52 +08:00
|
|
|
if (await tableExists('consumable_logs')) {
|
|
|
|
|
await addColumnIfNotExists('consumable_logs', 'isConsumableDeleted', 'BOOLEAN DEFAULT 0');
|
|
|
|
|
await addColumnIfNotExists('consumable_logs', 'consumableSnapshot', 'TEXT');
|
2026-02-24 13:47:00 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function removeConsumableLogFK() {
|
2026-03-05 10:07:52 +08:00
|
|
|
const dialect = sequelize.getDialect();
|
2026-03-27 19:12:16 +08:00
|
|
|
|
2026-03-05 10:07:52 +08:00
|
|
|
if (dialect === 'sqlite') {
|
2026-03-27 19:12:16 +08:00
|
|
|
const fks = await sequelize.query(`PRAGMA foreign_key_list(consumable_logs);`, {
|
|
|
|
|
type: sequelize.QueryTypes.SELECT,
|
|
|
|
|
});
|
2026-02-24 13:47:00 +08:00
|
|
|
|
2026-03-05 10:07:52 +08:00
|
|
|
if (!fks || fks.length === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await sequelize.query(`
|
|
|
|
|
CREATE TABLE consumable_logs_new (
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
consumableId VARCHAR(255),
|
|
|
|
|
consumableName VARCHAR(255),
|
|
|
|
|
operationType VARCHAR(50),
|
|
|
|
|
quantity INTEGER,
|
|
|
|
|
previousStock INTEGER,
|
|
|
|
|
currentStock INTEGER,
|
|
|
|
|
operator VARCHAR(255),
|
|
|
|
|
reason TEXT,
|
|
|
|
|
notes TEXT,
|
|
|
|
|
isEditable BOOLEAN DEFAULT 1,
|
|
|
|
|
originalLogId INTEGER,
|
|
|
|
|
modifiedBy VARCHAR(255),
|
|
|
|
|
modifiedAt DATETIME,
|
|
|
|
|
modificationReason TEXT,
|
|
|
|
|
isConsumableDeleted BOOLEAN DEFAULT 0,
|
|
|
|
|
consumableSnapshot TEXT,
|
|
|
|
|
relatedId VARCHAR(255),
|
|
|
|
|
createdAt DATETIME,
|
|
|
|
|
updatedAt DATETIME
|
|
|
|
|
)
|
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
await sequelize.query(`
|
|
|
|
|
INSERT INTO consumable_logs_new
|
|
|
|
|
SELECT id, consumableId, consumableName, operationType, quantity,
|
|
|
|
|
previousStock, currentStock, operator, reason, notes,
|
|
|
|
|
isEditable, originalLogId, modifiedBy, modifiedAt, modificationReason,
|
|
|
|
|
isConsumableDeleted, consumableSnapshot, relatedId, createdAt, updatedAt
|
|
|
|
|
FROM consumable_logs
|
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
await sequelize.query(`DROP TABLE consumable_logs`);
|
|
|
|
|
await sequelize.query(`ALTER TABLE consumable_logs_new RENAME TO consumable_logs`);
|
|
|
|
|
|
|
|
|
|
await sequelize.query(`CREATE INDEX idx_logs_consumable_id ON consumable_logs(consumableId)`);
|
|
|
|
|
await sequelize.query(`CREATE INDEX idx_logs_operation_type ON consumable_logs(operationType)`);
|
|
|
|
|
await sequelize.query(`CREATE INDEX idx_logs_created_at ON consumable_logs(createdAt)`);
|
2026-03-27 19:12:16 +08:00
|
|
|
await sequelize.query(
|
|
|
|
|
`CREATE INDEX idx_logs_is_consumable_deleted ON consumable_logs(isConsumableDeleted)`
|
|
|
|
|
);
|
2026-02-24 13:47:00 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function migrateConsumableLogArchive() {
|
2026-03-05 10:07:52 +08:00
|
|
|
if (await tableExists('consumable_log_archives')) {
|
|
|
|
|
return;
|
2026-02-24 13:47:00 +08:00
|
|
|
}
|
|
|
|
|
|
2026-03-05 10:07:52 +08:00
|
|
|
const queryInterface = sequelize.getQueryInterface();
|
|
|
|
|
await queryInterface.createTable('consumable_log_archives', {
|
|
|
|
|
id: {
|
|
|
|
|
type: sequelize.Sequelize.INTEGER,
|
|
|
|
|
primaryKey: true,
|
2026-03-27 19:12:16 +08:00
|
|
|
autoIncrement: true,
|
2026-03-05 10:07:52 +08:00
|
|
|
},
|
|
|
|
|
archiveId: {
|
|
|
|
|
type: sequelize.Sequelize.STRING,
|
|
|
|
|
allowNull: false,
|
2026-03-27 19:12:16 +08:00
|
|
|
unique: true,
|
2026-03-05 10:07:52 +08:00
|
|
|
},
|
|
|
|
|
consumableId: {
|
|
|
|
|
type: sequelize.Sequelize.STRING,
|
2026-03-27 19:12:16 +08:00
|
|
|
allowNull: false,
|
2026-03-05 10:07:52 +08:00
|
|
|
},
|
|
|
|
|
consumableName: {
|
|
|
|
|
type: sequelize.Sequelize.STRING,
|
2026-03-27 19:12:16 +08:00
|
|
|
allowNull: false,
|
2026-03-05 10:07:52 +08:00
|
|
|
},
|
|
|
|
|
consumableSnapshot: {
|
2026-03-27 19:12:16 +08:00
|
|
|
type: sequelize.Sequelize.TEXT,
|
2026-03-05 10:07:52 +08:00
|
|
|
},
|
|
|
|
|
totalOperations: {
|
|
|
|
|
type: sequelize.Sequelize.INTEGER,
|
2026-03-27 19:12:16 +08:00
|
|
|
defaultValue: 0,
|
2026-03-05 10:07:52 +08:00
|
|
|
},
|
|
|
|
|
firstOperationAt: {
|
2026-03-27 19:12:16 +08:00
|
|
|
type: sequelize.Sequelize.DATE,
|
2026-03-05 10:07:52 +08:00
|
|
|
},
|
|
|
|
|
lastOperationAt: {
|
2026-03-27 19:12:16 +08:00
|
|
|
type: sequelize.Sequelize.DATE,
|
2026-03-05 10:07:52 +08:00
|
|
|
},
|
|
|
|
|
totalInQuantity: {
|
|
|
|
|
type: sequelize.Sequelize.INTEGER,
|
2026-03-27 19:12:16 +08:00
|
|
|
defaultValue: 0,
|
2026-03-05 10:07:52 +08:00
|
|
|
},
|
|
|
|
|
totalOutQuantity: {
|
|
|
|
|
type: sequelize.Sequelize.INTEGER,
|
2026-03-27 19:12:16 +08:00
|
|
|
defaultValue: 0,
|
2026-03-05 10:07:52 +08:00
|
|
|
},
|
|
|
|
|
finalStock: {
|
|
|
|
|
type: sequelize.Sequelize.INTEGER,
|
2026-03-27 19:12:16 +08:00
|
|
|
defaultValue: 0,
|
2026-03-05 10:07:52 +08:00
|
|
|
},
|
|
|
|
|
deletedBy: {
|
2026-03-27 19:12:16 +08:00
|
|
|
type: sequelize.Sequelize.STRING,
|
2026-03-05 10:07:52 +08:00
|
|
|
},
|
|
|
|
|
deletedAt: {
|
2026-03-27 19:12:16 +08:00
|
|
|
type: sequelize.Sequelize.DATE,
|
2026-03-05 10:07:52 +08:00
|
|
|
},
|
|
|
|
|
deleteReason: {
|
2026-03-27 19:12:16 +08:00
|
|
|
type: sequelize.Sequelize.STRING,
|
2026-03-05 10:07:52 +08:00
|
|
|
},
|
|
|
|
|
createdAt: {
|
|
|
|
|
type: sequelize.Sequelize.DATE,
|
|
|
|
|
allowNull: false,
|
2026-03-27 19:12:16 +08:00
|
|
|
defaultValue: sequelize.Sequelize.literal('CURRENT_TIMESTAMP'),
|
2026-03-05 10:07:52 +08:00
|
|
|
},
|
|
|
|
|
updatedAt: {
|
|
|
|
|
type: sequelize.Sequelize.DATE,
|
|
|
|
|
allowNull: false,
|
2026-03-27 19:12:16 +08:00
|
|
|
defaultValue: sequelize.Sequelize.literal('CURRENT_TIMESTAMP'),
|
|
|
|
|
},
|
2026-03-05 10:07:52 +08:00
|
|
|
});
|
2026-02-24 13:47:00 +08:00
|
|
|
|
2026-03-05 10:13:02 +08:00
|
|
|
if (dbDialect === 'sqlite') {
|
2026-03-27 19:12:16 +08:00
|
|
|
await sequelize.query(
|
|
|
|
|
`CREATE INDEX idx_archive_consumable_id ON consumable_log_archives(consumableId)`
|
|
|
|
|
);
|
|
|
|
|
await sequelize.query(
|
|
|
|
|
`CREATE INDEX idx_archive_archive_id ON consumable_log_archives(archiveId)`
|
|
|
|
|
);
|
|
|
|
|
await sequelize.query(
|
|
|
|
|
`CREATE INDEX idx_archive_deleted_at ON consumable_log_archives(deletedAt)`
|
|
|
|
|
);
|
2026-03-05 10:07:52 +08:00
|
|
|
}
|
2026-02-24 13:47:00 +08:00
|
|
|
}
|
|
|
|
|
|
2026-03-05 09:57:44 +08:00
|
|
|
async function migrateSnList() {
|
|
|
|
|
const tables = ['consumables', 'consumable_records', 'consumable_logs'];
|
2026-03-27 19:12:16 +08:00
|
|
|
|
2026-03-05 09:57:44 +08:00
|
|
|
for (const table of tables) {
|
2026-03-05 10:07:52 +08:00
|
|
|
if (await tableExists(table)) {
|
2026-03-27 19:12:16 +08:00
|
|
|
const columnDef = dbDialect === 'sqlite' ? "TEXT DEFAULT '[]'" : 'JSON';
|
2026-03-05 10:07:52 +08:00
|
|
|
await addColumnIfNotExists(table, 'snList', columnDef);
|
2026-03-05 09:57:44 +08:00
|
|
|
} else {
|
2026-03-05 10:07:52 +08:00
|
|
|
console.log(` ${table} 表不存在,跳过`);
|
2026-03-05 09:57:44 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-06 13:57:37 +08:00
|
|
|
async function migrateDeviceModelField() {
|
|
|
|
|
if (!(await tableExists('devices'))) {
|
|
|
|
|
console.log(' devices 表不存在,跳过');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const dialect = sequelize.getDialect();
|
2026-03-27 19:12:16 +08:00
|
|
|
|
2026-03-06 13:57:37 +08:00
|
|
|
if (dialect === 'mysql') {
|
2026-03-27 19:12:16 +08:00
|
|
|
await sequelize.query('ALTER TABLE devices MODIFY COLUMN model VARCHAR(255) NULL');
|
2026-03-06 13:57:37 +08:00
|
|
|
console.log(' devices 表 model 字段已改为可空');
|
|
|
|
|
} else if (dialect === 'sqlite') {
|
|
|
|
|
const columns = await getTableColumns('devices');
|
|
|
|
|
if (columns.includes('model_old')) {
|
|
|
|
|
console.log(' model_old 字段已存在,跳过迁移');
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-03-27 19:12:16 +08:00
|
|
|
|
2026-03-06 13:57:37 +08:00
|
|
|
await sequelize.query('ALTER TABLE devices RENAME COLUMN model TO model_old');
|
|
|
|
|
await sequelize.query('ALTER TABLE devices ADD COLUMN model VARCHAR(255)');
|
|
|
|
|
await sequelize.query('UPDATE devices SET model = model_old');
|
|
|
|
|
await sequelize.query('ALTER TABLE devices DROP COLUMN model_old');
|
|
|
|
|
console.log(' devices 表 model 字段已改为可空');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function migrateDeviceFieldsConfig() {
|
|
|
|
|
const DeviceField = require('../models/DeviceField');
|
2026-03-27 19:12:16 +08:00
|
|
|
|
2026-03-06 13:57:37 +08:00
|
|
|
const updates = [
|
|
|
|
|
{ fieldName: 'model', required: false },
|
|
|
|
|
{ fieldName: 'powerConsumption', required: true },
|
|
|
|
|
{ fieldName: 'purchaseDate', required: false },
|
|
|
|
|
{ fieldName: 'warrantyExpiry', required: false },
|
|
|
|
|
];
|
2026-03-27 19:12:16 +08:00
|
|
|
|
2026-03-06 13:57:37 +08:00
|
|
|
for (const update of updates) {
|
|
|
|
|
const field = await DeviceField.findOne({ where: { fieldName: update.fieldName } });
|
|
|
|
|
if (field && field.required !== update.required) {
|
|
|
|
|
await field.update(update);
|
|
|
|
|
console.log(` 更新字段 ${update.fieldName}: required=${update.required}`);
|
|
|
|
|
} else if (!field) {
|
|
|
|
|
console.log(` 字段 ${update.fieldName} 不存在,跳过`);
|
|
|
|
|
} else {
|
|
|
|
|
console.log(` 字段 ${update.fieldName} 配置已正确,跳过`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function migrateDeviceFieldsNullable() {
|
|
|
|
|
const dialect = sequelize.getDialect();
|
2026-03-27 19:12:16 +08:00
|
|
|
|
2026-03-06 13:57:37 +08:00
|
|
|
if (dialect === 'mysql') {
|
|
|
|
|
const alterCommands = [
|
2026-03-27 19:12:16 +08:00
|
|
|
'ALTER TABLE devices MODIFY COLUMN name VARCHAR(255) NULL',
|
|
|
|
|
'ALTER TABLE devices MODIFY COLUMN type VARCHAR(255) NULL',
|
|
|
|
|
'ALTER TABLE devices MODIFY COLUMN model VARCHAR(255) NULL',
|
|
|
|
|
'ALTER TABLE devices MODIFY COLUMN serialNumber VARCHAR(255) NULL',
|
|
|
|
|
'ALTER TABLE devices MODIFY COLUMN rackId VARCHAR(255) NULL',
|
|
|
|
|
'ALTER TABLE devices MODIFY COLUMN position INTEGER NULL',
|
|
|
|
|
'ALTER TABLE devices MODIFY COLUMN height INTEGER NULL',
|
|
|
|
|
'ALTER TABLE devices MODIFY COLUMN powerConsumption FLOAT NULL',
|
|
|
|
|
'ALTER TABLE devices MODIFY COLUMN customFields JSON NULL',
|
2026-03-06 13:57:37 +08:00
|
|
|
];
|
2026-03-27 19:12:16 +08:00
|
|
|
|
2026-03-06 13:57:37 +08:00
|
|
|
for (const sql of alterCommands) {
|
|
|
|
|
try {
|
|
|
|
|
await sequelize.query(sql);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
if (!e.message.includes('Unknown column')) {
|
|
|
|
|
console.log(` 警告: ${e.message}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
console.log(' devices 表字段已改为可空');
|
|
|
|
|
} else if (dialect === 'sqlite') {
|
|
|
|
|
const columns = await getTableColumns('devices');
|
|
|
|
|
const hasNullableFlag = columns.includes('_nullable_migration_done');
|
2026-03-27 19:12:16 +08:00
|
|
|
|
2026-03-06 13:57:37 +08:00
|
|
|
if (hasNullableFlag) {
|
|
|
|
|
console.log(' 已完成可空迁移,跳过');
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-03-27 19:12:16 +08:00
|
|
|
|
2026-03-06 13:57:37 +08:00
|
|
|
await sequelize.query('PRAGMA foreign_keys = OFF');
|
2026-03-27 19:12:16 +08:00
|
|
|
|
2026-03-06 13:57:37 +08:00
|
|
|
try {
|
|
|
|
|
await sequelize.query('DROP TABLE IF EXISTS devices_new');
|
2026-03-27 19:12:16 +08:00
|
|
|
|
2026-03-06 13:57:37 +08:00
|
|
|
await sequelize.query(`
|
|
|
|
|
CREATE TABLE devices_new (
|
|
|
|
|
deviceId VARCHAR(255) PRIMARY KEY NOT NULL UNIQUE,
|
|
|
|
|
name VARCHAR(255),
|
|
|
|
|
type VARCHAR(255),
|
|
|
|
|
model VARCHAR(255),
|
|
|
|
|
serialNumber VARCHAR(255) UNIQUE,
|
|
|
|
|
rackId VARCHAR(255),
|
|
|
|
|
position INTEGER,
|
|
|
|
|
height INTEGER DEFAULT 1,
|
|
|
|
|
powerConsumption FLOAT DEFAULT 0,
|
|
|
|
|
status VARCHAR(255) DEFAULT 'offline',
|
|
|
|
|
purchaseDate DATETIME,
|
|
|
|
|
warrantyExpiry DATETIME,
|
|
|
|
|
ipAddress VARCHAR(255),
|
|
|
|
|
description TEXT,
|
|
|
|
|
customFields JSON DEFAULT '{}',
|
|
|
|
|
createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
|
|
|
updatedAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
|
|
|
_nullable_migration_done INTEGER DEFAULT 1
|
|
|
|
|
)
|
|
|
|
|
`);
|
2026-03-27 19:12:16 +08:00
|
|
|
|
2026-03-06 13:57:37 +08:00
|
|
|
await sequelize.query(`
|
|
|
|
|
INSERT INTO devices_new (
|
|
|
|
|
deviceId, name, type, model, serialNumber, rackId, position, height,
|
|
|
|
|
powerConsumption, status, purchaseDate, warrantyExpiry, ipAddress,
|
|
|
|
|
description, customFields, createdAt, updatedAt
|
|
|
|
|
)
|
2026-03-23 14:07:40 +08:00
|
|
|
SELECT
|
2026-03-06 13:57:37 +08:00
|
|
|
deviceId, name, type, model, serialNumber, rackId, position, height,
|
|
|
|
|
powerConsumption, status, purchaseDate, warrantyExpiry, ipAddress,
|
|
|
|
|
description, customFields, createdAt, updatedAt
|
|
|
|
|
FROM devices
|
|
|
|
|
`);
|
2026-03-27 19:12:16 +08:00
|
|
|
|
2026-03-06 13:57:37 +08:00
|
|
|
await sequelize.query('DROP TABLE devices');
|
|
|
|
|
await sequelize.query('ALTER TABLE devices_new RENAME TO devices');
|
2026-03-27 19:12:16 +08:00
|
|
|
|
2026-03-06 13:57:37 +08:00
|
|
|
await sequelize.query('CREATE INDEX IF NOT EXISTS idx_devices_status ON devices(status)');
|
|
|
|
|
await sequelize.query('CREATE INDEX IF NOT EXISTS idx_devices_type ON devices(type)');
|
|
|
|
|
await sequelize.query('CREATE INDEX IF NOT EXISTS idx_devices_rackId ON devices(rackId)');
|
|
|
|
|
await sequelize.query('CREATE INDEX IF NOT EXISTS idx_devices_name ON devices(name)');
|
2026-03-27 19:12:16 +08:00
|
|
|
|
2026-03-06 13:57:37 +08:00
|
|
|
console.log(' devices 表字段已改为可空');
|
|
|
|
|
} finally {
|
|
|
|
|
await sequelize.query('PRAGMA foreign_keys = ON');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 09:20:31 +08:00
|
|
|
async function migratePendingDeviceCustomFields() {
|
|
|
|
|
if (!(await tableExists('pending_devices'))) {
|
|
|
|
|
console.log(' pending_devices 表不存在,跳过');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-27 19:12:16 +08:00
|
|
|
const columnDef = dbDialect === 'sqlite' ? "JSON DEFAULT '{}'" : 'JSON';
|
2026-03-10 09:20:31 +08:00
|
|
|
await addColumnIfNotExists('pending_devices', 'customFields', columnDef);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-20 17:15:14 +08:00
|
|
|
async function migrateDeviceFieldsIsSystem() {
|
|
|
|
|
const dialect = sequelize.getDialect();
|
|
|
|
|
|
|
|
|
|
if (!(await tableExists('deviceFields'))) {
|
|
|
|
|
console.log(' deviceFields 表不存在,跳过');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (dialect === 'mysql') {
|
|
|
|
|
const [columns] = await sequelize.query(`
|
|
|
|
|
SELECT COLUMN_NAME
|
|
|
|
|
FROM INFORMATION_SCHEMA.COLUMNS
|
|
|
|
|
WHERE TABLE_NAME = 'deviceFields'
|
|
|
|
|
AND COLUMN_NAME = 'isSystem'
|
|
|
|
|
AND TABLE_SCHEMA = '${process.env.MYSQL_DATABASE || 'it_assest'}'
|
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
if (columns.length === 0) {
|
|
|
|
|
await sequelize.query(`
|
|
|
|
|
ALTER TABLE deviceFields
|
|
|
|
|
ADD COLUMN isSystem BOOLEAN DEFAULT 0
|
|
|
|
|
COMMENT '是否为系统字段,系统字段不可删除'
|
|
|
|
|
`);
|
|
|
|
|
console.log(' deviceFields 表添加 isSystem 字段成功');
|
|
|
|
|
} else {
|
|
|
|
|
console.log(' deviceFields 表 isSystem 字段已存在,跳过');
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
const columns = await getTableColumns('deviceFields');
|
|
|
|
|
if (!columns.includes('isSystem')) {
|
2026-03-27 19:12:16 +08:00
|
|
|
await sequelize.query('ALTER TABLE deviceFields ADD COLUMN isSystem BOOLEAN DEFAULT 0', {
|
|
|
|
|
type: sequelize.QueryTypes.RAW,
|
|
|
|
|
});
|
2026-03-20 17:15:14 +08:00
|
|
|
console.log(' deviceFields 表添加 isSystem 字段成功');
|
|
|
|
|
} else {
|
|
|
|
|
console.log(' deviceFields 表 isSystem 字段已存在,跳过');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const systemFields = [
|
2026-03-27 19:12:16 +08:00
|
|
|
'deviceId',
|
|
|
|
|
'name',
|
|
|
|
|
'type',
|
|
|
|
|
'model',
|
|
|
|
|
'serialNumber',
|
|
|
|
|
'rackId',
|
|
|
|
|
'position',
|
|
|
|
|
'height',
|
|
|
|
|
'powerConsumption',
|
|
|
|
|
'status',
|
|
|
|
|
'purchaseDate',
|
|
|
|
|
'warrantyExpiry',
|
2026-03-20 17:15:14 +08:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
for (const fieldName of systemFields) {
|
2026-03-27 19:12:16 +08:00
|
|
|
await sequelize.query(`UPDATE deviceFields SET isSystem = 1 WHERE fieldName = ?`, {
|
|
|
|
|
replacements: [fieldName],
|
|
|
|
|
type: sequelize.QueryTypes.RAW,
|
|
|
|
|
});
|
2026-03-20 17:15:14 +08:00
|
|
|
console.log(` 标记系统字段: ${fieldName}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(' 设备字段系统标记迁移完成');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function migrateIdleDeviceAndBusiness() {
|
|
|
|
|
const queryInterface = sequelize.getQueryInterface();
|
|
|
|
|
const dialect = sequelize.getDialect();
|
|
|
|
|
|
|
|
|
|
if (dialect === 'sqlite') {
|
|
|
|
|
await sequelize.query('PRAGMA foreign_keys = OFF');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
if (!(await tableExists('businesses'))) {
|
|
|
|
|
await queryInterface.createTable('businesses', {
|
2026-03-27 19:12:16 +08:00
|
|
|
businessId: {
|
|
|
|
|
type: sequelize.Sequelize.STRING,
|
|
|
|
|
primaryKey: true,
|
|
|
|
|
allowNull: false,
|
|
|
|
|
unique: true,
|
|
|
|
|
},
|
2026-03-20 17:15:14 +08:00
|
|
|
name: { type: sequelize.Sequelize.STRING, allowNull: false },
|
|
|
|
|
description: { type: sequelize.Sequelize.TEXT },
|
|
|
|
|
status: { type: sequelize.Sequelize.ENUM('active', 'offline'), defaultValue: 'active' },
|
|
|
|
|
offlineDate: { type: sequelize.Sequelize.DATE },
|
|
|
|
|
offlineReason: { type: sequelize.Sequelize.STRING },
|
|
|
|
|
createdAt: { type: sequelize.Sequelize.DATE, allowNull: false },
|
2026-03-27 19:12:16 +08:00
|
|
|
updatedAt: { type: sequelize.Sequelize.DATE, allowNull: false },
|
2026-03-20 17:15:14 +08:00
|
|
|
});
|
|
|
|
|
console.log(' businesses 表创建成功');
|
|
|
|
|
} else {
|
|
|
|
|
console.log(' businesses 表已存在,跳过');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!(await tableExists('warehouses'))) {
|
|
|
|
|
await queryInterface.createTable('warehouses', {
|
2026-03-27 19:12:16 +08:00
|
|
|
warehouseId: {
|
|
|
|
|
type: sequelize.Sequelize.STRING,
|
|
|
|
|
primaryKey: true,
|
|
|
|
|
allowNull: false,
|
|
|
|
|
unique: true,
|
|
|
|
|
},
|
2026-03-20 17:15:14 +08:00
|
|
|
name: { type: sequelize.Sequelize.STRING, allowNull: false },
|
|
|
|
|
location: { type: sequelize.Sequelize.STRING },
|
|
|
|
|
capacity: { type: sequelize.Sequelize.INTEGER, defaultValue: 100 },
|
|
|
|
|
status: { type: sequelize.Sequelize.ENUM('active', 'inactive'), defaultValue: 'active' },
|
|
|
|
|
description: { type: sequelize.Sequelize.TEXT },
|
|
|
|
|
createdAt: { type: sequelize.Sequelize.DATE, allowNull: false },
|
2026-03-27 19:12:16 +08:00
|
|
|
updatedAt: { type: sequelize.Sequelize.DATE, allowNull: false },
|
2026-03-20 17:15:14 +08:00
|
|
|
});
|
|
|
|
|
console.log(' warehouses 表创建成功');
|
|
|
|
|
} else {
|
|
|
|
|
console.log(' warehouses 表已存在,跳过');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!(await tableExists('device_business'))) {
|
|
|
|
|
await queryInterface.createTable('device_business', {
|
|
|
|
|
id: { type: sequelize.Sequelize.INTEGER, primaryKey: true, autoIncrement: true },
|
|
|
|
|
deviceId: { type: sequelize.Sequelize.STRING, allowNull: false },
|
|
|
|
|
businessId: { type: sequelize.Sequelize.STRING, allowNull: false },
|
|
|
|
|
isPrimary: { type: sequelize.Sequelize.BOOLEAN, defaultValue: false },
|
|
|
|
|
createdAt: { type: sequelize.Sequelize.DATE, allowNull: false },
|
2026-03-27 19:12:16 +08:00
|
|
|
updatedAt: { type: sequelize.Sequelize.DATE, allowNull: false },
|
2026-03-20 17:15:14 +08:00
|
|
|
});
|
|
|
|
|
console.log(' device_business 表创建成功');
|
|
|
|
|
} else {
|
|
|
|
|
console.log(' device_business 表已存在,跳过');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (await tableExists('devices')) {
|
|
|
|
|
await addColumnIfNotExists('devices', 'isIdle', 'BOOLEAN DEFAULT 0');
|
|
|
|
|
await addColumnIfNotExists('devices', 'idleDate', 'DATETIME');
|
|
|
|
|
await addColumnIfNotExists('devices', 'idleReason', 'TEXT');
|
|
|
|
|
await addColumnIfNotExists('devices', 'warehouseId', 'VARCHAR(255)');
|
2026-03-20 17:21:42 +08:00
|
|
|
await addColumnIfNotExists('devices', 'sourceType', "VARCHAR(255) DEFAULT 'rack'");
|
2026-03-20 17:15:14 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(' 空闲设备与业务关联迁移完成');
|
|
|
|
|
} finally {
|
|
|
|
|
if (dialect === 'sqlite') {
|
|
|
|
|
await sequelize.query('PRAGMA foreign_keys = ON');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-24 13:47:00 +08:00
|
|
|
// 执行迁移
|
|
|
|
|
runMigrations().catch(error => {
|
|
|
|
|
console.error('迁移执行失败:', error);
|
|
|
|
|
process.exit(1);
|
|
|
|
|
});
|