using Microsoft.Extensions.Logging; using NSubstitute.ReturnsExtensions; using Service.Transaction; namespace WebApi.Test.Budget; /// /// 预算统计 - 归档数据重复计算测试 /// public class BudgetStatsArchiveTest : BaseTest { private readonly IBudgetRepository _budgetRepo = Substitute.For(); private readonly IBudgetArchiveRepository _archiveRepo = Substitute.For(); private readonly ITransactionStatisticsService _transactionStatsService = Substitute.For(); private readonly IDateTimeProvider _dateTimeProvider = Substitute.For(); private IBudgetStatsService CreateService() { return new BudgetStatsService( _budgetRepo, _archiveRepo, _transactionStatsService, _dateTimeProvider, Substitute.For>() ); } /// /// 测试场景:当前为2月,用户切换到1月(已归档)查看预算 /// 预期:年度统计不应重复计算1月的数据 /// [Fact] public async Task GetCategoryStats_切换到已归档月份_年度统计不重复计算_Test() { // Arrange - 模拟当前时间为2026年2月1日 var now = new DateTime(2026, 2, 1); _dateTimeProvider.Now.Returns(now); // 用户在前端选择查看1月的预算(referenceDate = 2026-01-01) var referenceDate = new DateTime(2026, 1, 1); // 创建一个月度预算:房贷 var monthlyBudget = new BudgetRecord { Id = 100, Name = "房贷", Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, Limit = 9000, // 每月9000元 StartDate = new DateTime(2026, 1, 1), SelectedCategories = "房贷", IsMandatoryExpense = false, NoLimit = false }; // 当前预算列表 _budgetRepo.GetAllAsync().Returns([monthlyBudget]); // 1月的归档数据(实际支出9158.7) var januaryArchive = new BudgetArchive { Year = 2026, Month = 1, Content = new[] { new BudgetArchiveContent { Id = 100, Name = "房贷", Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, Limit = 9000, Actual = 9158.7m, // 1月实际支出 SelectedCategories = ["房贷"], IsMandatoryExpense = false, NoLimit = false } } }; _archiveRepo.GetArchiveAsync(2026, 1).Returns(januaryArchive); _archiveRepo.GetArchiveAsync(2026, 2).ReturnsNull(); // 模拟2月的实际交易数据(假设2月到现在实际支出了3000) var feb1 = new DateTime(2026, 2, 1); var feb28 = new DateTime(2026, 2, 28); _budgetRepo.GetCurrentAmountAsync( Arg.Is(b => b.Id == 100), Arg.Is(d => d >= feb1 && d <= feb28), Arg.Any() ).Returns(3000m); // 模拟交易统计数据(用于趋势图) _transactionStatsService.GetFilteredTrendStatisticsAsync( Arg.Any(), Arg.Any(), TransactionType.Expense, Arg.Any>(), true ).Returns(new Dictionary { { new DateTime(2026, 1, 1), 9158.7m }, // 1月 { new DateTime(2026, 2, 1), 3000m } // 2月 }); // 模拟月度统计的交易数据 _transactionStatsService.GetFilteredTrendStatisticsAsync( Arg.Any(), Arg.Any(), TransactionType.Expense, Arg.Any>() ).Returns(new Dictionary { { new DateTime(2026, 1, 1), 9158.7m } }); var service = CreateService(); // Act - 调用获取分类统计(用户选择查看1月) var result = await service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate); // Assert - 验证年度统计 result.Should().NotBeNull(); result.Year.Should().NotBeNull(); // 年度预算限额 = 1月归档(9000) + 2月当前(9000) + 未来10个月(9000 * 10) = 108000 result.Year.Limit.Should().Be(108000); // 年度实际支出 = 1月归档(9158.7) + 2月当前(3000) = 12158.7 // 关键:不应该包含两次1月的数据! result.Year.Current.Should().Be(12158.7m); // 使用率 = 12158.7 / 108000 * 100 = 11.26% result.Year.Rate.Should().BeApproximately(11.26m, 0.01m); } /// /// 测试场景:当前为2月,用户切换到1月(已归档)查看预算,包含年度预算 /// 预期:年度预算只计算一次 /// [Fact] public async Task GetCategoryStats_年度预算_切换到已归档月份_不重复计算_Test() { // Arrange - 模拟当前时间为2026年2月1日 var now = new DateTime(2026, 2, 1); _dateTimeProvider.Now.Returns(now); var referenceDate = new DateTime(2026, 1, 1); // 创建年度预算和月度预算 var yearlyBudget = new BudgetRecord { Id = 200, Name = "教育费", Category = BudgetCategory.Expense, Type = BudgetPeriodType.Year, Limit = 8000, // 全年8000元 StartDate = new DateTime(2026, 1, 1), SelectedCategories = "教育", IsMandatoryExpense = false, NoLimit = false }; var monthlyBudget = new BudgetRecord { Id = 100, Name = "生活费", Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, Limit = 2000, StartDate = new DateTime(2026, 1, 1), SelectedCategories = "餐饮", IsMandatoryExpense = false, NoLimit = false }; _budgetRepo.GetAllAsync().Returns([yearlyBudget, monthlyBudget]); // 1月归档数据 var januaryArchive = new BudgetArchive { Year = 2026, Month = 1, Content = new[] { new BudgetArchiveContent { Id = 200, Name = "教育费", Category = BudgetCategory.Expense, Type = BudgetPeriodType.Year, Limit = 8000, Actual = 7257m, // 全年实际(从1月累计) SelectedCategories = ["教育"], IsMandatoryExpense = false, NoLimit = false }, new BudgetArchiveContent { Id = 100, Name = "生活费", Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, Limit = 2000, Actual = 2000m, // 1月实际 SelectedCategories = ["餐饮"], IsMandatoryExpense = false, NoLimit = false } } }; _archiveRepo.GetArchiveAsync(2026, 1).Returns(januaryArchive); _archiveRepo.GetArchiveAsync(2026, 2).ReturnsNull(); // 2月的实际数据 var feb1 = new DateTime(2026, 2, 1); var feb28 = new DateTime(2026, 2, 28); _budgetRepo.GetCurrentAmountAsync( Arg.Is(b => b.Id == 100), Arg.Is(d => d >= feb1 && d <= feb28), Arg.Any() ).Returns(1800m); // 年度预算的当前实际值(整年累计,包括1月归档的7257) var year1 = new DateTime(2026, 1, 1); var year12 = new DateTime(2026, 12, 31); _budgetRepo.GetCurrentAmountAsync( Arg.Is(b => b.Id == 200), Arg.Is(d => d >= year1), Arg.Any() ).Returns(7257m); // 全年累计 _transactionStatsService.GetFilteredTrendStatisticsAsync( Arg.Any(), Arg.Any(), TransactionType.Expense, Arg.Any>(), true ).Returns(new Dictionary { { new DateTime(2026, 1, 1), 9257m }, // 1月: 教育7257 + 生活2000 { new DateTime(2026, 2, 1), 1800m } // 2月: 生活1800 }); // 模拟月度统计的交易数据 _transactionStatsService.GetFilteredTrendStatisticsAsync( Arg.Any(), Arg.Any(), TransactionType.Expense, Arg.Any>() ).Returns(new Dictionary { { new DateTime(2026, 1, 1), 9257m } }); var service = CreateService(); // Act var result = await service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate); // Assert result.Year.Should().NotBeNull(); // 年度限额 = 教育费(8000) + 生活费1月归档(2000) + 生活费2月(2000) + 生活费未来10月(2000*10) = 32000 result.Year.Limit.Should().Be(32000); // 年度实际支出 = 教育费(7257) + 生活费1月(2000) + 生活费2月(1800) = 11057 // 关键:教育费(年度预算)只应该计算一次! result.Year.Current.Should().Be(11057m); } /// /// 测试场景:当前为3月,用户切换到1月查看 /// 预期:年度统计应包含1月归档 + 2月归档 + 3月当前 /// [Fact] public async Task GetCategoryStats_多个归档月份_不重复计算_Test() { // Arrange - 模拟当前时间为2026年3月15日 var now = new DateTime(2026, 3, 15); _dateTimeProvider.Now.Returns(now); var referenceDate = new DateTime(2026, 1, 1); var monthlyBudget = new BudgetRecord { Id = 100, Name = "房贷", Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, Limit = 9000, StartDate = new DateTime(2026, 1, 1), SelectedCategories = "房贷", IsMandatoryExpense = false, NoLimit = false }; _budgetRepo.GetAllAsync().Returns([monthlyBudget]); // 1月归档 _archiveRepo.GetArchiveAsync(2026, 1).Returns(new BudgetArchive { Year = 2026, Month = 1, Content = new[] { new BudgetArchiveContent { Id = 100, Name = "房贷", Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, Limit = 9000, Actual = 9158.7m, SelectedCategories = ["房贷"], IsMandatoryExpense = false, NoLimit = false } } }); // 2月归档 _archiveRepo.GetArchiveAsync(2026, 2).Returns(new BudgetArchive { Year = 2026, Month = 2, Content = new[] { new BudgetArchiveContent { Id = 100, Name = "房贷", Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, Limit = 9000, Actual = 9126.1m, SelectedCategories = ["房贷"], IsMandatoryExpense = false, NoLimit = false } } }); _archiveRepo.GetArchiveAsync(2026, 3).ReturnsNull(); // 3月当前实际数据(到3月15日) _budgetRepo.GetCurrentAmountAsync( Arg.Is(b => b.Id == 100), Arg.Any(), Arg.Any() ).Returns(4500m); // 3月已支出4500 _transactionStatsService.GetFilteredTrendStatisticsAsync( Arg.Any(), Arg.Any(), TransactionType.Expense, Arg.Any>(), true ).Returns(new Dictionary { { new DateTime(2026, 1, 1), 9158.7m }, { new DateTime(2026, 2, 1), 9126.1m }, { new DateTime(2026, 3, 1), 4500m } }); // 模拟月度统计的交易数据 _transactionStatsService.GetFilteredTrendStatisticsAsync( Arg.Any(), Arg.Any(), TransactionType.Expense, Arg.Any>() ).Returns(new Dictionary { { new DateTime(2026, 1, 1), 9158.7m } }); var service = CreateService(); // Act var result = await service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate); // Assert result.Year.Should().NotBeNull(); // 年度限额 = 1月归档(9000) + 2月归档(9000) + 3月当前(9000) + 未来9月(9000*9) = 108000 result.Year.Limit.Should().Be(108000); // 年度实际 = 1月归档(9158.7) + 2月归档(9126.1) + 3月当前(4500) = 22784.8 result.Year.Current.Should().Be(22784.8m); // 验证每个月只计算了一次 result.Year.Rate.Should().BeApproximately(21.10m, 0.01m); } }