/** * 工单步骤导入脚本 - 支持多种 Excel 格式 * * 用法: * npx tsx scripts/import-steps.ts [dbPath] * * 支持格式: * 1. 工单跟踪记录 (标准): Sheet名=工单号, 步骤在行4-6 * 2. 故障跟踪记录 (不同): Sheet名=故障编号YYYYMMDD-XX, 步骤在行3-13, 需匹配IP * 3. 批量Sheet: 含"等共XX个"的Sheet, 自动拆分为多个工单 */ import path from 'path' import fs from 'fs' import Database from 'better-sqlite3' const XLSX = require('xlsx') const excelPath = process.argv[2]?.endsWith('.xlsx') ? process.argv[2] : (process.argv[3]?.endsWith('.xlsx') ? process.argv[3] : null) const dbPath = process.argv[2] && !process.argv[2].endsWith('.xlsx') ? process.argv[2] : (process.argv[3] && !process.argv[3].endsWith('.xlsx') ? process.argv[3] : path.join(__dirname, '..', 'data', 'issue.db')) if (!excelPath) { console.error('用法: npx tsx scripts/import-steps.ts [dbPath]') process.exit(1) } // --------------------------------------------------------------------------- // 日期解析 // --------------------------------------------------------------------------- function parseDate(val: number | string | null | undefined): string | null { if (val === null || val === undefined) return null if (typeof val === 'string') { val = val.trim() if (!val) return null // 批量Sheet中的时间是北京时间字符串,直接解析为北京时间 // 存储北京时间字符串,UI toLocaleString 直接显示正确值 const d = new Date(val) if (!isNaN(d.getTime())) { return d.toISOString().replace('T', ' ').slice(0, 19) } return null } const serial = Number(val) if (isNaN(serial)) return null // Excel serial → 北京时间(直接用 XLSX 内置解析,不走 JS Date 时区转换) // XLSX SSF.parse_date_code 返回 {y,m,d,H,M,S},直接格式化 const dt = XLSX.SSF.parse_date_code(serial) if (!dt) return null return `${dt.y}-${String(dt.m).padStart(2, '0')}-${String(dt.d).padStart(2, '0')} ${String(dt.H).padStart(2, '0')}:${String(dt.M).padStart(2, '0')}:${String(dt.S).padStart(2, '0')}` } // --------------------------------------------------------------------------- // 解析批量Sheet (如"20250820042383等共20个") // 返回每个工单的 { ticket_no, device_ip, steps, conclusion } 数组 // --------------------------------------------------------------------------- function parseBatchSheet(data: (string | number | null)[][]): Array<{ ticket_no: string device_ip: string content: string assign_time: string | null close_time: string | null steps: Array<{ time_node: string | null; handler: string | null; description: string | null }> conclusion: string }> { // 行0: 工单号(20个\n分隔), 设备IP(20个\n分隔) const ticketNos = (data[0]?.[1] as string || '') .split('\n') .map(s => s.trim()) .filter(Boolean) const ips = (data[0]?.[3] as string || '') .split('\n') .map(s => s.trim()) .filter(Boolean) const count = Math.min(ticketNos.length, ips.length) const content = String(data[1]?.[1] || '').trim() const assignTime = parseDate(data[2]?.[1] as number) const closeTimeRaw = data[2]?.[3] as number const conclusion = String(data.find(r => r[0] === '工单结论')?.[1] || '').trim() // 行4-12: 步骤 (多行时间 + 统一描述) // 行13: 结单时间 (多行时间) const result: Array<{ ticket_no: string; device_ip: string; content: string assign_time: string | null; close_time: string | null steps: Array<{ time_node: string | null; handler: string | null; description: string | null }> conclusion: string }> = [] for (let i = 0; i < count; i++) { const steps: Array<{ time_node: string | null; handler: string | null; description: string | null }> = [] // 遍历行4-12: 每行一个共享描述 + 每台机器不同时间 for (let rowIdx = 4; rowIdx <= 12; rowIdx++) { const row = data[rowIdx] if (!row || row.length < 4) continue const desc = String(row[3] || '').trim() const handler = String(row[2] || '').trim() || null if (!desc) continue // 时间可能是单个序列值,也可能是多行字符串 const timeRaw = row[1] let timeNode: string | null = null if (typeof timeRaw === 'number') { timeNode = parseDate(timeRaw) } else if (typeof timeRaw === 'string' && timeRaw.includes('\n')) { // 多行时间,取第i个 const times = timeRaw.split('\n').map((t: string) => t.trim()).filter(Boolean) timeNode = parseDate(times[i] || null) } else if (typeof timeRaw === 'string') { timeNode = parseDate(timeRaw) } steps.push({ time_node: timeNode, handler, description: desc }) } // 行13: 结单时间 (多行) const closeRow = data[13] if (closeRow && closeRow[1]) { const closeRaw = closeRow[1] let closeTime: string | null = null if (typeof closeRaw === 'string' && closeRaw.includes('\n')) { const times = closeRaw.split('\n').map((t: string) => t.trim()).filter(Boolean) closeTime = parseDate(times[i] || null) } else { closeTime = parseDate(closeRaw as number) } if (closeTime) { steps.push({ time_node: closeTime, handler: '图灵', description: '结单' }) } } result.push({ ticket_no: ticketNos[i], device_ip: ips[i], content, assign_time: assignTime, close_time: null, steps, conclusion }) } return result } // --------------------------------------------------------------------------- // 解析标准工单跟踪记录 Sheet // ticket_no = sheet名,IP在行0列3,步骤在行4-6 // --------------------------------------------------------------------------- function parseStandardSheet(data: (string | number | null)[][]): { ticket_no: string; device_ip: string; steps: Array<{ time_node: string | null; handler: string | null; description: string | null }> } | null { // 行4-6: 步骤 const stepRows = data.slice(4, 7) const steps: Array<{ time_node: string | null; handler: string | null; description: string | null }> = [] for (let i = 0; i < stepRows.length; i++) { const row = stepRows[i] if (!row || row.length < 4) continue const desc = String(row[3] || '').trim() const handler = String(row[2] || '').trim() || null if (!desc) continue steps.push({ time_node: parseDate(row[1] as number), handler, description: desc }) } if (steps.length === 0) return null // 行0[3] 是设备IP地址(如29.237.253.2) const device_ip = String(data[0]?.[3] || '').trim() return { ticket_no: '', device_ip, steps } } // --------------------------------------------------------------------------- // 解析故障跟踪记录 Sheet (不同格式) // --------------------------------------------------------------------------- function parseFaultSheet(data: (string | number | null)[][]): { ticket_no: string; device_ip: string; content: string steps: Array<{ time_node: string | null; handler: string | null; description: string | null }> conclusion: string } | null { const content = String(data[0]?.[3] || '').trim() // 从内容中提取IP: "gpu-node-88(29.237.253.88)存储网卡丢失" const ipMatch = content.match(/[((](\d+\.\d+\.\d+\.\d+)[))]/) const device_ip = ipMatch ? ipMatch[1] : '' // 步骤: 行3到倒数第2行(故障结论) const conclusion = String(data.find(r => r[0] === '故障结论' || r[0] === '工单结论')?.[1] || '').trim() const stepRows = data.slice(3).filter(r => r[0] === null && (r[1] !== null && r[1] !== undefined)) const steps: Array<{ time_node: string | null; handler: string | null; description: string | null }> = [] for (const row of stepRows) { const desc = String(row[3] || '').trim() const handler = String(row[2] || '').trim() || null if (!desc) continue steps.push({ time_node: parseDate(row[1] as number), handler, description: desc }) } return steps.length > 0 ? { ticket_no: '', device_ip, content, steps, conclusion } : null } // --------------------------------------------------------------------------- // 主逻辑 // --------------------------------------------------------------------------- function main() { if (!excelPath) { console.error('用法: npx tsx scripts/import-steps.ts [dbPath]') process.exit(1) } if (!fs.existsSync(dbPath)) { console.error(`数据库文件不存在: ${dbPath}`) process.exit(1) } if (!fs.existsSync(excelPath)) { console.error(`Excel 文件不存在: ${excelPath}`) process.exit(1) } const db = new Database(dbPath) db.pragma('journal_mode = WAL') db.pragma('foreign_keys = ON') // 确保索引存在 db.exec(` CREATE INDEX IF NOT EXISTS idx_ticket_steps_ticket_id ON ticket_steps(ticket_id); CREATE UNIQUE INDEX IF NOT EXISTS idx_ticket_steps_unique ON ticket_steps(ticket_id, step_order); `) const wb = XLSX.readFile(excelPath) // 按实际行数据判断格式,不按文件名 const isFaultFormat = false // 行0[0]="故障编号"才为真故障格式(目前仅2025年3月) const isBatchFormat = (name: string) => name.includes('等共') || name.includes('共') && /共\d+个/.test(name) // 先取第一个sheet的首行判断真实格式 const sampleData = XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]], { header: 1, defval: null }) const row0Key = sampleData[0]?.[0] const realIsFaultFormat = row0Key === '故障编号' || row0Key === '故障内容' console.log(`真实格式: ${realIsFaultFormat ? '故障跟踪记录' : '工单跟踪记录'} (row0[0]="${row0Key}")`) console.log(`\n读取: ${path.basename(excelPath)}`) console.log(`格式: ${isFaultFormat ? '故障跟踪记录' : '工单跟踪记录'}`) console.log(`Sheet 数: ${wb.SheetNames.length}\n`) const findTicketByNo = db.prepare('SELECT id FROM tickets WHERE ticket_no = ?') const findTicketByIP = db.prepare('SELECT id FROM tickets WHERE device_ip = ? LIMIT 1') const insertStep = db.prepare(` INSERT OR IGNORE INTO ticket_steps (ticket_id, step_order, time_node, handler, description) VALUES (?, ?, ?, ?, ?) `) let totalSheets = 0 let totalSteps = 0 let skipped = 0 for (const sheetName of wb.SheetNames) { const ws = wb.Sheets[sheetName] const data = XLSX.utils.sheet_to_json(ws, { header: 1, defval: null }) as (string | number | null)[][] if (isBatchFormat(sheetName)) { // 批量Sheet拆分 const tickets = parseBatchSheet(data) console.log(`[批量] ${sheetName} -> 拆分为 ${tickets.length} 个工单`) for (const t of tickets) { const ticket = findTicketByNo.get(t.ticket_no) as { id: number } | undefined if (!ticket) { console.log(` [跳过] ${t.ticket_no} (${t.device_ip}) 不存在于数据库`) skipped++ continue } let n = 0 for (let i = 0; i < t.steps.length; i++) { const s = t.steps[i] insertStep.run(ticket.id, i + 1, s.time_node, s.handler, s.description) n++ } totalSheets++ totalSteps += n console.log(` ✅ ${t.ticket_no}: ${n} 条步骤`) } } else if (isFaultFormat) { // 故障跟踪记录格式 (按IP匹配) const parsed = parseFaultSheet(data) if (!parsed) { skipped++; continue } // 从sheet名提取故障编号作为工单号候选 const faultId = sheetName // 用于查找是否有对应工单 const ticket = findTicketByIP.get(parsed.device_ip) as { id: number } | undefined if (!ticket) { console.log(`[跳过] ${sheetName} IP=${parsed.device_ip} 未匹配到数据库工单`) skipped++ continue } let n = 0 for (let i = 0; i < parsed.steps.length; i++) { const s = parsed.steps[i] insertStep.run(ticket.id, i + 1, s.time_node, s.handler, s.description) n++ } totalSheets++ totalSteps += n console.log(` ✅ ${sheetName}(IP:${parsed.device_ip}): ${n} 条步骤`) } else { // 标准工单跟踪记录格式 (Sheet名=工单号) const ticket = findTicketByNo.get(sheetName) as { id: number } | undefined if (!ticket) { console.log(`[跳过] ${sheetName} 不存在于数据库`) skipped++ continue } const parsed = parseStandardSheet(data) if (!parsed) { skipped++; continue } let n = 0 for (let i = 0; i < parsed.steps.length; i++) { const s = parsed.steps[i] insertStep.run(ticket.id, i + 1, s.time_node, s.handler, s.description) n++ } totalSheets++ totalSteps += n console.log(` ✅ ${sheetName}: ${n} 条步骤`) } } console.log(`\n========================================`) console.log(`处理工单: ${totalSheets}`) console.log(`成功导入步骤: ${totalSteps} 条`) console.log(`跳过: ${skipped}`) console.log(`========================================`) db.close() } main()