2026-02-10 17:49:19 +08:00
|
|
|
|
using NSubstitute.ReturnsExtensions;
|
2026-02-01 10:27:04 +08:00
|
|
|
|
using Service.Transaction;
|
|
|
|
|
|
|
|
|
|
|
|
namespace WebApi.Test.Budget;
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 预算统计 - 归档数据重复计算测试
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public class BudgetStatsArchiveTest : BaseTest
|
|
|
|
|
|
{
|
|
|
|
|
|
private readonly IBudgetRepository _budgetRepo = Substitute.For<IBudgetRepository>();
|
|
|
|
|
|
private readonly IBudgetArchiveRepository _archiveRepo = Substitute.For<IBudgetArchiveRepository>();
|
|
|
|
|
|
private readonly ITransactionStatisticsService _transactionStatsService = Substitute.For<ITransactionStatisticsService>();
|
|
|
|
|
|
private readonly IDateTimeProvider _dateTimeProvider = Substitute.For<IDateTimeProvider>();
|
|
|
|
|
|
|
|
|
|
|
|
private IBudgetStatsService CreateService()
|
|
|
|
|
|
{
|
|
|
|
|
|
return new BudgetStatsService(
|
|
|
|
|
|
_budgetRepo,
|
|
|
|
|
|
_archiveRepo,
|
|
|
|
|
|
_transactionStatsService,
|
|
|
|
|
|
_dateTimeProvider,
|
|
|
|
|
|
Substitute.For<ILogger<BudgetStatsService>>()
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 测试场景:当前为2月,用户切换到1月(已归档)查看预算
|
|
|
|
|
|
/// 预期:年度统计不应重复计算1月的数据
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
[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<BudgetRecord>(b => b.Id == 100),
|
|
|
|
|
|
Arg.Is<DateTime>(d => d >= feb1 && d <= feb28),
|
|
|
|
|
|
Arg.Any<DateTime>()
|
|
|
|
|
|
).Returns(3000m);
|
|
|
|
|
|
|
|
|
|
|
|
// 模拟交易统计数据(用于趋势图)
|
|
|
|
|
|
_transactionStatsService.GetFilteredTrendStatisticsAsync(
|
|
|
|
|
|
Arg.Any<DateTime>(),
|
|
|
|
|
|
Arg.Any<DateTime>(),
|
|
|
|
|
|
TransactionType.Expense,
|
|
|
|
|
|
Arg.Any<List<string>>(),
|
|
|
|
|
|
true
|
|
|
|
|
|
).Returns(new Dictionary<DateTime, decimal>
|
|
|
|
|
|
{
|
|
|
|
|
|
{ new DateTime(2026, 1, 1), 9158.7m }, // 1月
|
|
|
|
|
|
{ new DateTime(2026, 2, 1), 3000m } // 2月
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 模拟月度统计的交易数据
|
|
|
|
|
|
_transactionStatsService.GetFilteredTrendStatisticsAsync(
|
|
|
|
|
|
Arg.Any<DateTime>(),
|
|
|
|
|
|
Arg.Any<DateTime>(),
|
|
|
|
|
|
TransactionType.Expense,
|
|
|
|
|
|
Arg.Any<List<string>>()
|
|
|
|
|
|
).Returns(new Dictionary<DateTime, decimal>
|
|
|
|
|
|
{
|
|
|
|
|
|
{ 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 测试场景:当前为2月,用户切换到1月(已归档)查看预算,包含年度预算
|
|
|
|
|
|
/// 预期:年度预算只计算一次
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
[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<BudgetRecord>(b => b.Id == 100),
|
|
|
|
|
|
Arg.Is<DateTime>(d => d >= feb1 && d <= feb28),
|
|
|
|
|
|
Arg.Any<DateTime>()
|
|
|
|
|
|
).Returns(1800m);
|
|
|
|
|
|
|
|
|
|
|
|
// 年度预算的当前实际值(整年累计,包括1月归档的7257)
|
|
|
|
|
|
var year1 = new DateTime(2026, 1, 1);
|
|
|
|
|
|
var year12 = new DateTime(2026, 12, 31);
|
|
|
|
|
|
_budgetRepo.GetCurrentAmountAsync(
|
|
|
|
|
|
Arg.Is<BudgetRecord>(b => b.Id == 200),
|
|
|
|
|
|
Arg.Is<DateTime>(d => d >= year1),
|
|
|
|
|
|
Arg.Any<DateTime>()
|
|
|
|
|
|
).Returns(7257m); // 全年累计
|
|
|
|
|
|
|
|
|
|
|
|
_transactionStatsService.GetFilteredTrendStatisticsAsync(
|
|
|
|
|
|
Arg.Any<DateTime>(),
|
|
|
|
|
|
Arg.Any<DateTime>(),
|
|
|
|
|
|
TransactionType.Expense,
|
|
|
|
|
|
Arg.Any<List<string>>(),
|
|
|
|
|
|
true
|
|
|
|
|
|
).Returns(new Dictionary<DateTime, decimal>
|
|
|
|
|
|
{
|
|
|
|
|
|
{ new DateTime(2026, 1, 1), 9257m }, // 1月: 教育7257 + 生活2000
|
|
|
|
|
|
{ new DateTime(2026, 2, 1), 1800m } // 2月: 生活1800
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 模拟月度统计的交易数据
|
|
|
|
|
|
_transactionStatsService.GetFilteredTrendStatisticsAsync(
|
|
|
|
|
|
Arg.Any<DateTime>(),
|
|
|
|
|
|
Arg.Any<DateTime>(),
|
|
|
|
|
|
TransactionType.Expense,
|
|
|
|
|
|
Arg.Any<List<string>>()
|
|
|
|
|
|
).Returns(new Dictionary<DateTime, decimal>
|
|
|
|
|
|
{
|
|
|
|
|
|
{ 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 测试场景:当前为3月,用户切换到1月查看
|
|
|
|
|
|
/// 预期:年度统计应包含1月归档 + 2月归档 + 3月当前
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
[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<BudgetRecord>(b => b.Id == 100),
|
|
|
|
|
|
Arg.Any<DateTime>(),
|
|
|
|
|
|
Arg.Any<DateTime>()
|
|
|
|
|
|
).Returns(4500m); // 3月已支出4500
|
|
|
|
|
|
|
|
|
|
|
|
_transactionStatsService.GetFilteredTrendStatisticsAsync(
|
|
|
|
|
|
Arg.Any<DateTime>(),
|
|
|
|
|
|
Arg.Any<DateTime>(),
|
|
|
|
|
|
TransactionType.Expense,
|
|
|
|
|
|
Arg.Any<List<string>>(),
|
|
|
|
|
|
true
|
|
|
|
|
|
).Returns(new Dictionary<DateTime, decimal>
|
|
|
|
|
|
{
|
|
|
|
|
|
{ new DateTime(2026, 1, 1), 9158.7m },
|
|
|
|
|
|
{ new DateTime(2026, 2, 1), 9126.1m },
|
|
|
|
|
|
{ new DateTime(2026, 3, 1), 4500m }
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 模拟月度统计的交易数据
|
|
|
|
|
|
_transactionStatsService.GetFilteredTrendStatisticsAsync(
|
|
|
|
|
|
Arg.Any<DateTime>(),
|
|
|
|
|
|
Arg.Any<DateTime>(),
|
|
|
|
|
|
TransactionType.Expense,
|
|
|
|
|
|
Arg.Any<List<string>>()
|
|
|
|
|
|
).Returns(new Dictionary<DateTime, decimal>
|
|
|
|
|
|
{
|
|
|
|
|
|
{ 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|