diff --git a/src/lib/monthly-report.ts b/src/lib/monthly-report.ts index baf2c0d..a143077 100644 --- a/src/lib/monthly-report.ts +++ b/src/lib/monthly-report.ts @@ -2,9 +2,13 @@ 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' @@ -57,34 +61,43 @@ export async function collectMonthlyReportData( if (d.hdm_ip) ipTypeMap.set(d.hdm_ip, 'storage') } - // 2. 查询当月已结单工单(按 close_time 范围) + // 2. 查询当月已结单工单 + 跨月进行中工单 const db = getDb() const endFull = periodEnd + ' 23:59:59' const ticketsRaw = db.prepare(` - SELECT * FROM tickets - WHERE close_time >= ? AND close_time <= ? - AND current_status IN ('resolved', 'closed') - AND duration_minutes IS NOT NULL + 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) as any[] + `).all(periodStart, endFull, endFull, endFull) as any[] - // 3. 分类工单 - const tickets: ClassifiedTicket[] = ticketsRaw.map(t => ({ - 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: t.close_time, - duration_minutes: t.duration_minutes || 0, - availability: t.availability, - })) + // 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 @@ -133,7 +146,8 @@ export async function collectMonthlyReportData( chapter2Map.get(key)!.push({ ip: t.device_ip, fault_subcategory: t.fault_subcategory || '未知故障', - recoveryDays: daysBetween(t.assign_time, t.close_time), + recoveryDays: t.isOngoing ? 0 : daysBetween(t.assign_time, t.close_time), + isOngoing: t.isOngoing || false, }) } const chapter2: Chapter2Entry[] = [] @@ -170,6 +184,7 @@ export async function collectMonthlyReportData( 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, } } @@ -182,6 +197,7 @@ export async function collectMonthlyReportData( ticketConclusion: t.conclusion || '', durationMinutes: t.duration_minutes, countedInSla: (t.availability !== null && t.availability < 0.99 && !t.conclusion?.includes('无异常')) ? '是' : '否', + isOngoing: t.isOngoing || false, } } @@ -189,12 +205,22 @@ export async function collectMonthlyReportData( const storageFaults = storageFaultTickets.map(toFaultEntry) const allOtherTickets = [...otherTickets, ...remainingOthers].map(toOtherEntry) - // 7. 第四章:服务可用性说明(仅已结单工单,按 IP 分组求和,排除"无故障"工单) + // 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 - ipDurationMap.set(t.device_ip, dur + t.duration_minutes) + 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) { @@ -205,6 +231,7 @@ export async function collectMonthlyReportData( totalDurationMinutes: totalDuration, monthDays, availabilityPercent: Math.round(availabilityPercent * 100) / 100, + hasOngoing: ipHasOngoing.get(ip) || false, }) } // 按 IP 排序 @@ -226,6 +253,72 @@ export async function collectMonthlyReportData( } } +/** 从采集数据构建 metadata JSON(供 API 路由和 generate 函数共用) */ +export function buildMonthlyMetadata(data: MonthlyReportData): string { + const gpuStorageTickets = data.tickets.filter(t => + t.device_type !== 'other' && t.fault_category !== '无故障' + ) + 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 + + const daysWithFaults = data.dailyStats.filter(d => + (d.gpuTotal - d.gpuOnline) + (d.storageTotal - d.storageOnline) > 0 + ).length + const faultFreeDays = data.monthDays - daysWithFaults + + 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() @@ -238,31 +331,48 @@ export async function generateMonthlyReport(reportId: number): Promise { 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'), ]) - // 组装 DOCX - const buffer = await buildMonthlyReportDocx(data, { gpuPng: gpuChartPng, storagePng: storageChartPng }) + // ---- 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 = 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: data.chapter4.length > 0 - ? Math.round(data.chapter4.reduce((s, e) => s + e.availabilityPercent, 0) / data.chapter4.length * 100) / 100 - : null, - }) + const metadata = buildMonthlyMetadata(data) db.prepare( "UPDATE reports SET status = 'completed', file_path = ?, file_name = ?, metadata = ? WHERE id = ?"