feat: 月报 metadata 扩展到 16 字段,抽取 buildMonthlyMetadata 辅助函数

This commit is contained in:
gitadmin 2026-05-07 16:05:19 +08:00
parent b6eed5d0c0
commit e5bdf61cce
1 changed files with 150 additions and 40 deletions

View File

@ -2,9 +2,13 @@ import { getDb } from './db'
import { getActiveDevices, DEVICE_TYPE_GPU, DEVICE_TYPE_STORAGE } from './assets-client' import { getActiveDevices, DEVICE_TYPE_GPU, DEVICE_TYPE_STORAGE } from './assets-client'
import { generateDailyOnlineChart } from './monthly-report-charts' import { generateDailyOnlineChart } from './monthly-report-charts'
import { buildMonthlyReportDocx } from './monthly-report-docx' 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 { import type {
ClassifiedTicket, DailyOnlineStats, Chapter2Entry, Chapter2FaultItem, ClassifiedTicket, DailyOnlineStats, Chapter2Entry, Chapter2FaultItem,
Chapter3FaultEntry, Chapter3OtherEntry, Chapter4Entry, MonthlyReportData, Chapter3FaultEntry, Chapter3OtherEntry, Chapter4Entry, MonthlyReportData,
WeeklyAttachment,
} from '@/types/report' } from '@/types/report'
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
@ -57,34 +61,43 @@ export async function collectMonthlyReportData(
if (d.hdm_ip) ipTypeMap.set(d.hdm_ip, 'storage') if (d.hdm_ip) ipTypeMap.set(d.hdm_ip, 'storage')
} }
// 2. 查询当月已结单工单(按 close_time 范围) // 2. 查询当月已结单工单 + 跨月进行中工单
const db = getDb() const db = getDb()
const endFull = periodEnd + ' 23:59:59' const endFull = periodEnd + ' 23:59:59'
const ticketsRaw = db.prepare(` const ticketsRaw = db.prepare(`
SELECT * FROM tickets SELECT * FROM tickets WHERE (
WHERE close_time >= ? AND close_time <= ? (close_time >= ? AND close_time <= ?)
AND current_status IN ('resolved', 'closed') OR
AND duration_minutes IS NOT NULL (assign_time <= ? AND (close_time IS NULL OR close_time > ?))
)
ORDER BY assign_time ORDER BY assign_time
`).all(periodStart, endFull) as any[] `).all(periodStart, endFull, endFull, endFull) as any[]
// 3. 分类工单 // 3. 分类工单(进行中工单使用 periodEnd+1 天作为合成 close_time 供后续计算)
const tickets: ClassifiedTicket[] = ticketsRaw.map(t => ({ const periodEndNext = new Date(periodEnd.replace(/-/g, '/'))
id: t.id, periodEndNext.setDate(periodEndNext.getDate() + 1)
device_ip: t.device_ip || '', const periodEndNextStr = `${periodEndNext.getFullYear()}-${String(periodEndNext.getMonth()+1).padStart(2,'0')}-${String(periodEndNext.getDate()).padStart(2,'0')}`
device_name: t.device_name,
device_type: ipTypeMap.get(t.device_ip) || 'other', const tickets: ClassifiedTicket[] = ticketsRaw.map(t => {
fault_category: t.fault_category, const isOngoing = !t.close_time || t.close_time > endFull
fault_subcategory: t.fault_subcategory, return {
parts_replaced: t.parts_replaced, id: t.id,
parts_name: t.parts_name, device_ip: t.device_ip || '',
content: t.content, device_name: t.device_name,
conclusion: t.conclusion, device_type: ipTypeMap.get(t.device_ip) || 'other',
assign_time: t.assign_time, fault_category: t.fault_category,
close_time: t.close_time, fault_subcategory: t.fault_subcategory,
duration_minutes: t.duration_minutes || 0, parts_replaced: t.parts_replaced,
availability: t.availability, 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 monthDays = getDateRange(periodStart, periodEnd).length
@ -133,7 +146,8 @@ export async function collectMonthlyReportData(
chapter2Map.get(key)!.push({ chapter2Map.get(key)!.push({
ip: t.device_ip, ip: t.device_ip,
fault_subcategory: t.fault_subcategory || '未知故障', 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[] = [] const chapter2: Chapter2Entry[] = []
@ -170,6 +184,7 @@ export async function collectMonthlyReportData(
faultCause: t.parts_name ? `更换${t.parts_name}` : '-', faultCause: t.parts_name ? `更换${t.parts_name}` : '-',
durationMinutes: t.duration_minutes, durationMinutes: t.duration_minutes,
countedInSla: (t.availability !== null && t.availability < 0.99 && !t.conclusion?.includes('无异常')) ? '是' : '否', 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 || '', ticketConclusion: t.conclusion || '',
durationMinutes: t.duration_minutes, durationMinutes: t.duration_minutes,
countedInSla: (t.availability !== null && t.availability < 0.99 && !t.conclusion?.includes('无异常')) ? '是' : '否', 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 storageFaults = storageFaultTickets.map(toFaultEntry)
const allOtherTickets = [...otherTickets, ...remainingOthers].map(toOtherEntry) const allOtherTickets = [...otherTickets, ...remainingOthers].map(toOtherEntry)
// 7. 第四章:服务可用性说明(仅已结单工单,按 IP 分组求和,排除"无故障"工单 // 7. 第四章:服务可用性说明(已结单工单按实际时长,进行中工单按本月部分
const ipDurationMap = new Map<string, number>() const ipDurationMap = new Map<string, number>()
const ipHasOngoing = new Map<string, boolean>()
for (const t of tickets) { for (const t of tickets) {
if (t.fault_category === '无故障') continue if (t.fault_category === '无故障') continue
const dur = ipDurationMap.get(t.device_ip) || 0 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[] = [] const chapter4: Chapter4Entry[] = []
for (const [ip, totalDuration] of ipDurationMap) { for (const [ip, totalDuration] of ipDurationMap) {
@ -205,6 +231,7 @@ export async function collectMonthlyReportData(
totalDurationMinutes: totalDuration, totalDurationMinutes: totalDuration,
monthDays, monthDays,
availabilityPercent: Math.round(availabilityPercent * 100) / 100, availabilityPercent: Math.round(availabilityPercent * 100) / 100,
hasOngoing: ipHasOngoing.get(ip) || false,
}) })
} }
// 按 IP 排序 // 按 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 风格) */ /** 异步生成月报fire-and-forget 风格) */
export async function generateMonthlyReport(reportId: number): Promise<void> { export async function generateMonthlyReport(reportId: number): Promise<void> {
const db = getDb() const db = getDb()
@ -238,31 +331,48 @@ export async function generateMonthlyReport(reportId: number): Promise<void> {
try { try {
const data = await collectMonthlyReportData(report.period_start, report.period_end) 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([ const [gpuChartPng, storageChartPng] = await Promise.all([
generateDailyOnlineChart(data.dailyStats, 'gpu'), generateDailyOnlineChart(data.dailyStats, 'gpu'),
generateDailyOnlineChart(data.dailyStats, 'storage'), generateDailyOnlineChart(data.dailyStats, 'storage'),
]) ])
// 组装 DOCX // ---- 3. 组装月报 DOCX ----
const buffer = await buildMonthlyReportDocx(data, { gpuPng: gpuChartPng, storagePng: storageChartPng }) let buffer = await buildMonthlyReportDocx(
data,
{ gpuPng: gpuChartPng, storagePng: storageChartPng },
weeklyLabels,
)
// ---- 4. ZIP 后处理:嵌入周报 OLE 对象 ----
if (weeklyAttachments.length > 0) {
buffer = await embedWeeklyReports(buffer, weeklyAttachments)
}
ensureReportsDir() ensureReportsDir()
const fileName = `${data.monthLabel}图灵IT基础设施运营月报.docx` const fileName = `${data.monthLabel}图灵IT基础设施运营月报.docx`
const filePath = path.join(REPORTS_DIR, fileName) const filePath = path.join(REPORTS_DIR, fileName)
fs.writeFileSync(filePath, buffer) fs.writeFileSync(filePath, buffer)
const metadata = JSON.stringify({ const metadata = buildMonthlyMetadata(data)
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,
})
db.prepare( db.prepare(
"UPDATE reports SET status = 'completed', file_path = ?, file_name = ?, metadata = ? WHERE id = ?" "UPDATE reports SET status = 'completed', file_path = ?, file_name = ?, metadata = ? WHERE id = ?"