feat(budget): 添加存款明细数据生成逻辑
- 实现 GenerateMonthlyDetails 方法生成月度存款明细 - 为每个预算项调用 BudgetItemCalculator 计算有效金额 - 生成计算说明(使用预算/使用实际/超支/按天折算) - 标记超支项目 - 生成汇总信息(总收入、总支出、计划存款) - GetForMonthAsync 现在返回 Details 字段 - 包含收入明细列表 - 包含支出明细列表 - 包含计算汇总和公式 - 新增集成测试验证 Details 字段生成正确 - 验证收入项计算规则 - 验证支出项超支标记 - 验证硬性支出处理 - 验证汇总计算 测试结果:58个预算测试全部通过
This commit is contained in:
@@ -400,12 +400,25 @@ public class BudgetSavingsService(
|
||||
UpdateTime = dateTimeProvider.Now
|
||||
};
|
||||
|
||||
return BudgetResult.FromEntity(
|
||||
// 生成明细数据
|
||||
var referenceDate = new DateTime(year, month, dateTimeProvider.Now.Day);
|
||||
var details = GenerateMonthlyDetails(
|
||||
monthlyIncomeItems,
|
||||
monthlyExpenseItems,
|
||||
yearlyIncomeItems,
|
||||
yearlyExpenseItems,
|
||||
referenceDate
|
||||
);
|
||||
|
||||
var result = BudgetResult.FromEntity(
|
||||
record,
|
||||
currentActual,
|
||||
new DateTime(year, month, 1),
|
||||
description.ToString()
|
||||
);
|
||||
result.Details = details;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<BudgetResult> GetForYearAsync(
|
||||
@@ -963,4 +976,144 @@ public class BudgetSavingsService(
|
||||
return archivedIncome + futureIncomeBudget
|
||||
- archivedExpense - futureExpenseBudget;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成月度存款明细数据
|
||||
/// </summary>
|
||||
private SavingsDetail GenerateMonthlyDetails(
|
||||
List<(string name, decimal limit, decimal current, bool isMandatory)> monthlyIncomeItems,
|
||||
List<(string name, decimal limit, decimal current, bool isMandatory)> monthlyExpenseItems,
|
||||
List<(string name, decimal limit, decimal current)> yearlyIncomeItems,
|
||||
List<(string name, decimal limit, decimal current)> yearlyExpenseItems,
|
||||
DateTime referenceDate)
|
||||
{
|
||||
var incomeDetails = new List<BudgetDetailItem>();
|
||||
var expenseDetails = new List<BudgetDetailItem>();
|
||||
|
||||
// 处理月度收入
|
||||
foreach (var item in monthlyIncomeItems)
|
||||
{
|
||||
var effectiveAmount = BudgetItemCalculator.CalculateEffectiveAmount(
|
||||
BudgetCategory.Income,
|
||||
item.limit,
|
||||
item.current,
|
||||
item.isMandatory,
|
||||
isArchived: false,
|
||||
referenceDate,
|
||||
BudgetPeriodType.Month
|
||||
);
|
||||
|
||||
var note = BudgetItemCalculator.GenerateCalculationNote(
|
||||
BudgetCategory.Income,
|
||||
item.limit,
|
||||
item.current,
|
||||
effectiveAmount,
|
||||
item.isMandatory,
|
||||
isArchived: false
|
||||
);
|
||||
|
||||
incomeDetails.Add(new BudgetDetailItem
|
||||
{
|
||||
Id = 0, // 临时ID
|
||||
Name = item.name,
|
||||
Type = BudgetPeriodType.Month,
|
||||
BudgetLimit = item.limit,
|
||||
ActualAmount = item.current,
|
||||
EffectiveAmount = effectiveAmount,
|
||||
CalculationNote = note,
|
||||
IsOverBudget = item.current > 0 && item.current < item.limit,
|
||||
IsArchived = false
|
||||
});
|
||||
}
|
||||
|
||||
// 处理月度支出
|
||||
foreach (var item in monthlyExpenseItems)
|
||||
{
|
||||
var effectiveAmount = BudgetItemCalculator.CalculateEffectiveAmount(
|
||||
BudgetCategory.Expense,
|
||||
item.limit,
|
||||
item.current,
|
||||
item.isMandatory,
|
||||
isArchived: false,
|
||||
referenceDate,
|
||||
BudgetPeriodType.Month
|
||||
);
|
||||
|
||||
var note = BudgetItemCalculator.GenerateCalculationNote(
|
||||
BudgetCategory.Expense,
|
||||
item.limit,
|
||||
item.current,
|
||||
effectiveAmount,
|
||||
item.isMandatory,
|
||||
isArchived: false
|
||||
);
|
||||
|
||||
expenseDetails.Add(new BudgetDetailItem
|
||||
{
|
||||
Id = 0,
|
||||
Name = item.name,
|
||||
Type = BudgetPeriodType.Month,
|
||||
BudgetLimit = item.limit,
|
||||
ActualAmount = item.current,
|
||||
EffectiveAmount = effectiveAmount,
|
||||
CalculationNote = note,
|
||||
IsOverBudget = item.current > item.limit,
|
||||
IsArchived = false
|
||||
});
|
||||
}
|
||||
|
||||
// 处理年度收入(发生在本月的)
|
||||
foreach (var item in yearlyIncomeItems)
|
||||
{
|
||||
incomeDetails.Add(new BudgetDetailItem
|
||||
{
|
||||
Id = 0,
|
||||
Name = item.name,
|
||||
Type = BudgetPeriodType.Year,
|
||||
BudgetLimit = item.limit,
|
||||
ActualAmount = item.current,
|
||||
EffectiveAmount = item.current, // 年度预算发生在本月的直接用实际值
|
||||
CalculationNote = "使用实际",
|
||||
IsOverBudget = false,
|
||||
IsArchived = false
|
||||
});
|
||||
}
|
||||
|
||||
// 处理年度支出(发生在本月的)
|
||||
foreach (var item in yearlyExpenseItems)
|
||||
{
|
||||
expenseDetails.Add(new BudgetDetailItem
|
||||
{
|
||||
Id = 0,
|
||||
Name = item.name,
|
||||
Type = BudgetPeriodType.Year,
|
||||
BudgetLimit = item.limit,
|
||||
ActualAmount = item.current,
|
||||
EffectiveAmount = item.current,
|
||||
CalculationNote = "使用实际",
|
||||
IsOverBudget = item.current > item.limit,
|
||||
IsArchived = false
|
||||
});
|
||||
}
|
||||
|
||||
// 计算汇总
|
||||
var totalIncome = incomeDetails.Sum(i => i.EffectiveAmount);
|
||||
var totalExpense = expenseDetails.Sum(e => e.EffectiveAmount);
|
||||
var plannedSavings = totalIncome - totalExpense;
|
||||
|
||||
var formula = $"收入 {totalIncome:N0} - 支出 {totalExpense:N0} = {plannedSavings:N0}";
|
||||
|
||||
return new SavingsDetail
|
||||
{
|
||||
IncomeItems = incomeDetails,
|
||||
ExpenseItems = expenseDetails,
|
||||
Summary = new SavingsCalculationSummary
|
||||
{
|
||||
TotalIncomeBudget = totalIncome,
|
||||
TotalExpenseBudget = totalExpense,
|
||||
PlannedSavings = plannedSavings,
|
||||
CalculationFormula = formula
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,9 +58,96 @@ public class BudgetSavingsTest : BaseTest
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Limit.Should().Be(10000 - 2000); // 计划收入 - 计划支出 = 10000 - 2000 = 8000
|
||||
result.Limit.Should().Be(8000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSavings_月度_应返回Details字段()
|
||||
{
|
||||
// Arrange
|
||||
var referenceDate = new DateTime(2024, 2, 15);
|
||||
_dateTimeProvider.Now.Returns(referenceDate);
|
||||
|
||||
var budgets = new List<BudgetRecord>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 1, Name = "工资", Type = BudgetPeriodType.Month, Limit = 10000, Category = BudgetCategory.Income,
|
||||
SelectedCategories = "工资"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 2, Name = "餐饮", Type = BudgetPeriodType.Month, Limit = 2000, Category = BudgetCategory.Expense,
|
||||
SelectedCategories = "餐饮"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 3, Name = "房租", Type = BudgetPeriodType.Month, Limit = 3000, Category = BudgetCategory.Expense,
|
||||
SelectedCategories = "房租", IsMandatoryExpense = true
|
||||
}
|
||||
};
|
||||
|
||||
var transactions = new Dictionary<(string, TransactionType), decimal>
|
||||
{
|
||||
{ ("工资", TransactionType.Income), 10000m },
|
||||
{ ("餐饮", TransactionType.Expense), 2500m }, // 超支
|
||||
{ ("房租", TransactionType.Expense), 0m } // 硬性未发生
|
||||
};
|
||||
|
||||
_transactionStatisticsService.GetAmountGroupByClassifyAsync(
|
||||
Arg.Any<DateTime>(),
|
||||
Arg.Any<DateTime>()
|
||||
).Returns(transactions);
|
||||
|
||||
_configService.GetConfigByKeyAsync<string>("SavingsCategories").Returns("存款");
|
||||
|
||||
// Act
|
||||
var result = await _service.GetSavingsDtoAsync(BudgetPeriodType.Month, referenceDate, budgets);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Details.Should().NotBeNull();
|
||||
|
||||
// 验证收入明细
|
||||
result.Details!.IncomeItems.Should().HaveCount(1);
|
||||
var incomeItem = result.Details.IncomeItems[0];
|
||||
incomeItem.Name.Should().Be("工资");
|
||||
incomeItem.BudgetLimit.Should().Be(10000);
|
||||
incomeItem.ActualAmount.Should().Be(10000);
|
||||
incomeItem.EffectiveAmount.Should().Be(10000);
|
||||
incomeItem.CalculationNote.Should().Be("使用实际");
|
||||
|
||||
// 验证支出明细
|
||||
result.Details.ExpenseItems.Should().HaveCount(2);
|
||||
|
||||
// 餐饮超支
|
||||
var expenseItem1 = result.Details.ExpenseItems.FirstOrDefault(e => e.Name == "餐饮");
|
||||
expenseItem1.Should().NotBeNull();
|
||||
expenseItem1!.BudgetLimit.Should().Be(2000);
|
||||
expenseItem1.ActualAmount.Should().Be(2500);
|
||||
expenseItem1.EffectiveAmount.Should().Be(2500); // MAX(2000, 2500)
|
||||
expenseItem1.CalculationNote.Should().Be("使用实际(超支)");
|
||||
expenseItem1.IsOverBudget.Should().BeTrue();
|
||||
|
||||
// 房租按天折算(硬性消费在实际为0时会自动填充)
|
||||
var expenseItem2 = result.Details.ExpenseItems.FirstOrDefault(e => e.Name == "房租");
|
||||
expenseItem2.Should().NotBeNull();
|
||||
expenseItem2!.BudgetLimit.Should().Be(3000);
|
||||
// 硬性消费在 GetForMonthAsync 中已经填充了按天折算的值到 current
|
||||
expenseItem2.ActualAmount.Should().BeApproximately(3000m / 29 * 15, 0.01m);
|
||||
// EffectiveAmount 使用 MAX(预算3000, 实际1551.72) = 3000
|
||||
expenseItem2.EffectiveAmount.Should().Be(3000);
|
||||
expenseItem2.CalculationNote.Should().Be("使用预算"); // MAX 后选择了预算值
|
||||
|
||||
// 验证汇总
|
||||
result.Details.Summary.Should().NotBeNull();
|
||||
result.Details.Summary.TotalIncomeBudget.Should().BeApproximately(10000, 0.01m);
|
||||
// 支出汇总:餐饮2500 + 房租3000(MAX) = 5500
|
||||
result.Details.Summary.TotalExpenseBudget.Should().BeApproximately(5500, 1m);
|
||||
result.Details.Summary.PlannedSavings.Should().BeApproximately(4500, 1m);
|
||||
}
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task GetSavings_月度_年度收支_Test()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user