Files
EmailBill/WebApi.Test/Budget/BudgetStatsTest.cs
SunCheng 2cf19a45e5
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 23s
Docker Build & Deploy / Deploy to Production (push) Successful in 6s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
1
2026-02-19 22:44:26 +08:00

607 lines
30 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.AI;
using Service.Message;
using Service.Transaction;
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 ISmartHandleService _smartHandleService = Substitute.For<ISmartHandleService>();
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 BudgetService _service;
public BudgetStatsTest()
{
_dateTimeProvider.Now.Returns(new DateTime(2024, 1, 15));
IBudgetStatsService budgetStatsService = new BudgetStatsService(
_budgetRepository,
_budgetArchiveRepository,
_transactionStatisticsService,
_dateTimeProvider,
Substitute.For<ILogger<BudgetStatsService>>()
);
_service = new BudgetService(
_budgetRepository,
_budgetArchiveRepository,
_transactionsRepository,
_transactionStatisticsService,
_smartHandleService,
_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("餐饮")))
.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("交通")))
.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>>())
.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);
}
[Fact]
public async Task GetCategoryStats_年度收入_不应包含支出预算_Test()
{
// Arrange: 模拟实际数据库中的情况
// 2026年1月当前日期为2026-02-19
var referenceDate = new DateTime(2026, 1, 15);
var currentNow = new DateTime(2026, 2, 19);
_dateTimeProvider.Now.Returns(currentNow);
var budgets = new List<BudgetRecord>
{
// Type=1 表示月度预算Category=0 表示支出(这些不应该被计入收入统计)
new() { Id = 1, Name = "工作餐预算", Limit = 700, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, SelectedCategories = "G工作餐", StartDate = new DateTime(2026, 1, 6) },
new() { Id = 2, Name = "副业投资", Limit = 2000, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, SelectedCategories = "Z钻石福袋", StartDate = new DateTime(2026, 1, 7) },
new() { Id = 3, Name = "通勤支出", Limit = 240, Category = BudgetCategory.Expense, Type = BudgetPeriodType.Month, SelectedCategories = "D地铁通勤", StartDate = new DateTime(2026, 1, 7) },
// Category=1 表示收入(只有这些应该被计入收入统计)
new() { Id = 4, Name = "工资-SYE", Limit = 6100, Category = BudgetCategory.Income, Type = BudgetPeriodType.Month, IsMandatoryExpense = true, SelectedCategories = "G工资SYE", StartDate = new DateTime(2026, 1, 7) },
new() { Id = 5, Name = "副业收入", Limit = 6000, Category = BudgetCategory.Income, Type = BudgetPeriodType.Month, SelectedCategories = "", StartDate = new DateTime(2026, 1, 7) },
new() { Id = 6, Name = "公积金收入", Limit = 5540, Category = BudgetCategory.Income, Type = BudgetPeriodType.Month, IsMandatoryExpense = true, SelectedCategories = "G公积金", StartDate = new DateTime(2026, 1, 7) },
new() { Id = 7, Name = "工资-SC", Limit = 17500, Category = BudgetCategory.Income, Type = BudgetPeriodType.Month, IsMandatoryExpense = true, SelectedCategories = "G工资SC", StartDate = new DateTime(2026, 1, 16) }
};
_budgetRepository.GetAllAsync().Returns(budgets);
// 模拟实际收入金额
_budgetRepository.GetCurrentAmountAsync(Arg.Any<BudgetRecord>(), Arg.Any<DateTime>(), Arg.Any<DateTime>())
.Returns(args =>
{
var budget = (BudgetRecord)args[0];
// 假设当前月2月没有收入记录
return 0m;
});
_transactionStatisticsService.GetFilteredTrendStatisticsAsync(Arg.Any<DateTime>(), Arg.Any<DateTime>(), Arg.Any<TransactionType>(), Arg.Any<List<string>>(), Arg.Any<bool>())
.Returns(new Dictionary<DateTime, decimal>());
_budgetArchiveRepository.GetArchiveAsync(Arg.Any<int>(), Arg.Any<int>())
.Returns((BudgetArchive?)null);
// Act
var result = await _service.GetCategoryStatsAsync(BudgetCategory.Income, referenceDate);
// Assert
// 年度已收应该是1月的4个收入预算
// 1月归档工资-SYE(6100) + 副业收入(6000) + 公积金收入(5540) + 工资-SC(17500) = 35140
// 2月当前0假设没有实际收入
// 3-12月未来0
// 总计应该约等于 35140 (取决于硬性收入的调整逻辑)
// 重点year.limit 应该只包含收入预算,不应该包含支出预算
// 正确的年度limit应该是(6100 + 6000 + 5540 + 17500) * (1 + 11) = 35140 * 12 = 421680
// 或者更准确地说1月归档(35140) + 2月当前月(35140) + 未来10个月(35140 * 10) = 35140 * 12
result.Year.Limit.Should().BeGreaterThan(35000 * 11); // 至少应该是35140的11倍以上
result.Year.Limit.Should().BeLessThan(36000 * 12); // 不应该超过36000的12倍
}
}