329 lines
13 KiB
TypeScript
329 lines
13 KiB
TypeScript
/**
|
||
* 工单步骤导入脚本 - 支持多种 Excel 格式
|
||
*
|
||
* 用法:
|
||
* npx tsx scripts/import-steps.ts <Excel文件路径> [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 <Excel文件路径> [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 <Excel文件路径> [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()
|