feat: 月报 metadata 扩展到 16 字段,抽取 buildMonthlyMetadata 辅助函数
This commit is contained in:
parent
b6eed5d0c0
commit
e5bdf61cce
|
|
@ -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 = ?"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue