Files
EmailBill/Service/Budget/BudgetStatsService.cs
SunCheng e93c3d6bae
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 27s
Docker Build & Deploy / Deploy to Production (push) Successful in 9s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 2s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s
测试覆盖率
2026-01-28 17:00:58 +08:00

1279 lines
61 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Service.Transaction;
namespace Service.Budget;
public interface IBudgetStatsService
{
/// <summary>
/// 获取指定分类的统计信息(月度和年度)
/// </summary>
Task<BudgetCategoryStats> GetCategoryStatsAsync(BudgetCategory category, DateTime referenceDate);
}
[UsedImplicitly]
public class BudgetStatsService(
IBudgetRepository budgetRepository,
IBudgetArchiveRepository budgetArchiveRepository,
ITransactionStatisticsService transactionStatisticsService,
IDateTimeProvider dateTimeProvider,
ILogger<BudgetStatsService> logger
) : IBudgetStatsService
{
public async Task<BudgetCategoryStats> 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<BudgetStatsDto> 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;
foreach (var budget in budgets)
{
var itemLimit = CalculateBudgetLimit(budget, BudgetPeriodType.Month, referenceDate);
totalLimit += itemLimit;
}
result.Limit = totalLimit;
logger.LogDebug("限额总值计算完成: {TotalLimit}", totalLimit);
// 3. 计算当前实际值
// 使用 GetAllBudgetsWithArchiveAsync 中已经计算好的 Current 值
var 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);
logger.LogDebug("获取到 {DayCount} 天的交易数据", dailyStats.Count);
// 计算累计值(用于趋势图)
decimal accumulated = 0;
var daysInMonth = DateTime.DaysInMonth(startDate.Year, startDate.Month);
logger.LogDebug("本月天数: {DaysInMonth}", daysInMonth);
for (var 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();
// 检查是否为当前月份
var isCurrentMonth = referenceDate.Year == now.Year && referenceDate.Month == now.Month;
if (isCurrentMonth && currentDate.Date <= now.Date)
{
// 关键accumulated是所有预算的实际交易累计不包含虚拟消耗
// totalMandatoryVirtual是所有硬性预算的虚拟消耗
// 但如果硬性预算有实际交易accumulated中已经包含了会重复
// 所以需要accumulated + (totalMandatoryVirtual - 硬性预算的实际交易部分)
// 更简单的理解:
// - 如果某个硬性预算本月完全没有交易记录它的虚拟值应该加到accumulated上
// - 如果某个硬性预算有部分交易记录,应该补齐到虚拟值
// - 实现:取 max(accumulated, totalMandatoryVirtual) 是不对的
// - 正确accumulated + 硬性预算中没有实际交易的那部分的虚拟值
// 由于无法精确区分,采用近似方案:
// 计算所有硬性预算的Current总和这个值已经包含了虚拟消耗在CalculateCurrentAmountAsync中处理
// 计算非硬性预算的交易累计这部分在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元就没有实际交易
var 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<BudgetStatsDto> 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;
var 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);
// 计算当前实际值,考虑硬性预算的特殊逻辑
var 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 (var 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();
// 检查是否为当前年份
var 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;
}
// 判断该硬性预算是否有实际交易
var 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<List<BudgetStatsItem>> 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<BudgetStatsItem>();
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<long>();
// 1. 处理历史归档月份1月到当前月-1
if (referenceDate.Year == now.Year && now.Month > 1)
{
logger.LogDebug("开始处理历史归档月份: 1月到{Month}月", now.Month - 1);
for (var 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.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)
{
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)
{
var 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();
var 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;
var 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<decimal> 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<BudgetStatsItem> 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);
var mandatoryIndex = 0;
foreach (var budget in mandatoryBudgets)
{
mandatoryIndex++;
// 检查是否为当前统计周期
bool isCurrentPeriod;
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;
var 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 (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 record BudgetStatsItem
{
public long Id { get; init; }
public string Name { get; init; } = string.Empty;
public BudgetPeriodType Type { get; init; }
public decimal Limit { get; init; }
public decimal Current { get; init; }
public BudgetCategory Category { get; set; }
public string[] SelectedCategories { get; init; } = [];
public bool NoLimit { get; init; }
public bool IsMandatoryExpense { get; init; }
public bool IsArchive { get; init; }
public int ArchiveMonth { get; init; } // 归档月份1-12用于标识归档数据来自哪个月
public int RemainingMonths { get; init; } // 剩余月份数,用于年度统计时的月度预算折算
public bool IsCurrentMonth { get; init; } // 标记是否为当前月的预算(用于年度统计中月度预算的计算)
}
private string GenerateMonthlyDescription(List<BudgetStatsItem> budgets, decimal totalLimit, decimal totalCurrent, DateTime referenceDate, BudgetCategory category)
{
var description = new StringBuilder();
var categoryName = category == BudgetCategory.Expense ? "支出" : "收入";
description.AppendLine($"<h3>月度{categoryName}预算明细</h3>");
description.AppendLine("""
<table>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
""");
foreach (var budget in budgets)
{
var budgetLimit = CalculateBudgetLimit(budget, BudgetPeriodType.Month, referenceDate);
var typeLabel = budget.IsMandatoryExpense ? "硬性" : "普通";
var archiveLabel = budget.IsArchive ? $" ({budget.ArchiveMonth}月归档)" : "";
description.AppendLine($"""
<tr>
<td>{budget.Name}{archiveLabel}</td>
<td>{budgetLimit:N0}</td>
<td><span class='{(category == BudgetCategory.Expense ? "expense-value" : "income-value")}'>{budget.Current:N1}</span></td>
<td>{typeLabel}</td>
</tr>
""");
}
description.AppendLine("</tbody></table>");
// 计算公式
description.AppendLine($"<h3>计算公式</h3>");
description.AppendLine($"<p><strong>预算额度合计:</strong>");
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)} = <span class='{(category == BudgetCategory.Expense ? "expense-value" : "income-value")}'><strong>{totalLimit:N0}</strong></span></p>");
description.AppendLine($"<p><strong>实际{categoryName}合计:</strong>");
var currentParts = budgets.Select(b =>
{
var archiveLabel = b.IsArchive ? $"({b.ArchiveMonth}月归档)" : "";
return $"{b.Name}{archiveLabel}({b.Current:N1})";
});
description.AppendLine($"{string.Join(" + ", currentParts)} = <span class='{(category == BudgetCategory.Expense ? "expense-value" : "income-value")}'><strong>{totalCurrent:N1}</strong></span></p>");
var rate = totalLimit > 0 ? totalCurrent / totalLimit * 100 : 0;
description.AppendLine($"<p><strong>使用率:</strong>{totalCurrent:N1} ÷ {totalLimit:N0} × 100% = <span class='highlight'><strong>{rate:F2}%</strong></span></p>");
return description.ToString();
}
private string GenerateYearlyDescription(List<BudgetStatsItem> budgets, decimal totalLimit, decimal totalCurrent, DateTime referenceDate, BudgetCategory category)
{
var description = new StringBuilder();
var categoryName = category == BudgetCategory.Expense ? "支出" : "收入";
// 分组:归档的月度预算、归档的年度预算、当前月度预算(剩余月份)、当前年度预算
var archivedMonthlyBudgets = budgets.Where(b => b is { IsArchive: true, Type: BudgetPeriodType.Month }).ToList();
var archivedYearlyBudgets = budgets.Where(b => b is { IsArchive: true, Type: BudgetPeriodType.Year }).ToList();
var currentMonthlyBudgets = budgets.Where(b => b is { IsArchive: false, Type: BudgetPeriodType.Month }).ToList();
var currentYearlyBudgets = budgets.Where(b => b is { IsArchive: false, Type: BudgetPeriodType.Year }).ToList();
// 归档月度预算明细
if (archivedMonthlyBudgets.Any())
{
description.AppendLine($"<h3>已归档月度{categoryName}预算</h3>");
description.AppendLine("""
<table>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
""");
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($"""
<tr>
<td>{first.Name}</td>
<td>{groupLimit:N0}</td>
<td><span class='{(category == BudgetCategory.Expense ? "expense-value" : "income-value")}'>{groupCurrent:N1}</span></td>
<td>{monthsText}</td>
</tr>
""");
}
description.AppendLine("</tbody></table>");
}
// 当前月度预算(剩余月份)
if (currentMonthlyBudgets.Any())
{
description.AppendLine($"<h3>当前月度{categoryName}预算(当前月及未来月)</h3>");
description.AppendLine("""
<table>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
""");
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($"""
<tr>
<td>{budget.Name}</td>
<td>{typeStr}</td>
<td>{calcStr}</td>
<td>{budgetLimit:N0}</td>
<td>{(budget.IsCurrentMonth ? budget.Current.ToString("N1") : "-")}</td>
</tr>
""");
}
description.AppendLine("</tbody></table>");
}
// 年度预算明细
if (archivedYearlyBudgets.Any() || currentYearlyBudgets.Any())
{
description.AppendLine($"<h3>年度{categoryName}预算</h3>");
description.AppendLine("""
<table>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
""");
foreach (var budget in archivedYearlyBudgets.Concat(currentYearlyBudgets))
{
var statusLabel = budget.IsArchive ? "归档" : "当前";
var typeLabel = budget.IsMandatoryExpense ? "硬性" : "普通";
description.AppendLine($"""
<tr>
<td>{budget.Name}</td>
<td>{budget.Limit:N0}</td>
<td><span class='{(category == BudgetCategory.Expense ? "expense-value" : "income-value")}'>{budget.Current:N1}</span></td>
<td>{statusLabel}/{typeLabel}</td>
</tr>
""");
}
description.AppendLine("</tbody></table>");
}
// 计算公式
description.AppendLine($"<h3>计算公式</h3>");
description.AppendLine($"<p><strong>年度预算额度合计:</strong>");
var limitParts = new List<string>();
// 归档月度预算部分
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)} = <span class='{(category == BudgetCategory.Expense ? "expense-value" : "income-value")}'><strong>{totalLimit:N0}</strong></span></p>");
description.AppendLine($"<p><strong>实际{categoryName}合计:</strong>");
var currentParts = new List<string>();
// 归档月度预算的实际值
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)} = <span class='{(category == BudgetCategory.Expense ? "expense-value" : "income-value")}'><strong>{totalCurrent:N1}</strong></span></p>");
var rate = totalLimit > 0 ? totalCurrent / totalLimit * 100 : 0;
description.AppendLine($"<p><strong>使用率:</strong>{totalCurrent:N1} ÷ {totalLimit:N0} × 100% = <span class='highlight'><strong>{rate:F2}%</strong></span></p>");
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);
var isContinuous = true;
for (var 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) + "月";
}
}