From dcbde4db23342d12c9c9f7ccbeb37fcaf399802e Mon Sep 17 00:00:00 2001 From: SunCheng Date: Thu, 22 Jan 2026 21:03:00 +0800 Subject: [PATCH] fix --- Service/Budget/BudgetService.cs | 5 + Service/Budget/BudgetStatsService.cs | 677 ++++++++++++++---- .../components/Budget/BudgetChartAnalysis.vue | 53 +- Web/src/views/BudgetView.vue | 6 +- Web/src/views/LogView.vue | 9 +- WebApi.Test/Budget/BudgetStatsTest.cs | 21 +- 6 files changed, 602 insertions(+), 169 deletions(-) diff --git a/Service/Budget/BudgetService.cs b/Service/Budget/BudgetService.cs index c616b65..128ce74 100644 --- a/Service/Budget/BudgetService.cs +++ b/Service/Budget/BudgetService.cs @@ -512,6 +512,11 @@ public class BudgetStatsDto /// 每日/每月累计金额趋势(对应当前周期内的实际发生额累计值) /// public List Trend { get; set; } = []; + + /// + /// HTML 格式的详细描述(罗列每个预算的额度和实际值及计算公式) + /// + public string Description { get; set; } = string.Empty; } /// diff --git a/Service/Budget/BudgetStatsService.cs b/Service/Budget/BudgetStatsService.cs index 4dad8db..90ec8bf 100644 --- a/Service/Budget/BudgetStatsService.cs +++ b/Service/Budget/BudgetStatsService.cs @@ -77,7 +77,7 @@ public class BudgetStatsService( result.Count = budgets.Count; - // 2. 计算限额总值(考虑不限额预算的特殊处理) + // 2. 计算限额总值 logger.LogDebug("开始计算限额总值,共 {BudgetCount} 个预算", budgets.Count); decimal totalLimit = 0; int budgetIndex = 0; @@ -85,42 +85,39 @@ public class BudgetStatsService( { budgetIndex++; var itemLimit = CalculateBudgetLimit(budget, BudgetPeriodType.Month, referenceDate); - logger.LogInformation("预算 {BudgetIndex}/{BudgetCount}: {BudgetName} (ID={BudgetId}) - 预算金额: {BudgetLimit}, 实际金额: {CurrentAmount}, 计算算法: {Algorithm}", - budgetIndex, budgets.Count, budget.Name, budget.Id, budget.Limit, budget.Current, - budget.NoLimit ? "不限额预算" : budget.IsMandatoryExpense ? "硬性预算" : "普通预算"); - logger.LogTrace("预算 {BudgetName} (ID={BudgetId}) 计算后限额: {ItemLimit}", - budget.Name, budget.Id, itemLimit); totalLimit += itemLimit; } result.Limit = totalLimit; logger.LogDebug("限额总值计算完成: {TotalLimit}", totalLimit); - // 3. 计算当前实际值(避免重复计算同一笔交易) + // 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 }; - logger.LogDebug("交易类型: {TransactionType}", transactionType); - // 计算当前实际值,考虑硬性预算的特殊逻辑 - decimal totalCurrent = 0; + // 计算趋势数据(用于图表展示) 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("开始获取交易趋势统计数据"); + // 获取趋势统计数据(仅用于图表展示) + logger.LogDebug("开始获取交易趋势统计数据(用于图表)"); var dailyStats = await transactionRecordRepository.GetFilteredTrendStatisticsAsync( startDate, endDate, @@ -129,7 +126,7 @@ public class BudgetStatsService( false); logger.LogDebug("获取到 {DayCount} 天的交易数据", dailyStats.Count); - // 计算累计值 + // 计算累计值(用于趋势图) decimal accumulated = 0; var daysInMonth = DateTime.DaysInMonth(startDate.Year, startDate.Month); logger.LogDebug("本月天数: {DaysInMonth}", daysInMonth); @@ -155,29 +152,154 @@ public class BudgetStatsService( logger.LogTrace("日期 {CurrentDate:yyyy-MM-dd}: 无交易数据,累计={Accumulated}", currentDate, accumulated); } - result.Trend.Add(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); } - - totalCurrent = accumulated; - logger.LogDebug("交易累计值计算完成: {TotalCurrent}", totalCurrent); + + logger.LogDebug("趋势图数据计算完成(已应用硬性预算调整)"); } else { - // 对于非收入/支出分类,使用逐预算累加 - logger.LogDebug("非收入/支出分类,使用逐预算累加,共 {BudgetCount} 个预算", budgets.Count); - budgetIndex = 0; - foreach (var budget in budgets) - { - budgetIndex++; - var currentAmount = await CalculateCurrentAmountAsync(budget, BudgetPeriodType.Month, referenceDate); - logger.LogInformation("预算 {BudgetIndex}/{BudgetCount}: {BudgetName} (ID={BudgetId}) - 实际金额计算: 预算金额={BudgetLimit}, 当前值={CurrentAmount}, 算法={Algorithm}", - budgetIndex, budgets.Count, budget.Name, budget.Id, budget.Limit, currentAmount, - budget.IsArchive ? "归档数据" : "实时计算"); - logger.LogTrace("预算 {BudgetName} (ID={BudgetId}) 当前值: {CurrentAmount}", - budget.Name, budget.Id, currentAmount); - totalCurrent += currentAmount; - } - logger.LogDebug("预算累加完成: {TotalCurrent}", totalCurrent); + // 对于非收入/支出分类,趋势图为空 + logger.LogDebug("非收入/支出分类,趋势图为空"); } // 对于硬性预算,如果当前月份且实际值为0,需要按时间比例计算 @@ -199,33 +321,26 @@ public class BudgetStatsService( } result.Current = totalCurrent; + logger.LogInformation("【最终确定值】月度实际支出: {TotalCurrent}元(已应用硬性预算调整)", totalCurrent); // 4. 计算使用率 result.Rate = totalLimit > 0 ? result.Current / totalLimit * 100 : 0; - logger.LogDebug("使用率计算完成: {Rate:F2}%", result.Rate); + logger.LogInformation("【使用率】{Current}元 ÷ {Limit}元 × 100% = {Rate:F2}%", result.Current, totalLimit, result.Rate); - // 5. 生成计算明细汇总日志 - var limitParts = new List(); - var currentParts = new List(); - budgetIndex = 0; - foreach (var budget in budgets) + // 5. 生成预算明细汇总日志 + var budgetDetails = budgets.Select(b => { - budgetIndex++; - var itemLimit = CalculateBudgetLimit(budget, BudgetPeriodType.Month, referenceDate); - var limitPart = budget.IsArchive - ? $"{budget.Name}({budget.ArchiveMonth}月归档){itemLimit}元" - : $"{budget.Name}{itemLimit}元"; - limitParts.Add(limitPart); - - var currentPart = budget.IsArchive - ? $"{budget.Name}({budget.ArchiveMonth}月归档){budget.Current}元" - : $"{budget.Name}{budget.Current}元"; - currentParts.Add(currentPart); - } - var limitSummary = string.Join(" + ", limitParts); - var currentSummary = string.Join(" + ", currentParts); - logger.LogInformation("月度统计计算明细: 预算={LimitSummary}={TotalLimit}元, 已支出={CurrentSummary}={TotalCurrent}元, 使用率={Rate:F2}%", - limitSummary, totalLimit, currentSummary, totalCurrent, result.Rate); + 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; @@ -266,18 +381,16 @@ public class BudgetStatsService( { budgetIndex++; var itemLimit = CalculateBudgetLimit(budget, BudgetPeriodType.Year, referenceDate); - logger.LogInformation("年度预算 {BudgetIndex}/{BudgetCount}: {BudgetName} (ID={BudgetId}) - 原始预算金额: {BudgetLimit}, 当前实际金额: {CurrentAmount}, 预算类型: {BudgetType}, 算法: {Algorithm}", - budgetIndex, budgets.Count, budget.Name, budget.Id, budget.Limit, budget.Current, + 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 ? "硬性预算" : "普通预算"); - logger.LogTrace("预算 {BudgetName} (ID={BudgetId}) 年度计算后限额: {ItemLimit}", - budget.Name, budget.Id, itemLimit); totalLimit += itemLimit; } result.Limit = totalLimit; logger.LogDebug("年度限额总值计算完成: {TotalLimit}", totalLimit); - // 3. 计算当前实际值(避免重复计算同一笔交易) + // 3. 计算当前实际值 var transactionType = category switch { BudgetCategory.Expense => TransactionType.Expense, @@ -287,22 +400,25 @@ public class BudgetStatsService( logger.LogDebug("交易类型: {TransactionType}", transactionType); // 计算当前实际值,考虑硬性预算的特殊逻辑 - decimal totalCurrent = 0; + 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("开始获取交易趋势统计数据"); + // 获取趋势统计数据(仅用于图表展示) + logger.LogDebug("开始获取交易趋势统计数据(用于图表)"); var dailyStats = await transactionRecordRepository.GetFilteredTrendStatisticsAsync( startDate, endDate, @@ -311,7 +427,7 @@ public class BudgetStatsService( true); logger.LogDebug("获取到 {MonthCount} 个月的交易数据", dailyStats.Count); - // 计算累计值 + // 计算累计值(用于趋势图) decimal accumulated = 0; for (int i = 1; i <= 12; i++) { @@ -335,29 +451,70 @@ public class BudgetStatsService( logger.LogTrace("月份 {Month:yyyy-MM}: 无交易数据,累计={Accumulated}", currentMonthDate, accumulated); } - result.Trend.Add(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); } - totalCurrent = accumulated; - logger.LogDebug("交易累计值计算完成: {TotalCurrent}", totalCurrent); + logger.LogDebug("趋势图数据计算完成(已应用硬性预算调整)"); } else { - // 对于非收入/支出分类,使用逐预算累加 - logger.LogDebug("非收入/支出分类,使用逐预算累加,共 {BudgetCount} 个预算", budgets.Count); - budgetIndex = 0; - foreach (var budget in budgets) - { - budgetIndex++; - var currentAmount = await CalculateCurrentAmountAsync(budget, BudgetPeriodType.Year, referenceDate); - logger.LogInformation("年度预算 {BudgetIndex}/{BudgetCount}: {BudgetName} (ID={BudgetId}) - 实际金额计算: 原始预算={BudgetLimit}, 年度实际值={CurrentAmount}, 数据来源: {DataSource}", - budgetIndex, budgets.Count, budget.Name, budget.Id, budget.Limit, currentAmount, - budget.IsArchive ? "归档数据" : "实时计算"); - logger.LogTrace("预算 {BudgetName} (ID={BudgetId}) 年度当前值: {CurrentAmount}", - budget.Name, budget.Id, currentAmount); - totalCurrent += currentAmount; - } - logger.LogDebug("年度预算累加完成: {TotalCurrent}", totalCurrent); + // 对于非收入/支出分类,趋势图为空 + logger.LogDebug("非收入/支出分类,趋势图为空"); } // 对于硬性预算,如果当前年份且实际值为0,需要按时间比例计算 @@ -379,37 +536,26 @@ public class BudgetStatsService( } result.Current = totalCurrent; + logger.LogInformation("【最终确定值】年度实际支出: {TotalCurrent}元(已应用硬性预算调整)", totalCurrent); // 4. 计算使用率 result.Rate = totalLimit > 0 ? result.Current / totalLimit * 100 : 0; - logger.LogDebug("使用率计算完成: {Rate:F2}%", result.Rate); + logger.LogInformation("【使用率】{Current}元 ÷ {Limit}元 × 100% = {Rate:F2}%", result.Current, totalLimit, result.Rate); - // 5. 生成计算明细汇总日志 - var limitParts = new List(); - var currentParts = new List(); - budgetIndex = 0; - foreach (var budget in budgets) + // 5. 生成预算明细汇总日志 + var budgetDetails = budgets.Select(b => { - budgetIndex++; - var itemLimit = CalculateBudgetLimit(budget, BudgetPeriodType.Year, referenceDate); - var limitPart = budget.IsArchive - ? $"{budget.Name}(归档){itemLimit}元" - : budget.RemainingMonths > 0 - ? $"{budget.Name}(剩余{budget.RemainingMonths}月){itemLimit}元" - : $"{budget.Name}{itemLimit}元"; - limitParts.Add(limitPart); - - var currentPart = budget.IsArchive - ? $"{budget.Name}(归档){budget.Current}元" - : budget.RemainingMonths > 0 - ? $"{budget.Name}(剩余{budget.RemainingMonths}月){budget.Current}元" - : $"{budget.Name}{budget.Current}元"; - currentParts.Add(currentPart); - } - var limitSummary = string.Join(" + ", limitParts); - var currentSummary = string.Join(" + ", currentParts); - logger.LogInformation("年度统计计算明细: 预算={LimitSummary}={TotalLimit}元, 已支出={CurrentSummary}={TotalCurrent}元, 使用率={Rate:F2}%", - limitSummary, totalLimit, currentSummary, totalCurrent, result.Rate); + 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; @@ -475,8 +621,8 @@ public class BudgetStatsService( IsArchive = true, ArchiveMonth = m }); - logger.LogInformation("添加归档月度预算: {BudgetName} (ID={BudgetId}) - {Month}月归档, 预算金额: {Limit}, 实际金额: {Actual}", - item.Name, item.Id, m, item.Limit, item.Actual); + logger.LogInformation("添加归档月度预算: {BudgetName} - {Month}月归档, 预算金额: {Limit}, 实际金额: {Actual}", + item.Name, m, item.Limit, item.Actual); } // 对于年度预算,只添加一次 else if (item.Type == BudgetPeriodType.Year && !processedBudgetIds.Contains(item.Id)) @@ -495,8 +641,8 @@ public class BudgetStatsService( IsMandatoryExpense = item.IsMandatoryExpense, IsArchive = true }); - logger.LogInformation("添加归档年度预算: {BudgetName} (ID={BudgetId}) - 预算金额: {Limit}, 实际金额: {Actual}", - item.Name, item.Id, item.Limit, item.Actual); + logger.LogInformation("添加归档年度预算: {BudgetName} - 预算金额: {Limit}, 实际金额: {Actual}", + item.Name, item.Limit, item.Actual); } } } @@ -527,8 +673,8 @@ public class BudgetStatsService( IsMandatoryExpense = budget.IsMandatoryExpense, IsArchive = false }); - logger.LogInformation("添加当前年度预算: {BudgetName} (ID={BudgetId}) - 预算金额: {Limit}, 实际金额: {Current}", - budget.Name, budget.Id, budget.Limit, currentAmount); + logger.LogInformation("添加当前年度预算: {BudgetName} - 预算金额: {Limit}, 实际金额: {Current}", + budget.Name, budget.Limit, currentAmount); } // 对于月度预算,添加当前及未来月份的预算(标记剩余月份数) else if (budget.Type == BudgetPeriodType.Month) @@ -550,8 +696,8 @@ public class BudgetStatsService( IsArchive = false, RemainingMonths = remainingMonths }); - logger.LogInformation("添加当前月度预算(剩余月份): {BudgetName} (ID={BudgetId}) - 预算金额: {Limit}, 剩余月份: {RemainingMonths}", - budget.Name, budget.Id, budget.Limit, remainingMonths); + logger.LogInformation("添加当前月度预算(剩余月份): {BudgetName} - 预算金额: {Limit}, 剩余月份: {RemainingMonths}", + budget.Name, budget.Limit, remainingMonths); } } } @@ -588,8 +734,8 @@ public class BudgetStatsService( IsMandatoryExpense = item.IsMandatoryExpense, IsArchive = true }); - logger.LogInformation("添加归档预算: {BudgetName} (ID={BudgetId}) - 归档月份: {Year}-{Month:00}, 预算金额: {BudgetLimit}, 实际金额: {ActualAmount}", - item.Name, item.Id, year, month, item.Limit, item.Actual); + logger.LogInformation("添加归档预算: {BudgetName} - 归档月份: {Year}-{Month:00}, 预算金额: {BudgetLimit}, 实际金额: {ActualAmount}", + item.Name, year, month, item.Limit, item.Actual); } } } @@ -626,8 +772,8 @@ public class BudgetStatsService( IsMandatoryExpense = budget.IsMandatoryExpense, IsArchive = false }); - logger.LogInformation("添加当前预算: {BudgetName} (ID={BudgetId}) - 预算金额: {BudgetLimit}, 实时计算实际金额: {CurrentAmount}, 预算类型: {BudgetType}", - budget.Name, budget.Id, budget.Limit, currentAmount, + logger.LogInformation("添加当前预算: {BudgetName} - 预算金额: {BudgetLimit}, 实时计算实际金额: {CurrentAmount}, 预算类型: {BudgetType}", + budget.Name, budget.Limit, currentAmount, budget.Type == BudgetPeriodType.Month ? "月度预算" : "年度预算"); } } @@ -679,7 +825,7 @@ public class BudgetStatsService( // 不记额预算的限额为0 if (budget.NoLimit) { - logger.LogTrace("预算 {BudgetName} (ID={BudgetId}) 为不记额预算,限额返回0", budget.Name, budget.Id); + logger.LogTrace("预算 {BudgetName} 为不记额预算,限额返回0", budget.Name); return 0; } @@ -704,7 +850,7 @@ public class BudgetStatsService( // 兼容旧逻辑(如果没有设置RemainingMonths) else { - logger.LogWarning("预算 {BudgetName} (ID={BudgetId}) 年度统计时未设置RemainingMonths,使用默认折算逻辑", budget.Name, budget.Id); + logger.LogWarning("预算 {BudgetName} 年度统计时未设置RemainingMonths,使用默认折算逻辑", budget.Name); if (budget.IsMandatoryExpense) { var now = dateTimeProvider.Now; @@ -728,8 +874,8 @@ public class BudgetStatsService( } } - logger.LogInformation("预算 {BudgetName} (ID={BudgetId}) 限额计算: 原始预算={OriginalLimit}, 计算后限额={CalculatedLimit}, 算法: {Algorithm}", - budget.Name, budget.Id, budget.Limit, itemLimit, algorithmDescription); + logger.LogInformation("预算 {BudgetName} 限额计算: 原始预算={OriginalLimit}, 计算后限额={CalculatedLimit}, 算法: {Algorithm}", + budget.Name, budget.Limit, itemLimit, algorithmDescription); return itemLimit; } @@ -791,15 +937,15 @@ public class BudgetStatsService( if (statType == BudgetPeriodType.Month) { isCurrentPeriod = referenceDate.Year == now.Year && referenceDate.Month == now.Month; - logger.LogInformation("硬性预算 {MandatoryIndex}/{MandatoryCount}: {BudgetName} (ID={BudgetId}) - 检查月度周期: 参考月份={RefMonth}, 当前月份={CurrentMonth}, 是否为当前周期={IsCurrent}", - mandatoryIndex, mandatoryBudgets.Count, budget.Name, budget.Id, + 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} (ID={BudgetId}) - 检查年度周期: 参考年份={RefYear}, 当前年份={CurrentYear}, 是否为当前周期={IsCurrent}", - mandatoryIndex, mandatoryBudgets.Count, budget.Name, budget.Id, + logger.LogInformation("硬性预算 {MandatoryIndex}/{MandatoryCount}: {BudgetName} - 检查年度周期: 参考年份={RefYear}, 当前年份={CurrentYear}, 是否为当前周期={IsCurrent}", + mandatoryIndex, mandatoryBudgets.Count, budget.Name, referenceDate.Year, now.Year, isCurrentPeriod); } @@ -830,26 +976,26 @@ public class BudgetStatsService( budget.Name, budget.Limit, daysInYear, daysElapsed, mandatoryAccumulation); } - logger.LogInformation("硬性预算 {BudgetName} (ID={BudgetId}) 应累加值计算: 算法={Algorithm}", - budget.Name, budget.Id, accumulationAlgorithm); + logger.LogInformation("硬性预算 {BudgetName} 应累加值计算: 算法={Algorithm}", + budget.Name, accumulationAlgorithm); // 如果趋势数据中的累计值小于硬性预算的应累加值,使用硬性预算的值 if (adjustedTotal < mandatoryAccumulation) { var adjustmentAmount = mandatoryAccumulation - adjustedTotal; - logger.LogInformation("硬性预算 {BudgetName} (ID={BudgetId}) 触发调整: 调整前总计={BeforeTotal}, 应累加值={MandatoryAccumulation}, 调整金额={AdjustmentAmount}, 调整后总计={AfterTotal}", - budget.Name, budget.Id, adjustedTotal, mandatoryAccumulation, adjustmentAmount, mandatoryAccumulation); + logger.LogInformation("硬性预算 {BudgetName} 触发调整: 调整前总计={BeforeTotal}, 应累加值={MandatoryAccumulation}, 调整金额={AdjustmentAmount}, 调整后总计={AfterTotal}", + budget.Name, adjustedTotal, mandatoryAccumulation, adjustmentAmount, mandatoryAccumulation); adjustedTotal = mandatoryAccumulation; } else { - logger.LogDebug("硬性预算 {BudgetName} (ID={BudgetId}) 未触发调整: 当前总计={CurrentTotal} >= 应累加值={MandatoryAccumulation}", - budget.Name, budget.Id, adjustedTotal, mandatoryAccumulation); + logger.LogDebug("硬性预算 {BudgetName} 未触发调整: 当前总计={CurrentTotal} >= 应累加值={MandatoryAccumulation}", + budget.Name, adjustedTotal, mandatoryAccumulation); } } else { - logger.LogInformation("硬性预算 {BudgetName} (ID={BudgetId}) 不在当前统计周期,跳过调整", budget.Name, budget.Id); + logger.LogInformation("硬性预算 {BudgetName} 不在当前统计周期,跳过调整", budget.Name); } } @@ -937,4 +1083,263 @@ public class BudgetStatsService( public int ArchiveMonth { get; set; } // 归档月份(1-12),用于标识归档数据来自哪个月 public int RemainingMonths { 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); + + description.AppendLine($""" + + + + + + + """); + } + + description.AppendLine("
名称单月预算剩余月份合计额度
{budget.Name}{budget.Limit:N0}{budget.RemainingMonths}{budgetLimit:N0}
"); + } + + // 年度预算明细 + 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); + 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) + "月"; + } } \ No newline at end of file diff --git a/Web/src/components/Budget/BudgetChartAnalysis.vue b/Web/src/components/Budget/BudgetChartAnalysis.vue index 5626da4..49fde20 100644 --- a/Web/src/components/Budget/BudgetChartAnalysis.vue +++ b/Web/src/components/Budget/BudgetChartAnalysis.vue @@ -7,8 +7,14 @@
- {{ activeTab === BudgetCategory.Expense ? '使用情况' : '完成情况' }} - (月度) + {{ activeTab === BudgetCategory.Expense ? '使用情况(月度)' : '完成情况(月度)' }} +
@@ -50,8 +56,14 @@
- {{ activeTab === BudgetCategory.Expense ? '使用情况' : '完成情况' }} - (年度) + {{ activeTab === BudgetCategory.Expense ? '使用情况(年度)' : '完成情况(年度)' }} +
@@ -161,6 +173,19 @@ />
+ + + +
+