Files
BIPMaterialManager/src/main/services/report/report-analyzer.ts
Misaka_Company a5f503773e feat: report analysis with date aggregation and user filtering
- Implement date aggregation logic, sum multiple reports on same day
- Add user filtering with multi-select Chip components
- Fix IpcResult data unwrapping bug in frontend
- Add loading state and empty state indicators
- Update test cases to verify aggregation logic

Fixes:
- Timeline confusion: Now sorted by date ascending
- Data duplication: Same-day data automatically aggregated
- No filtering: Added user selector for filtering by users
2026-03-26 09:38:29 +08:00

378 lines
12 KiB
TypeScript

import { createLogger } from '../logger'
const log = createLogger('ReportAnalyzer')
/**
* Parsed report data structure - 单条报告记录
*/
export interface ParsedReportData {
/** 报告日期 (从文件名或内容提取) */
date: string
/** 操作用户 */
username: string
/** 处理订单数 */
ordersProcessed: number
/** 删除物料数 */
materialsDeleted: number
/** 跳过物料数 */
materialsSkipped: number
/** 错误数量 */
errorCount: number
/** 重试订单数 */
retriedOrders: number
/** 成功重试数 */
successfulRetries: number
/** 执行耗时 (秒) */
durationSeconds: number
}
/**
* Aggregated daily data - 按日期聚合后的数据
*/
export interface AggregatedDailyData {
/** 日期 */
date: string
/** 处理订单数总和 */
ordersProcessed: number
/** 删除物料数总和 */
materialsDeleted: number
/** 跳过物料数总和 */
materialsSkipped: number
/** 错误数量总和 */
errorCount: number
/** 重试订单数总和 */
retriedOrders: number
/** 成功重试数总和 */
successfulRetries: number
/** 执行耗时平均值 (秒) */
avgDurationSeconds: number
/** 执行次数 */
executionCount: number
}
/**
* ReportAnalyzer - 解析和分析 ERP 物料清理执行报告
*/
export class ReportAnalyzer {
/**
* 解析单个 markdown 报告内容
* @param content markdown 报告内容
* @returns 解析后的结构化数据,解析失败返回 null
*/
parseMarkdownReport(content: string): ParsedReportData | null {
try {
// 从执行摘要表格中提取数据 - 捕获整个表格直到空行或下一个标题
const summarySectionMatch = content.match(/## 执行摘要\s*\n([\s\S]*?)(?=\n---|\n##|\n$)/)
if (!summarySectionMatch) {
log.warn('Failed to find execution summary section')
return null
}
const summaryTable = summarySectionMatch[0]
// 提取各个字段的值
const username = this.extractFieldValue(summaryTable, '操作用户') || ''
const ordersProcessed = this.extractNumericField(summaryTable, '处理订单数') || 0
const materialsDeleted = this.extractNumericField(summaryTable, '删除物料数') || 0
const materialsSkipped = this.extractNumericField(summaryTable, '跳过物料数') || 0
const errorCount = this.extractNumericField(summaryTable, '错误数量') || 0
const retriedOrders = this.extractNumericField(summaryTable, '重试订单数') || 0
const successfulRetries = this.extractNumericField(summaryTable, '成功重试数') || 0
// 提取执行耗时并转换为秒
const durationStr = this.extractFieldValue(summaryTable, '执行耗时')
const durationSeconds = durationStr ? this.parseDuration(durationStr) : 0
// 从内容或执行时间字段提取日期
const executionTimeStr = this.extractFieldValue(summaryTable, '执行时间')
const date = executionTimeStr
? this.extractDateFromDateTime(executionTimeStr)
: this.extractDateFromContent(content)
return {
date,
username,
ordersProcessed,
materialsDeleted,
materialsSkipped,
errorCount,
retriedOrders,
successfulRetries,
durationSeconds
}
} catch (error) {
log.error('Failed to parse markdown report', {
error: error instanceof Error ? error.message : error
})
return null
}
}
/**
* 批量解析多个报告并按日期聚合
* @param reports 报告元数据和内容列表
* @returns 按日期聚合后的数据,按日期升序排序
*/
analyzeReports(reports: Array<{ content: string; filename?: string }>): AggregatedDailyData[] {
// 第一步:解析所有报告
const parsedReports: ParsedReportData[] = []
for (const report of reports) {
const parsed = this.parseMarkdownReport(report.content)
if (parsed) {
// 如果有文件名,尝试从文件名提取日期覆盖
if (report.filename) {
const dateFromFilename = this.extractDateFromFilename(report.filename)
if (dateFromFilename) {
parsed.date = dateFromFilename
}
}
parsedReports.push(parsed)
} else {
log.warn('Skipping report due to parse failure', { filename: report.filename })
}
}
// 第二步:按日期聚合数据
const dateMap = new Map<string, ParsedReportData[]>()
for (const report of parsedReports) {
const existing = dateMap.get(report.date) || []
existing.push(report)
dateMap.set(report.date, existing)
}
// 第三步:计算每天的聚合数据
const aggregated: AggregatedDailyData[] = []
for (const [date, reportsOnDate] of dateMap.entries()) {
const totalOrdersProcessed = reportsOnDate.reduce((sum, r) => sum + r.ordersProcessed, 0)
const totalMaterialsDeleted = reportsOnDate.reduce((sum, r) => sum + r.materialsDeleted, 0)
const totalMaterialsSkipped = reportsOnDate.reduce((sum, r) => sum + r.materialsSkipped, 0)
const totalErrorCount = reportsOnDate.reduce((sum, r) => sum + r.errorCount, 0)
const totalRetriedOrders = reportsOnDate.reduce((sum, r) => sum + r.retriedOrders, 0)
const totalSuccessfulRetries = reportsOnDate.reduce((sum, r) => sum + r.successfulRetries, 0)
const totalDurationSeconds = reportsOnDate.reduce((sum, r) => sum + r.durationSeconds, 0)
const executionCount = reportsOnDate.length
aggregated.push({
date,
ordersProcessed: totalOrdersProcessed,
materialsDeleted: totalMaterialsDeleted,
materialsSkipped: totalMaterialsSkipped,
errorCount: totalErrorCount,
retriedOrders: totalRetriedOrders,
successfulRetries: totalSuccessfulRetries,
avgDurationSeconds: Math.round(totalDurationSeconds / executionCount),
executionCount
})
}
// 第四步:按日期升序排序(从早到晚),确保图表时间轴正确
aggregated.sort((a, b) => {
const dateA = new Date(a.date)
const dateB = new Date(b.date)
return dateA.getTime() - dateB.getTime()
})
return aggregated
}
/**
* 获取所有唯一的用户名列表
* @param reports 报告元数据和内容列表
* @returns 按字母顺序排序的用户名列表
*/
getAllUsernames(reports: Array<{ content: string; filename?: string }>): string[] {
const usernames = new Set<string>()
for (const report of reports) {
const parsed = this.parseMarkdownReport(report.content)
if (parsed && parsed.username) {
usernames.add(parsed.username)
}
}
return Array.from(usernames).sort()
}
/**
* 按用户筛选报告并返回聚合数据
* @param reports 报告元数据和内容列表
* @param selectedUsernames 选中的用户名列表
* @returns 按日期聚合后的数据,仅包含选中用户的报告
*/
analyzeReportsByUsers(
reports: Array<{ content: string; filename?: string }>,
selectedUsernames: string[]
): AggregatedDailyData[] {
// 如果未选择任何用户,返回空数组
if (selectedUsernames.length === 0) {
return []
}
// 第一步:解析所有报告
const parsedReports: ParsedReportData[] = []
for (const report of reports) {
const parsed = this.parseMarkdownReport(report.content)
if (parsed) {
// 如果有文件名,尝试从文件名提取日期覆盖
if (report.filename) {
const dateFromFilename = this.extractDateFromFilename(report.filename)
if (dateFromFilename) {
parsed.date = dateFromFilename
}
}
parsedReports.push(parsed)
} else {
log.warn('Skipping report due to parse failure', { filename: report.filename })
}
}
// 第二步:按选中的用户过滤
const filteredReports = parsedReports.filter((report) =>
selectedUsernames.includes(report.username)
)
// 第三步:按日期聚合过滤后的数据
const dateMap = new Map<string, ParsedReportData[]>()
for (const report of filteredReports) {
const existing = dateMap.get(report.date) || []
existing.push(report)
dateMap.set(report.date, existing)
}
// 第四步:计算每天的聚合数据
const aggregated: AggregatedDailyData[] = []
for (const [date, reportsOnDate] of dateMap.entries()) {
const totalOrdersProcessed = reportsOnDate.reduce((sum, r) => sum + r.ordersProcessed, 0)
const totalMaterialsDeleted = reportsOnDate.reduce((sum, r) => sum + r.materialsDeleted, 0)
const totalMaterialsSkipped = reportsOnDate.reduce((sum, r) => sum + r.materialsSkipped, 0)
const totalErrorCount = reportsOnDate.reduce((sum, r) => sum + r.errorCount, 0)
const totalRetriedOrders = reportsOnDate.reduce((sum, r) => sum + r.retriedOrders, 0)
const totalSuccessfulRetries = reportsOnDate.reduce((sum, r) => sum + r.successfulRetries, 0)
const totalDurationSeconds = reportsOnDate.reduce((sum, r) => sum + r.durationSeconds, 0)
const executionCount = reportsOnDate.length
aggregated.push({
date,
ordersProcessed: totalOrdersProcessed,
materialsDeleted: totalMaterialsDeleted,
materialsSkipped: totalMaterialsSkipped,
errorCount: totalErrorCount,
retriedOrders: totalRetriedOrders,
successfulRetries: totalSuccessfulRetries,
avgDurationSeconds: Math.round(totalDurationSeconds / executionCount),
executionCount
})
}
// 第五步:按日期升序排序(从早到晚),确保图表时间轴正确
aggregated.sort((a, b) => {
const dateA = new Date(a.date)
const dateB = new Date(b.date)
return dateA.getTime() - dateB.getTime()
})
return aggregated
}
/**
* 从表格行中提取字段的字符串值
*/
private extractFieldValue(tableContent: string, fieldName: string): string | null {
// 匹配格式:| **字段名** | `值` |
const pattern = new RegExp(
`\\|\\s*\\*\\*${this.escapeRegex(fieldName)}\\*\\*\\s*\\|\\s*\`([^\`]*)\``,
'i'
)
const match = tableContent.match(pattern)
log.debug('extractFieldValue', { fieldName, matched: match?.[1], pattern })
return match ? match[1].trim() : null
}
/**
* 从表格行中提取数值字段
*/
private extractNumericField(tableContent: string, fieldName: string): number | null {
const value = this.extractFieldValue(tableContent, fieldName)
if (value === null) return null
// 移除可能的非数字字符 (如单位)
const numStr = value.replace(/[^\d]/g, '')
if (!numStr) return null
return parseInt(numStr, 10)
}
/**
* 解析耗时字符串为秒数
* 支持格式:"2 分 30 秒", "1 分", "30 秒", "5 分钟"
*/
private parseDuration(durationStr: string): number {
let totalSeconds = 0
// 匹配分钟 - 支持 "X 分" 或 "X 分钟" 格式
const minMatch = durationStr.match(/(\d+) 分/)
if (minMatch) {
totalSeconds += parseInt(minMatch[1], 10) * 60
}
// 匹配秒
const secMatch = durationStr.match(/(\d+) 秒/)
if (secMatch) {
totalSeconds += parseInt(secMatch[1], 10)
}
return totalSeconds
}
/**
* 从日期时间字符串中提取日期部分
* 格式:"2026-03-25 08:30:45" -> "2026-03-25"
*/
private extractDateFromDateTime(dateTimeStr: string): string {
const match = dateTimeStr.match(/(\d{4}-\d{2}-\d{2})/)
return match ? match[1] : dateTimeStr
}
/**
* 从内容中提取日期 (备选方案)
*/
private extractDateFromContent(content: string): string {
// 尝试从报告标题或执行时间提取
const dateMatch = content.match(/(\d{4}-\d{2}-\d{2})/)
return dateMatch ? dateMatch[1] : new Date().toISOString().split('T')[0]
}
/**
* 从文件名中提取日期
* 格式:"cleaner-report-2026-03-25-08-30-45.md" -> "2026-03-25"
*/
private extractDateFromFilename(filename: string): string | null {
// 匹配 ISO 日期格式
const isoMatch = filename.match(/(\d{4}-\d{2}-\d{2})/)
if (isoMatch) {
return isoMatch[1]
}
// 匹配其他常见日期格式
const dateMatch = filename.match(/(\d{8})/)
if (dateMatch) {
const str = dateMatch[1]
// 尝试解析 YYYYMMDD
if (str.length === 8) {
return `${str.slice(0, 4)}-${str.slice(4, 6)}-${str.slice(6, 8)}`
}
}
return null
}
/**
* 转义正则表达式特殊字符
*/
private escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
}