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 { // 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() 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() 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() const ipHasOngoing = new Map() 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() 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 { 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 } }