diff --git a/Service/Budget/BudgetSavingsService.cs b/Service/Budget/BudgetSavingsService.cs index 83c012e..56577ad 100644 --- a/Service/Budget/BudgetSavingsService.cs +++ b/Service/Budget/BudgetSavingsService.cs @@ -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 GetForYearAsync( @@ -963,4 +976,144 @@ public class BudgetSavingsService( return archivedIncome + futureIncomeBudget - archivedExpense - futureExpenseBudget; } + + /// + /// 生成月度存款明细数据 + /// + 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(); + var expenseDetails = new List(); + + // 处理月度收入 + 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 + } + }; + } } diff --git a/WebApi.Test/Budget/BudgetSavingsTest.cs b/WebApi.Test/Budget/BudgetSavingsTest.cs index 18949fa..23226b6 100644 --- a/WebApi.Test/Budget/BudgetSavingsTest.cs +++ b/WebApi.Test/Budget/BudgetSavingsTest.cs @@ -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 + { + 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(), + Arg.Any() + ).Returns(transactions); + + _configService.GetConfigByKeyAsync("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() {