namespace Service.Budget; public interface IBudgetStatsService { /// /// 获取指定分类的统计信息(月度和年度) /// Task GetCategoryStatsAsync(BudgetCategory category, DateTime referenceDate); } [UsedImplicitly] public class BudgetStatsService( IBudgetRepository budgetRepository, IBudgetArchiveRepository budgetArchiveRepository, ITransactionStatisticsService transactionStatisticsService, IDateTimeProvider dateTimeProvider, ILogger logger ) : IBudgetStatsService { public async Task GetCategoryStatsAsync(BudgetCategory category, DateTime referenceDate) { logger.LogInformation("开始计算分类统计信息: Category={Category}, ReferenceDate={ReferenceDate:yyyy-MM-dd}", category, referenceDate); var result = new BudgetCategoryStats(); try { // 获取月度统计 logger.LogDebug("开始计算月度统计"); result.Month = await CalculateMonthlyCategoryStatsAsync(category, referenceDate); logger.LogInformation("月度统计计算完成: Count={Count}, Limit={Limit}, Current={Current}, Rate={Rate:F2}%", result.Month.Count, result.Month.Limit, result.Month.Current, result.Month.Rate); // 获取年度统计 logger.LogDebug("开始计算年度统计"); result.Year = await CalculateYearlyCategoryStatsAsync(category, referenceDate); logger.LogInformation("年度统计计算完成: Count={Count}, Limit={Limit}, Current={Current}, Rate={Rate:F2}%", result.Year.Count, result.Year.Limit, result.Year.Current, result.Year.Rate); logger.LogInformation("分类统计信息计算完成"); } catch (Exception ex) { logger.LogError(ex, "计算分类统计信息时发生错误: Category={Category}, ReferenceDate={ReferenceDate:yyyy-MM-dd}", category, referenceDate); throw; } return result; } private async Task CalculateMonthlyCategoryStatsAsync(BudgetCategory category, DateTime referenceDate) { logger.LogDebug("开始计算月度分类统计: Category={Category}, ReferenceDate={ReferenceDate:yyyy-MM}", category, referenceDate); var result = new BudgetStatsDto { PeriodType = BudgetPeriodType.Month, Rate = 0, Current = 0, Limit = 0, Count = 0 }; // 1. 获取所有预算(包含归档数据) logger.LogDebug("开始获取预算数据(包含归档)"); var budgets = await GetAllBudgetsWithArchiveAsync(category, BudgetPeriodType.Month, referenceDate); logger.LogDebug("获取到 {BudgetCount} 个预算", budgets.Count); if (budgets.Count == 0) { logger.LogDebug("未找到相关预算,返回空结果"); return result; } result.Count = budgets.Count; // 2. 计算限额总值 logger.LogDebug("开始计算限额总值,共 {BudgetCount} 个预算", budgets.Count); decimal totalLimit = 0; int budgetIndex = 0; foreach (var budget in budgets) { budgetIndex++; var itemLimit = CalculateBudgetLimit(budget, BudgetPeriodType.Month, referenceDate); totalLimit += itemLimit; } result.Limit = totalLimit; logger.LogDebug("限额总值计算完成: {TotalLimit}", totalLimit); // 3. 计算当前实际值 // 使用 GetAllBudgetsWithArchiveAsync 中已经计算好的 Current 值 decimal totalCurrent = budgets.Sum(b => b.Current); logger.LogInformation("【实际值来源】累加各预算的实际值: {TotalCurrent}元(共{Count}个预算)", totalCurrent, budgets.Count); var transactionType = category switch { BudgetCategory.Expense => TransactionType.Expense, BudgetCategory.Income => TransactionType.Income, _ => TransactionType.None }; // 计算趋势数据(用于图表展示) var now = dateTimeProvider.Now; var (startDate, endDate) = GetStatPeriodRange(BudgetPeriodType.Month, referenceDate); logger.LogDebug("统计时间段: {StartDate:yyyy-MM-dd} 到 {EndDate:yyyy-MM-dd}", startDate, endDate); if (transactionType != TransactionType.None) { // 获取所有相关分类(用于趋势图表) var allClassifies = budgets .SelectMany(b => b.SelectedCategories) .Distinct() .ToList(); logger.LogDebug("相关分类数量: {ClassifyCount}", allClassifies.Count); // 获取趋势统计数据(仅用于图表展示) logger.LogDebug("开始获取交易趋势统计数据(用于图表)"); var dailyStats = await transactionStatisticsService.GetFilteredTrendStatisticsAsync( startDate, endDate, transactionType, allClassifies, false); logger.LogDebug("获取到 {DayCount} 天的交易数据", dailyStats.Count); // 计算累计值(用于趋势图) decimal accumulated = 0; var daysInMonth = DateTime.DaysInMonth(startDate.Year, startDate.Month); logger.LogDebug("本月天数: {DaysInMonth}", daysInMonth); for (int i = 1; i <= daysInMonth; i++) { var currentDate = new DateTime(startDate.Year, startDate.Month, i); if (currentDate.Date > now.Date) { result.Trend.Add(null); logger.LogTrace("日期 {CurrentDate:yyyy-MM-dd} 为未来日期,趋势数据为 null", currentDate); continue; } if (dailyStats.TryGetValue(currentDate.Date, out var amount)) { accumulated += amount; logger.LogTrace("日期 {CurrentDate:yyyy-MM-dd}: 金额={Amount}, 累计={Accumulated}", currentDate, amount, accumulated); } else { logger.LogTrace("日期 {CurrentDate:yyyy-MM-dd}: 无交易数据,累计={Accumulated}", currentDate, accumulated); } // 对每一天的累计值应用硬性预算调整 var adjustedAccumulated = accumulated; if (transactionType == TransactionType.Expense) { var mandatoryBudgets = budgets.Where(b => b.IsMandatoryExpense).ToList(); // 检查是否为当前月份 bool isCurrentMonth = referenceDate.Year == now.Year && referenceDate.Month == now.Month; if (isCurrentMonth && currentDate.Date <= now.Date) { // 对于每个硬性预算,计算其虚拟消耗并累加 foreach (var budget in mandatoryBudgets) { decimal mandatoryDailyAmount = 0; if (budget.Type == BudgetPeriodType.Month) { // 月度硬性预算按当天的天数比例 mandatoryDailyAmount = budget.Limit * i / daysInMonth; } else if (budget.Type == BudgetPeriodType.Year) { // 年度硬性预算按当天的天数比例 var daysInYear = DateTime.IsLeapYear(referenceDate.Year) ? 366 : 365; var dayOfYear = currentDate.DayOfYear; mandatoryDailyAmount = budget.Limit * dayOfYear / daysInYear; } // 检查该硬性预算当天是否有实际交易记录 // 如果budget.Current为0或很小,说明没有实际交易,需要加上虚拟消耗 // 简化处理:直接检查如果这个硬性预算的实际值小于应有值,就补上差额 var expectedTotal = mandatoryDailyAmount; // 获取这个硬性预算对应的实际交易累计(从accumulated中无法单独提取) // 简化方案:直接添加硬性预算的虚拟值,让其累加到实际支出上 // 但这样会重复计算有交易记录的硬性预算 // 更好的方案:只在硬性预算没有实际交易时才添加虚拟值 // 由于budget.Current已经包含了虚拟消耗(在CalculateCurrentAmountAsync中处理) // 我们需要知道是否有实际交易 // 最简单的方案:如果budget.Current等于虚拟值,说明没有实际交易,累加虚拟值 // 但这在趋势计算中无法判断每一天的情况 // 实际上,正确的做法是: // 1. dailyStats 只包含实际交易 // 2. 对于硬性预算,如果它没有实际交易,需要补充虚拟消耗 // 3. 判断方法:比较当天该预算应有的虚拟值和实际累计值 // 由于我们无法在这里区分某个特定预算的交易, // 使用简化方案:总的实际交易 + 总的硬性预算虚拟消耗的差额 } // 简化实现:计算所有硬性预算的总虚拟消耗 decimal totalMandatoryVirtual = 0; foreach (var budget in mandatoryBudgets) { if (budget.Type == BudgetPeriodType.Month) { totalMandatoryVirtual += budget.Limit * i / daysInMonth; } else if (budget.Type == BudgetPeriodType.Year) { var daysInYear = DateTime.IsLeapYear(referenceDate.Year) ? 366 : 365; var dayOfYear = currentDate.DayOfYear; totalMandatoryVirtual += budget.Limit * dayOfYear / daysInYear; } } // 关键:accumulated是所有预算的实际交易累计(不包含虚拟消耗) // totalMandatoryVirtual是所有硬性预算的虚拟消耗 // 但如果硬性预算有实际交易,accumulated中已经包含了,会重复 // 所以需要:accumulated + (totalMandatoryVirtual - 硬性预算的实际交易部分) // 更简单的理解: // - 如果某个硬性预算本月完全没有交易记录,它的虚拟值应该加到accumulated上 // - 如果某个硬性预算有部分交易记录,应该补齐到虚拟值 // - 实现:取 max(accumulated, totalMandatoryVirtual) 是不对的 // - 正确:accumulated + 硬性预算中没有实际交易的那部分的虚拟值 // 由于无法精确区分,采用近似方案: // 计算所有硬性预算的Current总和,这个值已经包含了虚拟消耗(在CalculateCurrentAmountAsync中处理) decimal totalMandatoryCurrent = budgets .Where(b => b.IsMandatoryExpense) .Sum(b => b.Current); // 计算非硬性预算的交易累计(这部分在accumulated中) // 但accumulated是所有交易的累计,包括硬性预算的实际交易 // 最终简化方案: // dailyStats包含所有实际交易(包括硬性预算的实际交易) // 对于没有实际交易的硬性预算,它们的虚拟消耗没有在dailyStats中 // 所以:adjustedAccumulated = accumulated + 没有实际交易的硬性预算的虚拟消耗 // 实用方法:每个硬性预算,取 max(它在dailyStats中的累计, 虚拟值) // 但我们无法从dailyStats中提取单个预算的数据 // 终极简化:如果硬性预算的Current值等于虚拟值,说明没有实际交易 // 这种情况下,accumulated中不包含这部分,需要加上虚拟值 // 如果Current值大于虚拟值,说明有实际交易,accumulated中已包含,不需要调整 decimal mandatoryAdjustment = 0; foreach (var budget in mandatoryBudgets) { decimal dailyVirtual = 0; if (budget.Type == BudgetPeriodType.Month) { dailyVirtual = budget.Limit * i / daysInMonth; } else if (budget.Type == BudgetPeriodType.Year) { var daysInYear = DateTime.IsLeapYear(referenceDate.Year) ? 366 : 365; var dayOfYear = currentDate.DayOfYear; dailyVirtual = budget.Limit * dayOfYear / daysInYear; } // 如果budget.Current约等于整月的虚拟值,说明没有实际交易 // 但Current是整个月的,dailyVirtual是到当前天的 // 需要判断该预算是否有实际交易记录 // 简化:假设如果硬性预算的Current等于虚拟值(误差<1元),就没有实际交易 decimal monthlyVirtual = budget.Type == BudgetPeriodType.Month ? budget.Limit * now.Day / daysInMonth : budget.Limit * now.DayOfYear / (DateTime.IsLeapYear(now.Year) ? 366 : 365); if (Math.Abs(budget.Current - monthlyVirtual) < 1) { // 没有实际交易,需要添加虚拟消耗 mandatoryAdjustment += dailyVirtual; logger.LogTrace("日期 {CurrentDate:yyyy-MM-dd}: 硬性预算 {BudgetName} 无实际交易,添加虚拟消耗 {Virtual}", currentDate, budget.Name, dailyVirtual); } } adjustedAccumulated += mandatoryAdjustment; } } result.Trend.Add(adjustedAccumulated); } logger.LogDebug("趋势图数据计算完成(已应用硬性预算调整)"); } else { // 对于非收入/支出分类,趋势图为空 logger.LogDebug("非收入/支出分类,趋势图为空"); } // 对于硬性预算,如果当前月份且实际值为0,需要按时间比例计算 if (transactionType == TransactionType.Expense) { logger.LogDebug("开始应用硬性预算调整,共 {BudgetCount} 个支出预算", budgets.Count); var beforeAdjustment = totalCurrent; totalCurrent = ApplyMandatoryBudgetAdjustment(budgets, totalCurrent, referenceDate, BudgetPeriodType.Month); if (Math.Abs(beforeAdjustment - totalCurrent) > 0.01m) { logger.LogInformation("硬性预算调整完成: 调整前={BeforeAdjustment}, 调整后={AfterAdjustment}, 调整金额={AdjustmentAmount}", beforeAdjustment, totalCurrent, totalCurrent - beforeAdjustment); logger.LogDebug("硬性预算调整算法: 当前月份={ReferenceDate:yyyy-MM}, 硬性预算按天数比例累加计算", referenceDate); } else { logger.LogDebug("硬性预算调整未改变值"); } } result.Current = totalCurrent; logger.LogInformation("【最终确定值】月度实际支出: {TotalCurrent}元(已应用硬性预算调整)", totalCurrent); // 4. 计算使用率 result.Rate = totalLimit > 0 ? result.Current / totalLimit * 100 : 0; logger.LogInformation("【使用率】{Current}元 ÷ {Limit}元 × 100% = {Rate:F2}%", result.Current, totalLimit, result.Rate); // 5. 生成预算明细汇总日志 var budgetDetails = budgets.Select(b => { var limit = CalculateBudgetLimit(b, BudgetPeriodType.Month, referenceDate); var prefix = b.IsArchive ? $"({b.ArchiveMonth}月归档)" : ""; var suffix = b.IsMandatoryExpense ? "[硬性]" : ""; return $"{b.Name}{prefix}{suffix}:{limit}元"; }); var budgetSummary = string.Join(" + ", budgetDetails); logger.LogInformation("【月度预算明细】{BudgetDetails} = {TotalLimit}元", budgetSummary, totalLimit); // 6. 生成 HTML 描述 result.Description = GenerateMonthlyDescription(budgets, totalLimit, totalCurrent, referenceDate, category); logger.LogDebug("月度分类统计计算完成"); return result; } private async Task CalculateYearlyCategoryStatsAsync(BudgetCategory category, DateTime referenceDate) { logger.LogDebug("开始计算年度分类统计: Category={Category}, ReferenceDate={ReferenceDate:yyyy}", category, referenceDate); var result = new BudgetStatsDto { PeriodType = BudgetPeriodType.Year, Rate = 0, Current = 0, Limit = 0, Count = 0 }; // 1. 获取所有预算(包含归档数据) logger.LogDebug("开始获取预算数据(包含归档)"); var budgets = await GetAllBudgetsWithArchiveAsync(category, BudgetPeriodType.Year, referenceDate); logger.LogDebug("获取到 {BudgetCount} 个预算", budgets.Count); if (budgets.Count == 0) { logger.LogDebug("未找到相关预算,返回空结果"); return result; } result.Count = budgets.Count; // 2. 计算限额总值(考虑不限额预算的特殊处理) logger.LogDebug("开始计算年度限额总值,共 {BudgetCount} 个预算", budgets.Count); decimal totalLimit = 0; int budgetIndex = 0; foreach (var budget in budgets) { budgetIndex++; var itemLimit = CalculateBudgetLimit(budget, BudgetPeriodType.Year, referenceDate); logger.LogInformation("年度预算 {BudgetIndex}/{BudgetCount}: {BudgetName} - 原始预算金额: {BudgetLimit}, 当前实际金额: {CurrentAmount}, 预算类型: {BudgetType}, 算法: {Algorithm}", budgetIndex, budgets.Count, budget.Name, budget.Limit, budget.Current, budget.Type == BudgetPeriodType.Month ? "月度预算" : "年度预算", budget.NoLimit ? "不限额预算" : budget.IsMandatoryExpense ? "硬性预算" : "普通预算"); totalLimit += itemLimit; } result.Limit = totalLimit; logger.LogDebug("年度限额总值计算完成: {TotalLimit}", totalLimit); // 3. 计算当前实际值 var transactionType = category switch { BudgetCategory.Expense => TransactionType.Expense, BudgetCategory.Income => TransactionType.Income, _ => TransactionType.None }; logger.LogDebug("交易类型: {TransactionType}", transactionType); // 计算当前实际值,考虑硬性预算的特殊逻辑 decimal totalCurrent = budgets.Sum(b => b.Current); logger.LogInformation("【实际值来源】累加各预算的实际值: {TotalCurrent}元(共{Count}个预算)", totalCurrent, budgets.Count); var now = dateTimeProvider.Now; var (startDate, endDate) = GetStatPeriodRange(BudgetPeriodType.Year, referenceDate); logger.LogDebug("统计时间段: {StartDate:yyyy-MM-dd} 到 {EndDate:yyyy-MM-dd}", startDate, endDate); if (transactionType != TransactionType.None) { // 获取所有相关分类(用于趋势图表) var allClassifies = budgets .SelectMany(b => b.SelectedCategories) .Distinct() .ToList(); logger.LogDebug("相关分类数量: {ClassifyCount}", allClassifies.Count); // 获取趋势统计数据(仅用于图表展示) logger.LogDebug("开始获取交易趋势统计数据(用于图表)"); var dailyStats = await transactionStatisticsService.GetFilteredTrendStatisticsAsync( startDate, endDate, transactionType, allClassifies, true); logger.LogDebug("获取到 {MonthCount} 个月的交易数据", dailyStats.Count); // 计算累计值(用于趋势图) decimal accumulated = 0; for (int i = 1; i <= 12; i++) { var currentMonthDate = new DateTime(startDate.Year, i, 1); if (currentMonthDate.Year > now.Year || (currentMonthDate.Year == now.Year && i > now.Month)) { result.Trend.Add(null); logger.LogTrace("月份 {Month:yyyy-MM} 为未来月份,趋势数据为 null", currentMonthDate); continue; } if (dailyStats.TryGetValue(currentMonthDate, out var amount)) { accumulated += amount; logger.LogTrace("月份 {Month:yyyy-MM}: 金额={Amount}, 累计={Accumulated}", currentMonthDate, amount, accumulated); } else { logger.LogTrace("月份 {Month:yyyy-MM}: 无交易数据,累计={Accumulated}", currentMonthDate, accumulated); } // 对每个月的累计值应用硬性预算调整 var adjustedAccumulated = accumulated; if (transactionType == TransactionType.Expense) { var mandatoryBudgets = budgets.Where(b => b.IsMandatoryExpense).ToList(); // 检查是否为当前年份 bool isCurrentYear = referenceDate.Year == now.Year; if (isCurrentYear && currentMonthDate <= now) { decimal mandatoryAdjustment = 0; foreach (var budget in mandatoryBudgets) { decimal monthlyVirtual = 0; if (budget.Type == BudgetPeriodType.Month) { // 月度硬性预算:如果该月已完成,累加整月;如果是当前月,按天数比例 if (i < now.Month) { monthlyVirtual = budget.Limit * i; } else if (i == now.Month) { var daysInMonth = DateTime.DaysInMonth(now.Year, now.Month); monthlyVirtual = budget.Limit * (i - 1) + (budget.Limit * now.Day / daysInMonth); } } else if (budget.Type == BudgetPeriodType.Year) { var daysInYear = DateTime.IsLeapYear(referenceDate.Year) ? 366 : 365; var lastDayOfMonth = new DateTime(currentMonthDate.Year, i, DateTime.DaysInMonth(currentMonthDate.Year, i)); var dayOfYear = i < now.Month ? lastDayOfMonth.DayOfYear : now.DayOfYear; monthlyVirtual = budget.Limit * dayOfYear / daysInYear; } // 判断该硬性预算是否有实际交易 decimal yearlyVirtual = budget.Type == BudgetPeriodType.Month ? budget.Limit * now.Month + (budget.Limit * now.Day / DateTime.DaysInMonth(now.Year, now.Month)) - budget.Limit : budget.Limit * now.DayOfYear / (DateTime.IsLeapYear(now.Year) ? 366 : 365); if (Math.Abs(budget.Current - yearlyVirtual) < 1) { // 没有实际交易,需要添加虚拟消耗 mandatoryAdjustment += monthlyVirtual; logger.LogTrace("月份 {Month:yyyy-MM}: 硬性预算 {BudgetName} 无实际交易,添加虚拟消耗 {Virtual}", currentMonthDate, budget.Name, monthlyVirtual); } } adjustedAccumulated += mandatoryAdjustment; } } result.Trend.Add(adjustedAccumulated); } logger.LogDebug("趋势图数据计算完成(已应用硬性预算调整)"); } else { // 对于非收入/支出分类,趋势图为空 logger.LogDebug("非收入/支出分类,趋势图为空"); } // 对于硬性预算,如果当前年份且实际值为0,需要按时间比例计算 if (transactionType == TransactionType.Expense) { logger.LogDebug("开始应用年度硬性预算调整,共 {BudgetCount} 个支出预算", budgets.Count); var beforeAdjustment = totalCurrent; totalCurrent = ApplyMandatoryBudgetAdjustment(budgets, totalCurrent, referenceDate, BudgetPeriodType.Year); if (Math.Abs(beforeAdjustment - totalCurrent) > 0.01m) { logger.LogInformation("年度硬性预算调整完成: 调整前={BeforeAdjustment}, 调整后={AfterAdjustment}, 调整金额={AdjustmentAmount}", beforeAdjustment, totalCurrent, totalCurrent - beforeAdjustment); logger.LogDebug("年度硬性预算调整算法: 当前年份={ReferenceDate:yyyy}, 硬性预算按天数比例累加计算", referenceDate); } else { logger.LogDebug("年度硬性预算调整未改变值"); } } result.Current = totalCurrent; logger.LogInformation("【最终确定值】年度实际支出: {TotalCurrent}元(已应用硬性预算调整)", totalCurrent); // 4. 计算使用率 result.Rate = totalLimit > 0 ? result.Current / totalLimit * 100 : 0; logger.LogInformation("【使用率】{Current}元 ÷ {Limit}元 × 100% = {Rate:F2}%", result.Current, totalLimit, result.Rate); // 5. 生成预算明细汇总日志 var budgetDetails = budgets.Select(b => { var limit = CalculateBudgetLimit(b, BudgetPeriodType.Year, referenceDate); var prefix = b.IsArchive ? "(归档)" : b.RemainingMonths > 0 ? $"(剩余{b.RemainingMonths}月)" : ""; var suffix = b.IsMandatoryExpense ? "[硬性]" : ""; return $"{b.Name}{prefix}{suffix}:{limit}元"; }); var budgetSummary = string.Join(" + ", budgetDetails); logger.LogInformation("【年度预算明细】{BudgetDetails} = {TotalLimit}元", budgetSummary, totalLimit); // 6. 生成 HTML 描述 result.Description = GenerateYearlyDescription(budgets, totalLimit, totalCurrent, referenceDate, category); logger.LogDebug("年度分类统计计算完成"); return result; } private async Task> GetAllBudgetsWithArchiveAsync( BudgetCategory category, BudgetPeriodType statType, DateTime referenceDate) { logger.LogDebug("开始获取预算数据: Category={Category}, StatType={StatType}, ReferenceDate={ReferenceDate:yyyy-MM-dd}", category, statType, referenceDate); var result = new List(); var year = referenceDate.Year; var month = referenceDate.Month; var now = dateTimeProvider.Now; // 对于年度统计,需要获取整年的归档数据和当前预算 if (statType == BudgetPeriodType.Year) { logger.LogDebug("年度统计:开始获取整年的预算数据"); // 获取当前有效的预算(用于当前月及未来月) var currentBudgets = await budgetRepository.GetAllAsync(); var currentBudgetsDict = currentBudgets .Where(b => b.Category == category && ShouldIncludeBudget(b, statType)) .ToDictionary(b => b.Id); logger.LogDebug("获取到 {Count} 个当前有效预算", currentBudgetsDict.Count); // 用于跟踪已处理的预算ID,避免重复 var processedBudgetIds = new HashSet(); // 1. 处理历史归档月份(1月到当前月-1) if (referenceDate.Year == now.Year && now.Month > 1) { logger.LogDebug("开始处理历史归档月份: 1月到{Month}月", now.Month - 1); for (int m = 1; m < now.Month; m++) { var archive = await budgetArchiveRepository.GetArchiveAsync(year, m); if (archive != null) { logger.LogDebug("找到{Month}月归档数据,包含 {ItemCount} 个项目", m, archive.Content.Count()); foreach (var item in archive.Content) { if (item.Category == category && ShouldIncludeBudget(item, statType)) { // 对于月度预算,每个月都添加一个归档项 if (item.Type == BudgetPeriodType.Month) { result.Add(new BudgetStatsItem { Id = item.Id, Name = item.Name, Type = item.Type, Limit = item.Limit, Current = item.Actual, Category = item.Category, SelectedCategories = item.SelectedCategories, NoLimit = item.NoLimit, IsMandatoryExpense = item.IsMandatoryExpense, IsArchive = true, ArchiveMonth = m }); logger.LogInformation("添加归档月度预算: {BudgetName} - {Month}月归档, 预算金额: {Limit}, 实际金额: {Actual}", item.Name, m, item.Limit, item.Actual); } // 对于年度预算,只添加一次 else if (item.Type == BudgetPeriodType.Year && !processedBudgetIds.Contains(item.Id)) { processedBudgetIds.Add(item.Id); result.Add(new BudgetStatsItem { Id = item.Id, Name = item.Name, Type = item.Type, Limit = item.Limit, Current = item.Actual, Category = item.Category, SelectedCategories = item.SelectedCategories, NoLimit = item.NoLimit, IsMandatoryExpense = item.IsMandatoryExpense, IsArchive = true }); logger.LogInformation("添加归档年度预算: {BudgetName} - 预算金额: {Limit}, 实际金额: {Actual}", item.Name, item.Limit, item.Actual); } } } } } } // 2. 处理当前月及未来月(使用当前预算) logger.LogDebug("开始处理当前及未来月份预算"); foreach (var budget in currentBudgetsDict.Values) { // 对于年度预算,如果还没有从归档中添加,则添加 if (budget.Type == BudgetPeriodType.Year && !processedBudgetIds.Contains(budget.Id)) { var currentAmount = await CalculateCurrentAmountAsync(budget, statType, referenceDate); result.Add(new BudgetStatsItem { Id = budget.Id, Name = budget.Name, Type = budget.Type, Limit = budget.Limit, Current = currentAmount, Category = budget.Category, SelectedCategories = string.IsNullOrEmpty(budget.SelectedCategories) ? [] : budget.SelectedCategories.Split(',', StringSplitOptions.RemoveEmptyEntries), NoLimit = budget.NoLimit, IsMandatoryExpense = budget.IsMandatoryExpense, IsArchive = false }); logger.LogInformation("添加当前年度预算: {BudgetName} - 预算金额: {Limit}, 实际金额: {Current}", budget.Name, budget.Limit, currentAmount); } // 对于月度预算,仅添加当前月的预算项 else if (budget.Type == BudgetPeriodType.Month) { // 只计算当前月的实际值 var currentAmount = await CalculateCurrentAmountAsync(budget, BudgetPeriodType.Month, referenceDate); result.Add(new BudgetStatsItem { Id = budget.Id, Name = budget.Name, Type = budget.Type, Limit = budget.Limit, // 月度预算的原始限额 Current = currentAmount, // 当前月的实际值 Category = budget.Category, SelectedCategories = string.IsNullOrEmpty(budget.SelectedCategories) ? [] : budget.SelectedCategories.Split(',', StringSplitOptions.RemoveEmptyEntries), NoLimit = budget.NoLimit, IsMandatoryExpense = budget.IsMandatoryExpense, IsArchive = false, // 标记这是当前月的月度预算,用于年度限额计算 IsCurrentMonth = true }); logger.LogInformation("添加当前月的月度预算: {BudgetName} - 月度限额: {Limit}, 当前月实际值: {Current}", budget.Name, budget.Limit, currentAmount); // 如果还有剩余月份(未来月份),再添加一项作为未来的预算占位 var remainingMonths = 12 - now.Month; if (remainingMonths > 0) { var futureLimit = budget.Limit * remainingMonths; result.Add(new BudgetStatsItem { Id = budget.Id, Name = budget.Name, Type = budget.Type, Limit = budget.Limit, // 月度预算的原始限额(用于描述时计算) Current = 0, // 未来月份无实际值 Category = budget.Category, SelectedCategories = string.IsNullOrEmpty(budget.SelectedCategories) ? [] : budget.SelectedCategories.Split(',', StringSplitOptions.RemoveEmptyEntries), NoLimit = budget.NoLimit, IsMandatoryExpense = budget.IsMandatoryExpense, IsArchive = false, IsCurrentMonth = false, RemainingMonths = remainingMonths }); logger.LogInformation("添加未来月份的月度预算: {BudgetName} - 月度限额: {Limit}, 剩余月份: {RemainingMonths}", budget.Name, budget.Limit, remainingMonths); } } } } else // 月度统计 { // 检查是否为归档月份 var isArchive = year < now.Year || (year == now.Year && month < now.Month); logger.LogDebug("月度统计 - 是否为归档月份: {IsArchive}", isArchive); if (isArchive) { // 获取归档数据 logger.LogDebug("开始获取归档数据: Year={Year}, Month={Month}", year, month); var archive = await budgetArchiveRepository.GetArchiveAsync(year, month); if (archive != null) { int itemCount = archive.Content.Count(); logger.LogDebug("找到归档数据,包含 {ItemCount} 个项目", itemCount); foreach (var item in archive.Content) { if (item.Category == category && ShouldIncludeBudget(item, statType)) { result.Add(new BudgetStatsItem { Id = item.Id, Name = item.Name, Type = item.Type, Limit = item.Limit, Current = item.Actual, Category = item.Category, SelectedCategories = item.SelectedCategories, NoLimit = item.NoLimit, IsMandatoryExpense = item.IsMandatoryExpense, IsArchive = true }); logger.LogInformation("添加归档预算: {BudgetName} - 归档月份: {Year}-{Month:00}, 预算金额: {BudgetLimit}, 实际金额: {ActualAmount}", item.Name, year, month, item.Limit, item.Actual); } } } else { logger.LogDebug("未找到归档数据"); } } else { // 获取当前预算数据 logger.LogDebug("开始获取当前预算数据"); var budgets = await budgetRepository.GetAllAsync(); int budgetCount = budgets.Count(); logger.LogDebug("获取到 {BudgetCount} 个预算", budgetCount); foreach (var budget in budgets) { if (budget.Category == category && ShouldIncludeBudget(budget, statType)) { var currentAmount = await CalculateCurrentAmountAsync(budget, statType, referenceDate); result.Add(new BudgetStatsItem { Id = budget.Id, Name = budget.Name, Type = budget.Type, Limit = budget.Limit, Current = currentAmount, Category = budget.Category, SelectedCategories = string.IsNullOrEmpty(budget.SelectedCategories) ? [] : budget.SelectedCategories.Split(',', StringSplitOptions.RemoveEmptyEntries), NoLimit = budget.NoLimit, IsMandatoryExpense = budget.IsMandatoryExpense, IsArchive = false }); logger.LogInformation("添加当前预算: {BudgetName} - 预算金额: {BudgetLimit}, 实时计算实际金额: {CurrentAmount}, 预算类型: {BudgetType}", budget.Name, budget.Limit, currentAmount, budget.Type == BudgetPeriodType.Month ? "月度预算" : "年度预算"); } } } } logger.LogDebug("预算数据获取完成: 共找到 {ResultCount} 个符合条件的预算", result.Count); return result; } private bool ShouldIncludeBudget(BudgetRecord budget, BudgetPeriodType statType) { // 排除不记额预算 if (budget.NoLimit) { return false; } // 月度统计只包含月度预算 if (statType == BudgetPeriodType.Month) { return budget.Type == BudgetPeriodType.Month; } // 年度统计包含所有预算 return true; } private bool ShouldIncludeBudget(BudgetArchiveContent budget, BudgetPeriodType statType) { // 排除不记额预算 if (budget.NoLimit) { return false; } // 月度统计只包含月度预算 if (statType == BudgetPeriodType.Month) { return budget.Type == BudgetPeriodType.Month; } // 年度统计包含所有预算 return true; } private decimal CalculateBudgetLimit(BudgetStatsItem budget, BudgetPeriodType statType, DateTime referenceDate) { // 不记额预算的限额为0 if (budget.NoLimit) { logger.LogTrace("预算 {BudgetName} 为不记额预算,限额返回0", budget.Name); return 0; } var itemLimit = budget.Limit; string algorithmDescription = $"直接使用原始预算金额: {budget.Limit}"; // 年度视图下,月度预算需要折算为年度 if (statType == BudgetPeriodType.Year && budget.Type == BudgetPeriodType.Month) { // 对于归档预算,直接使用归档的限额,不折算 if (budget.IsArchive) { itemLimit = budget.Limit; algorithmDescription = $"归档月度预算: 直接使用归档限额 {budget.Limit}"; } // 对于当前月的月度预算,直接使用原始限额 else if (budget.IsCurrentMonth) { itemLimit = budget.Limit; algorithmDescription = $"当前月的月度预算: 直接使用月度限额 {budget.Limit}"; } // 对于当前及未来月份的预算,使用剩余月份折算 else if (budget.RemainingMonths > 0) { itemLimit = budget.Limit * budget.RemainingMonths; algorithmDescription = $"月度预算剩余月份折算: {budget.Limit} × {budget.RemainingMonths} (剩余月份) = {itemLimit}"; } // 兼容旧逻辑(如果没有设置RemainingMonths) else { logger.LogWarning("预算 {BudgetName} 年度统计时未设置RemainingMonths,使用默认折算逻辑", budget.Name); if (budget.IsMandatoryExpense) { var now = dateTimeProvider.Now; if (referenceDate.Year == now.Year) { var monthsElapsed = now.Month; itemLimit = budget.Limit * monthsElapsed; algorithmDescription = $"硬性预算当前年份折算: {budget.Limit} × {monthsElapsed} (已过月份) = {itemLimit}"; } else { itemLimit = budget.Limit * 12; algorithmDescription = $"硬性预算完整年度折算: {budget.Limit} × 12 = {itemLimit}"; } } else { itemLimit = budget.Limit * 12; algorithmDescription = $"月度预算年度折算: {budget.Limit} × 12 = {itemLimit}"; } } } logger.LogInformation("预算 {BudgetName} 限额计算: 原始预算={OriginalLimit}, 计算后限额={CalculatedLimit}, 算法: {Algorithm}", budget.Name, budget.Limit, itemLimit, algorithmDescription); return itemLimit; } private async Task CalculateCurrentAmountAsync(BudgetRecord budget, BudgetPeriodType statType, DateTime referenceDate) { var (startDate, endDate) = GetStatPeriodRange(statType, referenceDate); // 获取预算的实际时间段 var (budgetStart, budgetEnd) = BudgetService.GetPeriodRange(budget.StartDate, budget.Type, referenceDate); // 确保在统计时间段内 if (budgetEnd < startDate || budgetStart > endDate) { return 0; } var actualAmount = await budgetRepository.GetCurrentAmountAsync(budget, startDate, endDate); // 硬性预算的特殊处理(参考BudgetSavingsService第92-97行) if (actualAmount == 0 && budget.IsMandatoryExpense) { if (budget.Type == BudgetPeriodType.Month) { // 月度硬性预算按天数比例累加 var daysInMonth = DateTime.DaysInMonth(referenceDate.Year, referenceDate.Month); var daysElapsed = dateTimeProvider.Now.Day; actualAmount = budget.Limit * daysElapsed / daysInMonth; } else if (budget.Type == BudgetPeriodType.Year) { // 年度硬性预算按天数比例累加 var daysInYear = DateTime.IsLeapYear(referenceDate.Year) ? 366 : 365; var daysElapsed = dateTimeProvider.Now.DayOfYear; actualAmount = budget.Limit * daysElapsed / daysInYear; } } return actualAmount; } private decimal ApplyMandatoryBudgetAdjustment(List budgets, decimal currentTotal, DateTime referenceDate, BudgetPeriodType statType) { logger.LogDebug("开始应用硬性预算调整: 当前总计={CurrentTotal}, 统计类型={StatType}, 参考日期={ReferenceDate:yyyy-MM-dd}", currentTotal, statType, referenceDate); var now = dateTimeProvider.Now; var adjustedTotal = currentTotal; var mandatoryBudgets = budgets.Where(b => b.IsMandatoryExpense).ToList(); logger.LogDebug("找到 {MandatoryCount} 个硬性预算", mandatoryBudgets.Count); int mandatoryIndex = 0; foreach (var budget in mandatoryBudgets) { mandatoryIndex++; // 检查是否为当前统计周期 var isCurrentPeriod = false; if (statType == BudgetPeriodType.Month) { isCurrentPeriod = referenceDate.Year == now.Year && referenceDate.Month == now.Month; logger.LogInformation("硬性预算 {MandatoryIndex}/{MandatoryCount}: {BudgetName} - 检查月度周期: 参考月份={RefMonth}, 当前月份={CurrentMonth}, 是否为当前周期={IsCurrent}", mandatoryIndex, mandatoryBudgets.Count, budget.Name, referenceDate.ToString("yyyy-MM"), now.ToString("yyyy-MM"), isCurrentPeriod); } else // Year { isCurrentPeriod = referenceDate.Year == now.Year; logger.LogInformation("硬性预算 {MandatoryIndex}/{MandatoryCount}: {BudgetName} - 检查年度周期: 参考年份={RefYear}, 当前年份={CurrentYear}, 是否为当前周期={IsCurrent}", mandatoryIndex, mandatoryBudgets.Count, budget.Name, referenceDate.Year, now.Year, isCurrentPeriod); } if (isCurrentPeriod) { // 计算硬性预算的应累加值 decimal mandatoryAccumulation = 0; string accumulationAlgorithm = ""; if (budget.Type == BudgetPeriodType.Month) { // 月度硬性预算按天数比例累加 var daysInMonth = DateTime.DaysInMonth(referenceDate.Year, referenceDate.Month); var daysElapsed = now.Day; mandatoryAccumulation = budget.Limit * daysElapsed / daysInMonth; accumulationAlgorithm = $"月度硬性预算按天数比例: {budget.Limit} × {daysElapsed} ÷ {daysInMonth} = {mandatoryAccumulation:F2}"; logger.LogDebug("月度硬性预算 {BudgetName}: 限额={Limit}, 本月天数={DaysInMonth}, 已过天数={DaysElapsed}, 应累加值={Accumulation}", budget.Name, budget.Limit, daysInMonth, daysElapsed, mandatoryAccumulation); } else if (budget.Type == BudgetPeriodType.Year) { // 年度硬性预算按天数比例累加 var daysInYear = DateTime.IsLeapYear(referenceDate.Year) ? 366 : 365; var daysElapsed = now.DayOfYear; mandatoryAccumulation = budget.Limit * daysElapsed / daysInYear; accumulationAlgorithm = $"年度硬性预算按天数比例: {budget.Limit} × {daysElapsed} ÷ {daysInYear} = {mandatoryAccumulation:F2}"; logger.LogDebug("年度硬性预算 {BudgetName}: 限额={Limit}, 本年天数={DaysInYear}, 已过天数={DaysElapsed}, 应累加值={Accumulation}", budget.Name, budget.Limit, daysInYear, daysElapsed, mandatoryAccumulation); } logger.LogInformation("硬性预算 {BudgetName} 应累加值计算: 算法={Algorithm}", budget.Name, accumulationAlgorithm); // 如果趋势数据中的累计值小于硬性预算的应累加值,使用硬性预算的值 if (adjustedTotal < mandatoryAccumulation) { var adjustmentAmount = mandatoryAccumulation - adjustedTotal; logger.LogInformation("硬性预算 {BudgetName} 触发调整: 调整前总计={BeforeTotal}, 应累加值={MandatoryAccumulation}, 调整金额={AdjustmentAmount}, 调整后总计={AfterTotal}", budget.Name, adjustedTotal, mandatoryAccumulation, adjustmentAmount, mandatoryAccumulation); adjustedTotal = mandatoryAccumulation; } else { logger.LogDebug("硬性预算 {BudgetName} 未触发调整: 当前总计={CurrentTotal} >= 应累加值={MandatoryAccumulation}", budget.Name, adjustedTotal, mandatoryAccumulation); } } else { logger.LogInformation("硬性预算 {BudgetName} 不在当前统计周期,跳过调整", budget.Name); } } logger.LogDebug("硬性预算调整完成: 最终总计={AdjustedTotal}", adjustedTotal); return adjustedTotal; } private async Task CalculateCurrentAmountAsync(BudgetStatsItem budget, BudgetPeriodType statType, DateTime referenceDate) { var (startDate, endDate) = GetStatPeriodRange(statType, referenceDate); // 创建临时的BudgetRecord用于查询 var tempRecord = new BudgetRecord { Id = budget.Id, Name = budget.Name, Type = budget.Type, Limit = budget.Limit, Category = budget.Category, SelectedCategories = string.Join(",", budget.SelectedCategories), StartDate = referenceDate, NoLimit = budget.NoLimit, IsMandatoryExpense = budget.IsMandatoryExpense }; // 获取预算的实际时间段 var (budgetStart, budgetEnd) = BudgetService.GetPeriodRange(tempRecord.StartDate, tempRecord.Type, referenceDate); // 确保在统计时间段内 if (budgetEnd < startDate || budgetStart > endDate) { return 0; } var actualAmount = await budgetRepository.GetCurrentAmountAsync(tempRecord, startDate, endDate); // 硬性预算的特殊处理 if (actualAmount == 0 && budget.IsMandatoryExpense) { if (budget.Type == BudgetPeriodType.Month) { var daysInMonth = DateTime.DaysInMonth(referenceDate.Year, referenceDate.Month); var daysElapsed = dateTimeProvider.Now.Day; actualAmount = budget.Limit * daysElapsed / daysInMonth; } else if (budget.Type == BudgetPeriodType.Year) { var daysInYear = DateTime.IsLeapYear(referenceDate.Year) ? 366 : 365; var daysElapsed = dateTimeProvider.Now.DayOfYear; actualAmount = budget.Limit * daysElapsed / daysInYear; } } return actualAmount; } private (DateTime start, DateTime end) GetStatPeriodRange(BudgetPeriodType statType, DateTime referenceDate) { if (statType == BudgetPeriodType.Month) { var start = new DateTime(referenceDate.Year, referenceDate.Month, 1); var end = start.AddMonths(1).AddDays(-1).AddHours(23).AddMinutes(59).AddSeconds(59); return (start, end); } else // Year { var start = new DateTime(referenceDate.Year, 1, 1); var end = new DateTime(referenceDate.Year, 12, 31).AddHours(23).AddMinutes(59).AddSeconds(59); return (start, end); } } private class BudgetStatsItem { public long Id { get; set; } public string Name { get; set; } = string.Empty; public BudgetPeriodType Type { get; set; } public decimal Limit { get; set; } public decimal Current { get; set; } public BudgetCategory Category { get; set; } public string[] SelectedCategories { get; set; } = []; public bool NoLimit { get; set; } public bool IsMandatoryExpense { get; set; } public bool IsArchive { get; set; } public int ArchiveMonth { get; set; } // 归档月份(1-12),用于标识归档数据来自哪个月 public int RemainingMonths { get; set; } // 剩余月份数,用于年度统计时的月度预算折算 public bool IsCurrentMonth { get; set; } // 标记是否为当前月的预算(用于年度统计中月度预算的计算) } private string GenerateMonthlyDescription(List budgets, decimal totalLimit, decimal totalCurrent, DateTime referenceDate, BudgetCategory category) { var description = new StringBuilder(); var categoryName = category == BudgetCategory.Expense ? "支出" : "收入"; description.AppendLine($"

月度{categoryName}预算明细

"); description.AppendLine(""" """); foreach (var budget in budgets) { var budgetLimit = CalculateBudgetLimit(budget, BudgetPeriodType.Month, referenceDate); var typeLabel = budget.IsMandatoryExpense ? "硬性" : "普通"; var archiveLabel = budget.IsArchive ? $" ({budget.ArchiveMonth}月归档)" : ""; description.AppendLine($""" """); } description.AppendLine("
名称 预算额度 实际金额 类型
{budget.Name}{archiveLabel} {budgetLimit:N0} {budget.Current:N1} {typeLabel}
"); // 计算公式 description.AppendLine($"

计算公式

"); description.AppendLine($"

预算额度合计:"); var limitParts = budgets.Select(b => { var limit = CalculateBudgetLimit(b, BudgetPeriodType.Month, referenceDate); var archiveLabel = b.IsArchive ? $"({b.ArchiveMonth}月归档)" : ""; return $"{b.Name}{archiveLabel}({limit:N0})"; }); description.AppendLine($"{string.Join(" + ", limitParts)} = {totalLimit:N0}

"); description.AppendLine($"

实际{categoryName}合计:"); var currentParts = budgets.Select(b => { var archiveLabel = b.IsArchive ? $"({b.ArchiveMonth}月归档)" : ""; return $"{b.Name}{archiveLabel}({b.Current:N1})"; }); description.AppendLine($"{string.Join(" + ", currentParts)} = {totalCurrent:N1}

"); var rate = totalLimit > 0 ? totalCurrent / totalLimit * 100 : 0; description.AppendLine($"

使用率:{totalCurrent:N1} ÷ {totalLimit:N0} × 100% = {rate:F2}%

"); return description.ToString(); } private string GenerateYearlyDescription(List budgets, decimal totalLimit, decimal totalCurrent, DateTime referenceDate, BudgetCategory category) { var description = new StringBuilder(); var categoryName = category == BudgetCategory.Expense ? "支出" : "收入"; // 分组:归档的月度预算、归档的年度预算、当前月度预算(剩余月份)、当前年度预算 var archivedMonthlyBudgets = budgets.Where(b => b.IsArchive && b.Type == BudgetPeriodType.Month).ToList(); var archivedYearlyBudgets = budgets.Where(b => b.IsArchive && b.Type == BudgetPeriodType.Year).ToList(); var currentMonthlyBudgets = budgets.Where(b => !b.IsArchive && b.Type == BudgetPeriodType.Month).ToList(); var currentYearlyBudgets = budgets.Where(b => !b.IsArchive && b.Type == BudgetPeriodType.Year).ToList(); // 归档月度预算明细 if (archivedMonthlyBudgets.Any()) { description.AppendLine($"

已归档月度{categoryName}预算

"); description.AppendLine(""" """); foreach (var budget in archivedMonthlyBudgets.GroupBy(b => b.Id)) { var first = budget.First(); var months = budget.Select(b => b.ArchiveMonth).OrderBy(m => m).ToArray(); var monthsText = FormatMonths(months); var groupLimit = first.Limit * budget.Count(); var groupCurrent = budget.Sum(b => b.Current); description.AppendLine($""" """); } description.AppendLine("
名称 预算额度 实际金额 归档月份
{first.Name} {groupLimit:N0} {groupCurrent:N1} {monthsText}
"); } // 当前月度预算(剩余月份) if (currentMonthlyBudgets.Any()) { description.AppendLine($"

当前月度{categoryName}预算(当前月及未来月)

"); description.AppendLine(""" """); foreach (var budget in currentMonthlyBudgets) { var budgetLimit = CalculateBudgetLimit(budget, BudgetPeriodType.Year, referenceDate); var typeStr = budget.IsCurrentMonth ? "当前月" : "未来月"; var calcStr = budget.IsCurrentMonth ? $"1月×{budget.Limit:N0}" : $"{budget.RemainingMonths}月×{budget.Limit:N0}"; description.AppendLine($""" """); } description.AppendLine("
名称 类型 计算方式 合计额度 实际金额
{budget.Name} {typeStr} {calcStr} {budgetLimit:N0} {(budget.IsCurrentMonth ? budget.Current.ToString("N1") : "-")}
"); } // 年度预算明细 if (archivedYearlyBudgets.Any() || currentYearlyBudgets.Any()) { description.AppendLine($"

年度{categoryName}预算

"); description.AppendLine(""" """); foreach (var budget in archivedYearlyBudgets.Concat(currentYearlyBudgets)) { var statusLabel = budget.IsArchive ? "归档" : "当前"; var typeLabel = budget.IsMandatoryExpense ? "硬性" : "普通"; description.AppendLine($""" """); } description.AppendLine("
名称 预算额度 实际金额 状态
{budget.Name} {budget.Limit:N0} {budget.Current:N1} {statusLabel}/{typeLabel}
"); } // 计算公式 description.AppendLine($"

计算公式

"); description.AppendLine($"

年度预算额度合计:"); var limitParts = new List(); // 归档月度预算部分 foreach (var group in archivedMonthlyBudgets.GroupBy(b => b.Id)) { var first = group.First(); var count = group.Count(); var groupTotalLimit = first.Limit * count; limitParts.Add($"{first.Name}(归档{count}月×{first.Limit:N0}={groupTotalLimit:N0})"); } // 当前月度预算部分 foreach (var budget in currentMonthlyBudgets) { var budgetLimit = CalculateBudgetLimit(budget, BudgetPeriodType.Year, referenceDate); if (budget.IsCurrentMonth) { limitParts.Add($"{budget.Name}(当前月×{budget.Limit:N0}={budgetLimit:N0})"); } else { limitParts.Add($"{budget.Name}(剩余{budget.RemainingMonths}月×{budget.Limit:N0}={budgetLimit:N0})"); } } // 年度预算部分 foreach (var budget in archivedYearlyBudgets.Concat(currentYearlyBudgets)) { limitParts.Add($"{budget.Name}({budget.Limit:N0})"); } description.AppendLine($"{string.Join(" + ", limitParts)} = {totalLimit:N0}

"); description.AppendLine($"

实际{categoryName}合计:"); var currentParts = new List(); // 归档月度预算的实际值 foreach (var group in archivedMonthlyBudgets.GroupBy(b => b.Id)) { var first = group.First(); var groupTotalCurrent = group.Sum(b => b.Current); currentParts.Add($"{first.Name}(归档{groupTotalCurrent:N1})"); } // 年度预算的实际值 foreach (var budget in archivedYearlyBudgets.Concat(currentYearlyBudgets)) { currentParts.Add($"{budget.Name}({budget.Current:N1})"); } description.AppendLine($"{string.Join(" + ", currentParts)} = {totalCurrent:N1}

"); var rate = totalLimit > 0 ? totalCurrent / totalLimit * 100 : 0; description.AppendLine($"

使用率:{totalCurrent:N1} ÷ {totalLimit:N0} × 100% = {rate:F2}%

"); return description.ToString(); } private string FormatMonths(int[] months) { if (months.Length == 0) return ""; if (months.Length == 1) return $"{months[0]}月"; // 如果是连续的月份,简化显示为 1~3月 Array.Sort(months); bool isContinuous = true; for (int i = 1; i < months.Length; i++) { if (months[i] != months[i - 1] + 1) { isContinuous = false; break; } } if (isContinuous) { return $"{months.First()}~{months.Last()}月"; } return string.Join(", ", months) + "月"; } }