/** * 导入历史工单数据 * 用法: npx tsx scripts/import-tickets.ts [excel文件路径] * 默认路径: templates-docs/工单跟踪记录-总表.xlsx */ import Database from 'better-sqlite3' import path from 'path' import fs from 'fs' import bcrypt from 'bcryptjs' const XLSX = require('xlsx') const dbPath = process.env.DATABASE_PATH || './data/issue.db' const dbDir = path.dirname(dbPath) if (!fs.existsSync(dbDir)) fs.mkdirSync(dbDir, { recursive: true }) const db = new Database(dbPath) db.pragma('journal_mode = WAL') db.pragma('foreign_keys = ON') function initDb() { db.exec(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, display_name TEXT NOT NULL, email TEXT, role TEXT NOT NULL DEFAULT 'viewer', is_active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS roles ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, permissions TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS tickets ( id INTEGER PRIMARY KEY AUTOINCREMENT, ticket_no TEXT NOT NULL UNIQUE, device_ip TEXT, device_sn TEXT, device_name TEXT, content TEXT, assign_time TEXT, close_time TEXT, duration_minutes INTEGER, availability REAL, process_summary TEXT, conclusion TEXT, fault_category TEXT, fault_subcategory TEXT, parts_replaced TEXT, current_status TEXT NOT NULL DEFAULT 'open', counted_in_sla INTEGER NOT NULL DEFAULT 1, responsibility TEXT, created_by INTEGER REFERENCES users(id), updated_by INTEGER REFERENCES users(id), created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS ticket_steps ( id INTEGER PRIMARY KEY AUTOINCREMENT, ticket_id INTEGER NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, step_order INTEGER NOT NULL, time_node TEXT, handler TEXT, description TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS reports ( id INTEGER PRIMARY KEY AUTOINCREMENT, report_type TEXT NOT NULL, period_start TEXT, period_end TEXT, format TEXT NOT NULL DEFAULT 'pdf', file_path TEXT, file_name TEXT, status TEXT NOT NULL DEFAULT 'pending', error_message TEXT, created_by INTEGER REFERENCES users(id), created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS audit_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER REFERENCES users(id), action TEXT NOT NULL, entity_type TEXT NOT NULL, entity_id INTEGER, details TEXT, ip_address TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_tickets_device_ip ON tickets(device_ip); CREATE INDEX IF NOT EXISTS idx_tickets_assign_time ON tickets(assign_time); CREATE INDEX IF NOT EXISTS idx_tickets_current_status ON tickets(current_status); CREATE INDEX IF NOT EXISTS idx_ticket_steps_ticket_id ON ticket_steps(ticket_id); `) const existingAdmin = db.prepare('SELECT id FROM users WHERE username = ?').get('admin') if (!existingAdmin) { const passwordHash = bcrypt.hashSync('admin123', 10) db.prepare('INSERT INTO users (username, password_hash, display_name, role) VALUES (?, ?, ?, ?)') .run('admin', passwordHash, '系统管理员', 'admin') } const defaultRoles = [ { name: 'admin', display_name: '管理员', permissions: '["*"]' }, { name: 'operator', display_name: '运维人员', permissions: '["tickets:read","tickets:write","reports:read"]' }, { name: 'viewer', display_name: '查看者', permissions: '["tickets:read","reports:read"]' }, ] for (const r of defaultRoles) { const ex = db.prepare('SELECT id FROM roles WHERE name = ?').get(r.name) if (!ex) db.prepare('INSERT INTO roles (name, display_name, permissions) VALUES (?, ?, ?)') .run(r.name, r.display_name, r.permissions) } } function excelDateToJS(val: any): string | null { if (!val) return null if (val instanceof Date) return val.toISOString().replace('T', ' ').slice(0, 19) if (typeof val === 'number') { // Excel serial date: days since 1900-01-01 (with 1900 leap year bug) const utcDays = val - 25569 const utcSeconds = utcDays * 86400 const d = new Date(utcSeconds * 1000) return d.toISOString().replace('T', ' ').slice(0, 19) } const s = String(val).trim() return s || null } function cleanValue(val: any): any { if (val === null || val === undefined || val === '' || (typeof val === 'number' && isNaN(val))) return null if (typeof val === 'string' && val.trim() === '') return null if (typeof val === 'string' && val.trim() === '/') return null return val } function importTicketSheet(ws: any) { const data = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '' }) if (data.length < 3) { console.log(' Sheet has insufficient data') return 0 } // Headers in row 0, title row in row 0 too, actual data from row 2 const headers = data[0] as string[] const rows = data.slice(2) // Skip header row + title row const colIdx: Record = {} for (let i = 0; i < headers.length; i++) { colIdx[headers[i]] = i } const stmt = db.prepare(` INSERT OR REPLACE INTO tickets ( ticket_no, device_ip, device_sn, device_name, content, assign_time, close_time, duration_minutes, availability, process_summary, conclusion, fault_category, fault_subcategory, parts_replaced, current_status, counted_in_sla ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `) let count = 0 const statusMap: Record = { '已恢复': 'resolved', '处理中': 'in_progress', '已结单': 'closed', '待处理': 'open', '已关闭': 'closed', } const transaction = db.transaction(() => { for (const row of rows) { const r = row as any[] if (!r || r.length === 0) continue const ticketNo = cleanValue(r[colIdx['故障编号/工单号']]) if (!ticketNo) continue const rawStatus = cleanValue(r[colIdx['目前状态']]) || 'resolved' const mappedStatus = statusMap[String(rawStatus)] || 'resolved' const slaVal = cleanValue(r[colIdx['是否计入SLA']]) const countedInSla = slaVal === '是' ? 1 : 0 stmt.run( ticketNo, cleanValue(r[colIdx['故障节点']]), // device_ip cleanValue(r[colIdx['SN']]), // device_sn null, // device_name (not in Excel) cleanValue(r[colIdx['故障表现/工单内容']]), // content excelDateToJS(r[colIdx['派单时间']]), // assign_time excelDateToJS(r[colIdx['结单时间']]), // close_time cleanValue(r[colIdx['处理时长\n(分钟)']]), // duration_minutes cleanValue(r[colIdx['单次月度可用性']]), // availability cleanValue(r[colIdx['处理过程']]), // process_summary cleanValue(r[colIdx['故障结论']]), // conclusion cleanValue(r[colIdx['故障分类']]), // fault_category cleanValue(r[colIdx['故障分类二']]), // fault_subcategory cleanValue(r[colIdx['是否更换配件']]), // parts_replaced mappedStatus, countedInSla, ) count++ } }) transaction() return count } async function main() { const excelPath = process.argv[2] || path.join(__dirname, '..', 'templates-docs', '工单跟踪记录-总表.xlsx') if (!fs.existsSync(excelPath)) { console.error(`Excel file not found: ${excelPath}`) process.exit(1) } console.log('Initializing database...') initDb() console.log(`Reading Excel: ${excelPath}`) const wb = XLSX.readFile(excelPath) console.log('Importing tickets from 故障记录汇总...') const sheet = wb.Sheets['故障记录汇总'] const count = importTicketSheet(sheet) console.log(` Imported ${count} tickets`) console.log(`\nTotal: ${count} tickets imported`) db.close() } main().catch(console.error)