diff --git a/Service/Budget/BudgetService.cs b/Service/Budget/BudgetService.cs index 8dcdf1f..c616b65 100644 --- a/Service/Budget/BudgetService.cs +++ b/Service/Budget/BudgetService.cs @@ -33,7 +33,8 @@ public class BudgetService( IMessageService messageService, ILogger logger, IBudgetSavingsService budgetSavingsService, - IDateTimeProvider dateTimeProvider + IDateTimeProvider dateTimeProvider, + IBudgetStatsService budgetStatsService ) : IBudgetService { public async Task> GetListAsync(DateTime referenceDate) @@ -109,17 +110,7 @@ public class BudgetService( public async Task GetCategoryStatsAsync(BudgetCategory category, DateTime referenceDate) { - var budgets = await GetListAsync(referenceDate); - - var result = new BudgetCategoryStats(); - - // 获取月度统计 - result.Month = await CalculateCategoryStatsAsync(budgets, category, BudgetPeriodType.Month, referenceDate); - - // 获取年度统计 - result.Year = await CalculateCategoryStatsAsync(budgets, category, BudgetPeriodType.Year, referenceDate); - - return result; + return await budgetStatsService.GetCategoryStatsAsync(category, referenceDate); } public async Task> GetUncoveredCategoriesAsync(BudgetCategory category, DateTime? referenceDate = null) @@ -163,179 +154,7 @@ public class BudgetService( return archive?.Summary; } - private async Task CalculateCategoryStatsAsync( - List budgets, - BudgetCategory category, - BudgetPeriodType statType, - DateTime referenceDate) - { - var result = new BudgetStatsDto - { - PeriodType = statType, - Rate = 0, - Current = 0, - Limit = 0, - Count = 0 - }; - // 获取当前分类下所有预算,排除不记额预算 - var relevant = budgets - .Where(b => b.Category == category && !b.NoLimit) - .ToList(); - - // 月度统计中,只包含月度预算;年度统计中,包含所有预算 - if (statType == BudgetPeriodType.Month) - { - relevant = relevant.Where(b => b.Type == BudgetPeriodType.Month).ToList(); - } - - if (relevant.Count == 0) - { - return result; - } - - result.Count = relevant.Count; - decimal totalCurrent = 0; - decimal totalLimit = 0; - - // 是否可以使用趋势统计来计算实际发生额(避免多预算重复计入同一笔账) - var transactionType = category switch - { - BudgetCategory.Expense => TransactionType.Expense, - BudgetCategory.Income => TransactionType.Income, - _ => TransactionType.None - }; - - foreach (var budget in relevant) - { - // 限额折算 - var itemLimit = budget.Limit; - if (statType == BudgetPeriodType.Year && budget.Type == BudgetPeriodType.Month) - { - // 年度视图下,月度预算折算为年度 - itemLimit = budget.Limit * 12; - } - totalLimit += itemLimit; - - // 先逐预算累加当前值(作为后备值) - var selectedCategories = string.Join(',', budget.SelectedCategories); - var currentAmount = await CalculateCurrentAmountAsync(new() - { - Name = budget.Name, - Type = budget.Type, - Limit = budget.Limit, - Category = budget.Category, - SelectedCategories = selectedCategories, - StartDate = new DateTime(referenceDate.Year, referenceDate.Month, 1), - IsMandatoryExpense = budget.IsMandatoryExpense - }, referenceDate); - - if (statType == BudgetPeriodType.Month) - { - totalCurrent += currentAmount; - } - else if (statType == BudgetPeriodType.Year) - { - // 年度视图下,累加所有预算的当前值 - totalCurrent += currentAmount; - } - } - - result.Limit = totalLimit; - - // 计算每日/每月趋势 - if (transactionType != TransactionType.None) - { - var hasGlobalBudget = relevant.Any(b => b.SelectedCategories.Length == 0); - - var allClassifies = hasGlobalBudget - ? [] - : relevant - .SelectMany(b => b.SelectedCategories) - .Distinct() - .ToList(); - - DateTime startDate, endDate; - bool groupByMonth; - - if (statType == BudgetPeriodType.Month) - { - startDate = new DateTime(referenceDate.Year, referenceDate.Month, 1); - endDate = startDate.AddMonths(1).AddDays(-1); - groupByMonth = false; - } - else // Year - { - startDate = new DateTime(referenceDate.Year, 1, 1); - endDate = startDate.AddYears(1).AddDays(-1); - groupByMonth = true; - } - - var dailyStats = await transactionRecordRepository.GetFilteredTrendStatisticsAsync( - startDate, - endDate, - transactionType, - allClassifies, - groupByMonth); - - decimal accumulated = 0; - decimal lastValidAccumulated = 0; - var now = dateTimeProvider.Now; - - if (statType == BudgetPeriodType.Month) - { - var daysInMonth = DateTime.DaysInMonth(startDate.Year, startDate.Month); - 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); - continue; - } - - if (dailyStats.TryGetValue(currentDate.Date, out var amount)) - { - accumulated += amount; - lastValidAccumulated = accumulated; - } - result.Trend.Add(accumulated); - } - } - else // Year - { - 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); - continue; - } - - if (dailyStats.TryGetValue(currentMonthDate, out var amount)) - { - accumulated += amount; - lastValidAccumulated = accumulated; - } - result.Trend.Add(accumulated); - } - } - - // 如果有有效的趋势数据,使用去重后的实际发生额(趋势的累计值),避免同一账单被多预算重复计入 - // 否则使用前面逐预算累加的值(作为后备) - if (lastValidAccumulated > 0) - { - totalCurrent = lastValidAccumulated; - } - } - - result.Current = totalCurrent; - result.Rate = totalLimit > 0 ? totalCurrent / totalLimit * 100 : 0; - - return result; - } public async Task ArchiveBudgetsAsync(int year, int month) { diff --git a/Service/Budget/BudgetStatsService.cs b/Service/Budget/BudgetStatsService.cs new file mode 100644 index 0000000..4dad8db --- /dev/null +++ b/Service/Budget/BudgetStatsService.cs @@ -0,0 +1,940 @@ +namespace Service.Budget; + +public interface IBudgetStatsService +{ + /// + /// 获取指定分类的统计信息(月度和年度) + /// + Task GetCategoryStatsAsync(BudgetCategory category, DateTime referenceDate); +} + +[UsedImplicitly] +public class BudgetStatsService( + IBudgetRepository budgetRepository, + IBudgetArchiveRepository budgetArchiveRepository, + ITransactionRecordRepository transactionRecordRepository, + 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); + 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. 计算当前实际值(避免重复计算同一笔交易) + 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("开始获取交易趋势统计数据"); + var dailyStats = await transactionRecordRepository.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); + } + result.Trend.Add(accumulated); + } + + totalCurrent = accumulated; + logger.LogDebug("交易累计值计算完成: {TotalCurrent}", totalCurrent); + } + 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); + } + + // 对于硬性预算,如果当前月份且实际值为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; + + // 4. 计算使用率 + result.Rate = totalLimit > 0 ? result.Current / totalLimit * 100 : 0; + logger.LogDebug("使用率计算完成: {Rate:F2}%", result.Rate); + + // 5. 生成计算明细汇总日志 + var limitParts = new List(); + var currentParts = new List(); + budgetIndex = 0; + foreach (var budget in budgets) + { + 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); + + 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} (ID={BudgetId}) - 原始预算金额: {BudgetLimit}, 当前实际金额: {CurrentAmount}, 预算类型: {BudgetType}, 算法: {Algorithm}", + budgetIndex, budgets.Count, budget.Name, budget.Id, 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. 计算当前实际值(避免重复计算同一笔交易) + 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.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 transactionRecordRepository.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); + } + result.Trend.Add(accumulated); + } + + totalCurrent = accumulated; + logger.LogDebug("交易累计值计算完成: {TotalCurrent}", totalCurrent); + } + 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); + } + + // 对于硬性预算,如果当前年份且实际值为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; + + // 4. 计算使用率 + result.Rate = totalLimit > 0 ? result.Current / totalLimit * 100 : 0; + logger.LogDebug("使用率计算完成: {Rate:F2}%", result.Rate); + + // 5. 生成计算明细汇总日志 + var limitParts = new List(); + var currentParts = new List(); + budgetIndex = 0; + foreach (var budget in budgets) + { + 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); + + 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} (ID={BudgetId}) - {Month}月归档, 预算金额: {Limit}, 实际金额: {Actual}", + item.Name, item.Id, 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} (ID={BudgetId}) - 预算金额: {Limit}, 实际金额: {Actual}", + item.Name, item.Id, 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} (ID={BudgetId}) - 预算金额: {Limit}, 实际金额: {Current}", + budget.Name, budget.Id, budget.Limit, currentAmount); + } + // 对于月度预算,添加当前及未来月份的预算(标记剩余月份数) + else if (budget.Type == BudgetPeriodType.Month) + { + var remainingMonths = 12 - now.Month + 1; // 包括当前月 + 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, + RemainingMonths = remainingMonths + }); + logger.LogInformation("添加当前月度预算(剩余月份): {BudgetName} (ID={BudgetId}) - 预算金额: {Limit}, 剩余月份: {RemainingMonths}", + budget.Name, budget.Id, 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} (ID={BudgetId}) - 归档月份: {Year}-{Month:00}, 预算金额: {BudgetLimit}, 实际金额: {ActualAmount}", + item.Name, item.Id, 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} (ID={BudgetId}) - 预算金额: {BudgetLimit}, 实时计算实际金额: {CurrentAmount}, 预算类型: {BudgetType}", + budget.Name, budget.Id, 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} (ID={BudgetId}) 为不记额预算,限额返回0", budget.Name, budget.Id); + 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.RemainingMonths > 0) + { + itemLimit = budget.Limit * budget.RemainingMonths; + algorithmDescription = $"月度预算剩余月份折算: {budget.Limit} × {budget.RemainingMonths} (剩余月份) = {itemLimit}"; + } + // 兼容旧逻辑(如果没有设置RemainingMonths) + else + { + logger.LogWarning("预算 {BudgetName} (ID={BudgetId}) 年度统计时未设置RemainingMonths,使用默认折算逻辑", budget.Name, budget.Id); + 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} (ID={BudgetId}) 限额计算: 原始预算={OriginalLimit}, 计算后限额={CalculatedLimit}, 算法: {Algorithm}", + budget.Name, budget.Id, 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} (ID={BudgetId}) - 检查月度周期: 参考月份={RefMonth}, 当前月份={CurrentMonth}, 是否为当前周期={IsCurrent}", + mandatoryIndex, mandatoryBudgets.Count, budget.Name, budget.Id, + 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, + 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} (ID={BudgetId}) 应累加值计算: 算法={Algorithm}", + budget.Name, budget.Id, 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); + adjustedTotal = mandatoryAccumulation; + } + else + { + logger.LogDebug("硬性预算 {BudgetName} (ID={BudgetId}) 未触发调整: 当前总计={CurrentTotal} >= 应累加值={MandatoryAccumulation}", + budget.Name, budget.Id, adjustedTotal, mandatoryAccumulation); + } + } + else + { + logger.LogInformation("硬性预算 {BudgetName} (ID={BudgetId}) 不在当前统计周期,跳过调整", budget.Name, budget.Id); + } + } + + 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; } // 剩余月份数,用于年度统计时的月度预算折算 + } +} \ No newline at end of file diff --git a/Web/src/api/log.js b/Web/src/api/log.js index 1e395f2..7cd75e6 100644 --- a/Web/src/api/log.js +++ b/Web/src/api/log.js @@ -1,4 +1,4 @@ -import request from './request' +import request from './request' /** * 日志相关 API @@ -12,6 +12,7 @@ * @param {string} [params.searchKeyword] - 搜索关键词 * @param {string} [params.logLevel] - 日志级别 * @param {string} [params.date] - 日期 (yyyyMMdd) + * @param {string} [params.className] - 类名 * @returns {Promise<{success: boolean, data: Array, total: number}>} */ export const getLogList = (params = {}) => { @@ -32,3 +33,34 @@ export const getAvailableDates = () => { method: 'get' }) } + +/** + * 获取可用的类名列表 + * @param {Object} params - 查询参数 + * @param {string} [params.date] - 日期 (yyyyMMdd) + * @returns {Promise<{success: boolean, data: Array}>} + */ +export const getAvailableClassNames = (params = {}) => { + return request({ + url: '/Log/GetAvailableClassNames', + method: 'get', + params + }) +} + +/** + * 根据请求ID查询关联日志 + * @param {Object} params - 查询参数 + * @param {string} params.requestId - 请求ID + * @param {number} [params.pageIndex=1] - 页码 + * @param {number} [params.pageSize=50] - 每页条数 + * @param {string} [params.date] - 日期 (yyyyMMdd) + * @returns {Promise<{success: boolean, data: Array, total: number}>} + */ +export const getLogsByRequestId = (params = {}) => { + return request({ + url: '/Log/GetLogsByRequestId', + method: 'get', + params + }) +} diff --git a/Web/src/api/request.js b/Web/src/api/request.js index b3829d1..7fe193b 100644 --- a/Web/src/api/request.js +++ b/Web/src/api/request.js @@ -1,4 +1,4 @@ -import axios from 'axios' +import axios from 'axios' import { showToast } from 'vant' import { useAuthStore } from '@/stores/auth' import router from '@/router' @@ -12,6 +12,15 @@ const request = axios.create({ } }) +// 生成请求ID +const generateRequestId = () => { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + const r = Math.random() * 16 | 0 + const v = c === 'x' ? r : (r & 0x3 | 0x8) + return v.toString(16) + }) +} + // 请求拦截器 request.interceptors.request.use( (config) => { @@ -20,6 +29,11 @@ request.interceptors.request.use( if (authStore.token) { config.headers.Authorization = `Bearer ${authStore.token}` } + + // 添加请求ID + const requestId = generateRequestId() + config.headers['X-Request-ID'] = requestId + return config }, (error) => { diff --git a/Web/src/views/LogView.vue b/Web/src/views/LogView.vue index 51a7927..609ef06 100644 --- a/Web/src/views/LogView.vue +++ b/Web/src/views/LogView.vue @@ -1,4 +1,4 @@ -