feat(budget): 添加存款明细数据生成逻辑
- 实现 GenerateMonthlyDetails 方法生成月度存款明细 - 为每个预算项调用 BudgetItemCalculator 计算有效金额 - 生成计算说明(使用预算/使用实际/超支/按天折算) - 标记超支项目 - 生成汇总信息(总收入、总支出、计划存款) - GetForMonthAsync 现在返回 Details 字段 - 包含收入明细列表 - 包含支出明细列表 - 包含计算汇总和公式 - 新增集成测试验证 Details 字段生成正确 - 验证收入项计算规则 - 验证支出项超支标记 - 验证硬性支出处理 - 验证汇总计算 测试结果:58个预算测试全部通过
This commit is contained in:
@@ -400,12 +400,25 @@ public class BudgetSavingsService(
|
|||||||
UpdateTime = dateTimeProvider.Now
|
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,
|
record,
|
||||||
currentActual,
|
currentActual,
|
||||||
new DateTime(year, month, 1),
|
new DateTime(year, month, 1),
|
||||||
description.ToString()
|
description.ToString()
|
||||||
);
|
);
|
||||||
|
result.Details = details;
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<BudgetResult> GetForYearAsync(
|
private async Task<BudgetResult> GetForYearAsync(
|
||||||
@@ -963,4 +976,144 @@ public class BudgetSavingsService(
|
|||||||
return archivedIncome + futureIncomeBudget
|
return archivedIncome + futureIncomeBudget
|
||||||
- archivedExpense - futureExpenseBudget;
|
- 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
|
// Assert
|
||||||
result.Should().NotBeNull();
|
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]
|
[Fact]
|
||||||
public async Task GetSavings_月度_年度收支_Test()
|
public async Task GetSavings_月度_年度收支_Test()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user