namespace Service; public interface IBudgetService { Task> GetListAsync(DateTime referenceDate); Task ArchiveBudgetsAsync(int year, int month); /// /// 获取指定分类的统计信息(月度和年度) /// Task GetCategoryStatsAsync(BudgetCategory category, DateTime referenceDate); /// /// 获取未被预算覆盖的分类统计信息 /// Task> GetUncoveredCategoriesAsync(BudgetCategory category, DateTime? referenceDate = null); Task GetArchiveSummaryAsync(int year, int month); Task UpdateArchiveSummaryAsync(int year, int month, string? summary); } public class BudgetService( IBudgetRepository budgetRepository, IBudgetArchiveRepository budgetArchiveRepository, ITransactionRecordRepository transactionRecordRepository, IOpenAiService openAiService, IConfigService configService, IMessageService messageService, ILogger logger ) : IBudgetService { public async Task> GetListAsync(DateTime referenceDate) { var year = referenceDate.Year; var month = referenceDate.Month; var isArchive = year < DateTime.Now.Year || (year == DateTime.Now.Year && month < DateTime.Now.Month); if (isArchive) { var archive = await budgetArchiveRepository.GetArchiveAsync(year, month); if (archive != null) { var periodRange = GetPeriodRange(DateTime.Now, BudgetPeriodType.Month, referenceDate); return archive.Content.Select(c => new BudgetResult { Name = c.Name, Type = c.Type, Limit = c.Limit, Current = c.Actual, Category = c.Category, SelectedCategories = c.SelectedCategories, Description = c.Description, PeriodStart = periodRange.start, PeriodEnd = periodRange.end, }).ToList(); } logger.LogWarning("获取预算列表时发现归档数据缺失,Year: {Year}, Month: {Month}", year, month); } var budgets = await budgetRepository.GetAllAsync(); var dtos = new List(); foreach (var budget in budgets) { var currentAmount = await CalculateCurrentAmountAsync(budget, referenceDate); dtos.Add(BudgetResult.FromEntity(budget, currentAmount, referenceDate)); } // 创造虚拟的存款预算 dtos.Add(await GetVirtualSavingsDtoAsync( BudgetPeriodType.Month, referenceDate, budgets)); dtos.Add(await GetVirtualSavingsDtoAsync( BudgetPeriodType.Year, referenceDate, budgets)); return dtos.Where(dto => dto != null).Cast().ToList(); } public async Task GetCategoryStatsAsync(BudgetCategory category, DateTime referenceDate) { var budgets = await GetListAsync(referenceDate); var result = new BudgetCategoryStats(); // 获取月度统计 result.Month = await CalculateCategoryStatsAsync(budgets, category, BudgetPeriodType.Month, referenceDate); // 获取年度统计 result.Year = await CalculateCategoryStatsAsync(budgets, category, BudgetPeriodType.Year, referenceDate); return result; } public async Task> GetUncoveredCategoriesAsync(BudgetCategory category, DateTime? referenceDate = null) { var date = referenceDate ?? DateTime.Now; var transactionType = category switch { BudgetCategory.Expense => TransactionType.Expense, BudgetCategory.Income => TransactionType.Income, _ => TransactionType.None }; if (transactionType == TransactionType.None) return new List(); // 1. 获取所有预算 var budgets = (await budgetRepository.GetAllAsync()).ToList(); var coveredCategories = budgets .Where(b => b.Category == category) .SelectMany(b => b.SelectedCategories.Split(',', StringSplitOptions.RemoveEmptyEntries)) .ToHashSet(); // 2. 获取分类统计 var stats = await transactionRecordRepository.GetCategoryStatisticsAsync(date.Year, date.Month, transactionType); // 3. 过滤未覆盖的 return stats .Where(s => !coveredCategories.Contains(s.Classify)) .Select(s => new UncoveredCategoryDetail { Category = s.Classify, TransactionCount = s.Count, TotalAmount = s.Amount }) .OrderByDescending(x => x.TotalAmount) .ToList(); } public async Task GetArchiveSummaryAsync(int year, int month) { var archive = await budgetArchiveRepository.GetArchiveAsync(year, month); return archive?.Summary; } public async Task UpdateArchiveSummaryAsync(int year, int month, string? summary) { var archive = await budgetArchiveRepository.GetArchiveAsync(year, month); if (archive == null) { await ArchiveBudgetsAsync(year, month); archive = await budgetArchiveRepository.GetArchiveAsync(year, month); } if (archive != null) { archive.Summary = summary; await budgetArchiveRepository.UpdateAsync(archive); } } private async Task CalculateCategoryStatsAsync( List budgets, BudgetCategory category, BudgetPeriodType statType, DateTime referenceDate) { var result = new BudgetStatsDto { PeriodType = statType, Rate = 0, Current = 0, Limit = 0, Count = 0 }; // 获取当前分类下所有预算 var relevant = budgets .Where(b => b.Category == category) .ToList(); if (relevant.Count == 0) { return result; } result.Count = relevant.Count; decimal totalCurrent = 0; decimal totalLimit = 0; foreach (var budget in relevant) { // 限额折算 var itemLimit = budget.Limit; if (statType == BudgetPeriodType.Month && budget.Type == BudgetPeriodType.Year) { // 月度视图下,年度预算不参与限额计算 itemLimit = 0; } else if (statType == BudgetPeriodType.Year && budget.Type == BudgetPeriodType.Month) { // 年度视图下,月度预算折算为年度 itemLimit = budget.Limit * 12; } totalLimit += itemLimit; // 当前值累加 var currentAmount = await CalculateCurrentAmountAsync(new() { Name = budget.Name, Type = budget.Type, Limit = budget.Limit, Category = budget.Category, SelectedCategories = string.Join(',', budget.SelectedCategories), StartDate = new DateTime(referenceDate.Year, referenceDate.Month, 1) }, referenceDate); if (budget.Type == statType) { totalCurrent += currentAmount; } else { // 如果周期不匹配 if (statType == BudgetPeriodType.Year && budget.Type == BudgetPeriodType.Month) { // 在年度视图下,月度预算计入其当前值(作为对年度目前的贡献) totalCurrent += currentAmount; } // 月度视图下,年度预算的 current 不计入 } } result.Limit = totalLimit; result.Current = totalCurrent; result.Rate = totalLimit > 0 ? totalCurrent / totalLimit * 100 : 0; return result; } public async Task ArchiveBudgetsAsync(int year, int month) { var referenceDate = new DateTime(year, month, 1); var budgets = await GetListAsync(referenceDate); var content = budgets.Select(b => new BudgetArchiveContent { Name = b.Name, Type = b.Type, Limit = b.Limit, Actual = b.Current, Category = b.Category, SelectedCategories = b.SelectedCategories, Description = b.Description }).ToArray(); var archive = await budgetArchiveRepository.GetArchiveAsync(year, month); if (archive != null) { archive.Content = content; archive.ArchiveDate = DateTime.Now; if (!await budgetArchiveRepository.UpdateAsync(archive)) { return "更新预算归档失败"; } } else { archive = new BudgetArchive { Year = year, Month = month, Content = content, ArchiveDate = DateTime.Now }; if (!await budgetArchiveRepository.AddAsync(archive)) { return "保存预算归档失败"; } } _ = NotifyAsync(year, month); return string.Empty; } private async Task NotifyAsync(int year, int month) { try { var archives = await budgetArchiveRepository.GetListAsync(year, month); var archiveData = archives.SelectMany(a => a.Content.Select(c => new { c.Name, Type = c.Type.ToString(), c.Limit, c.Actual, Category = c.Category.ToString(), c.SelectedCategories })).ToList(); var yearTransactions = await transactionRecordRepository.ExecuteDynamicSqlAsync( $""" SELECT COUNT(*) AS TransactionCount, SUM(ABS(Amount)) AS TotalAmount, Type, Classify FROM TransactionRecord WHERE OccurredAt >= '{year}-01-01' AND OccurredAt < '{year + 1}-01-01' GROUP BY Type, Classify ORDER BY TotalAmount DESC """ ); var monthYear = new DateTime(year, month, 1).AddMonths(1); var monthTransactions = await transactionRecordRepository.ExecuteDynamicSqlAsync( $""" SELECT COUNT(*) AS TransactionCount, SUM(ABS(Amount)) AS TotalAmount, Type, Classify FROM TransactionRecord WHERE OccurredAt >= '{year}-{month:00}-01' AND OccurredAt < '{monthYear:yyyy-MM-dd}' GROUP BY Type, Classify ORDER BY TotalAmount DESC """ ); // 分析未被预算覆盖的分类 (仅针对支出类型 Type=0) var budgetedCategories = archiveData .SelectMany(b => b.SelectedCategories) .Where(c => !string.IsNullOrEmpty(c)) .Distinct() .ToHashSet(); var uncovered = monthTransactions .Where(t => { var dict = (IDictionary)t; var classify = dict["Classify"]?.ToString() ?? ""; var type = Convert.ToInt32(dict["Type"]); return type == 0 && !budgetedCategories.Contains(classify); }) .ToList(); logger.LogInformation("预算执行数据{JSON}", JsonSerializer.Serialize(archiveData)); logger.LogInformation("本月消费明细{JSON}", JsonSerializer.Serialize(monthTransactions)); logger.LogInformation("全年累计消费概况{JSON}", JsonSerializer.Serialize(yearTransactions)); logger.LogInformation("未被预算覆盖的分类{JSON}", JsonSerializer.Serialize(uncovered)); var dataPrompt = $""" 报告周期:{year}年{month}月 账单数据说明:支出金额已取绝对值(TotalAmount 为正数表示支出/收入的总量)。 1. 预算执行数据(JSON): {JsonSerializer.Serialize(archiveData)} 2. 本月消费明细(按分类, JSON): {JsonSerializer.Serialize(monthTransactions)} 3. 全年累计消费概况(按分类, JSON): {JsonSerializer.Serialize(yearTransactions)} 4. 未被任何预算覆盖的支出分类(JSON): {JsonSerializer.Serialize(uncovered)} 请生成一份专业且美观的预算执行分析报告,严格遵守以下要求: 【内容要求】 1. 概览:总结本月预算达成情况。 2. 预算详情:使用 HTML 表格展示预算执行明细(预算项、预算额、实际额、使用/达成率、状态)。 3. 超支/异常预警:重点分析超支项或支出异常的分类。 4. 消费透视:针对“未被预算覆盖的支出”提供分析建议。分析这些账单产生的合理性,并评估是否需要为其中的大额或频发分类建立新预算。 5. 改进建议:根据当前时间进度和预算完成进度,基于本月整体收入支出情况,给出下月预算调整或消费改进的专业化建议。 6. 语言风格:专业、清晰、简洁,适合财务报告阅读。 7. 【格式要求】 1. 使用HTML格式(移动端H5页面风格) 2. 生成清晰的报告标题(基于用户问题) 3. 使用表格展示统计数据(table > thead/tbody > tr > th/td), 3.1 table要求不能超过屏幕宽度,尽可能简洁明了,避免冗余信息 3.2 预算金额精确到整数即可,实际金额精确到小数点后1位 4. 使用合适的HTML标签:h2(标题)、h3(小节)、p(段落)、table(表格)、ul/li(列表)、strong(强调) 5. 支出金额用 金额 包裹 6. 收入金额用 金额 包裹 7. 重要结论用 内容 高亮 【样式限制(重要)】 8. 不要包含 html、body、head 标签 9. 不要使用任何 style 属性或