namespace Service.Transaction; public interface ITransactionStatisticsService { Task> GetDailyStatisticsAsync(int year, int month, string? savingClassify = null); Task> GetDailyStatisticsByRangeAsync(DateTime startDate, DateTime endDate, string? savingClassify = null); Task GetMonthlyStatisticsAsync(int year, int month); Task> GetCategoryStatisticsAsync(int year, int month, TransactionType type); /// /// 按日期范围获取分类统计数据 /// Task> GetCategoryStatisticsByDateRangeAsync(DateTime startDate, DateTime endDate, TransactionType type); Task> GetTrendStatisticsAsync(int startYear, int startMonth, int monthCount); Task<(List list, int total)> GetReasonGroupsAsync(int pageIndex = 1, int pageSize = 20); Task> GetClassifiedByKeywordsWithScoreAsync(List keywords, double minMatchRate = 0.3, int limit = 10); Task> GetFilteredTrendStatisticsAsync( DateTime startDate, DateTime endDate, TransactionType type, IEnumerable classifies, bool groupByMonth = false); Task> GetAmountGroupByClassifyAsync(DateTime startTime, DateTime endTime); } public class TransactionStatisticsService( ITransactionRecordRepository transactionRepository ) : ITransactionStatisticsService { public async Task> GetDailyStatisticsAsync(int year, int month, string? savingClassify = null) { // 当 month=0 时,表示查询整年数据 DateTime startDate; DateTime endDate; if (month == 0) { // 查询整年:1月1日至12月31日 startDate = new DateTime(year, 1, 1); endDate = new DateTime(year, 12, 31).AddDays(1); // 包含12月31日 } else { // 查询指定月份 startDate = new DateTime(year, month, 1); endDate = startDate.AddMonths(1); } return await GetDailyStatisticsByRangeAsync(startDate, endDate, savingClassify); } public async Task> GetDailyStatisticsByRangeAsync(DateTime startDate, DateTime endDate, string? savingClassify = null) { var records = await transactionRepository.QueryAsync( startDate: startDate, endDate: endDate, pageSize: int.MaxValue); return records .GroupBy(t => t.OccurredAt.ToString("yyyy-MM-dd")) .ToDictionary( g => g.Key, g => { var income = g.Where(t => t.Type == TransactionType.Income).Sum(t => Math.Abs(t.Amount)); var expense = g.Where(t => t.Type == TransactionType.Expense).Sum(t => Math.Abs(t.Amount)); var saving = 0m; if (!string.IsNullOrEmpty(savingClassify)) { saving = g.Where(t => savingClassify.Split(',').Contains(t.Classify)).Sum(t => Math.Abs(t.Amount)); } return (count: g.Count(), expense, income, saving); } ); } public async Task GetMonthlyStatisticsAsync(int year, int month) { var records = await transactionRepository.QueryAsync( year: year, month: month, pageSize: int.MaxValue); var statistics = new MonthlyStatistics { Year = year, Month = month }; foreach (var record in records) { var amount = Math.Abs(record.Amount); if (record.Type == TransactionType.Expense) { statistics.TotalExpense += amount; statistics.ExpenseCount++; } else if (record.Type == TransactionType.Income) { statistics.TotalIncome += amount; statistics.IncomeCount++; } } statistics.Balance = statistics.TotalIncome - statistics.TotalExpense; statistics.TotalCount = records.Count; return statistics; } public async Task> GetCategoryStatisticsAsync(int year, int month, TransactionType type) { var records = await transactionRepository.QueryAsync( year: year, month: month, type: type, pageSize: int.MaxValue); var categoryGroups = records .GroupBy(t => t.Classify) .Select(g => new CategoryStatistics { Classify = g.Key, Amount = g.Sum(t => Math.Abs(t.Amount)), Count = g.Count() }) .OrderByDescending(c => c.Amount) .ToList(); var total = categoryGroups.Sum(c => c.Amount); if (total > 0) { foreach (var category in categoryGroups) { category.Percent = Math.Round((category.Amount / total) * 100, 1); } } return categoryGroups; } /// /// 按日期范围获取分类统计数据 /// public async Task> GetCategoryStatisticsByDateRangeAsync(DateTime startDate, DateTime endDate, TransactionType type) { var records = await transactionRepository.QueryAsync( startDate: startDate, endDate: endDate, type: type, pageSize: int.MaxValue); var categoryGroups = records .GroupBy(t => t.Classify) .Select(g => new CategoryStatistics { Classify = g.Key, Amount = g.Sum(t => Math.Abs(t.Amount)), Count = g.Count() }) .OrderByDescending(c => c.Amount) .ToList(); var total = categoryGroups.Sum(c => c.Amount); if (total > 0) { foreach (var category in categoryGroups) { category.Percent = Math.Round((category.Amount / total) * 100, 1); } } return categoryGroups; } public async Task> GetTrendStatisticsAsync(int startYear, int startMonth, int monthCount) { var trends = new List(); for (var i = 0; i < monthCount; i++) { var targetYear = startYear; var targetMonth = startMonth + i; while (targetMonth > 12) { targetMonth -= 12; targetYear++; } var records = await transactionRepository.QueryAsync( year: targetYear, month: targetMonth, pageSize: int.MaxValue); var expense = records.Where(t => t.Type == TransactionType.Expense).Sum(t => Math.Abs(t.Amount)); var income = records.Where(t => t.Type == TransactionType.Income).Sum(t => Math.Abs(t.Amount)); trends.Add(new TrendStatistics { Year = targetYear, Month = targetMonth, Expense = expense, Income = income, Balance = income - expense }); } return trends; } public async Task<(List list, int total)> GetReasonGroupsAsync(int pageIndex = 1, int pageSize = 20) { var records = await transactionRepository.QueryAsync( pageSize: int.MaxValue); var unclassifiedRecords = records .Where(t => !string.IsNullOrEmpty(t.Reason) && string.IsNullOrEmpty(t.Classify)) .GroupBy(t => t.Reason) .Select(g => new { Reason = g.Key, Count = g.Count(), TotalAmount = g.Sum(r => r.Amount), SampleType = g.First().Type, SampleClassify = g.First().Classify, TransactionIds = g.Select(r => r.Id).ToList() }) .OrderByDescending(g => Math.Abs(g.TotalAmount)) .ToList(); var total = unclassifiedRecords.Count; var pagedGroups = unclassifiedRecords .Skip((pageIndex - 1) * pageSize) .Take(pageSize) .Select(g => new ReasonGroupDto { Reason = g.Reason, Count = g.Count, SampleType = g.SampleType, SampleClassify = g.SampleClassify, TransactionIds = g.TransactionIds, TotalAmount = Math.Abs(g.TotalAmount) }) .ToList(); return (pagedGroups, total); } public async Task> GetClassifiedByKeywordsWithScoreAsync(List keywords, double minMatchRate = 0.3, int limit = 10) { if (keywords.Count == 0) { return []; } var candidates = await transactionRepository.GetClassifiedByKeywordsAsync(keywords, limit: int.MaxValue); var scoredResults = candidates .Select(record => { var matchedCount = keywords.Count(keyword => record.Reason.Contains(keyword, StringComparison.OrdinalIgnoreCase)); var matchRate = (double)matchedCount / keywords.Count; var exactMatchBonus = keywords.Any(k => record.Reason.Equals(k, StringComparison.OrdinalIgnoreCase)) ? 0.2 : 0.0; var avgKeywordLength = keywords.Average(k => k.Length); var lengthSimilarity = 1.0 - Math.Min(1.0, Math.Abs(record.Reason.Length - avgKeywordLength) / Math.Max(record.Reason.Length, avgKeywordLength)); var lengthBonus = lengthSimilarity * 0.1; var score = matchRate + exactMatchBonus + lengthBonus; return (record, score); }) .Where(x => x.score >= minMatchRate) .OrderByDescending(x => x.score) .ThenByDescending(x => x.record.OccurredAt) .Take(limit) .ToList(); return scoredResults; } public async Task> GetFilteredTrendStatisticsAsync( DateTime startDate, DateTime endDate, TransactionType type, IEnumerable classifies, bool groupByMonth = false) { var records = await transactionRepository.QueryAsync( startDate: startDate, endDate: endDate, type: type, classifies: classifies.ToArray(), pageSize: int.MaxValue); if (groupByMonth) { return records .GroupBy(t => new DateTime(t.OccurredAt.Year, t.OccurredAt.Month, 1)) .ToDictionary(g => g.Key, g => g.Sum(x => Math.Abs(x.Amount))); } return records .GroupBy(t => t.OccurredAt.Date) .ToDictionary(g => g.Key, g => g.Sum(x => Math.Abs(x.Amount))); } public async Task> GetAmountGroupByClassifyAsync(DateTime startTime, DateTime endTime) { var records = await transactionRepository.QueryAsync( startDate: startTime, endDate: endTime, pageSize: int.MaxValue); return records .GroupBy(t => new { t.Classify, t.Type }) .ToDictionary(g => (g.Key.Classify, g.Key.Type), g => g.Sum(t => t.Amount)); } } public record ReasonGroupDto { public string Reason { get; set; } = string.Empty; public int Count { get; set; } public TransactionType SampleType { get; set; } public string SampleClassify { get; set; } = string.Empty; public List TransactionIds { get; set; } = []; public decimal TotalAmount { get; set; } } public record MonthlyStatistics { public int Year { get; set; } public int Month { get; set; } public decimal TotalExpense { get; set; } public decimal TotalIncome { get; set; } public decimal Balance { get; set; } public int ExpenseCount { get; set; } public int IncomeCount { get; set; } public int TotalCount { get; set; } } public record CategoryStatistics { public string Classify { get; set; } = string.Empty; public decimal Amount { get; set; } public int Count { get; set; } public decimal Percent { get; set; } } public record TrendStatistics { public int Year { get; set; } public int Month { get; set; } public decimal Expense { get; set; } public decimal Income { get; set; } public decimal Balance { get; set; } }