重构: 将 LogCleanupService 转为 Quartz Job 服务
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 22s
Docker Build & Deploy / Deploy to Production (push) Successful in 7s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 22s
Docker Build & Deploy / Deploy to Production (push) Successful in 7s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
- 创建 LogCleanupJob 替代 LogCleanupService (BackgroundService) - 在 Expand.cs 中注册 LogCleanupJob (每天凌晨2点执行, 保留30天日志) - 从 Program.cs 移除 LogCleanupService 的 HostedService 注册 - 删除 Service/LogCleanupService.cs - 删除 Service/PeriodicBillBackgroundService.cs (已无用的重复服务) 所有后台任务现在统一通过 Quartz.NET 管理, 支持运行时控制
This commit is contained in:
319
Service/Transaction/TransactionPeriodicService.cs
Normal file
319
Service/Transaction/TransactionPeriodicService.cs
Normal file
@@ -0,0 +1,319 @@
|
||||
namespace Service.Transaction;
|
||||
|
||||
/// <summary>
|
||||
/// 周期性账单服务接口
|
||||
/// </summary>
|
||||
public interface ITransactionPeriodicService
|
||||
{
|
||||
/// <summary>
|
||||
/// 执行周期性账单检查和生成
|
||||
/// </summary>
|
||||
Task ExecutePeriodicBillsAsync();
|
||||
|
||||
/// <summary>
|
||||
/// 计算下次执行时间
|
||||
/// </summary>
|
||||
DateTime? CalculateNextExecuteTime(TransactionPeriodic periodic, DateTime baseTime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 周期性账单服务实现
|
||||
/// </summary>
|
||||
public class TransactionPeriodicService(
|
||||
ITransactionPeriodicRepository periodicRepository,
|
||||
ITransactionRecordRepository transactionRepository,
|
||||
IMessageRecordRepository messageRepository,
|
||||
ILogger<TransactionPeriodicService> logger
|
||||
) : ITransactionPeriodicService
|
||||
{
|
||||
public async Task ExecutePeriodicBillsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
logger.LogInformation("开始执行周期性账单检查...");
|
||||
|
||||
var pendingBills = await periodicRepository.GetPendingPeriodicBillsAsync();
|
||||
var billsList = pendingBills.ToList();
|
||||
|
||||
logger.LogInformation("找到 {Count} 条需要执行的周期性账单", billsList.Count);
|
||||
|
||||
foreach (var bill in billsList)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 检查是否满足执行条件
|
||||
if (!ShouldExecuteToday(bill))
|
||||
{
|
||||
logger.LogInformation("周期性账单 {Id} 今天不需要执行", bill.Id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 创建交易记录
|
||||
var transaction = new TransactionRecord
|
||||
{
|
||||
Amount = bill.Amount,
|
||||
Type = bill.Type,
|
||||
Classify = bill.Classify,
|
||||
Reason = bill.Reason,
|
||||
OccurredAt = DateTime.Now,
|
||||
Card = "周期性账单",
|
||||
ImportFrom = "周期性账单自动生成"
|
||||
};
|
||||
|
||||
var success = await transactionRepository.AddAsync(transaction);
|
||||
|
||||
if (success)
|
||||
{
|
||||
logger.LogInformation("成功创建周期性账单交易记录: {Reason}, 金额: {Amount}",
|
||||
bill.Reason, bill.Amount);
|
||||
|
||||
// 创建未读消息
|
||||
var message = new MessageRecord
|
||||
{
|
||||
Title = "周期性账单提醒",
|
||||
Content = $"已自动生成{(bill.Type == TransactionType.Expense ? "支出" : "收入")}账单:{bill.Reason},金额:{bill.Amount:F2}元",
|
||||
IsRead = false
|
||||
};
|
||||
await messageRepository.AddAsync(message);
|
||||
|
||||
// 更新执行时间
|
||||
var now = DateTime.Now;
|
||||
var nextTime = CalculateNextExecuteTime(bill, now);
|
||||
await periodicRepository.UpdateExecuteTimeAsync(bill.Id, now, nextTime);
|
||||
|
||||
logger.LogInformation("周期性账单 {Id} 下次执行时间: {NextTime}",
|
||||
bill.Id, nextTime?.ToString("yyyy-MM-dd HH:mm:ss") ?? "无");
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogWarning("创建周期性账单交易记录失败: {BillId}", bill.Id);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "处理周期性账单 {BillId} 时出错", bill.Id);
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation("周期性账单检查执行完成");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "执行周期性账单检查时发生错误");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断今天是否需要执行
|
||||
/// </summary>
|
||||
private bool ShouldExecuteToday(TransactionPeriodic bill)
|
||||
{
|
||||
var today = DateTime.Today;
|
||||
|
||||
// 如果从未执行过,需要执行
|
||||
if (bill.LastExecuteTime == null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// 如果今天已经执行过,不需要再执行
|
||||
if (bill.LastExecuteTime.Value.Date == today)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return bill.PeriodicType switch
|
||||
{
|
||||
PeriodicType.Daily => true, // 每天都执行
|
||||
PeriodicType.Weekly => ShouldExecuteWeekly(bill.PeriodicConfig, today),
|
||||
PeriodicType.Monthly => ShouldExecuteMonthly(bill.PeriodicConfig, today),
|
||||
PeriodicType.Quarterly => ShouldExecuteQuarterly(bill.PeriodicConfig, today),
|
||||
PeriodicType.Yearly => ShouldExecuteYearly(bill.PeriodicConfig, today),
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断是否需要在本周执行
|
||||
/// </summary>
|
||||
private bool ShouldExecuteWeekly(string config, DateTime today)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(config))
|
||||
return false;
|
||||
|
||||
var dayOfWeek = (int)today.DayOfWeek; // 0=Sunday, 1=Monday, ..., 6=Saturday
|
||||
var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(d => int.TryParse(d.Trim(), out var day) ? day : -1)
|
||||
.Where(d => d is >= 0 and <= 6)
|
||||
.ToList();
|
||||
|
||||
return executeDays.Contains(dayOfWeek);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断是否需要在本月执行
|
||||
/// </summary>
|
||||
private bool ShouldExecuteMonthly(string config, DateTime today)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(config))
|
||||
return false;
|
||||
|
||||
var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(d => int.TryParse(d.Trim(), out var day) ? day : -1)
|
||||
.Where(d => d is >= 1 and <= 31)
|
||||
.ToList();
|
||||
|
||||
// 如果当前为月末,且配置中有大于当月天数的日期,则也执行
|
||||
var daysInMonth = DateTime.DaysInMonth(today.Year, today.Month);
|
||||
if (today.Day == daysInMonth && executeDays.Any(d => d > daysInMonth))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return executeDays.Contains(today.Day);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断是否需要在本季度执行
|
||||
/// </summary>
|
||||
private bool ShouldExecuteQuarterly(string config, DateTime today)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(config) || !int.TryParse(config, out var dayOfQuarter))
|
||||
return false;
|
||||
|
||||
// 计算当前是本季度的第几天
|
||||
var quarterStartMonth = (today.Month - 1) / 3 * 3 + 1;
|
||||
var quarterStart = new DateTime(today.Year, quarterStartMonth, 1);
|
||||
var daysSinceQuarterStart = (today - quarterStart).Days + 1;
|
||||
|
||||
return daysSinceQuarterStart == dayOfQuarter;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断是否需要在本年执行
|
||||
/// </summary>
|
||||
private bool ShouldExecuteYearly(string config, DateTime today)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(config) || !int.TryParse(config, out var dayOfYear))
|
||||
return false;
|
||||
|
||||
return today.DayOfYear == dayOfYear;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 计算下次执行时间
|
||||
/// </summary>
|
||||
public DateTime? CalculateNextExecuteTime(TransactionPeriodic periodic, DateTime baseTime)
|
||||
{
|
||||
return periodic.PeriodicType switch
|
||||
{
|
||||
PeriodicType.Daily => baseTime.Date.AddDays(1),
|
||||
PeriodicType.Weekly => CalculateNextWeekly(periodic.PeriodicConfig, baseTime),
|
||||
PeriodicType.Monthly => CalculateNextMonthly(periodic.PeriodicConfig, baseTime),
|
||||
PeriodicType.Quarterly => CalculateNextQuarterly(periodic.PeriodicConfig, baseTime),
|
||||
PeriodicType.Yearly => CalculateNextYearly(periodic.PeriodicConfig, baseTime),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private DateTime? CalculateNextWeekly(string config, DateTime baseTime)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(config))
|
||||
return null;
|
||||
|
||||
var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(d => int.TryParse(d.Trim(), out var day) ? day : -1)
|
||||
.Where(d => d is >= 0 and <= 6)
|
||||
.OrderBy(d => d)
|
||||
.ToList();
|
||||
|
||||
if (!executeDays.Any())
|
||||
return null;
|
||||
|
||||
var currentDayOfWeek = (int)baseTime.DayOfWeek;
|
||||
|
||||
// 找下一个执行日
|
||||
var nextDay = executeDays.FirstOrDefault(d => d > currentDayOfWeek);
|
||||
if (nextDay > 0)
|
||||
{
|
||||
var daysToAdd = nextDay - currentDayOfWeek;
|
||||
return baseTime.Date.AddDays(daysToAdd);
|
||||
}
|
||||
|
||||
// 下周的第一个执行日
|
||||
var firstDay = executeDays.First();
|
||||
var daysUntilNextWeek = 7 - currentDayOfWeek + firstDay;
|
||||
return baseTime.Date.AddDays(daysUntilNextWeek);
|
||||
}
|
||||
|
||||
private DateTime? CalculateNextMonthly(string config, DateTime baseTime)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(config))
|
||||
return null;
|
||||
|
||||
var executeDays = config.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(d => int.TryParse(d.Trim(), out var day) ? day : -1)
|
||||
.Where(d => d is >= 1 and <= 31)
|
||||
.OrderBy(d => d)
|
||||
.ToList();
|
||||
|
||||
if (!executeDays.Any())
|
||||
return null;
|
||||
|
||||
// 找本月的下一个执行日
|
||||
var nextDay = executeDays.FirstOrDefault(d => d > baseTime.Day);
|
||||
if (nextDay > 0)
|
||||
{
|
||||
var daysInMonth = DateTime.DaysInMonth(baseTime.Year, baseTime.Month);
|
||||
if (nextDay <= daysInMonth)
|
||||
{
|
||||
return new DateTime(baseTime.Year, baseTime.Month, nextDay);
|
||||
}
|
||||
}
|
||||
|
||||
// 下个月的第一个执行日
|
||||
var nextMonth = baseTime.AddMonths(1);
|
||||
var firstDay = executeDays.First();
|
||||
var daysInNextMonth = DateTime.DaysInMonth(nextMonth.Year, nextMonth.Month);
|
||||
var actualDay = Math.Min(firstDay, daysInNextMonth);
|
||||
return new DateTime(nextMonth.Year, nextMonth.Month, actualDay);
|
||||
}
|
||||
|
||||
private DateTime? CalculateNextQuarterly(string config, DateTime baseTime)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(config) || !int.TryParse(config, out var dayOfQuarter))
|
||||
return null;
|
||||
|
||||
// 计算下一个季度的开始时间
|
||||
var currentQuarterStartMonth = ((baseTime.Month - 1) / 3) * 3 + 1;
|
||||
var nextQuarterStartMonth = currentQuarterStartMonth + 3;
|
||||
var nextQuarterYear = baseTime.Year;
|
||||
|
||||
if (nextQuarterStartMonth > 12)
|
||||
{
|
||||
nextQuarterStartMonth = 1;
|
||||
nextQuarterYear++;
|
||||
}
|
||||
|
||||
var nextQuarterStart = new DateTime(nextQuarterYear, nextQuarterStartMonth, 1);
|
||||
return nextQuarterStart.AddDays(dayOfQuarter - 1);
|
||||
}
|
||||
|
||||
private DateTime? CalculateNextYearly(string config, DateTime baseTime)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(config) || !int.TryParse(config, out var dayOfYear))
|
||||
return null;
|
||||
|
||||
var nextYear = baseTime.Year;
|
||||
if (baseTime.DayOfYear >= dayOfYear)
|
||||
{
|
||||
nextYear++;
|
||||
}
|
||||
|
||||
// 处理闰年情况
|
||||
var daysInYear = DateTime.IsLeapYear(nextYear) ? 366 : 365;
|
||||
var actualDay = Math.Min(dayOfYear, daysInYear);
|
||||
|
||||
return new DateTime(nextYear, 1, 1).AddDays(actualDay - 1);
|
||||
}
|
||||
}
|
||||
336
Service/Transaction/TransactionStatisticsService.cs
Normal file
336
Service/Transaction/TransactionStatisticsService.cs
Normal file
@@ -0,0 +1,336 @@
|
||||
namespace Service.Transaction;
|
||||
|
||||
public interface ITransactionStatisticsService
|
||||
{
|
||||
Task<Dictionary<string, (int count, decimal expense, decimal income, decimal saving)>> GetDailyStatisticsAsync(int year, int month, string? savingClassify = null);
|
||||
|
||||
Task<Dictionary<string, (int count, decimal expense, decimal income, decimal saving)>> GetDailyStatisticsByRangeAsync(DateTime startDate, DateTime endDate, string? savingClassify = null);
|
||||
|
||||
Task<MonthlyStatistics> GetMonthlyStatisticsAsync(int year, int month);
|
||||
|
||||
Task<List<CategoryStatistics>> GetCategoryStatisticsAsync(int year, int month, TransactionType type);
|
||||
|
||||
Task<List<TrendStatistics>> GetTrendStatisticsAsync(int startYear, int startMonth, int monthCount);
|
||||
|
||||
Task<(List<ReasonGroupDto> list, int total)> GetReasonGroupsAsync(int pageIndex = 1, int pageSize = 20);
|
||||
|
||||
Task<List<(TransactionRecord record, double relevanceScore)>> GetClassifiedByKeywordsWithScoreAsync(List<string> keywords, double minMatchRate = 0.3, int limit = 10);
|
||||
|
||||
Task<Dictionary<DateTime, decimal>> GetFilteredTrendStatisticsAsync(
|
||||
DateTime startDate,
|
||||
DateTime endDate,
|
||||
TransactionType type,
|
||||
IEnumerable<string> classifies,
|
||||
bool groupByMonth = false);
|
||||
|
||||
Task<Dictionary<(string, TransactionType), decimal>> GetAmountGroupByClassifyAsync(DateTime startTime, DateTime endTime);
|
||||
}
|
||||
|
||||
public class TransactionStatisticsService(
|
||||
ITransactionRecordRepository transactionRepository
|
||||
) : ITransactionStatisticsService
|
||||
{
|
||||
public async Task<Dictionary<string, (int count, decimal expense, decimal income, decimal saving)>> 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<Dictionary<string, (int count, decimal expense, decimal income, decimal saving)>> 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<MonthlyStatistics> 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<List<CategoryStatistics>> 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<List<TrendStatistics>> GetTrendStatisticsAsync(int startYear, int startMonth, int monthCount)
|
||||
{
|
||||
var trends = new List<TrendStatistics>();
|
||||
|
||||
for (int 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<ReasonGroupDto> 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<List<(TransactionRecord record, double relevanceScore)>> GetClassifiedByKeywordsWithScoreAsync(List<string> 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<Dictionary<DateTime, decimal>> GetFilteredTrendStatisticsAsync(
|
||||
DateTime startDate,
|
||||
DateTime endDate,
|
||||
TransactionType type,
|
||||
IEnumerable<string> 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<Dictionary<(string, TransactionType), decimal>> 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<long> 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; }
|
||||
}
|
||||
Reference in New Issue
Block a user