namespace Repository; public interface ITransactionRecordRepository : IBaseRepository { Task ExistsByEmailMessageIdAsync(long emailMessageId, DateTime occurredAt); Task ExistsByImportNoAsync(string importNo, string importFrom); /// /// 分页获取交易记录列表 /// /// 页码,从1开始 /// 每页数量 /// 搜索关键词(搜索交易摘要和分类) /// 筛选分类列表 /// 筛选交易类型 /// 筛选年份 /// 筛选月份 /// 筛选开始日期 /// 筛选结束日期 /// 筛选交易摘要 /// 是否按金额降序排列,默认为false按时间降序 /// 交易记录列表 Task> GetPagedListAsync( int pageIndex = 1, int pageSize = 20, string? searchKeyword = null, string[]? classifies = null, TransactionType? type = null, int? year = null, int? month = null, DateTime? startDate = null, DateTime? endDate = null, string? reason = null, bool sortByAmount = false); /// /// 获取总数 /// Task GetTotalCountAsync( string? searchKeyword = null, string[]? classifies = null, TransactionType? type = null, int? year = null, int? month = null, DateTime? startDate = null, DateTime? endDate = null, string? reason = null); /// /// 获取所有不同的交易分类 /// Task> GetDistinctClassifyAsync(); /// /// 获取指定月份每天的消费统计 /// /// 年份 /// 月份 /// 每天的消费笔数和金额详情 Task> GetDailyStatisticsAsync(int year, int month, string? savingClassify = null); /// /// 获取指定日期范围内的每日统计 /// /// 开始日期 /// 结束日期 /// 每天的消费笔数和金额详情 Task> GetDailyStatisticsByRangeAsync(DateTime startDate, DateTime endDate, string? savingClassify = null); /// /// 获取指定日期范围内的交易记录 /// /// 开始日期 /// 结束日期 /// 交易记录列表 Task> GetByDateRangeAsync(DateTime startDate, DateTime endDate); /// /// 获取指定邮件的交易记录数量 /// /// 邮件ID /// 交易记录数量 Task GetCountByEmailIdAsync(long emailMessageId); /// /// 获取月度统计数据 /// /// 年份 /// 月份 /// 月度统计数据 Task GetMonthlyStatisticsAsync(int year, int month); /// /// 获取分类统计数据 /// /// 年份 /// 月份 /// 交易类型(0:支出, 1:收入) /// 分类统计列表 Task> GetCategoryStatisticsAsync(int year, int month, TransactionType type); /// /// 获取多个月的趋势统计数据 /// /// 开始年份 /// 开始月份 /// 月份数量 /// 趋势统计列表 Task> GetTrendStatisticsAsync(int startYear, int startMonth, int monthCount); /// /// 获取指定邮件的交易记录列表 /// /// 邮件ID /// 交易记录列表 Task> GetByEmailIdAsync(long emailMessageId); /// /// 获取未分类的账单数量 /// /// 未分类账单数量 Task GetUnclassifiedCountAsync(); /// /// 获取未分类的账单列表 /// /// 每页数量 /// 未分类账单列表 Task> GetUnclassifiedAsync(int pageSize = 10); /// /// 获取按交易摘要(Reason)分组的统计信息(支持分页) /// /// 页码,从1开始 /// 每页数量 /// 分组统计列表和总数 Task<(List list, int total)> GetReasonGroupsAsync(int pageIndex = 1, int pageSize = 20); /// /// 按摘要批量更新交易记录的分类 /// /// 交易摘要 /// 交易类型 /// 分类名称 /// 更新的记录数量 Task BatchUpdateByReasonAsync(string reason, TransactionType type, string classify); /// /// 根据关键词查询交易记录(模糊匹配Reason字段) /// /// 关键词 /// 匹配的交易记录列表 Task> QueryByWhereAsync(string sql); /// /// 执行完整的SQL查询 /// /// 完整的SELECT SQL语句 /// 查询结果列表 Task> ExecuteRawSqlAsync(string completeSql); /// /// 根据关键词查询已分类的账单(用于智能分类参考) /// /// 关键词列表 /// 返回结果数量限制 /// 已分类的账单列表 Task> GetClassifiedByKeywordsAsync(List keywords, int limit = 10); /// /// 根据关键词查询已分类的账单,并计算相关度分数 /// /// 关键词列表 /// 最小匹配率(0.0-1.0),默认0.3表示至少匹配30%的关键词 /// 返回结果数量限制 /// 带相关度分数的已分类账单列表 Task> GetClassifiedByKeywordsWithScoreAsync(List keywords, double minMatchRate = 0.3, int limit = 10); /// /// 获取抵账候选列表 /// /// 当前交易ID /// 当前交易金额 /// 当前交易类型 /// 候选交易列表 Task> GetCandidatesForOffsetAsync(long currentId, decimal amount, TransactionType currentType); /// /// 获取待确认分类的账单列表 /// /// 待确认账单列表 Task> GetUnconfirmedRecordsAsync(); /// /// 全部确认待确认的分类 /// /// 影响行数 Task ConfirmAllUnconfirmedAsync(long[] ids); /// /// 更新分类名称 /// /// 旧分类名称 /// 新分类名称 /// 交易类型 /// 影响行数 Task UpdateCategoryNameAsync(string oldName, string newName, TransactionType type); } public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository(freeSql), ITransactionRecordRepository { public async Task ExistsByEmailMessageIdAsync(long emailMessageId, DateTime occurredAt) { return await FreeSql.Select() .Where(t => t.EmailMessageId == emailMessageId && t.OccurredAt == occurredAt) .FirstAsync(); } public async Task ExistsByImportNoAsync(string importNo, string importFrom) { return await FreeSql.Select() .Where(t => t.ImportNo == importNo && t.ImportFrom == importFrom) .FirstAsync(); } public async Task> GetPagedListAsync( int pageIndex = 1, int pageSize = 20, string? searchKeyword = null, string[]? classifies = null, TransactionType? type = null, int? year = null, int? month = null, DateTime? startDate = null, DateTime? endDate = null, string? reason = null, bool sortByAmount = false) { var query = FreeSql.Select(); // 如果提供了搜索关键词,则添加搜索条件 query = query.WhereIf(!string.IsNullOrWhiteSpace(searchKeyword), t => t.Reason.Contains(searchKeyword!) || t.Classify.Contains(searchKeyword!) || t.Card.Contains(searchKeyword!) || t.ImportFrom.Contains(searchKeyword!)) .WhereIf(!string.IsNullOrWhiteSpace(reason), t => t.Reason == reason); // 按分类筛选 if (classifies != null && classifies.Length > 0) { var filterClassifies = classifies.Select(c => c == "未分类" ? string.Empty : c).ToList(); query = query.Where(t => filterClassifies.Contains(t.Classify)); } // 按交易类型筛选 query = query.WhereIf(type.HasValue, t => t.Type == type!.Value); // 按年月筛选 if (year.HasValue && month.HasValue) { var dateStart = new DateTime(year.Value, month.Value, 1); var dateEnd = dateStart.AddMonths(1); query = query.Where(t => t.OccurredAt >= dateStart && t.OccurredAt < dateEnd); } // 按日期范围筛选 query = query.WhereIf(startDate.HasValue, t => t.OccurredAt >= startDate!.Value) .WhereIf(endDate.HasValue, t => t.OccurredAt <= endDate!.Value); // 根据sortByAmount参数决定排序方式 if (sortByAmount) { // 按金额降序排列 return await query .OrderByDescending(t => t.Amount) .OrderByDescending(t => t.Id) .Page(pageIndex, pageSize) .ToListAsync(); } else { // 按时间降序排列 return await query .OrderByDescending(t => t.OccurredAt) .OrderByDescending(t => t.Id) .Page(pageIndex, pageSize) .ToListAsync(); } } public async Task GetTotalCountAsync( string? searchKeyword = null, string[]? classifies = null, TransactionType? type = null, int? year = null, int? month = null, DateTime? startDate = null, DateTime? endDate = null, string? reason = null) { var query = FreeSql.Select(); // 如果提供了搜索关键词,则添加搜索条件 query = query.WhereIf(!string.IsNullOrWhiteSpace(searchKeyword), t => t.Reason.Contains(searchKeyword!) || t.Classify.Contains(searchKeyword!) || t.Card.Contains(searchKeyword!) || t.ImportFrom.Contains(searchKeyword!)) .WhereIf(!string.IsNullOrWhiteSpace(reason), t => t.Reason == reason); // 按分类筛选 if (classifies != null && classifies.Length > 0) { var filterClassifies = classifies.Select(c => c == "未分类" ? string.Empty : c).ToList(); query = query.Where(t => filterClassifies.Contains(t.Classify)); } // 按交易类型筛选 query = query.WhereIf(type.HasValue, t => t.Type == type!.Value); // 按年月筛选 if (year.HasValue && month.HasValue) { var dateStart = new DateTime(year.Value, month.Value, 1); var dateEnd = dateStart.AddMonths(1); query = query.Where(t => t.OccurredAt >= dateStart && t.OccurredAt < dateEnd); } // 按日期范围筛选 query = query.WhereIf(startDate.HasValue, t => t.OccurredAt >= startDate!.Value) .WhereIf(endDate.HasValue, t => t.OccurredAt <= endDate!.Value); return await query.CountAsync(); } public async Task> GetDistinctClassifyAsync() { return await FreeSql.Select() .Where(t => !string.IsNullOrEmpty(t.Classify)) .Distinct() .ToListAsync(t => t.Classify); } public async Task> GetDailyStatisticsAsync(int year, int month, string? savingClassify = null) { var startDate = new DateTime(year, month, 1); var endDate = startDate.AddMonths(1); return await GetDailyStatisticsByRangeAsync(startDate, endDate, savingClassify); } public async Task> GetDailyStatisticsByRangeAsync(DateTime startDate, DateTime endDate, string? savingClassify = null) { var records = await FreeSql.Select() .Where(t => t.OccurredAt >= startDate && t.OccurredAt < endDate) .ToListAsync(); var statistics = 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); } ); return statistics; } public async Task> GetByDateRangeAsync(DateTime startDate, DateTime endDate) { return await FreeSql.Select() .Where(t => t.OccurredAt >= startDate && t.OccurredAt <= endDate) .OrderBy(t => t.OccurredAt) .ToListAsync(); } public async Task GetCountByEmailIdAsync(long emailMessageId) { return (int)await FreeSql.Select() .Where(t => t.EmailMessageId == emailMessageId) .CountAsync(); } public async Task> GetByEmailIdAsync(long emailMessageId) { return await FreeSql.Select() .Where(t => t.EmailMessageId == emailMessageId) .OrderBy(t => t.OccurredAt) .ToListAsync(); } public async Task GetUnclassifiedCountAsync() { return (int)await FreeSql.Select() .Where(t => string.IsNullOrEmpty(t.Classify)) .CountAsync(); } public async Task> GetUnclassifiedAsync(int pageSize = 10) { return await FreeSql.Select() .Where(t => string.IsNullOrEmpty(t.Classify)) .OrderByDescending(t => t.OccurredAt) .Page(1, pageSize) .ToListAsync(); } public async Task<(List list, int total)> GetReasonGroupsAsync(int pageIndex = 1, int pageSize = 20) { // 先按照Reason分组,统计每个Reason的数量和总金额 var groups = await FreeSql.Select() .Where(t => !string.IsNullOrEmpty(t.Reason)) .Where(t => string.IsNullOrEmpty(t.Classify)) // 只统计未分类的 .GroupBy(t => t.Reason) .ToListAsync(g => new { Reason = g.Key, Count = g.Count(), TotalAmount = g.Sum(g.Value.Amount) }); // 按总金额绝对值降序排序 var sortedGroups = groups.OrderByDescending(g => Math.Abs(g.TotalAmount)).ToList(); var total = sortedGroups.Count; // 分页 var pagedGroups = sortedGroups .Skip((pageIndex - 1) * pageSize) .Take(pageSize) .ToList(); // 为每个分组获取详细信息 var result = new List(); foreach (var group in pagedGroups) { // 获取该分组的所有记录 var records = await FreeSql.Select() .Where(t => t.Reason == group.Reason) .Where(t => string.IsNullOrEmpty(t.Classify)) .ToListAsync(); if (records.Count > 0) { var sample = records.First(); result.Add(new ReasonGroupDto { Reason = group.Reason, Count = (int)group.Count, SampleType = sample.Type, SampleClassify = sample.Classify ?? string.Empty, TransactionIds = records.Select(r => r.Id).ToList(), TotalAmount = Math.Abs(group.TotalAmount) }); } } return (result, total); } public async Task BatchUpdateByReasonAsync(string reason, TransactionType type, string classify) { return await FreeSql.Update() .Set(t => t.Type, type) .Set(t => t.Classify, classify) .Where(t => t.Reason == reason) .ExecuteAffrowsAsync(); } public async Task> QueryByWhereAsync(string sql) { return await FreeSql.Select() .Where(sql) .OrderByDescending(t => t.OccurredAt) .ToListAsync(); } public async Task> ExecuteRawSqlAsync(string completeSql) { return await FreeSql.Ado.QueryAsync(completeSql); } public async Task GetMonthlyStatisticsAsync(int year, int month) { var startDate = new DateTime(year, month, 1); var endDate = startDate.AddMonths(1); var records = await FreeSql.Select() .Where(t => t.OccurredAt >= startDate && t.OccurredAt < endDate) .ToListAsync(); 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 startDate = new DateTime(year, month, 1); var endDate = startDate.AddMonths(1); var records = await FreeSql.Select() .Where(t => t.OccurredAt >= startDate && t.OccurredAt < endDate && t.Type == type) .ToListAsync(); 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 (int i = 0; i < monthCount; i++) { var targetYear = startYear; var targetMonth = startMonth + i; // 处理月份溢出 while (targetMonth > 12) { targetMonth -= 12; targetYear++; } var startDate = new DateTime(targetYear, targetMonth, 1); var endDate = startDate.AddMonths(1); var records = await FreeSql.Select() .Where(t => t.OccurredAt >= startDate && t.OccurredAt < endDate) .ToListAsync(); 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> GetClassifiedByKeywordsAsync(List keywords, int limit = 10) { if (keywords == null || keywords.Count == 0) { return new List(); } var query = FreeSql.Select() .Where(t => t.Classify != ""); // 只查询已分类的账单 // 构建OR条件:Reason包含任意一个关键词 if (keywords.Count > 0) { query = query.Where(t => keywords.Any(keyword => t.Reason.Contains(keyword))); } return await query .OrderByDescending(t => t.OccurredAt) .Limit(limit) .ToListAsync(); } public async Task> GetClassifiedByKeywordsWithScoreAsync(List keywords, double minMatchRate = 0.3, int limit = 10) { if (keywords == null || keywords.Count == 0) { return new List<(TransactionRecord, double)>(); } // 查询所有已分类且包含任意关键词的账单 var candidates = await FreeSql.Select() .Where(t => t.Classify != "") .Where(t => keywords.Any(keyword => t.Reason.Contains(keyword))) .ToListAsync(); // 计算每个候选账单的相关度分数 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> GetCandidatesForOffsetAsync(long currentId, decimal amount, TransactionType currentType) { var absAmount = Math.Abs(amount); var minAmount = absAmount - 5; var maxAmount = absAmount + 5; var currentRecord = await FreeSql.Select() .Where(t => t.Id == currentId) .FirstAsync(); if (currentRecord == null) { return new List(); } var list = await FreeSql.Select() .Where(t => t.Id != currentId) .Where(t => t.Type != currentType) .Where(t => Math.Abs(t.Amount) >= minAmount && Math.Abs(t.Amount) <= maxAmount) .Take(50) .ToListAsync(); return list.OrderBy(t => Math.Abs(Math.Abs(t.Amount) - absAmount)) .ThenBy(x=> Math.Abs((x.OccurredAt - currentRecord.OccurredAt).TotalSeconds)) .ToList(); } public async Task UpdateCategoryNameAsync(string oldName, string newName, TransactionType type) { return await FreeSql.Update() .Set(a => a.Classify, newName) .Where(a => a.Classify == oldName && a.Type == type) .ExecuteAffrowsAsync(); } public async Task> GetUnconfirmedRecordsAsync() { return await FreeSql.Select() .Where(t => t.UnconfirmedClassify != null && t.UnconfirmedClassify != "") .OrderByDescending(t => t.OccurredAt) .ToListAsync(); } public async Task ConfirmAllUnconfirmedAsync(long[] ids) { return await FreeSql.Update() .Set(t => t.Classify == t.UnconfirmedClassify) .Set(t => t.Type == (t.UnconfirmedType ?? t.Type)) .Set(t => t.UnconfirmedClassify, null) .Set(t => t.UnconfirmedType, null) .Where(t => t.UnconfirmedClassify != null && t.UnconfirmedClassify != "") .Where(t => ids.Contains(t.Id)) .ExecuteAffrowsAsync(); } } /// /// 按Reason分组统计DTO /// public class 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; /// /// 该分组的所有账单ID列表 /// public List TransactionIds { get; set; } = new(); /// /// 该分组的总金额(绝对值) /// public decimal TotalAmount { get; set; } } /// /// 月度统计数据 /// public class 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 class CategoryStatistics { public string Classify { get; set; } = string.Empty; public decimal Amount { get; set; } public int Count { get; set; } public decimal Percent { get; set; } } /// /// 趋势统计数据 /// public class 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; } }