397 lines
15 KiB
TypeScript
397 lines
15 KiB
TypeScript
import { getDb } from './db'
|
||
import { getActiveDevices, DEVICE_TYPE_GPU, DEVICE_TYPE_STORAGE } from './assets-client'
|
||
import { generateDailyOnlineChart } from './monthly-report-charts'
|
||
import { buildMonthlyReportDocx } from './monthly-report-docx'
|
||
import { getMonthNaturalWeeks, embedWeeklyReports } from './ole-embed'
|
||
import { collectWeeklyReportData } from './weekly-report'
|
||
import { buildWeeklyReportDocx } from './weekly-report-docx'
|
||
import type {
|
||
ClassifiedTicket, DailyOnlineStats, Chapter2Entry, Chapter2FaultItem,
|
||
Chapter3FaultEntry, Chapter3OtherEntry, Chapter4Entry, MonthlyReportData,
|
||
WeeklyAttachment,
|
||
} from '@/types/report'
|
||
import fs from 'fs'
|
||
import path from 'path'
|
||
|
||
// 报告文件存储目录
|
||
const REPORTS_DIR = process.env.REPORTS_DIR || './reports'
|
||
|
||
function ensureReportsDir() {
|
||
if (!fs.existsSync(REPORTS_DIR)) fs.mkdirSync(REPORTS_DIR, { recursive: true })
|
||
}
|
||
|
||
// 获取当月所有日期
|
||
function getDateRange(start: string, end: string): string[] {
|
||
const dates: string[] = []
|
||
const cur = new Date(start)
|
||
const last = new Date(end)
|
||
while (cur <= last) {
|
||
dates.push(cur.toISOString().slice(0, 10))
|
||
cur.setDate(cur.getDate() + 1)
|
||
}
|
||
return dates
|
||
}
|
||
|
||
// 计算两个日期之间的天数差
|
||
function daysBetween(dateStr1: string, dateStr2: string): number {
|
||
const d1 = new Date(dateStr1.slice(0, 10))
|
||
const d2 = new Date(dateStr2.slice(0, 10))
|
||
return Math.floor((d2.getTime() - d1.getTime()) / (1000 * 60 * 60 * 24))
|
||
}
|
||
|
||
/** 采集月报所需全部数据 */
|
||
export async function collectMonthlyReportData(
|
||
periodStart: string, periodEnd: string
|
||
): Promise<MonthlyReportData> {
|
||
|
||
// 1. 从 assets-ai 获取 GPU + 存储设备清单
|
||
const [gpuDevices, storageDevices] = await Promise.all([
|
||
getActiveDevices(DEVICE_TYPE_GPU),
|
||
getActiveDevices(DEVICE_TYPE_STORAGE),
|
||
])
|
||
|
||
// 构建 IP → device_type 映射(用 business_ip 作为主 IP)
|
||
const ipTypeMap = new Map<string, 'gpu' | 'storage'>()
|
||
for (const d of gpuDevices) {
|
||
if (d.business_ip) ipTypeMap.set(d.business_ip, 'gpu')
|
||
if (d.hdm_ip) ipTypeMap.set(d.hdm_ip, 'gpu')
|
||
}
|
||
for (const d of storageDevices) {
|
||
if (d.business_ip) ipTypeMap.set(d.business_ip, 'storage')
|
||
if (d.hdm_ip) ipTypeMap.set(d.hdm_ip, 'storage')
|
||
}
|
||
|
||
// 2. 查询当月已结单工单 + 跨月进行中工单
|
||
const db = getDb()
|
||
const endFull = periodEnd + ' 23:59:59'
|
||
const ticketsRaw = db.prepare(`
|
||
SELECT * FROM tickets WHERE (
|
||
(close_time >= ? AND close_time <= ?)
|
||
OR
|
||
(assign_time <= ? AND (close_time IS NULL OR close_time > ?))
|
||
)
|
||
ORDER BY assign_time
|
||
`).all(periodStart, endFull, endFull, endFull) as any[]
|
||
|
||
// 3. 分类工单(进行中工单使用 periodEnd+1 天作为合成 close_time 供后续计算)
|
||
const periodEndNext = new Date(periodEnd.replace(/-/g, '/'))
|
||
periodEndNext.setDate(periodEndNext.getDate() + 1)
|
||
const periodEndNextStr = `${periodEndNext.getFullYear()}-${String(periodEndNext.getMonth()+1).padStart(2,'0')}-${String(periodEndNext.getDate()).padStart(2,'0')}`
|
||
|
||
const tickets: ClassifiedTicket[] = ticketsRaw.map(t => {
|
||
const isOngoing = !t.close_time || t.close_time > endFull
|
||
return {
|
||
id: t.id,
|
||
device_ip: t.device_ip || '',
|
||
device_name: t.device_name,
|
||
device_type: ipTypeMap.get(t.device_ip) || 'other',
|
||
fault_category: t.fault_category,
|
||
fault_subcategory: t.fault_subcategory,
|
||
parts_replaced: t.parts_replaced,
|
||
parts_name: t.parts_name,
|
||
content: t.content,
|
||
conclusion: t.conclusion,
|
||
assign_time: t.assign_time,
|
||
close_time: isOngoing ? periodEndNextStr : (t.close_time || ''),
|
||
duration_minutes: t.duration_minutes || 0,
|
||
availability: t.availability,
|
||
isOngoing,
|
||
}
|
||
})
|
||
|
||
const monthDays = getDateRange(periodStart, periodEnd).length
|
||
|
||
// 生成月份 & 报告月份
|
||
const periodDate = new Date(periodStart)
|
||
const genDate = new Date(periodDate.getFullYear(), periodDate.getMonth() + 1, 1)
|
||
const monthLabel = `${periodDate.getFullYear()}年${periodDate.getMonth() + 1}月`
|
||
const generationLabel = `${genDate.getFullYear()}年${genDate.getMonth() + 1}月`
|
||
|
||
// 4. 第一章:每日在线节点数
|
||
const dates = getDateRange(periodStart, periodEnd)
|
||
// 排除"无故障"分类和"其他"子分类(其他工单不计入节点在线数)
|
||
const monthFaults = tickets.filter(t =>
|
||
t.fault_category !== '无故障' && t.fault_subcategory !== '其他'
|
||
)
|
||
|
||
const dailyStats: DailyOnlineStats[] = dates.map(date => {
|
||
// 当天不在线:assign 日期 ≤ date < close 日期(当日恢复不计入离线)
|
||
const gpuOffline = monthFaults.filter(t =>
|
||
t.device_type === 'gpu' &&
|
||
t.assign_time.slice(0, 10) <= date &&
|
||
date < t.close_time.slice(0, 10)
|
||
).length
|
||
const storageOffline = monthFaults.filter(t =>
|
||
t.device_type === 'storage' &&
|
||
t.assign_time.slice(0, 10) <= date &&
|
||
date < t.close_time.slice(0, 10)
|
||
).length
|
||
|
||
return {
|
||
date,
|
||
gpuOnline: gpuDevices.length - gpuOffline,
|
||
gpuTotal: gpuDevices.length,
|
||
storageOnline: storageDevices.length - storageOffline,
|
||
storageTotal: storageDevices.length,
|
||
}
|
||
})
|
||
|
||
// 5. 第二章:运营数据总览(仅 gpu/storage,排除"无故障"和"其他"工单)
|
||
const gpuStorageTickets = tickets.filter(t => t.device_type !== 'other' && t.fault_category !== '无故障' && t.fault_subcategory !== '其他')
|
||
const chapter2Map = new Map<string, Chapter2FaultItem[]>()
|
||
for (const t of gpuStorageTickets) {
|
||
const assignDate = t.assign_time.slice(0, 10)
|
||
const key = `${t.device_type}|${assignDate}`
|
||
if (!chapter2Map.has(key)) chapter2Map.set(key, [])
|
||
chapter2Map.get(key)!.push({
|
||
ip: t.device_ip,
|
||
fault_subcategory: t.fault_subcategory || '未知故障',
|
||
recoveryDays: t.isOngoing ? 0 : daysBetween(t.assign_time, t.close_time),
|
||
isOngoing: t.isOngoing || false,
|
||
})
|
||
}
|
||
const chapter2: Chapter2Entry[] = []
|
||
for (const [key, faults] of chapter2Map) {
|
||
const [device_type, date] = key.split('|')
|
||
chapter2.push({ device_type: device_type as 'gpu' | 'storage', date, faults })
|
||
}
|
||
// 按日期排序
|
||
chapter2.sort((a, b) => a.date.localeCompare(b.date))
|
||
|
||
// 6. 第三章:运营故障概览
|
||
// 先按 fault_subcategory === '其他' 分流
|
||
const otherTickets = tickets.filter(t => t.fault_subcategory === '其他')
|
||
// 其余按 device_type 分
|
||
const gpuFaultTickets = tickets.filter(t =>
|
||
t.fault_subcategory !== '其他' && t.device_type === 'gpu'
|
||
)
|
||
const storageFaultTickets = tickets.filter(t =>
|
||
t.fault_subcategory !== '其他' && t.device_type === 'storage'
|
||
)
|
||
|
||
// 还有:fault_subcategory !== '其他' 但 device_type 也不对(比如未匹配到的)
|
||
// 这些也归入 other
|
||
const remainingOthers = tickets.filter(t =>
|
||
t.fault_subcategory !== '其他' && t.device_type === 'other'
|
||
)
|
||
|
||
function toFaultEntry(t: ClassifiedTicket): Chapter3FaultEntry {
|
||
return {
|
||
ticketId: t.id,
|
||
nodeIp: t.device_ip,
|
||
faultDate: t.assign_time,
|
||
faultProblem: t.fault_subcategory || '',
|
||
faultCause: t.parts_name ? `更换${t.parts_name}` : '-',
|
||
durationMinutes: t.duration_minutes,
|
||
countedInSla: (t.availability !== null && t.availability < 0.99 && !t.conclusion?.includes('无异常')) ? '是' : '否',
|
||
isOngoing: t.isOngoing || false,
|
||
}
|
||
}
|
||
|
||
function toOtherEntry(t: ClassifiedTicket): Chapter3OtherEntry {
|
||
return {
|
||
ticketId: t.id,
|
||
deviceIp: t.device_ip,
|
||
ticketDate: t.assign_time,
|
||
ticketContent: t.content || '',
|
||
ticketConclusion: t.conclusion || '',
|
||
durationMinutes: t.duration_minutes,
|
||
countedInSla: (t.availability !== null && t.availability < 0.99 && !t.conclusion?.includes('无异常')) ? '是' : '否',
|
||
isOngoing: t.isOngoing || false,
|
||
}
|
||
}
|
||
|
||
const gpuFaults = gpuFaultTickets.map(toFaultEntry)
|
||
const storageFaults = storageFaultTickets.map(toFaultEntry)
|
||
const allOtherTickets = [...otherTickets, ...remainingOthers].map(toOtherEntry)
|
||
|
||
// 7. 第四章:服务可用性说明(已结单工单按实际时长,进行中工单按本月部分)
|
||
const ipDurationMap = new Map<string, number>()
|
||
const ipHasOngoing = new Map<string, boolean>()
|
||
for (const t of tickets) {
|
||
if (t.fault_category === '无故障') continue
|
||
const dur = ipDurationMap.get(t.device_ip) || 0
|
||
if (t.isOngoing) {
|
||
// 进行中工单:计算本月内部分(从 max(assign_date, periodStart) 到月末最后一天)
|
||
const assignDate = t.assign_time.slice(0, 10)
|
||
const effectiveStart = assignDate > periodStart ? assignDate : periodStart
|
||
const affectedDays = daysBetween(effectiveStart, periodEnd) + 1
|
||
ipDurationMap.set(t.device_ip, dur + affectedDays * 24 * 60)
|
||
ipHasOngoing.set(t.device_ip, true)
|
||
} else {
|
||
ipDurationMap.set(t.device_ip, dur + t.duration_minutes)
|
||
}
|
||
}
|
||
const chapter4: Chapter4Entry[] = []
|
||
for (const [ip, totalDuration] of ipDurationMap) {
|
||
const totalMinutes = monthDays * 24 * 60
|
||
const availabilityPercent = ((totalMinutes - totalDuration) / totalMinutes) * 100
|
||
chapter4.push({
|
||
ip,
|
||
totalDurationMinutes: totalDuration,
|
||
monthDays,
|
||
availabilityPercent: Math.round(availabilityPercent * 100) / 100,
|
||
hasOngoing: ipHasOngoing.get(ip) || false,
|
||
})
|
||
}
|
||
// 按 IP 排序
|
||
chapter4.sort((a, b) => a.ip.localeCompare(b.ip))
|
||
|
||
return {
|
||
periodStart,
|
||
periodEnd,
|
||
monthDays,
|
||
monthLabel,
|
||
generationLabel,
|
||
gpuTotal: gpuDevices.length,
|
||
storageTotal: storageDevices.length,
|
||
tickets,
|
||
dailyStats,
|
||
chapter2,
|
||
chapter3: { gpuFaults, storageFaults, otherTickets: allOtherTickets },
|
||
chapter4,
|
||
}
|
||
}
|
||
|
||
/** 从采集数据构建 metadata JSON(供 API 路由和 generate 函数共用) */
|
||
export function buildMonthlyMetadata(data: MonthlyReportData): string {
|
||
const gpuStorageTickets = data.tickets.filter(t =>
|
||
t.device_type !== 'other' && t.fault_category !== '无故障' && t.fault_subcategory !== '其他'
|
||
)
|
||
const resolvedTickets = gpuStorageTickets.filter(t => !t.isOngoing)
|
||
|
||
const resolvedCount = resolvedTickets.length
|
||
|
||
const durations = resolvedTickets
|
||
.map(t => t.duration_minutes)
|
||
.filter(d => d > 0)
|
||
const avgDurationMinutes = durations.length > 0
|
||
? Math.round(durations.reduce((s, d) => s + d, 0) / durations.length)
|
||
: 0
|
||
|
||
const ongoingCount = data.tickets.filter(t => t.isOngoing).length
|
||
|
||
const faultTicketCount = data.chapter3.gpuFaults.length + data.chapter3.storageFaults.length
|
||
|
||
const affectedDeviceIps = new Set(gpuStorageTickets.map(t => t.device_ip))
|
||
const affectedDeviceCount = affectedDeviceIps.size
|
||
|
||
// 无故障天数:按故障影响的全部日期范围(assign ~ close,含 close),当日恢复也计入
|
||
// 预览与 DOCX 图表计算方式不同:图表用 date < close 排除当日恢复,预览用全部日期范围
|
||
const faultDateSet = new Set<string>()
|
||
for (const t of gpuStorageTickets) {
|
||
const start = new Date(t.assign_time.slice(0, 10).replace(/-/g, '/'))
|
||
const endRaw = t.isOngoing ? data.periodEnd : t.close_time.slice(0, 10)
|
||
const end = new Date(endRaw.replace(/-/g, '/'))
|
||
const cur = new Date(start)
|
||
while (cur <= end) {
|
||
faultDateSet.add(`${cur.getFullYear()}-${String(cur.getMonth()+1).padStart(2,'0')}-${String(cur.getDate()).padStart(2,'0')}`)
|
||
cur.setDate(cur.getDate() + 1)
|
||
}
|
||
}
|
||
const faultFreeDays = data.monthDays - faultDateSet.size
|
||
|
||
const avgAvailability = data.chapter4.length > 0
|
||
? Math.round(data.chapter4.reduce((s, e) => s + e.availabilityPercent, 0) / data.chapter4.length * 100) / 100
|
||
: 100
|
||
|
||
const availabilityDetails = data.chapter4
|
||
.filter(e => e.availabilityPercent < 100)
|
||
.map(e => {
|
||
const ticket = data.tickets.find(t => t.device_ip === e.ip)
|
||
return {
|
||
ip: e.ip,
|
||
deviceType: ticket?.device_type || 'other',
|
||
durationMinutes: e.totalDurationMinutes,
|
||
availabilityPercent: e.availabilityPercent,
|
||
isOngoing: e.hasOngoing || false,
|
||
}
|
||
})
|
||
|
||
const reportLabel = `${data.monthLabel}图灵IT基础设施运营月报`
|
||
|
||
return JSON.stringify({
|
||
gpuCount: data.gpuTotal,
|
||
storageCount: data.storageTotal,
|
||
totalTickets: data.tickets.length,
|
||
gpuFaultCount: data.chapter3.gpuFaults.length,
|
||
storageFaultCount: data.chapter3.storageFaults.length,
|
||
otherTicketCount: data.chapter3.otherTickets.length,
|
||
avgAvailability,
|
||
resolvedCount,
|
||
avgDurationMinutes,
|
||
ongoingCount,
|
||
faultTicketCount,
|
||
affectedDeviceCount,
|
||
faultFreeDays,
|
||
availabilityDetails,
|
||
reportLabel,
|
||
})
|
||
}
|
||
|
||
/** 异步生成月报(fire-and-forget 风格) */
|
||
export async function generateMonthlyReport(reportId: number): Promise<void> {
|
||
const db = getDb()
|
||
|
||
const report = db.prepare('SELECT * FROM reports WHERE id = ?').get(reportId) as any
|
||
if (!report) throw new Error('报告不存在')
|
||
|
||
db.prepare("UPDATE reports SET status = 'generating' WHERE id = ?").run(reportId)
|
||
|
||
try {
|
||
const data = await collectMonthlyReportData(report.period_start, report.period_end)
|
||
|
||
// ---- 1. 计算自然周并生成周报 Buffer(纯内存) ----
|
||
const weeks = getMonthNaturalWeeks(report.period_start, report.period_end)
|
||
const weeklyAttachments: WeeklyAttachment[] = []
|
||
const weeklyLabels: string[] = []
|
||
|
||
for (const week of weeks) {
|
||
try {
|
||
const weekData = await collectWeeklyReportData(week.start, week.end)
|
||
const weekBuffer = await buildWeeklyReportDocx(weekData)
|
||
weeklyAttachments.push({ label: week.label, buffer: weekBuffer })
|
||
weeklyLabels.push(week.label)
|
||
console.log(`Weekly report generated: ${week.label}`)
|
||
} catch (err) {
|
||
console.error(`Failed to generate weekly report for ${week.label}:`, err)
|
||
// 周报生成失败不阻塞月报生成
|
||
}
|
||
}
|
||
|
||
// ---- 2. 并行生成两张图表 ----
|
||
const [gpuChartPng, storageChartPng] = await Promise.all([
|
||
generateDailyOnlineChart(data.dailyStats, 'gpu'),
|
||
generateDailyOnlineChart(data.dailyStats, 'storage'),
|
||
])
|
||
|
||
// ---- 3. 组装月报 DOCX ----
|
||
let buffer = await buildMonthlyReportDocx(
|
||
data,
|
||
{ gpuPng: gpuChartPng, storagePng: storageChartPng },
|
||
weeklyLabels,
|
||
)
|
||
|
||
// ---- 4. ZIP 后处理:嵌入周报 OLE 对象 ----
|
||
if (weeklyAttachments.length > 0) {
|
||
buffer = await embedWeeklyReports(buffer, weeklyAttachments)
|
||
}
|
||
|
||
ensureReportsDir()
|
||
const fileName = `${data.monthLabel}图灵IT基础设施运营月报.docx`
|
||
const filePath = path.join(REPORTS_DIR, fileName)
|
||
fs.writeFileSync(filePath, buffer)
|
||
|
||
const metadata = buildMonthlyMetadata(data)
|
||
|
||
db.prepare(
|
||
"UPDATE reports SET status = 'completed', file_path = ?, file_name = ?, metadata = ? WHERE id = ?"
|
||
).run(filePath, fileName, metadata, reportId)
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : '生成失败'
|
||
console.error(`Report ${reportId} generation failed:`, e)
|
||
db.prepare("UPDATE reports SET status = 'failed', error_message = ? WHERE id = ?").run(msg, reportId)
|
||
throw e
|
||
}
|
||
}
|