Files
EmailBill/WebApi.Test/Budget/BudgetStatsTest.cs
SunCheng b71eadd4f9
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 26s
Docker Build & Deploy / Deploy to Production (push) Successful in 7s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 2s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s
重构账单查询sql
2026-01-28 10:58:15 +08:00

551 lines
26 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 Microsoft.Extensions.Logging;
namespace WebApi.Test.Budget;
public class BudgetStatsTest : BaseTest
{
private readonly IBudgetRepository _budgetRepository = Substitute.For<IBudgetRepository>();
private readonly IBudgetArchiveRepository _budgetArchiveRepository = Substitute.For<IBudgetArchiveRepository>();
private readonly ITransactionRecordRepository _transactionsRepository = Substitute.For<ITransactionRecordRepository>();
private readonly ITransactionStatisticsService _transactionStatisticsService = Substitute.For<ITransactionStatisticsService>();
private readonly IOpenAiService _openAiService = Substitute.For<IOpenAiService>();
private readonly IMessageService _messageService = Substitute.For<IMessageService>();
private readonly ILogger<BudgetService> _logger = Substitute.For<ILogger<BudgetService>>();
private readonly IBudgetSavingsService _budgetSavingsService = Substitute.For<IBudgetSavingsService>();
private readonly IDateTimeProvider _dateTimeProvider = Substitute.For<IDateTimeProvider>();
private readonly IBudgetStatsService _budgetStatsService;
private readonly BudgetService _service;
public BudgetStatsTest()
{
_dateTimeProvider.Now.Returns(new DateTime(2024, 1, 15));
_budgetStatsService = new BudgetStatsService(
_budgetRepository,
_budgetArchiveRepository,
_transactionStatisticsService,
_dateTimeProvider,
Substitute.For<ILogger<BudgetStatsService>>()
);
_service = new BudgetService(
_budgetRepository,
_budgetArchiveRepository,
_transactionsRepository,
_transactionStatisticsService,
_openAiService,
_messageService,
_logger,
_budgetSavingsService,
_dateTimeProvider,
_budgetStatsService
);
}
[Fact]
public async Task GetCategoryStats_月度_Test()
{
// Arrange
var referenceDate = new DateTime(2024, 1, 15);
var budgets = new List<BudgetRecord>
{
new() { Id = 1, Name = "吃喝", Limit = 2000, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, SelectedCategories = "餐饮,零食" },
new() { Id = 2, Name = "交通", Limit = 500, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, SelectedCategories = "交通" }
};
_budgetRepository.GetAllAsync().Returns(budgets);
_budgetRepository.GetCurrentAmountAsync(Arg.Any<BudgetRecord>(), Arg.Any<DateTime>(), Arg.Any<DateTime>())
.Returns(args =>
{
var b = (BudgetRecord)args[0];
return b.Name == "吃喝" ? 1200m : 300m;
});
_transactionStatisticsService.GetFilteredTrendStatisticsAsync(Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<TransactionType>(), Arg.Any<List<string>>(), Arg.Any<bool>())
.Returns(new Dictionary<DateTime, decimal>
{
{ new DateTime(2024, 1, 15), 1500m } // 1月15日累计1500吃喝1200+交通300
});
// Act
var result = await _service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate);
// Assert
result.Month.Limit.Should().Be(2500); // 吃喝2000 + 交通500
result.Month.Current.Should().Be(1500); // 吃喝1200 + 交通300
}
[Fact]
public async Task GetCategoryStats_月度_硬性收支_Test()
{
// Arrange
var referenceDate = new DateTime(2024, 1, 15);
var budgets = new List<BudgetRecord>
{
new() { Id = 1, Name = "房租", Limit = 3100, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, IsMandatoryExpense = true }
};
_budgetRepository.GetAllAsync().Returns(budgets);
_budgetRepository.GetCurrentAmountAsync(Arg.Any<BudgetRecord>(), Arg.Any<DateTime>(), Arg.Any<DateTime>())
.Returns(0m); // 实际支出的金额为0
_dateTimeProvider.Now.Returns(referenceDate);
_transactionStatisticsService.GetFilteredTrendStatisticsAsync(Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<TransactionType>(), Arg.Any<List<string>>(), Arg.Any<bool>())
.Returns(new Dictionary<DateTime, decimal>());
// Act
var result = await _service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate);
// Assert
// 硬性预算的限额保持不变,不根据时间计算
result.Month.Limit.Should().Be(3100);
// 实际使用值根据时间计算1月有31天15号经过了15天
// 3100 * 15 / 31 ≈ 1500
result.Month.Current.Should().BeApproximately(1500, 1);
}
[Fact]
public async Task GetCategoryStats_年度_1月_Test()
{
// Arrange
var referenceDate = new DateTime(2024, 1, 15);
var budgets = new List<BudgetRecord>
{
new() { Id = 2, Name = "月度吃饭", Limit = 3000, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, SelectedCategories = "餐饮" },
new() { Id = 1, Name = "年度旅游", Limit = 12000, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Year, SelectedCategories = "旅游" }
};
_budgetRepository.GetAllAsync().Returns(budgets);
// 月度统计使用趋势统计数据(只包含月度预算的分类)
_transactionStatisticsService.GetFilteredTrendStatisticsAsync(
Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 1 && d.Day == 1),
Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 1 && d.Day == 31),
TransactionType.Expense,
Arg.Is<List<string>>(list => list.Count == 1 && list.Contains("餐饮")), // 只包含月度预算的分类
false)
.Returns(new Dictionary<DateTime, decimal>
{
{ new DateTime(2024, 1, 15), 800m } // 1月15日月度吃饭累计800
});
// 年度统计使用GetCurrentAmountAsync
_budgetRepository.GetCurrentAmountAsync(Arg.Any<BudgetRecord>(), Arg.Any<DateTime>(), Arg.Any<DateTime>())
.Returns(args =>
{
var b = (BudgetRecord)args[0];
var startDate = (DateTime)args[1];
var endDate = (DateTime)args[2];
// 月度范围查询 - 月度吃饭1月
if (startDate.Month == 1 && startDate.Day == 1 && endDate.Month == 1)
{
return b.Name == "月度吃饭" ? 800m : 0m;
}
// 年度范围查询 - 年度旅游
if (startDate.Month == 1 && startDate.Day == 1 && endDate.Month == 12)
{
return b.Name == "年度旅游" ? 2000m : 0m;
}
return 0m;
});
// 年度趋势统计(包含所有分类)
_transactionStatisticsService.GetFilteredTrendStatisticsAsync(
Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 1 && d.Day == 1),
Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 12 && d.Day == 31),
TransactionType.Expense,
Arg.Is<List<string>>(list => list.Count == 2), // 包含所有分类:餐饮、旅游
true)
.Returns(new Dictionary<DateTime, decimal>
{
{ new DateTime(2024, 1, 1), 2800m } // 1月累计月度吃饭800 + 年度旅游2000 = 2800
});
// Act
var result = await _service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate);
// Assert
// 月度统计中:只包含月度预算
result.Month.Limit.Should().Be(3000); // 月度吃饭3000
result.Month.Current.Should().Be(800); // 月度吃饭已用800从GetCurrentAmountAsync获取
result.Month.Count.Should().Be(1); // 只包含1个月度预算
// 年度统计中:包含所有预算(月度预算按剩余月份折算)
// 1月时月度预算分为当前月(1月) + 剩余月份(2-12月共11个月)
result.Year.Limit.Should().Be(12000 + (3000 * 12)); // 年度旅游12000 + 月度吃饭折算年度(3000*12=36000) = 48000
result.Year.Current.Should().Be(2000 + 800); // 年度旅游2000 + 月度吃饭800 = 2800
result.Year.Count.Should().Be(3); // 包含3个预算项年度旅游、月度吃饭(当前月)、月度吃饭(剩余11个月)
}
[Fact]
public async Task GetCategoryStats_年度_1月_硬性收支_Test()
{
// Arrange
var referenceDate = new DateTime(2024, 1, 1); // 元旦
var budgets = new List<BudgetRecord>
{
new() { Id = 1, Name = "年度固定支出", Limit = 3660, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Year, IsMandatoryExpense = true }
};
_budgetRepository.GetAllAsync().Returns(budgets);
_budgetRepository.GetCurrentAmountAsync(Arg.Any<BudgetRecord>(), Arg.Any<DateTime>(), Arg.Any<DateTime>()).Returns(0m);
_dateTimeProvider.Now.Returns(referenceDate);
_transactionStatisticsService.GetFilteredTrendStatisticsAsync(Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<TransactionType>(), Arg.Any<List<string>>(), Arg.Any<bool>())
.Returns(new Dictionary<DateTime, decimal>());
// Act
var result = await _service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate);
// Assert
// 硬性预算的限额保持不变
result.Year.Limit.Should().Be(3660);
// 实际使用值根据时间计算2024是闰年366天。1月1号是第1天。
// 3660 * 1 / 366 ≈ 10
result.Year.Current.Should().BeApproximately(10, 0.1m);
}
[Fact]
public async Task GetCategoryStats_年度_3月_硬性收支_Test()
{
// Arrange
var referenceDate = new DateTime(2024, 3, 31); // 3月最后一天
var budgets = new List<BudgetRecord>
{
new() { Id = 1, Name = "年度固定支出", Limit = 3660, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Year, IsMandatoryExpense = true }
};
_budgetRepository.GetAllAsync().Returns(budgets);
_budgetRepository.GetCurrentAmountAsync(Arg.Any<BudgetRecord>(), Arg.Any<DateTime>(), Arg.Any<DateTime>()).Returns(0m);
_dateTimeProvider.Now.Returns(referenceDate);
_transactionStatisticsService.GetFilteredTrendStatisticsAsync(Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<TransactionType>(), Arg.Any<List<string>>(), Arg.Any<bool>())
.Returns(new Dictionary<DateTime, decimal>());
// Act
var result = await _service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate);
// Assert
// 硬性预算的限额保持不变
result.Year.Limit.Should().Be(3660);
// 实际使用值根据时间计算2024是闰年。1月(31) + 2月(29) + 3月(31) = 91天
// 3660 * 91 / 366 ≈ 910
result.Year.Current.Should().BeApproximately(910, 1);
}
[Fact]
public async Task GetCategoryStats_月度_发生年度收支_Test()
{
// Arrange
var referenceDate = new DateTime(2024, 1, 15);
// 设置预算:包含月度预算和年度预算
var budgets = new List<BudgetRecord>
{
// 月度预算:吃喝
new() { Id = 1, Name = "吃喝", Limit = 2000, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, SelectedCategories = "餐饮,零食" },
// 月度预算:交通
new() { Id = 2, Name = "交通", Limit = 500, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, SelectedCategories = "交通" },
// 年度预算:年度旅游(当前月度发生了相关支出)
new() { Id = 3, Name = "年度旅游", Limit = 12000, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Year, SelectedCategories = "旅游,度假" },
// 年度预算:年度奖金(当前月度发生了相关收入)
new() { Id = 4, Name = "年度奖金", Limit = 50000, Category = BudgetCategory.Income, Type = BudgetPeriodType.Year, SelectedCategories = "奖金,年终奖" }
};
_budgetRepository.GetAllAsync().Returns(budgets);
// 设置月度预算的当前金额
_budgetRepository.GetCurrentAmountAsync(Arg.Any<BudgetRecord>(), Arg.Any<DateTime>(), Arg.Any<DateTime>())
.Returns(args =>
{
var b = (BudgetRecord)args[0];
return b.Name switch
{
"吃喝" => 1200m, // 月度预算已用1200元
"交通" => 300m, // 月度预算已用300元
"年度旅游" => 2000m, // 年度预算1月份已用2000元
"年度奖金" => 10000m, // 年度预算1月份已收10000元
_ => 0m
};
});
// 设置月度趋势统计数据:只包含月度预算相关的分类(餐饮、零食、交通)
// 注意:不应包含年度预算的分类(旅游、度假、奖金、年终奖)
_transactionStatisticsService.GetFilteredTrendStatisticsAsync(
Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 1),
Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 1),
TransactionType.Expense,
Arg.Is<List<string>>(list => list.Count == 3 && list.Contains("餐饮") && list.Contains("零食") && list.Contains("交通")),
false)
.Returns(new Dictionary<DateTime, decimal>
{
{ new DateTime(2024, 1, 15), 1500m } // 1月15日累计1500吃喝1200+交通300不包含年度旅游2000
});
// 设置年度趋势统计数据:包含所有预算相关的分类
_transactionStatisticsService.GetFilteredTrendStatisticsAsync(
Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 1 && d.Day == 1),
Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 12 && d.Day == 31),
TransactionType.Expense,
Arg.Is<List<string>>(list => list.Count == 5), // 餐饮、零食、交通、旅游、度假
true)
.Returns(new Dictionary<DateTime, decimal>
{
{ new DateTime(2024, 1, 1), 3500m } // 1月累计3500吃喝1200+交通300+年度旅游2000
});
// 设置收入相关的趋势统计数据
_transactionStatisticsService.GetFilteredTrendStatisticsAsync(
Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 1),
Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 1),
TransactionType.Income,
Arg.Any<List<string>>(),
false)
.Returns(new Dictionary<DateTime, decimal>()); // 月度收入为空
_transactionStatisticsService.GetFilteredTrendStatisticsAsync(
Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 1 && d.Day == 1),
Arg.Is<DateTime>(d => d.Year == 2024 && d.Month == 12 && d.Day == 31),
TransactionType.Income,
Arg.Any<List<string>>(),
true)
.Returns(new Dictionary<DateTime, decimal>
{
{ new DateTime(2024, 1, 1), 10000m } // 年度奖金10000
});
// Act - 测试支出统计
var expenseResult = await _service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate);
// Act - 测试收入统计
var incomeResult = await _service.GetCategoryStatsAsync(BudgetCategory.Income, referenceDate);
// Assert - 支出月度统计:只包含月度预算,不包含年度预算
expenseResult.Month.Limit.Should().Be(2500); // 吃喝2000 + 交通500
expenseResult.Month.Current.Should().Be(1500); // 吃喝1200 + 交通300不包含年度旅游2000
expenseResult.Month.Count.Should().Be(2); // 只包含2个月度预算
expenseResult.Month.Rate.Should().Be(1500m / 2500m * 100); // 60%
// Assert - 支出年度统计:包含所有预算(月度+年度)
// 1月时月度预算分为当前月(1月) + 剩余月份(2-12月共11个月)
expenseResult.Year.Limit.Should().Be(12000 + (2500 * 12)); // 年度旅游12000 + 月度预算折算为年度(2500*12)
expenseResult.Year.Current.Should().Be(3500); // 吃喝1200 + 交通300 + 年度旅游2000
expenseResult.Year.Count.Should().Be(5); // 包含5个预算项年度旅游、吃喝(当前月)、交通(当前月)、吃喝(剩余11个月)、交通(剩余11个月)
// Assert - 收入月度统计只包含月度预算这里没有月度收入预算所以应该为0
incomeResult.Month.Limit.Should().Be(0); // 没有月度收入预算
incomeResult.Month.Current.Should().Be(0); // 没有月度收入预算不包含年度奖金10000
incomeResult.Month.Count.Should().Be(0); // 没有月度收入预算
// Assert - 收入年度统计:包含所有预算(只有年度收入预算)
incomeResult.Year.Limit.Should().Be(50000); // 年度奖金50000
incomeResult.Year.Current.Should().Be(10000); // 年度奖金已收10000
incomeResult.Year.Count.Should().Be(1); // 包含1个年度收入预算
}
[Fact]
public async Task GetCategoryStats_年度_3月_2月预算变更_Test()
{
// Arrange
// 测试场景2024年3月查看年度预算统计其中2月份发生了预算变更吃喝预算从2000增加到2500
var referenceDate = new DateTime(2024, 3, 15);
// 设置当前时间确保3月被认为是当前月份
_dateTimeProvider.Now.Returns(new DateTime(2024, 3, 15));
// 当前3月份有效的预算
var currentBudgets = new List<BudgetRecord>
{
new() { Id = 1, Name = "吃喝", Limit = 2500, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, SelectedCategories = "餐饮,零食" }, // 2月预算变更后
new() { Id = 2, Name = "交通", Limit = 500, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, SelectedCategories = "交通" },
new() { Id = 3, Name = "年度旅游", Limit = 12000, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Year, SelectedCategories = "旅游,度假" }
};
// 2月份的归档数据预算变更前
var febArchive = new BudgetArchive
{
Year = 2024,
Month = 2,
Content = new[]
{
new BudgetArchiveContent
{
Id = 1,
Name = "吃喝",
Limit = 2000, // 2月份时预算还是2000
Actual = 1800, // 2月份实际花费1800
Category = BudgetCategory.Expense,
Type = BudgetPeriodType.Month,
SelectedCategories = new[] { "餐饮", "零食" }
},
new BudgetArchiveContent
{
Id = 2,
Name = "交通",
Limit = 500,
Actual = 300,
Category = BudgetCategory.Expense,
Type = BudgetPeriodType.Month,
SelectedCategories = new[] { "交通" }
}
}
};
// 1月份的归档数据
var janArchive = new BudgetArchive
{
Year = 2024,
Month = 1,
Content = new[]
{
new BudgetArchiveContent
{
Id = 1,
Name = "吃喝",
Limit = 2000, // 1月份预算也是2000
Actual = 1500, // 1月份实际花费1500
Category = BudgetCategory.Expense,
Type = BudgetPeriodType.Month,
SelectedCategories = new[] { "餐饮", "零食" }
},
new BudgetArchiveContent
{
Id = 2,
Name = "交通",
Limit = 500,
Actual = 250,
Category = BudgetCategory.Expense,
Type = BudgetPeriodType.Month,
SelectedCategories = new[] { "交通" }
}
}
};
// 设置仓储响应
_budgetRepository.GetAllAsync().Returns(currentBudgets);
// 设置归档仓储响应
_budgetArchiveRepository.GetArchiveAsync(2024, 2).Returns(febArchive);
_budgetArchiveRepository.GetArchiveAsync(2024, 1).Returns(janArchive);
// 设置月度预算的当前金额查询仅用于3月份
_budgetRepository.GetCurrentAmountAsync(Arg.Any<BudgetRecord>(), Arg.Is<DateTime>(d => d.Month == 3), Arg.Is<DateTime>(d => d.Month == 3))
.Returns(args =>
{
var b = (BudgetRecord)args[0];
return b.Name switch
{
"吃喝" => 800m, // 3月份已花费800
"交通" => 200m, // 3月份已花费200
_ => 0m
};
});
// 年度旅游的年度金额查询
_budgetRepository.GetCurrentAmountAsync(
Arg.Is<BudgetRecord>(b => b.Id == 3),
Arg.Is<DateTime>(d => d.Month == 1),
Arg.Is<DateTime>(d => d.Month == 12))
.Returns(2500m); // 年度旅游1-3月已花费2500
// 设置趋势统计数据查询(用于月度统计)
_transactionStatisticsService.GetFilteredTrendStatisticsAsync(
Arg.Is<DateTime>(d => d.Month == 3),
Arg.Is<DateTime>(d => d.Month == 3),
Arg.Any<TransactionType>(),
Arg.Any<List<string>>(),
Arg.Any<bool>())
.Returns(new Dictionary<DateTime, decimal>
{
{ new DateTime(2024, 3, 15), 1000m } // 3月15日累计1000吃喝800+交通200
});
// 年度趋势统计数据查询
// 注意年度统计使用GetFilteredTrendStatisticsAsync获取趋势数据
// 需要返回所有分类的累计金额包括年度旅游的2500
_transactionStatisticsService.GetFilteredTrendStatisticsAsync(
Arg.Is<DateTime>(d => d.Month == 1),
Arg.Is<DateTime>(d => d.Month == 12),
Arg.Any<TransactionType>(),
Arg.Any<List<string>>(),
Arg.Is<bool>(b => b == true))
.Returns(new Dictionary<DateTime, decimal>
{
// 3月累计月度预算1000 + 年度旅游2500 = 3500
{ new DateTime(2024, 3, 1), 3500m }
});
// 补充年度旅游的GetCurrentAmountAsync调用用于计算Current
_budgetRepository.GetCurrentAmountAsync(
Arg.Is<BudgetRecord>(b => b.Id == 3),
Arg.Is<DateTime>(d => d.Month == 1),
Arg.Is<DateTime>(d => d.Month == 12))
.Returns(2500m); // 年度旅游1-3月已花费2500
// Act
// 直接测试BudgetStatsService而不是通过BudgetService
var budgetStatsService = new BudgetStatsService(
_budgetRepository,
_budgetArchiveRepository,
_transactionStatisticsService,
_dateTimeProvider,
Substitute.For<ILogger<BudgetStatsService>>()
);
var result = await budgetStatsService.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate);
// Assert - 月度统计3月份
// 月度统计应该只包含月度预算,使用当前预算限额
result.Month.Limit.Should().Be(3000); // 吃喝2500 + 交通500使用变更后的预算
result.Month.Current.Should().Be(1000); // 吃喝800 + 交通200
result.Month.Count.Should().Be(2); // 包含2个月度预算
// Assert - 年度统计(需要考虑预算变更和剩余月份)
// 新逻辑:
// 1. 对于归档数据,直接使用归档的限额,不折算
// 2. 对于当前及未来月份,使用当前预算 × 剩余月份
//
// 预期年度限额计算:
// 1月归档吃喝2000 + 交通500 = 2500
// 2月归档吃喝2000 + 交通500 = 2500
// 3-12月剩余12 - 3 + 1 = 10个月吃喝2500×10 + 交通500×10 = 30000
// 年度旅游12000
// 总计2500 + 2500 + 30000 + 12000 = 47000
result.Year.Limit.Should().Be(47000);
// 预期年度实际金额:
// 根据趋势统计数据3月累计: 月度预算1000 + 年度旅游2500 = 3500
// 但业务代码会累加所有预算项的Current值
// - 1月归档吃喝1500
// - 1月归档交通250
// - 2月归档吃喝1800
// - 2月归档交通300
// - 3月吃喝800
// - 3月交通200
// - 年度旅游2500
// 总计1500+250+1800+300+800+200+2500 = 7350
result.Year.Current.Should().Be(7350);
// 应该包含:
// - 1月归档的月度预算吃喝、1个
// - 1月归档的月度预算交通、1个
// - 2月归档的月度预算吃喝、1个
// - 2月归档的月度预算交通、1个
// - 3月当前月的月度预算吃喝、1个
// - 3月当前月的月度预算交通、1个
// - 4-12月未来月的月度预算吃喝、1个RemainingMonths=9
// - 4-12月未来月的月度预算交通、1个RemainingMonths=9
// - 年度旅游1个
// 总计9个
result.Year.Count.Should().Be(9);
// 验证使用率计算正确
result.Month.Rate.Should().BeApproximately(1000m / 3000m * 100, 0.01m);
// 年度使用率7350 / 47000 * 100 = 15.64%
result.Year.Rate.Should().BeApproximately(7350m / 47000m * 100, 0.01m);
}
}