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
This commit is contained in:
Misaka_Company
2026-03-26 09:38:29 +08:00
parent 72d452f75e
commit a5f503773e
7 changed files with 454 additions and 37 deletions

View File

@@ -3,7 +3,7 @@ import { createLogger } from '../logger'
const log = createLogger('ReportAnalyzer')
/**
* Parsed report data structure
* Parsed report data structure - 单条报告记录
*/
export interface ParsedReportData {
/** 报告日期 (从文件名或内容提取) */
@@ -26,6 +26,30 @@ export interface ParsedReportData {
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 物料清理执行报告
*/
@@ -85,12 +109,13 @@ export class ReportAnalyzer {
}
/**
* 批量解析多个报告
* 批量解析多个报告并按日期聚合
* @param reports 报告元数据和内容列表
* @returns 解析后的数据列表 (自动跳过解析失败的报告)
* @returns 按日期聚合后的数据,按日期升序排序
*/
analyzeReports(reports: Array<{ content: string; filename?: string }>): ParsedReportData[] {
const results: ParsedReportData[] = []
analyzeReports(reports: Array<{ content: string; filename?: string }>): AggregatedDailyData[] {
// 第一步:解析所有报告
const parsedReports: ParsedReportData[] = []
for (const report of reports) {
const parsed = this.parseMarkdownReport(report.content)
@@ -102,13 +127,153 @@ export class ReportAnalyzer {
parsed.date = dateFromFilename
}
}
results.push(parsed)
parsedReports.push(parsed)
} else {
log.warn('Skipping report due to parse failure', { filename: report.filename })
}
}
return results
// 第二步:按日期聚合数据
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
}
/**