- 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
378 lines
12 KiB
TypeScript
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, '\\$&')
|
|
}
|
|
}
|