issue-ai/src/lib/monthly-report.ts

397 lines
15 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.

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
}
}