issue-ai/scripts/import-steps.ts

329 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 工单步骤导入脚本 - 支持多种 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-8829.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()