Compare commits
5 Commits
32d5ed62d0
...
a7414c792e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7414c792e | ||
|
|
3c3172fc81 | ||
|
|
f46b9d4bd6 | ||
|
|
2cb5bffc70 | ||
|
|
4cc205fc25 |
@@ -224,7 +224,51 @@ public class BudgetApplication(
|
||||
StartDate = startDate,
|
||||
NoLimit = result.NoLimit,
|
||||
IsMandatoryExpense = result.IsMandatoryExpense,
|
||||
UsagePercentage = result.Limit > 0 ? result.Current / result.Limit * 100 : 0
|
||||
UsagePercentage = result.Limit > 0 ? result.Current / result.Limit * 100 : 0,
|
||||
Details = result.Details != null ? MapToSavingsDetailDto(result.Details) : null
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 映射存款明细数据到DTO
|
||||
/// </summary>
|
||||
private static SavingsDetailDto MapToSavingsDetailDto(Service.Budget.SavingsDetail details)
|
||||
{
|
||||
return new SavingsDetailDto
|
||||
{
|
||||
IncomeItems = details.IncomeItems.Select(item => new BudgetDetailItemDto
|
||||
{
|
||||
Id = item.Id,
|
||||
Name = item.Name,
|
||||
Type = item.Type,
|
||||
BudgetLimit = item.BudgetLimit,
|
||||
ActualAmount = item.ActualAmount,
|
||||
EffectiveAmount = item.EffectiveAmount,
|
||||
CalculationNote = item.CalculationNote,
|
||||
IsOverBudget = item.IsOverBudget,
|
||||
IsArchived = item.IsArchived,
|
||||
ArchivedMonths = item.ArchivedMonths
|
||||
}).ToList(),
|
||||
ExpenseItems = details.ExpenseItems.Select(item => new BudgetDetailItemDto
|
||||
{
|
||||
Id = item.Id,
|
||||
Name = item.Name,
|
||||
Type = item.Type,
|
||||
BudgetLimit = item.BudgetLimit,
|
||||
ActualAmount = item.ActualAmount,
|
||||
EffectiveAmount = item.EffectiveAmount,
|
||||
CalculationNote = item.CalculationNote,
|
||||
IsOverBudget = item.IsOverBudget,
|
||||
IsArchived = item.IsArchived,
|
||||
ArchivedMonths = item.ArchivedMonths
|
||||
}).ToList(),
|
||||
Summary = new SavingsCalculationSummaryDto
|
||||
{
|
||||
TotalIncomeBudget = details.Summary.TotalIncomeBudget,
|
||||
TotalExpenseBudget = details.Summary.TotalExpenseBudget,
|
||||
PlannedSavings = details.Summary.PlannedSavings,
|
||||
CalculationFormula = details.Summary.CalculationFormula
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -16,8 +16,52 @@ public record BudgetResponse
|
||||
public bool NoLimit { get; init; }
|
||||
public bool IsMandatoryExpense { get; init; }
|
||||
public decimal UsagePercentage { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 存款明细数据(仅存款预算返回)
|
||||
/// </summary>
|
||||
public SavingsDetailDto? Details { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 存款明细数据 DTO
|
||||
/// </summary>
|
||||
public record SavingsDetailDto
|
||||
{
|
||||
public List<BudgetDetailItemDto> IncomeItems { get; init; } = new();
|
||||
public List<BudgetDetailItemDto> ExpenseItems { get; init; } = new();
|
||||
public SavingsCalculationSummaryDto Summary { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 预算明细项 DTO
|
||||
/// </summary>
|
||||
public record BudgetDetailItemDto
|
||||
{
|
||||
public long Id { get; init; }
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public BudgetPeriodType Type { get; init; }
|
||||
public decimal BudgetLimit { get; init; }
|
||||
public decimal ActualAmount { get; init; }
|
||||
public decimal EffectiveAmount { get; init; }
|
||||
public string CalculationNote { get; init; } = string.Empty;
|
||||
public bool IsOverBudget { get; init; }
|
||||
public bool IsArchived { get; init; }
|
||||
public int[]? ArchivedMonths { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 存款计算汇总 DTO
|
||||
/// </summary>
|
||||
public record SavingsCalculationSummaryDto
|
||||
{
|
||||
public decimal TotalIncomeBudget { get; init; }
|
||||
public decimal TotalExpenseBudget { get; init; }
|
||||
public decimal PlannedSavings { get; init; }
|
||||
public string CalculationFormula { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 创建预算请求
|
||||
/// </summary>
|
||||
@@ -89,3 +133,41 @@ public record UpdateArchiveSummaryRequest
|
||||
public DateTime ReferenceDate { get; init; }
|
||||
public string? Summary { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 存款明细数据
|
||||
/// </summary>
|
||||
public record SavingsDetail
|
||||
{
|
||||
public List<BudgetDetailItem> IncomeItems { get; init; } = new();
|
||||
public List<BudgetDetailItem> ExpenseItems { get; init; } = new();
|
||||
public SavingsCalculationSummary Summary { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 预算明细项
|
||||
/// </summary>
|
||||
public record BudgetDetailItem
|
||||
{
|
||||
public long Id { get; init; }
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public BudgetPeriodType Type { get; init; }
|
||||
public decimal BudgetLimit { get; init; }
|
||||
public decimal ActualAmount { get; init; }
|
||||
public decimal EffectiveAmount { get; init; }
|
||||
public string CalculationNote { get; init; } = string.Empty;
|
||||
public bool IsOverBudget { get; init; }
|
||||
public bool IsArchived { get; init; }
|
||||
public int[]? ArchivedMonths { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 存款计算汇总
|
||||
/// </summary>
|
||||
public record SavingsCalculationSummary
|
||||
{
|
||||
public decimal TotalIncomeBudget { get; init; }
|
||||
public decimal TotalExpenseBudget { get; init; }
|
||||
public decimal PlannedSavings { get; init; }
|
||||
public string CalculationFormula { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
121
Service/Budget/BudgetItemCalculator.cs
Normal file
121
Service/Budget/BudgetItemCalculator.cs
Normal file
@@ -0,0 +1,121 @@
|
||||
namespace Service.Budget;
|
||||
|
||||
/// <summary>
|
||||
/// 预算明细项计算辅助类
|
||||
/// 用于计算单个预算项的有效金额(计算用金额)
|
||||
/// </summary>
|
||||
public static class BudgetItemCalculator
|
||||
{
|
||||
/// <summary>
|
||||
/// 计算预算项的有效金额
|
||||
/// </summary>
|
||||
/// <param name="category">预算类别(收入/支出)</param>
|
||||
/// <param name="budgetLimit">预算金额</param>
|
||||
/// <param name="actualAmount">实际金额</param>
|
||||
/// <param name="isMandatory">是否为硬性消费</param>
|
||||
/// <param name="isArchived">是否为归档数据</param>
|
||||
/// <param name="referenceDate">参考日期</param>
|
||||
/// <param name="periodType">预算周期类型(月度/年度)</param>
|
||||
/// <returns>有效金额(用于计算的金额)</returns>
|
||||
public static decimal CalculateEffectiveAmount(
|
||||
BudgetCategory category,
|
||||
decimal budgetLimit,
|
||||
decimal actualAmount,
|
||||
bool isMandatory,
|
||||
bool isArchived,
|
||||
DateTime referenceDate,
|
||||
BudgetPeriodType periodType)
|
||||
{
|
||||
// 归档数据直接返回实际值
|
||||
if (isArchived)
|
||||
{
|
||||
return actualAmount;
|
||||
}
|
||||
|
||||
// 收入:实际>0取实际,否则取预算
|
||||
if (category == BudgetCategory.Income)
|
||||
{
|
||||
return actualAmount > 0 ? actualAmount : budgetLimit;
|
||||
}
|
||||
|
||||
// 支出(硬性且实际=0):按天数折算
|
||||
if (category == BudgetCategory.Expense && isMandatory && actualAmount == 0)
|
||||
{
|
||||
return CalculateMandatoryAmount(budgetLimit, referenceDate, periodType);
|
||||
}
|
||||
|
||||
// 支出(普通):取MAX
|
||||
if (category == BudgetCategory.Expense)
|
||||
{
|
||||
return Math.Max(budgetLimit, actualAmount);
|
||||
}
|
||||
|
||||
return budgetLimit;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 计算硬性消费按天数折算的金额
|
||||
/// </summary>
|
||||
private static decimal CalculateMandatoryAmount(
|
||||
decimal limit,
|
||||
DateTime date,
|
||||
BudgetPeriodType periodType)
|
||||
{
|
||||
if (periodType == BudgetPeriodType.Month)
|
||||
{
|
||||
var daysInMonth = DateTime.DaysInMonth(date.Year, date.Month);
|
||||
return limit / daysInMonth * date.Day;
|
||||
}
|
||||
else // Year
|
||||
{
|
||||
var daysInYear = DateTime.IsLeapYear(date.Year) ? 366 : 365;
|
||||
return limit / daysInYear * date.DayOfYear;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成计算说明
|
||||
/// </summary>
|
||||
/// <param name="category">预算类别</param>
|
||||
/// <param name="budgetLimit">预算金额</param>
|
||||
/// <param name="actualAmount">实际金额</param>
|
||||
/// <param name="effectiveAmount">有效金额</param>
|
||||
/// <param name="isMandatory">是否为硬性消费</param>
|
||||
/// <param name="isArchived">是否为归档数据</param>
|
||||
/// <returns>计算说明文本</returns>
|
||||
public static string GenerateCalculationNote(
|
||||
BudgetCategory category,
|
||||
decimal budgetLimit,
|
||||
decimal actualAmount,
|
||||
decimal effectiveAmount,
|
||||
bool isMandatory,
|
||||
bool isArchived)
|
||||
{
|
||||
if (isArchived)
|
||||
{
|
||||
return "归档实际";
|
||||
}
|
||||
|
||||
if (category == BudgetCategory.Income)
|
||||
{
|
||||
return actualAmount > 0 ? "使用实际" : "使用预算";
|
||||
}
|
||||
|
||||
if (category == BudgetCategory.Expense)
|
||||
{
|
||||
if (isMandatory && actualAmount == 0)
|
||||
{
|
||||
return "按天折算";
|
||||
}
|
||||
|
||||
if (actualAmount > budgetLimit)
|
||||
{
|
||||
return "使用实际(超支)";
|
||||
}
|
||||
|
||||
return effectiveAmount == actualAmount ? "使用实际" : "使用预算";
|
||||
}
|
||||
|
||||
return "使用预算";
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
@@ -863,12 +876,26 @@ public class BudgetSavingsService(
|
||||
UpdateTime = dateTimeProvider.Now
|
||||
};
|
||||
|
||||
return BudgetResult.FromEntity(
|
||||
// 生成明细数据
|
||||
var details = GenerateYearlyDetails(
|
||||
currentMonthlyIncomeItems,
|
||||
currentYearlyIncomeItems,
|
||||
currentMonthlyExpenseItems,
|
||||
currentYearlyExpenseItems,
|
||||
archiveIncomeItems,
|
||||
archiveExpenseItems,
|
||||
new DateTime(year, 1, 1)
|
||||
);
|
||||
|
||||
var result = BudgetResult.FromEntity(
|
||||
record,
|
||||
currentActual,
|
||||
new DateTime(year, 1, 1),
|
||||
description.ToString()
|
||||
);
|
||||
result.Details = details;
|
||||
|
||||
return result;
|
||||
|
||||
void AddOrIncCurrentItem(
|
||||
long id,
|
||||
@@ -935,4 +962,334 @@ public class BudgetSavingsService(
|
||||
return string.Join(", ", months) + "月";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 计算月度计划存款
|
||||
/// 公式:收入预算 + 发生在本月的年度收入 - 支出预算 - 发生在本月的年度支出
|
||||
/// </summary>
|
||||
public static decimal CalculateMonthlyPlannedSavings(
|
||||
decimal monthlyIncomeBudget,
|
||||
decimal yearlyIncomeInThisMonth,
|
||||
decimal monthlyExpenseBudget,
|
||||
decimal yearlyExpenseInThisMonth)
|
||||
{
|
||||
return monthlyIncomeBudget + yearlyIncomeInThisMonth
|
||||
- monthlyExpenseBudget - yearlyExpenseInThisMonth;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 计算年度计划存款
|
||||
/// 公式:归档月已实收 + 未来月收入预算 - 归档月已实支 - 未来月支出预算
|
||||
/// </summary>
|
||||
public static decimal CalculateYearlyPlannedSavings(
|
||||
decimal archivedIncome,
|
||||
decimal futureIncomeBudget,
|
||||
decimal archivedExpense,
|
||||
decimal futureExpenseBudget)
|
||||
{
|
||||
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
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成年度存款明细数据
|
||||
/// </summary>
|
||||
private SavingsDetail GenerateYearlyDetails(
|
||||
List<(long id, string name, decimal limit, int factor, decimal current, bool isMandatory)> currentMonthlyIncomeItems,
|
||||
List<(long id, string name, decimal limit, int factor, decimal current, bool isMandatory)> currentYearlyIncomeItems,
|
||||
List<(long id, string name, decimal limit, int factor, decimal current, bool isMandatory)> currentMonthlyExpenseItems,
|
||||
List<(long id, string name, decimal limit, int factor, decimal current, bool isMandatory)> currentYearlyExpenseItems,
|
||||
List<(long id, string name, int[] months, decimal limit, decimal current)> archiveIncomeItems,
|
||||
List<(long id, string name, int[] months, decimal limit, decimal current)> archiveExpenseItems,
|
||||
DateTime referenceDate)
|
||||
{
|
||||
var incomeDetails = new List<BudgetDetailItem>();
|
||||
var expenseDetails = new List<BudgetDetailItem>();
|
||||
|
||||
// 处理已归档的收入预算
|
||||
foreach (var item in archiveIncomeItems)
|
||||
{
|
||||
incomeDetails.Add(new BudgetDetailItem
|
||||
{
|
||||
Id = item.id,
|
||||
Name = item.name,
|
||||
Type = BudgetPeriodType.Month,
|
||||
BudgetLimit = item.limit,
|
||||
ActualAmount = item.current,
|
||||
EffectiveAmount = item.current,
|
||||
CalculationNote = $"已归档 ({string.Join(", ", item.months)}月)",
|
||||
IsOverBudget = false,
|
||||
IsArchived = true,
|
||||
ArchivedMonths = item.months
|
||||
});
|
||||
}
|
||||
|
||||
// 处理当前月度收入预算
|
||||
foreach (var item in currentMonthlyIncomeItems)
|
||||
{
|
||||
// 年度预算中,月度预算按 factor 倍率计算有效金额
|
||||
var effectiveAmount = item.limit == 0 ? item.current : item.limit * item.factor;
|
||||
var note = item.limit == 0
|
||||
? "不记额(使用实际)"
|
||||
: $"预算 {item.limit:N0} × {item.factor}月 = {effectiveAmount:N0}";
|
||||
|
||||
incomeDetails.Add(new BudgetDetailItem
|
||||
{
|
||||
Id = item.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 currentYearlyIncomeItems)
|
||||
{
|
||||
// 年度预算:硬性预算或不记额预算使用实际值,否则使用预算值
|
||||
var effectiveAmount = item.isMandatory || item.limit == 0 ? item.current : item.limit;
|
||||
var note = item.isMandatory
|
||||
? "硬性(使用实际)"
|
||||
: (item.limit == 0 ? "不记额(使用实际)" : "使用预算");
|
||||
|
||||
incomeDetails.Add(new BudgetDetailItem
|
||||
{
|
||||
Id = item.id,
|
||||
Name = item.name,
|
||||
Type = BudgetPeriodType.Year,
|
||||
BudgetLimit = item.limit,
|
||||
ActualAmount = item.current,
|
||||
EffectiveAmount = effectiveAmount,
|
||||
CalculationNote = note,
|
||||
IsOverBudget = false,
|
||||
IsArchived = false
|
||||
});
|
||||
}
|
||||
|
||||
// 处理已归档的支出预算
|
||||
foreach (var item in archiveExpenseItems)
|
||||
{
|
||||
expenseDetails.Add(new BudgetDetailItem
|
||||
{
|
||||
Id = item.id,
|
||||
Name = item.name,
|
||||
Type = BudgetPeriodType.Month,
|
||||
BudgetLimit = item.limit,
|
||||
ActualAmount = item.current,
|
||||
EffectiveAmount = item.current,
|
||||
CalculationNote = $"已归档 ({string.Join(", ", item.months)}月)",
|
||||
IsOverBudget = false,
|
||||
IsArchived = true,
|
||||
ArchivedMonths = item.months
|
||||
});
|
||||
}
|
||||
|
||||
// 处理当前月度支出预算
|
||||
foreach (var item in currentMonthlyExpenseItems)
|
||||
{
|
||||
var effectiveAmount = item.limit == 0 ? item.current : item.limit * item.factor;
|
||||
var note = item.limit == 0
|
||||
? "不记额(使用实际)"
|
||||
: $"预算 {item.limit:N0} × {item.factor}月 = {effectiveAmount:N0}";
|
||||
|
||||
expenseDetails.Add(new BudgetDetailItem
|
||||
{
|
||||
Id = item.id,
|
||||
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 currentYearlyExpenseItems)
|
||||
{
|
||||
var effectiveAmount = item.isMandatory || item.limit == 0 ? item.current : item.limit;
|
||||
var note = item.isMandatory
|
||||
? "硬性(使用实际)"
|
||||
: (item.limit == 0 ? "不记额(使用实际)" : "使用预算");
|
||||
|
||||
expenseDetails.Add(new BudgetDetailItem
|
||||
{
|
||||
Id = item.id,
|
||||
Name = item.name,
|
||||
Type = BudgetPeriodType.Year,
|
||||
BudgetLimit = item.limit,
|
||||
ActualAmount = item.current,
|
||||
EffectiveAmount = effectiveAmount,
|
||||
CalculationNote = note,
|
||||
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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -449,6 +449,11 @@ public record BudgetResult
|
||||
public bool IsMandatoryExpense { get; set; }
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 存款明细数据(可选,用于存款预算)
|
||||
/// </summary>
|
||||
public SavingsDetail? Details { get; set; }
|
||||
|
||||
public static BudgetResult FromEntity(
|
||||
BudgetRecord entity,
|
||||
decimal currentAmount,
|
||||
@@ -547,3 +552,41 @@ public class UncoveredCategoryDetail
|
||||
public int TransactionCount { get; set; }
|
||||
public decimal TotalAmount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 存款明细数据
|
||||
/// </summary>
|
||||
public record SavingsDetail
|
||||
{
|
||||
public List<BudgetDetailItem> IncomeItems { get; init; } = new();
|
||||
public List<BudgetDetailItem> ExpenseItems { get; init; } = new();
|
||||
public SavingsCalculationSummary Summary { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 预算明细项
|
||||
/// </summary>
|
||||
public record BudgetDetailItem
|
||||
{
|
||||
public long Id { get; init; }
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public BudgetPeriodType Type { get; init; }
|
||||
public decimal BudgetLimit { get; init; }
|
||||
public decimal ActualAmount { get; init; }
|
||||
public decimal EffectiveAmount { get; init; }
|
||||
public string CalculationNote { get; init; } = string.Empty;
|
||||
public bool IsOverBudget { get; init; }
|
||||
public bool IsArchived { get; init; }
|
||||
public int[]? ArchivedMonths { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 存款计算汇总
|
||||
/// </summary>
|
||||
public record SavingsCalculationSummary
|
||||
{
|
||||
public decimal TotalIncomeBudget { get; init; }
|
||||
public decimal TotalExpenseBudget { get; init; }
|
||||
public decimal PlannedSavings { get; init; }
|
||||
public string CalculationFormula { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
@@ -508,6 +508,11 @@ const handleQueryBills = async () => {
|
||||
}
|
||||
|
||||
const percentage = computed(() => {
|
||||
// 优先使用后端返回的 usagePercentage 字段
|
||||
if (props.budget.usagePercentage !== undefined && props.budget.usagePercentage !== null) {
|
||||
return Math.round(props.budget.usagePercentage)
|
||||
}
|
||||
// 降级方案:如果后端没有返回该字段,前端计算
|
||||
if (!props.budget.limit) {
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -1013,6 +1013,7 @@ const yearBurndownChartOptions = computed(() => {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.gauge-text-overlay {
|
||||
@@ -1048,6 +1049,8 @@ const yearBurndownChartOptions = computed(() => {
|
||||
|
||||
.chart-header {
|
||||
margin-bottom: 12px;
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
|
||||
@@ -123,6 +123,8 @@
|
||||
<SavingsBudgetContent
|
||||
v-else-if="activeTab === BudgetCategory.Savings"
|
||||
:budgets="savingsBudgets"
|
||||
:income-budgets="incomeBudgets"
|
||||
:expense-budgets="expenseBudgets"
|
||||
@savings-nav="handleSavingsNav"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -74,12 +74,197 @@
|
||||
<PopupContainerV2
|
||||
v-model:show="showDetailPopup"
|
||||
title="计划存款明细"
|
||||
:height="'80%'"
|
||||
:height="'85%'"
|
||||
>
|
||||
<div class="popup-body">
|
||||
<div
|
||||
v-if="currentBudget"
|
||||
class="detail-content"
|
||||
>
|
||||
<!-- 明细表格 -->
|
||||
<div
|
||||
v-if="currentBudget.details"
|
||||
class="detail-tables"
|
||||
>
|
||||
<!-- 收入明细 -->
|
||||
<div class="detail-section income-section">
|
||||
<div class="section-title">
|
||||
<van-icon name="balance-o" />
|
||||
收入明细
|
||||
</div>
|
||||
<div class="rich-html-content">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>预算额度</th>
|
||||
<th>实际金额</th>
|
||||
<th>计算用</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in currentBudget.details.incomeItems"
|
||||
:key="item.id"
|
||||
>
|
||||
<td>
|
||||
<div style="display: flex; align-items: center; gap: 4px; flex-wrap: wrap;">
|
||||
<span>{{ item.name }}</span>
|
||||
<van-tag
|
||||
size="mini"
|
||||
plain
|
||||
:type="item.type === 1 ? 'default' : 'primary'"
|
||||
>
|
||||
{{ item.type === 1 ? '月' : '年' }}
|
||||
</van-tag>
|
||||
<van-tag
|
||||
v-if="item.isArchived"
|
||||
size="mini"
|
||||
type="success"
|
||||
>
|
||||
已归档
|
||||
</van-tag>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ formatMoney(item.budgetLimit) }}</td>
|
||||
<td>
|
||||
<span
|
||||
class="income-value"
|
||||
:class="{ 'expense-value': item.isOverBudget }"
|
||||
>
|
||||
{{ formatMoney(item.actualAmount) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="income-value">{{ formatMoney(item.effectiveAmount) }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p style="margin-top: 12px;">
|
||||
<strong>收入预算合计:</strong>
|
||||
<template v-if="hasArchivedIncome">
|
||||
已归档 <span class="income-value"><strong>{{ formatMoney(archivedIncomeTotal) }}</strong></span>
|
||||
+ 未来预算 <span class="income-value"><strong>{{ formatMoney(futureIncomeTotal) }}</strong></span>
|
||||
= <span class="income-value"><strong>{{ formatMoney(currentBudget.details.summary.totalIncomeBudget) }}</strong></span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="income-value">
|
||||
<strong>{{ formatMoney(currentBudget.details.summary.totalIncomeBudget) }}</strong>
|
||||
</span>
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 支出明细 -->
|
||||
<div class="detail-section expense-section">
|
||||
<div class="section-title">
|
||||
<van-icon name="bill-o" />
|
||||
支出明细
|
||||
</div>
|
||||
<div class="rich-html-content">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>预算额度</th>
|
||||
<th>实际金额</th>
|
||||
<th>计算用</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in currentBudget.details.expenseItems"
|
||||
:key="item.id"
|
||||
>
|
||||
<td>
|
||||
<div style="display: flex; align-items: center; gap: 4px; flex-wrap: wrap;">
|
||||
<span>{{ item.name }}</span>
|
||||
<van-tag
|
||||
size="mini"
|
||||
plain
|
||||
:type="item.type === 1 ? 'default' : 'primary'"
|
||||
>
|
||||
{{ item.type === 1 ? '月' : '年' }}
|
||||
</van-tag>
|
||||
<van-tag
|
||||
v-if="item.isArchived"
|
||||
size="mini"
|
||||
type="success"
|
||||
>
|
||||
已归档
|
||||
</van-tag>
|
||||
<van-tag
|
||||
v-if="item.isOverBudget"
|
||||
size="mini"
|
||||
type="danger"
|
||||
>
|
||||
超支
|
||||
</van-tag>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ formatMoney(item.budgetLimit) }}</td>
|
||||
<td>
|
||||
<span class="expense-value">{{ formatMoney(item.actualAmount) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="expense-value">{{ formatMoney(item.effectiveAmount) }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p style="margin-top: 12px;">
|
||||
<strong>支出预算合计:</strong>
|
||||
<template v-if="hasArchivedExpense">
|
||||
已归档 <span class="expense-value"><strong>{{ formatMoney(archivedExpenseTotal) }}</strong></span>
|
||||
+ 未来预算 <span class="expense-value"><strong>{{ formatMoney(futureExpenseTotal) }}</strong></span>
|
||||
= <span class="expense-value"><strong>{{ formatMoney(currentBudget.details.summary.totalExpenseBudget) }}</strong></span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="expense-value">
|
||||
<strong>{{ formatMoney(currentBudget.details.summary.totalExpenseBudget) }}</strong>
|
||||
</span>
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 计算汇总 -->
|
||||
<div class="detail-section formula-section">
|
||||
<div class="section-title">
|
||||
<van-icon name="calculator-o" />
|
||||
计算汇总
|
||||
</div>
|
||||
<div class="rich-html-content">
|
||||
<h3>计算公式</h3>
|
||||
<p>
|
||||
<strong>收入预算合计:</strong>
|
||||
<span class="income-value">
|
||||
<strong>{{ formatMoney(currentBudget.details.summary.totalIncomeBudget) }}</strong>
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<strong>支出预算合计:</strong>
|
||||
<span class="expense-value">
|
||||
<strong>{{ formatMoney(currentBudget.details.summary.totalExpenseBudget) }}</strong>
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<strong>计划存款:</strong>
|
||||
{{ currentBudget.details.summary.calculationFormula }}
|
||||
= <span class="highlight">
|
||||
<strong>{{ formatMoney(currentBudget.details.summary.plannedSavings) }}</strong>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 旧版汇总(无明细数据时显示) -->
|
||||
<div
|
||||
v-else
|
||||
class="legacy-summary"
|
||||
>
|
||||
<div class="detail-section income-section">
|
||||
<div class="section-title">
|
||||
@@ -169,6 +354,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopupContainerV2>
|
||||
</template>
|
||||
|
||||
@@ -183,6 +369,14 @@ const props = defineProps({
|
||||
budgets: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
incomeBudgets: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
expenseBudgets: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
@@ -195,10 +389,85 @@ const currentBudget = ref(null)
|
||||
|
||||
// 处理显示明细
|
||||
const handleShowDetail = (budget) => {
|
||||
console.log('=== 存款预算数据 ===')
|
||||
console.log('完整数据:', budget)
|
||||
console.log('是否有 details:', !!budget.details)
|
||||
console.log('是否有 Details:', !!budget.Details)
|
||||
if (budget.details) {
|
||||
console.log('details 内容:', budget.details)
|
||||
}
|
||||
if (budget.Details) {
|
||||
console.log('Details 内容:', budget.Details)
|
||||
}
|
||||
console.log('===================')
|
||||
|
||||
currentBudget.value = budget
|
||||
showDetailPopup.value = true
|
||||
}
|
||||
|
||||
// 匹配收入预算
|
||||
const matchedIncomeBudget = computed(() => {
|
||||
if (!currentBudget.value) {return null}
|
||||
return props.incomeBudgets?.find(
|
||||
b => b.periodStart === currentBudget.value.periodStart && b.type === currentBudget.value.type
|
||||
)
|
||||
})
|
||||
|
||||
// 匹配支出预算
|
||||
const matchedExpenseBudget = computed(() => {
|
||||
if (!currentBudget.value) {return null}
|
||||
return props.expenseBudgets?.find(
|
||||
b => b.periodStart === currentBudget.value.periodStart && b.type === currentBudget.value.type
|
||||
)
|
||||
})
|
||||
|
||||
// 收入预算数据
|
||||
const incomeLimit = computed(() => matchedIncomeBudget.value?.limit || 0)
|
||||
const incomeCurrent = computed(() => matchedIncomeBudget.value?.current || 0)
|
||||
|
||||
// 支出预算数据
|
||||
const expenseLimit = computed(() => matchedExpenseBudget.value?.limit || 0)
|
||||
const expenseCurrent = computed(() => matchedExpenseBudget.value?.current || 0)
|
||||
|
||||
// 归档和未来预算的汇总 (仅用于年度存款计划)
|
||||
const hasArchivedIncome = computed(() => {
|
||||
if (!currentBudget.value?.details) return false
|
||||
return currentBudget.value.details.incomeItems.some(item => item.isArchived)
|
||||
})
|
||||
|
||||
const archivedIncomeTotal = computed(() => {
|
||||
if (!currentBudget.value?.details) return 0
|
||||
return currentBudget.value.details.incomeItems
|
||||
.filter(item => item.isArchived)
|
||||
.reduce((sum, item) => sum + item.effectiveAmount, 0)
|
||||
})
|
||||
|
||||
const futureIncomeTotal = computed(() => {
|
||||
if (!currentBudget.value?.details) return 0
|
||||
return currentBudget.value.details.incomeItems
|
||||
.filter(item => !item.isArchived)
|
||||
.reduce((sum, item) => sum + item.effectiveAmount, 0)
|
||||
})
|
||||
|
||||
const hasArchivedExpense = computed(() => {
|
||||
if (!currentBudget.value?.details) return false
|
||||
return currentBudget.value.details.expenseItems.some(item => item.isArchived)
|
||||
})
|
||||
|
||||
const archivedExpenseTotal = computed(() => {
|
||||
if (!currentBudget.value?.details) return 0
|
||||
return currentBudget.value.details.expenseItems
|
||||
.filter(item => item.isArchived)
|
||||
.reduce((sum, item) => sum + item.effectiveAmount, 0)
|
||||
})
|
||||
|
||||
const futureExpenseTotal = computed(() => {
|
||||
if (!currentBudget.value?.details) return 0
|
||||
return currentBudget.value.details.expenseItems
|
||||
.filter(item => !item.isArchived)
|
||||
.reduce((sum, item) => sum + item.effectiveAmount, 0)
|
||||
})
|
||||
|
||||
// 辅助函数
|
||||
const formatMoney = (val) => {
|
||||
return parseFloat(val || 0).toLocaleString(undefined, {
|
||||
@@ -445,4 +714,43 @@ const getProgressColor = (budget) => {
|
||||
color: var(--van-text-color-2);
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
/* 明细表格样式 - 使用 rich-html-content 统一样式 */
|
||||
.detail-tables {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.formula-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.formula-row.highlight {
|
||||
margin-top: 8px;
|
||||
padding-top: 12px;
|
||||
border-top: 2px solid var(--van-border-color);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.formula-value.primary {
|
||||
color: var(--van-primary-color);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.formula-text {
|
||||
margin-top: 12px;
|
||||
padding: 10px;
|
||||
background-color: var(--van-light-gray);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--van-text-color-2);
|
||||
text-align: center;
|
||||
font-family: DIN Alternate, system-ui;
|
||||
}
|
||||
</style>
|
||||
|
||||
260
WebApi.Test/Budget/BudgetItemCalculatorTest.cs
Normal file
260
WebApi.Test/Budget/BudgetItemCalculatorTest.cs
Normal file
@@ -0,0 +1,260 @@
|
||||
using Service.Budget;
|
||||
|
||||
namespace WebApi.Test.Budget;
|
||||
|
||||
/// <summary>
|
||||
/// BudgetItemCalculator 单元测试
|
||||
/// 测试明细项计算用金额的各种规则
|
||||
/// </summary>
|
||||
public class BudgetItemCalculatorTest : BaseTest
|
||||
{
|
||||
[Fact]
|
||||
public void 收入项实际已发生_应返回实际值()
|
||||
{
|
||||
// Arrange
|
||||
var budgetLimit = 10000m;
|
||||
var actualAmount = 9500m;
|
||||
|
||||
// Act
|
||||
var result = BudgetItemCalculator.CalculateEffectiveAmount(
|
||||
BudgetCategory.Income,
|
||||
budgetLimit,
|
||||
actualAmount,
|
||||
isMandatory: false,
|
||||
isArchived: false,
|
||||
new DateTime(2026, 2, 15),
|
||||
BudgetPeriodType.Month
|
||||
);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(9500m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 收入项实际未发生_应返回预算值()
|
||||
{
|
||||
// Arrange
|
||||
var budgetLimit = 5000m;
|
||||
var actualAmount = 0m;
|
||||
|
||||
// Act
|
||||
var result = BudgetItemCalculator.CalculateEffectiveAmount(
|
||||
BudgetCategory.Income,
|
||||
budgetLimit,
|
||||
actualAmount,
|
||||
isMandatory: false,
|
||||
isArchived: false,
|
||||
new DateTime(2026, 2, 15),
|
||||
BudgetPeriodType.Month
|
||||
);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(5000m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 支出项普通情况_应返回MAX预算和实际()
|
||||
{
|
||||
// Arrange
|
||||
var budgetLimit = 2000m;
|
||||
var actualAmount = 2500m;
|
||||
|
||||
// Act
|
||||
var result = BudgetItemCalculator.CalculateEffectiveAmount(
|
||||
BudgetCategory.Expense,
|
||||
budgetLimit,
|
||||
actualAmount,
|
||||
isMandatory: false,
|
||||
isArchived: false,
|
||||
new DateTime(2026, 2, 15),
|
||||
BudgetPeriodType.Month
|
||||
);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(2500m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 支出项未超预算_应返回预算值()
|
||||
{
|
||||
// Arrange
|
||||
var budgetLimit = 2000m;
|
||||
var actualAmount = 1800m;
|
||||
|
||||
// Act
|
||||
var result = BudgetItemCalculator.CalculateEffectiveAmount(
|
||||
BudgetCategory.Expense,
|
||||
budgetLimit,
|
||||
actualAmount,
|
||||
isMandatory: false,
|
||||
isArchived: false,
|
||||
new DateTime(2026, 2, 15),
|
||||
BudgetPeriodType.Month
|
||||
);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(2000m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 支出项超预算_应返回实际值()
|
||||
{
|
||||
// Arrange
|
||||
var budgetLimit = 2000m;
|
||||
var actualAmount = 2500m;
|
||||
|
||||
// Act
|
||||
var result = BudgetItemCalculator.CalculateEffectiveAmount(
|
||||
BudgetCategory.Expense,
|
||||
budgetLimit,
|
||||
actualAmount,
|
||||
isMandatory: false,
|
||||
isArchived: false,
|
||||
new DateTime(2026, 2, 15),
|
||||
BudgetPeriodType.Month
|
||||
);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(2500m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 支出项硬性且实际为0_月度_应按天数折算()
|
||||
{
|
||||
// Arrange
|
||||
var budgetLimit = 3000m;
|
||||
var actualAmount = 0m;
|
||||
var date = new DateTime(2026, 2, 15); // 2月共28天,当前15号
|
||||
|
||||
// Act
|
||||
var result = BudgetItemCalculator.CalculateEffectiveAmount(
|
||||
BudgetCategory.Expense,
|
||||
budgetLimit,
|
||||
actualAmount,
|
||||
isMandatory: true,
|
||||
isArchived: false,
|
||||
date,
|
||||
BudgetPeriodType.Month
|
||||
);
|
||||
|
||||
// Assert
|
||||
var expected = 3000m / 28 * 15; // ≈ 1607.14
|
||||
result.Should().BeApproximately(expected, 0.01m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 支出项硬性且实际为0_年度_应按天数折算()
|
||||
{
|
||||
// Arrange
|
||||
var budgetLimit = 12000m;
|
||||
var actualAmount = 0m;
|
||||
var date = new DateTime(2026, 2, 15); // 2026年第46天(31+15)
|
||||
|
||||
// Act
|
||||
var result = BudgetItemCalculator.CalculateEffectiveAmount(
|
||||
BudgetCategory.Expense,
|
||||
budgetLimit,
|
||||
actualAmount,
|
||||
isMandatory: true,
|
||||
isArchived: false,
|
||||
date,
|
||||
BudgetPeriodType.Year
|
||||
);
|
||||
|
||||
// Assert
|
||||
var expected = 12000m / 365 * 46; // ≈ 1512.33
|
||||
result.Should().BeApproximately(expected, 0.01m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 支出项硬性且实际大于0_应返回MAX值()
|
||||
{
|
||||
// Arrange
|
||||
var budgetLimit = 3000m;
|
||||
var actualAmount = 3200m;
|
||||
|
||||
// Act
|
||||
var result = BudgetItemCalculator.CalculateEffectiveAmount(
|
||||
BudgetCategory.Expense,
|
||||
budgetLimit,
|
||||
actualAmount,
|
||||
isMandatory: true,
|
||||
isArchived: false,
|
||||
new DateTime(2026, 2, 15),
|
||||
BudgetPeriodType.Month
|
||||
);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(3200m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 归档数据_应直接返回实际值()
|
||||
{
|
||||
// Arrange
|
||||
var budgetLimit = 2000m;
|
||||
var actualAmount = 1800m;
|
||||
|
||||
// Act
|
||||
var result = BudgetItemCalculator.CalculateEffectiveAmount(
|
||||
BudgetCategory.Expense,
|
||||
budgetLimit,
|
||||
actualAmount,
|
||||
isMandatory: false,
|
||||
isArchived: true, // 归档数据
|
||||
new DateTime(2026, 2, 15),
|
||||
BudgetPeriodType.Month
|
||||
);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(1800m); // 归档数据直接返回实际值,不走MAX逻辑
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 闰年2月按天折算边界情况()
|
||||
{
|
||||
// Arrange
|
||||
var budgetLimit = 3000m;
|
||||
var actualAmount = 0m;
|
||||
var date = new DateTime(2024, 2, 29); // 闰年2月29日
|
||||
|
||||
// Act
|
||||
var result = BudgetItemCalculator.CalculateEffectiveAmount(
|
||||
BudgetCategory.Expense,
|
||||
budgetLimit,
|
||||
actualAmount,
|
||||
isMandatory: true,
|
||||
isArchived: false,
|
||||
date,
|
||||
BudgetPeriodType.Month
|
||||
);
|
||||
|
||||
// Assert
|
||||
var expected = 3000m / 29 * 29; // = 3000
|
||||
result.Should().Be(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 平年2月按天折算边界情况()
|
||||
{
|
||||
// Arrange
|
||||
var budgetLimit = 3000m;
|
||||
var actualAmount = 0m;
|
||||
var date = new DateTime(2026, 2, 28); // 平年2月28日
|
||||
|
||||
// Act
|
||||
var result = BudgetItemCalculator.CalculateEffectiveAmount(
|
||||
BudgetCategory.Expense,
|
||||
budgetLimit,
|
||||
actualAmount,
|
||||
isMandatory: true,
|
||||
isArchived: false,
|
||||
date,
|
||||
BudgetPeriodType.Month
|
||||
);
|
||||
|
||||
// Assert
|
||||
var expected = 3000m / 28 * 28; // = 3000
|
||||
result.Should().Be(expected);
|
||||
}
|
||||
}
|
||||
135
WebApi.Test/Budget/BudgetSavingsCalculationTest.cs
Normal file
135
WebApi.Test/Budget/BudgetSavingsCalculationTest.cs
Normal file
@@ -0,0 +1,135 @@
|
||||
using Service.Budget;
|
||||
|
||||
namespace WebApi.Test.Budget;
|
||||
|
||||
/// <summary>
|
||||
/// 存款计划核心公式单元测试
|
||||
/// </summary>
|
||||
public class BudgetSavingsCalculationTest : BaseTest
|
||||
{
|
||||
[Fact]
|
||||
public void 月度计划存款公式_纯月度预算场景()
|
||||
{
|
||||
// Arrange
|
||||
var monthlyIncomeBudget = 15000m; // 工资10000 + 奖金5000
|
||||
var yearlyIncomeInThisMonth = 0m;
|
||||
var monthlyExpenseBudget = 5000m; // 房租3000 + 餐饮2000
|
||||
var yearlyExpenseInThisMonth = 0m;
|
||||
|
||||
// Act
|
||||
var result = BudgetSavingsService.CalculateMonthlyPlannedSavings(
|
||||
monthlyIncomeBudget,
|
||||
yearlyIncomeInThisMonth,
|
||||
monthlyExpenseBudget,
|
||||
yearlyExpenseInThisMonth
|
||||
);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(10000m); // 15000 - 5000
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 月度计划存款公式_月度预算加本月发生的年度预算()
|
||||
{
|
||||
// Arrange
|
||||
var monthlyIncomeBudget = 10000m; // 工资
|
||||
var yearlyIncomeInThisMonth = 0m;
|
||||
var monthlyExpenseBudget = 3000m; // 房租
|
||||
var yearlyExpenseInThisMonth = 3000m; // 旅游实际发生
|
||||
|
||||
// Act
|
||||
var result = BudgetSavingsService.CalculateMonthlyPlannedSavings(
|
||||
monthlyIncomeBudget,
|
||||
yearlyIncomeInThisMonth,
|
||||
monthlyExpenseBudget,
|
||||
yearlyExpenseInThisMonth
|
||||
);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(4000m); // 10000 - 3000 - 3000
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 月度计划存款公式_年度预算未在本月发生应不计入()
|
||||
{
|
||||
// Arrange
|
||||
var monthlyIncomeBudget = 10000m;
|
||||
var yearlyIncomeInThisMonth = 0m; // 年终奖未发生
|
||||
var monthlyExpenseBudget = 3000m;
|
||||
var yearlyExpenseInThisMonth = 0m; // 旅游未发生
|
||||
|
||||
// Act
|
||||
var result = BudgetSavingsService.CalculateMonthlyPlannedSavings(
|
||||
monthlyIncomeBudget,
|
||||
yearlyIncomeInThisMonth,
|
||||
monthlyExpenseBudget,
|
||||
yearlyExpenseInThisMonth
|
||||
);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(7000m); // 10000 - 3000
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 年度计划存款公式_年初无归档数据场景()
|
||||
{
|
||||
// Arrange
|
||||
var archivedIncome = 0m;
|
||||
var futureIncomeBudget = 120000m; // 10000×12
|
||||
var archivedExpense = 0m;
|
||||
var futureExpenseBudget = 36000m; // 3000×12
|
||||
|
||||
// Act
|
||||
var result = BudgetSavingsService.CalculateYearlyPlannedSavings(
|
||||
archivedIncome,
|
||||
futureIncomeBudget,
|
||||
archivedExpense,
|
||||
futureExpenseBudget
|
||||
);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(84000m); // 120000 - 36000
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 年度计划存款公式_年中有归档数据场景()
|
||||
{
|
||||
// Arrange
|
||||
var archivedIncome = 29000m; // 1月15000 + 2月14000
|
||||
var futureIncomeBudget = 100000m; // 10000×10月
|
||||
var archivedExpense = 10000m; // 1月4800 + 2月5200
|
||||
var futureExpenseBudget = 30000m; // 3000×10月
|
||||
|
||||
// Act
|
||||
var result = BudgetSavingsService.CalculateYearlyPlannedSavings(
|
||||
archivedIncome,
|
||||
futureIncomeBudget,
|
||||
archivedExpense,
|
||||
futureExpenseBudget
|
||||
);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(89000m); // 29000 + 100000 - 10000 - 30000
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 年度计划存款公式_归档数据包含年度预算()
|
||||
{
|
||||
// Arrange
|
||||
var archivedIncome = 15000m;
|
||||
var futureIncomeBudget = 110000m;
|
||||
var archivedExpense = 7800m; // 包含1月旅游3000的年度支出
|
||||
var futureExpenseBudget = 30000m;
|
||||
|
||||
// Act
|
||||
var result = BudgetSavingsService.CalculateYearlyPlannedSavings(
|
||||
archivedIncome,
|
||||
futureIncomeBudget,
|
||||
archivedExpense,
|
||||
futureExpenseBudget
|
||||
);
|
||||
|
||||
// Assert
|
||||
result.Should().Be(87200m); // 15000 + 110000 - 7800 - 30000
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-20
|
||||
@@ -0,0 +1,117 @@
|
||||
## Context
|
||||
|
||||
`SavingsBudgetContent.vue` 是一个显示存款计划列表和明细的组件。每个存款计划卡片可以点击查看详细信息,弹窗会显示:
|
||||
1. 收入预算(预算限额和实际收入)
|
||||
2. 支出预算(预算限额和实际支出)
|
||||
3. 计划存款公式(收入预算 - 支出预算 = 计划存款)
|
||||
4. 存款结果(计划存款、实际存款、还差)
|
||||
|
||||
问题在于弹窗模板引用了 `incomeLimit`、`incomeCurrent`、`expenseLimit`、`expenseCurrent` 这些计算属性,但在 `<script setup>` 中并未定义,导致弹窗内容为空。
|
||||
|
||||
父组件 `Index.vue` 维护了三个独立的预算数组:
|
||||
- `expenseBudgets` - 支出预算列表
|
||||
- `incomeBudgets` - 收入预算列表
|
||||
- `savingsBudgets` - 存款计划列表
|
||||
|
||||
当前 `SavingsBudgetContent.vue` 只接收 `savingsBudgets` 数组,无法访问收入和支出预算数据。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 修复存款明细弹窗显示为空的问题
|
||||
- 正确显示收入预算和支出预算的限额及实际值
|
||||
- 确保计算逻辑与后端逻辑一致
|
||||
- 保持组件的单一职责,不引入不必要的依赖
|
||||
|
||||
**Non-Goals:**
|
||||
- 不修改存款计划的计算逻辑(后端已有)
|
||||
- 不改变预算数据的加载方式
|
||||
- 不重构整个预算页面的架构
|
||||
|
||||
## Decisions
|
||||
|
||||
### 决策 1: 数据传递方式
|
||||
|
||||
**选择:** 通过 props 传递收入和支出预算数据
|
||||
|
||||
**理由:**
|
||||
- **为什么不用 provide/inject?** 数据流更清晰,易于追踪和调试
|
||||
- **为什么不在子组件中调用 API?** 违反单一数据源原则,会导致重复加载和不一致
|
||||
- **为什么不用 Pinia store?** 这是页面级别的临时数据,不需要全局状态管理
|
||||
|
||||
**实现:**
|
||||
在 `Index.vue` 中传递 `incomeBudgets` 和 `expenseBudgets` 给 `SavingsBudgetContent.vue`:
|
||||
```vue
|
||||
<SavingsBudgetContent
|
||||
:budgets="savingsBudgets"
|
||||
:income-budgets="incomeBudgets"
|
||||
:expense-budgets="expenseBudgets"
|
||||
@savings-nav="handleSavingsNav"
|
||||
/>
|
||||
```
|
||||
|
||||
### 决策 2: 匹配收入/支出预算的逻辑
|
||||
|
||||
**选择:** 根据 `periodStart` 和 `type` 进行匹配
|
||||
|
||||
**理由:**
|
||||
- 存款计划、收入预算、支出预算都有相同的 `periodStart`(周期开始时间)和 `type`(月度/年度)字段
|
||||
- 同一周期、同一类型的预算应该对应同一个存款计划
|
||||
- 后端逻辑中存款 = 收入预算限额 - 支出预算限额
|
||||
|
||||
**实现:**
|
||||
在 `SavingsBudgetContent.vue` 中添加计算属性:
|
||||
```javascript
|
||||
const matchedIncomeBudget = computed(() => {
|
||||
if (!currentBudget.value) return null
|
||||
return props.incomeBudgets?.find(
|
||||
b => b.periodStart === currentBudget.value.periodStart
|
||||
&& b.type === currentBudget.value.type
|
||||
)
|
||||
})
|
||||
|
||||
const matchedExpenseBudget = computed(() => {
|
||||
if (!currentBudget.value) return null
|
||||
return props.expenseBudgets?.find(
|
||||
b => b.periodStart === currentBudget.value.periodStart
|
||||
&& b.type === currentBudget.value.type
|
||||
)
|
||||
})
|
||||
|
||||
const incomeLimit = computed(() => matchedIncomeBudget.value?.limit || 0)
|
||||
const incomeCurrent = computed(() => matchedIncomeBudget.value?.current || 0)
|
||||
const expenseLimit = computed(() => matchedExpenseBudget.value?.limit || 0)
|
||||
const expenseCurrent = computed(() => matchedExpenseBudget.value?.current || 0)
|
||||
```
|
||||
|
||||
### 决策 3: 处理数据缺失情况
|
||||
|
||||
**选择:** 使用默认值 0,不显示错误提示
|
||||
|
||||
**理由:**
|
||||
- 如果找不到对应的收入或支出预算,说明用户可能还未设置
|
||||
- 显示 0 比显示错误信息更友好,符合"计划存款 = 收入 - 支出"的语义
|
||||
- 用户可以在主界面看到预算设置情况,弹窗只是详情展示
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
### [风险] 如果收入/支出预算数据未加载完成
|
||||
|
||||
**缓解措施:**
|
||||
- 父组件 `Index.vue` 已经统一加载所有预算数据
|
||||
- 弹窗在用户点击时打开,此时数据应已加载完成
|
||||
- 使用可选链和默认值避免运行时错误
|
||||
|
||||
### [权衡] Props 增加导致组件耦合度提高
|
||||
|
||||
**接受理由:**
|
||||
- 父组件本身就维护了所有预算数据,传递给子组件是合理的
|
||||
- 子组件不负责数据加载,只负责展示,职责依然清晰
|
||||
- 如果未来需要重构,可以考虑引入 Pinia store 统一管理预算数据
|
||||
|
||||
### [权衡] 按周期和类型匹配可能不够健壮
|
||||
|
||||
**接受理由:**
|
||||
- 这是后端设计的数据模型,前端保持一致
|
||||
- 后端保证同一周期同一类型只有一条预算记录
|
||||
- 如果后端逻辑变更,前端需要同步调整(这是正常的维护成本)
|
||||
@@ -0,0 +1,33 @@
|
||||
## Why
|
||||
|
||||
存款明细弹窗显示为空白内容,因为 `SavingsBudgetContent.vue` 组件中引用了未定义的计算属性 `incomeLimit`、`incomeCurrent`、`expenseLimit`、`expenseCurrent`,导致用户无法查看存款计划的详细构成。这影响了用户理解存款目标的计算逻辑和追踪存款进度的能力。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 修复 `SavingsBudgetContent.vue` 组件中缺失的计算属性
|
||||
- 添加从父组件获取收入和支出预算数据的逻辑
|
||||
- 确保存款明细弹窗正确显示收入预算、支出预算、计划存款公式和存款结果
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
无新增能力。
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `savings-budget-display`: 修复存款明细弹窗内容显示功能,确保收入预算和支出预算数据正确传递和渲染
|
||||
|
||||
## Impact
|
||||
|
||||
**受影响文件:**
|
||||
- `Web/src/views/budgetV2/modules/SavingsBudgetContent.vue` - 添加缺失的计算属性
|
||||
- `Web/src/views/budgetV2/Index.vue` - 可能需要传递额外的收入/支出预算数据给子组件
|
||||
|
||||
**受影响功能:**
|
||||
- 存款计划明细查看功能
|
||||
- 用户对存款目标计算逻辑的理解
|
||||
|
||||
**依赖:**
|
||||
- Vue 3 computed API
|
||||
- 组件间数据传递(props 或 provide/inject)
|
||||
@@ -0,0 +1,45 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Display income and expense budget in savings detail popup
|
||||
The savings detail popup SHALL display the associated income budget and expense budget information for the selected savings plan, including both budget limits and current amounts.
|
||||
|
||||
#### Scenario: User opens savings detail popup with matched budgets
|
||||
- **WHEN** user clicks the detail button on a savings plan card
|
||||
- **AND** there exist income and expense budgets for the same period and type
|
||||
- **THEN** the popup SHALL display the income budget limit and current amount
|
||||
- **AND** the popup SHALL display the expense budget limit and current amount
|
||||
- **AND** the popup SHALL display the savings formula (Income Limit - Expense Limit = Planned Savings)
|
||||
- **AND** the popup SHALL display the savings result (Planned Savings, Actual Savings, Remaining)
|
||||
|
||||
#### Scenario: User opens savings detail popup without matched budgets
|
||||
- **WHEN** user clicks the detail button on a savings plan card
|
||||
- **AND** there are no income or expense budgets for the same period and type
|
||||
- **THEN** the popup SHALL display 0 for income budget limit and current amount
|
||||
- **AND** the popup SHALL display 0 for expense budget limit and current amount
|
||||
- **AND** the popup SHALL still display the savings formula and result with these values
|
||||
|
||||
### Requirement: Pass budget data to savings component
|
||||
The parent component (Index.vue) SHALL pass income budgets and expense budgets to the SavingsBudgetContent component to enable detail popup display.
|
||||
|
||||
#### Scenario: Budget data is loaded successfully
|
||||
- **WHEN** the budget data is loaded from the API
|
||||
- **THEN** the income budgets SHALL be passed to SavingsBudgetContent via props
|
||||
- **AND** the expense budgets SHALL be passed to SavingsBudgetContent via props
|
||||
- **AND** the savings budgets SHALL be passed to SavingsBudgetContent via props (existing behavior)
|
||||
|
||||
### Requirement: Match income and expense budgets to savings plan
|
||||
The SavingsBudgetContent component SHALL match income and expense budgets to the current savings plan based on periodStart and type fields.
|
||||
|
||||
#### Scenario: Match budgets with same period and type
|
||||
- **WHEN** displaying savings plan details
|
||||
- **AND** the component searches for matching budgets
|
||||
- **THEN** the component SHALL find income budgets where periodStart and type match the savings plan
|
||||
- **AND** the component SHALL find expense budgets where periodStart and type match the savings plan
|
||||
- **AND** if multiple matches exist, the component SHALL use the first match
|
||||
|
||||
#### Scenario: No matching budgets found
|
||||
- **WHEN** displaying savings plan details
|
||||
- **AND** no income budget matches the savings plan's periodStart and type
|
||||
- **OR** no expense budget matches the savings plan's periodStart and type
|
||||
- **THEN** the component SHALL use 0 as the default value for unmatched budget fields
|
||||
- **AND** the popup SHALL still render without errors
|
||||
@@ -0,0 +1,22 @@
|
||||
## 1. 修改父组件传递数据
|
||||
|
||||
- [x] 1.1 在 Index.vue 中修改 SavingsBudgetContent 组件调用,添加 income-budgets 和 expense-budgets props
|
||||
- [x] 1.2 验证数据传递正确(通过 Vue DevTools 检查 props)
|
||||
|
||||
## 2. 修改 SavingsBudgetContent 组件
|
||||
|
||||
- [x] 2.1 在 props 定义中添加 incomeBudgets 和 expenseBudgets 数组
|
||||
- [x] 2.2 添加 matchedIncomeBudget 计算属性(根据 periodStart 和 type 匹配)
|
||||
- [x] 2.3 添加 matchedExpenseBudget 计算属性(根据 periodStart 和 type 匹配)
|
||||
- [x] 2.4 添加 incomeLimit 计算属性(从 matchedIncomeBudget 获取或默认 0)
|
||||
- [x] 2.5 添加 incomeCurrent 计算属性(从 matchedIncomeBudget 获取或默认 0)
|
||||
- [x] 2.6 添加 expenseLimit 计算属性(从 matchedExpenseBudget 获取或默认 0)
|
||||
- [x] 2.7 添加 expenseCurrent 计算属性(从 matchedExpenseBudget 获取或默认 0)
|
||||
|
||||
## 3. 测试验证
|
||||
|
||||
- [ ] 3.1 测试有对应收入和支出预算的存款计划,打开明细弹窗验证数据显示正确
|
||||
- [ ] 3.2 测试没有对应收入或支出预算的存款计划,验证弹窗显示 0 且不报错
|
||||
- [ ] 3.3 验证计划存款公式计算正确(收入预算 - 支出预算 = 计划存款)
|
||||
- [ ] 3.4 测试月度和年度两种类型的存款计划明细
|
||||
- [ ] 3.5 使用不同月份的存款计划测试,验证匹配逻辑正确
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-20
|
||||
255
openspec/changes/saving-detail-calculation/design.md
Normal file
255
openspec/changes/saving-detail-calculation/design.md
Normal file
@@ -0,0 +1,255 @@
|
||||
# Design: 存款明细计算优化
|
||||
|
||||
## Context
|
||||
|
||||
当前 `BudgetSavingsService` 在计算存款时使用了复杂的逻辑,包括归档数据读取、月度/年度预算折算、硬性消费的天数折算等。但核心计算公式不明确,导致代码可读性差,且难以验证计算结果的正确性。
|
||||
|
||||
### 现状
|
||||
- `GetForMonthAsync`: 计算月度存款,需要处理月度预算和发生在本月的年度预算
|
||||
- `GetForYearAsync`: 计算年度存款,需要整合归档数据和未来月份预算
|
||||
- 归档数据存储在 `BudgetArchive` 表中,每月的实际收支被固化
|
||||
- 硬性消费(`IsMandatoryExpense`)在实际为0时按天数比例折算
|
||||
|
||||
### 约束
|
||||
- 不改变数据库结构和归档格式
|
||||
- 保持与现有 `BudgetArchiveRepository` 和 `TransactionStatisticsService` 的兼容性
|
||||
- 必须通过 TDD 方式开发,先写测试再实现
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- 明确定义月度和年度存款的计算公式
|
||||
- 重构 `BudgetSavingsService` 以提高代码可读性和可维护性
|
||||
- 提供详细的明细数据结构,支持前端展示计算过程
|
||||
- 确保所有计算场景都有单元测试覆盖
|
||||
|
||||
**Non-Goals:**
|
||||
- 修改前端展示逻辑(仅提供数据结构)
|
||||
- 改变归档任务的行为
|
||||
- 优化数据库查询性能(保持现有逻辑)
|
||||
|
||||
## Decisions
|
||||
|
||||
### 决策1:核心计算公式明确化
|
||||
|
||||
**选择:将核心公式提取为独立方法**
|
||||
|
||||
```csharp
|
||||
// 月度计划存款
|
||||
private decimal CalculateMonthlyPlannedSavings(
|
||||
decimal monthlyIncomeBudget,
|
||||
decimal yearlyIncomeInThisMonth,
|
||||
decimal monthlyExpenseBudget,
|
||||
decimal yearlyExpenseInThisMonth)
|
||||
{
|
||||
return monthlyIncomeBudget + yearlyIncomeInThisMonth
|
||||
- monthlyExpenseBudget - yearlyExpenseInThisMonth;
|
||||
}
|
||||
|
||||
// 年度计划存款
|
||||
private decimal CalculateYearlyPlannedSavings(
|
||||
decimal archivedIncome,
|
||||
decimal futureIncomeBudget,
|
||||
decimal archivedExpense,
|
||||
decimal futureExpenseBudget)
|
||||
{
|
||||
return archivedIncome + futureIncomeBudget
|
||||
- archivedExpense - futureExpenseBudget;
|
||||
}
|
||||
```
|
||||
|
||||
**理由:**
|
||||
- 公式清晰可见,便于验证和维护
|
||||
- 单元测试可以直接测试公式本身
|
||||
- 与明细计算逻辑解耦
|
||||
|
||||
**替代方案:内联计算**
|
||||
- 被拒绝:代码可读性差,难以测试
|
||||
|
||||
### 决策2:明细项计算用金额的规则实现
|
||||
|
||||
**选择:创建 `BudgetItemCalculator` 辅助类**
|
||||
|
||||
```csharp
|
||||
public class BudgetItemCalculator
|
||||
{
|
||||
public static decimal CalculateEffectiveAmount(
|
||||
BudgetCategory category,
|
||||
decimal budgetLimit,
|
||||
decimal actualAmount,
|
||||
bool isMandatory,
|
||||
DateTime referenceDate,
|
||||
BudgetPeriodType periodType)
|
||||
{
|
||||
// 归档数据直接返回实际值
|
||||
if (isArchived) return actualAmount;
|
||||
|
||||
// 收入:实际>0取实际,否则取预算
|
||||
if (category == BudgetCategory.Income)
|
||||
return actualAmount > 0 ? actualAmount : budgetLimit;
|
||||
|
||||
// 支出(硬性且实际=0):按天数折算
|
||||
if (category == BudgetCategory.Expense && isMandatory && actualAmount == 0)
|
||||
return CalculateMandatoryAmount(budgetLimit, referenceDate, periodType);
|
||||
|
||||
// 支出(普通):取MAX
|
||||
if (category == BudgetCategory.Expense)
|
||||
return Math.Max(budgetLimit, actualAmount);
|
||||
|
||||
return budgetLimit;
|
||||
}
|
||||
|
||||
private static decimal CalculateMandatoryAmount(
|
||||
decimal limit, DateTime date, BudgetPeriodType type)
|
||||
{
|
||||
if (type == BudgetPeriodType.Month)
|
||||
return limit / DateTime.DaysInMonth(date.Year, date.Month) * date.Day;
|
||||
else
|
||||
return limit / (DateTime.IsLeapYear(date.Year) ? 366 : 365) * date.DayOfYear;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**理由:**
|
||||
- 逻辑集中,易于测试和维护
|
||||
- 明确了"归档"、"收入"、"支出"、"硬性"四种场景的处理规则
|
||||
- 可以在单元测试中独立验证每种规则
|
||||
|
||||
**替代方案:内联在 GetForMonthAsync 中**
|
||||
- 被拒绝:代码重复,难以测试
|
||||
|
||||
### 决策3:明细数据结构设计
|
||||
|
||||
**选择:返回结构化的明细对象**
|
||||
|
||||
```csharp
|
||||
public record SavingsDetail
|
||||
{
|
||||
public List<BudgetDetailItem> IncomeItems { get; init; } = new();
|
||||
public List<BudgetDetailItem> ExpenseItems { get; init; } = new();
|
||||
public SavingsCalculationSummary Summary { get; init; } = new();
|
||||
}
|
||||
|
||||
public record BudgetDetailItem
|
||||
{
|
||||
public long Id { get; init; }
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public BudgetPeriodType Type { get; init; }
|
||||
public decimal BudgetLimit { get; init; }
|
||||
public decimal ActualAmount { get; init; }
|
||||
public decimal EffectiveAmount { get; init; } // 计算用金额
|
||||
public string CalculationNote { get; init; } = string.Empty; // "使用预算"/"使用实际"/"按天折算"
|
||||
public bool IsOverBudget { get; init; } // 是否超支/未达标
|
||||
}
|
||||
|
||||
public record SavingsCalculationSummary
|
||||
{
|
||||
public decimal TotalIncomeBudget { get; init; }
|
||||
public decimal TotalExpenseBudget { get; init; }
|
||||
public decimal PlannedSavings { get; init; }
|
||||
public string CalculationFormula { get; init; } = string.Empty;
|
||||
}
|
||||
```
|
||||
|
||||
**理由:**
|
||||
- 结构化数据便于前端展示
|
||||
- `CalculationNote` 让用户清楚看到每项如何计算
|
||||
- `IsOverBudget` 支持前端高亮显示
|
||||
- 分离明细和汇总,符合单一职责原则
|
||||
|
||||
**替代方案:返回 HTML 字符串**
|
||||
- 被拒绝:前端无法灵活控制展示样式
|
||||
|
||||
### 决策4:归档数据的处理
|
||||
|
||||
**选择:年度计算时,归档月份直接使用 `BudgetArchive.Content[].Actual`**
|
||||
|
||||
**理由:**
|
||||
- 归档数据已经固化,不应重新计算
|
||||
- 与现有归档逻辑保持一致
|
||||
- 避免因预算调整导致历史数据变化
|
||||
|
||||
### 决策5:测试策略
|
||||
|
||||
**选择:TDD 红-绿-重构流程**
|
||||
|
||||
测试文件结构:
|
||||
```
|
||||
WebApi.Test/Budget/
|
||||
├── BudgetSavingsCalculationTest.cs (新增 - 核心计算逻辑单元测试)
|
||||
│ ├── CalculateMonthlyPlannedSavings_Test
|
||||
│ ├── CalculateYearlyPlannedSavings_Test
|
||||
│ ├── BudgetItemCalculator_收入项_实际已发生_Test
|
||||
│ ├── BudgetItemCalculator_收入项_实际未发生_Test
|
||||
│ ├── BudgetItemCalculator_支出项_普通_Test
|
||||
│ ├── BudgetItemCalculator_支出项_硬性_Test
|
||||
│ └── BudgetItemCalculator_归档数据_Test
|
||||
└── BudgetSavingsTest.cs (修改 - 集成测试)
|
||||
├── GetForMonthAsync_完整场景_Test
|
||||
└── GetForYearAsync_完整场景_Test
|
||||
```
|
||||
|
||||
**测试覆盖场景:**
|
||||
1. 月度计算:纯月度预算、月度+年度混合
|
||||
2. 年度计算:有归档数据、无归档数据、部分归档
|
||||
3. 收入项:实际>0、实际=0
|
||||
4. 支出项:普通、硬性且实际=0、硬性且实际>0
|
||||
5. 边界情况:闰年、月初、月末
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
### 风险1:归档数据不一致
|
||||
**风险:** 历史归档数据可能因旧逻辑生成,导致与新逻辑不兼容
|
||||
**缓解:** 在单元测试中使用实际的归档数据结构,验证兼容性
|
||||
|
||||
### 风险2:硬性消费按天折算的边界问题
|
||||
**风险:** 月初/月末、闰年等边界情况可能导致计算偏差
|
||||
**缓解:** 针对边界情况编写专门的单元测试
|
||||
|
||||
### 风险3:年度预算的月份分配
|
||||
**风险:** 年度预算如何分配到未来月份不明确(是平均分配还是一次性计入?)
|
||||
**缓解:** 根据现有逻辑,年度预算的"发生在本月"部分使用实际发生金额,未来月份不折算
|
||||
|
||||
### Trade-off:明细数据结构复杂度
|
||||
**权衡:** 返回结构化对象增加了 DTO 复杂度,但提高了前端灵活性
|
||||
**选择:** 接受复杂度,因为可维护性和用户体验更重要
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### 阶段1:后端重构(TDD)
|
||||
1. 编写 `BudgetSavingsCalculationTest.cs` 中的核心公式测试(红灯)
|
||||
2. 实现 `CalculateMonthlyPlannedSavings` 和 `CalculateYearlyPlannedSavings`(绿灯)
|
||||
3. 编写 `BudgetItemCalculator` 的测试(红灯)
|
||||
4. 实现 `BudgetItemCalculator`(绿灯)
|
||||
5. 重构 `GetForMonthAsync` 和 `GetForYearAsync`,使用新方法
|
||||
6. 运行所有测试,确保通过
|
||||
|
||||
### 阶段2:明细数据结构
|
||||
1. 定义 `SavingsDetail` 相关的 record 类型
|
||||
2. 修改 `GetForMonthAsync` 和 `GetForYearAsync` 返回明细
|
||||
3. 更新 API 响应(如果需要)
|
||||
|
||||
### 阶段3:前端适配(后续变更)
|
||||
- 本次变更不涉及前端,仅提供数据结构
|
||||
|
||||
### Rollback 策略
|
||||
- 如果新逻辑导致计算错误,可以通过 Git 回滚到旧版本
|
||||
- 由于不涉及数据库变更,回滚无副作用
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **年度预算在月度计算中的处理**:
|
||||
"发生在本月的年度收入/支出"是否仅指实际发生金额(actual),还是也要考虑预算?
|
||||
**假设**:仅使用实际发生金额(与现有逻辑一致)
|
||||
|
||||
2. **明细展示的优先级**:
|
||||
收入/支出项在明细表格中的排序规则?
|
||||
**假设**:按预算金额降序排列
|
||||
|
||||
3. **不记额预算(NoLimit)的处理**:
|
||||
在明细中如何展示?
|
||||
**假设**:显示为"不限额",不参与预算汇总
|
||||
|
||||
4. **前端 API 契约**:
|
||||
是否需要新增 API 接口,还是复用现有的存款统计接口?
|
||||
**假设**:复用现有接口,扩展返回字段
|
||||
57
openspec/changes/saving-detail-calculation/proposal.md
Normal file
57
openspec/changes/saving-detail-calculation/proposal.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Proposal: 存款明细计算优化
|
||||
|
||||
## Why
|
||||
|
||||
当前的存款计划计算逻辑不够清晰明确,用户难以理解"计划存款"的具体含义和计算方式。特别是在年度视图中,如何处理已归档月份(使用实际值)和未来月份(使用预算值)的逻辑不够透明。需要明确核心计算公式,并提供详细的明细展示,让用户能够清楚看到每一项收入和支出如何影响最终的计划存款金额。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **重构存款计划核心计算公式**:
|
||||
|
||||
**月度计划存款** = 收入预算 + 发生在本月的年度收入 - 支出预算 - 发生在本月的年度支出
|
||||
|
||||
**年度计划存款** = 归档月已实收 + 未来月(包含本月)收入预算 - 归档月已实支 - 未来月(包含本月)支出预算
|
||||
|
||||
- **明细项计算用金额规则**(用于明细展示):
|
||||
- **支出项**:取预算与实际的较大值(`MAX(预算, 实际)`)
|
||||
- **支出项(硬性且实际=0)**:按天数折算,不做MAX比较(可能大于预算)
|
||||
- **收入项**:实际已发生时取实际值,未发生时取预算值
|
||||
- **归档月份**:直接使用归档的实际值,不重新计算
|
||||
|
||||
- **增强存款明细展示**:
|
||||
- 显示每个预算项的预算金额、实际金额、计算用金额
|
||||
- 标注使用了哪个值(预算/实际/按天折算)
|
||||
- 高亮超支/未达标项目
|
||||
- 明确展示计算过程和中间步骤
|
||||
|
||||
- 支持月度和年度两种时间维度的存款明细
|
||||
- 确保与现有归档逻辑(`BudgetArchive`)和固定收支(`IsMandatoryExpense`)兼容
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `saving-detail-calculation`: 存款明细计算核心算法,包括月度和年度计算逻辑
|
||||
- `saving-detail-display`: 存款明细前端展示组件,包括明细表格和计算过程说明
|
||||
|
||||
### Modified Capabilities
|
||||
- `budget-savings`: 现有存款预算服务的计算逻辑需要根据新规则重构
|
||||
|
||||
## Impact
|
||||
|
||||
### 后端影响
|
||||
- **修改**:`Service/Budget/BudgetSavingsService.cs` - 重构 `GetForMonthAsync` 和 `GetForYearAsync` 方法
|
||||
- **新增**:计算用金额的辅助方法(支出/收入/硬性的判断逻辑)
|
||||
- **依赖**:依赖现有的 `BudgetArchiveRepository` 和 `TransactionStatisticsService`
|
||||
|
||||
### 前端影响
|
||||
- **修改**:存款统计页面,增加"明细"标签页或折叠面板
|
||||
- **新增**:明细表格组件,展示预算、实际、计算用值三列
|
||||
|
||||
### 测试影响
|
||||
- **新增**:`WebApi.Test/Budget/BudgetSavingsCalculationTest.cs` - 覆盖所有计算场景的单元测试
|
||||
- **修改**:`WebApi.Test/Budget/BudgetSavingsTest.cs` - 更新现有测试以匹配新逻辑
|
||||
|
||||
### 数据影响
|
||||
- 无数据库结构变更
|
||||
- 无需数据迁移
|
||||
- 归档数据格式保持不变
|
||||
@@ -0,0 +1,126 @@
|
||||
# Spec: 预算存款服务重构
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: GetForMonthAsync 返回明细数据
|
||||
`BudgetSavingsService.GetForMonthAsync` 方法 SHALL 返回包含明细数据的 `BudgetResult` 对象,除了原有的 HTML 描述外,还包括结构化的明细数据。
|
||||
|
||||
返回对象应包含:
|
||||
- `Limit`: 计划存款金额(使用新公式计算)
|
||||
- `Current`: 实际存款金额(从配置的存款分类中累加)
|
||||
- `Description`: HTML 格式的详细说明(保留兼容性)
|
||||
- `Details`: 新增的结构化明细数据(`SavingsDetail` 对象)
|
||||
|
||||
#### Scenario: 月度查询返回明细
|
||||
- **WHEN** 调用 `GetForMonthAsync(BudgetPeriodType.Month, new DateTime(2026, 2, 1))`
|
||||
- **THEN** 返回的 `BudgetResult` 对象包含 `Details` 字段,其中 `IncomeItems` 和 `ExpenseItems` 包含所有月度预算项和本月发生的年度预算项
|
||||
|
||||
#### Scenario: 向后兼容 HTML 描述
|
||||
- **WHEN** 调用 `GetForMonthAsync` 方法
|
||||
- **THEN** 返回的 `Description` 字段仍包含原有的 HTML 表格格式说明,确保旧版前端不受影响
|
||||
|
||||
### Requirement: GetForYearAsync 返回明细数据
|
||||
`BudgetSavingsService.GetForYearAsync` 方法 SHALL 返回包含归档月份和未来月份明细的完整数据结构。
|
||||
|
||||
归档月份明细应标注:
|
||||
- `IsArchived`: true
|
||||
- `ArchivedMonths`: 归档月份列表(如 [1, 2])
|
||||
|
||||
#### Scenario: 年度查询包含归档明细
|
||||
- **WHEN** 调用 `GetForYearAsync(BudgetPeriodType.Year, new DateTime(2026, 3, 1))`,且1月、2月已归档
|
||||
- **THEN** 返回的 `Details.IncomeItems` 和 `Details.ExpenseItems` 中,归档月份的项目标记为 `IsArchived = true`
|
||||
|
||||
#### Scenario: 年初无归档数据
|
||||
- **WHEN** 调用 `GetForYearAsync(BudgetPeriodType.Year, new DateTime(2026, 1, 1))`,无归档数据
|
||||
- **THEN** 返回的明细中所有项目 `IsArchived = false`,未来月份数 = 12
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: BudgetItemCalculator 辅助类
|
||||
系统 SHALL 提供 `BudgetItemCalculator` 静态类,用于计算单个预算项的计算用金额。
|
||||
|
||||
方法签名:
|
||||
```csharp
|
||||
public static decimal CalculateEffectiveAmount(
|
||||
BudgetCategory category,
|
||||
decimal budgetLimit,
|
||||
decimal actualAmount,
|
||||
bool isMandatory,
|
||||
bool isArchived,
|
||||
DateTime referenceDate,
|
||||
BudgetPeriodType periodType)
|
||||
```
|
||||
|
||||
#### Scenario: 调用计算器计算收入项
|
||||
- **WHEN** 调用 `CalculateEffectiveAmount(BudgetCategory.Income, 10000, 9500, false, false, date, Month)`
|
||||
- **THEN** 返回 9500(使用实际值)
|
||||
|
||||
#### Scenario: 调用计算器计算硬性支出
|
||||
- **WHEN** 调用 `CalculateEffectiveAmount(BudgetCategory.Expense, 3000, 0, true, false, new DateTime(2026, 2, 15), Month)`
|
||||
- **THEN** 返回 ≈ 1607.14(按天数折算)
|
||||
|
||||
### Requirement: SavingsDetail 数据结构定义
|
||||
系统 SHALL 定义以下 record 类型用于存储明细数据:
|
||||
|
||||
```csharp
|
||||
public record SavingsDetail
|
||||
{
|
||||
public List<BudgetDetailItem> IncomeItems { get; init; } = new();
|
||||
public List<BudgetDetailItem> ExpenseItems { get; init; } = new();
|
||||
public SavingsCalculationSummary Summary { get; init; } = new();
|
||||
}
|
||||
|
||||
public record BudgetDetailItem
|
||||
{
|
||||
public long Id { get; init; }
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public BudgetPeriodType Type { get; init; }
|
||||
public decimal BudgetLimit { get; init; }
|
||||
public decimal ActualAmount { get; init; }
|
||||
public decimal EffectiveAmount { get; init; }
|
||||
public string CalculationNote { get; init; } = string.Empty;
|
||||
public bool IsOverBudget { get; init; }
|
||||
public bool IsArchived { get; init; }
|
||||
public int[]? ArchivedMonths { get; init; }
|
||||
}
|
||||
|
||||
public record SavingsCalculationSummary
|
||||
{
|
||||
public decimal TotalIncomeBudget { get; init; }
|
||||
public decimal TotalExpenseBudget { get; init; }
|
||||
public decimal PlannedSavings { get; init; }
|
||||
public string CalculationFormula { get; init; } = string.Empty;
|
||||
}
|
||||
```
|
||||
|
||||
#### Scenario: 创建明细对象
|
||||
- **WHEN** 系统计算完月度存款明细
|
||||
- **THEN** 创建 `SavingsDetail` 对象,填充 `IncomeItems`、`ExpenseItems` 和 `Summary`
|
||||
|
||||
### Requirement: 核心计算公式方法提取
|
||||
系统 SHALL 将核心计算公式提取为独立的私有方法:
|
||||
- `CalculateMonthlyPlannedSavings`: 月度计划存款计算
|
||||
- `CalculateYearlyPlannedSavings`: 年度计划存款计算
|
||||
|
||||
#### Scenario: 单元测试可测试性
|
||||
- **WHEN** 开发人员编写单元测试
|
||||
- **THEN** 可以通过反射或测试友好的设计(如 internal 可见性)测试核心计算公式
|
||||
|
||||
### Requirement: 计算说明生成
|
||||
系统 SHALL 为每个明细项生成 `CalculationNote` 字段,说明使用了哪种计算规则:
|
||||
- "使用预算"
|
||||
- "使用实际"
|
||||
- "使用实际(超支)"
|
||||
- "按天折算"
|
||||
- "归档实际"
|
||||
|
||||
#### Scenario: 生成计算说明
|
||||
- **WHEN** 餐饮预算2000,实际2500
|
||||
- **THEN** `CalculationNote = "使用实际(超支)"`,`IsOverBudget = true`
|
||||
|
||||
### Requirement: 年度归档月份标注
|
||||
对于年度查询中的归档月份数据,系统 SHALL 标注 `IsArchived = true` 和 `ArchivedMonths` 字段。
|
||||
|
||||
#### Scenario: 标注归档月份
|
||||
- **WHEN** 工资预算在1月和2月都有归档数据
|
||||
- **THEN** 返回的明细项中 `IsArchived = true`,`ArchivedMonths = [1, 2]`
|
||||
@@ -0,0 +1,131 @@
|
||||
# Spec: 存款明细计算核心算法
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 月度计划存款计算公式
|
||||
系统 SHALL 使用以下公式计算月度计划存款:
|
||||
|
||||
**月度计划存款 = 收入预算 + 发生在本月的年度收入 - 支出预算 - 发生在本月的年度支出**
|
||||
|
||||
其中:
|
||||
- **收入预算**:所有月度收入预算项的预算金额之和
|
||||
- **发生在本月的年度收入**:年度收入预算项在本月实际发生的金额(actual > 0)
|
||||
- **支出预算**:所有月度支出预算项的预算金额之和
|
||||
- **发生在本月的年度支出**:年度支出预算项在本月实际发生的金额(actual > 0)
|
||||
|
||||
#### Scenario: 纯月度预算计算
|
||||
- **WHEN** 用户查询 2026年2月的月度存款,且只有月度预算(工资10000、奖金5000、房租3000、餐饮2000)
|
||||
- **THEN** 系统返回计划存款 = 10000 + 5000 - 3000 - 2000 = 10000
|
||||
|
||||
#### Scenario: 月度预算 + 本月发生的年度预算
|
||||
- **WHEN** 用户查询 2026年2月的月度存款,月度预算(工资10000、房租3000),且年度旅游支出在本月实际发生3000元
|
||||
- **THEN** 系统返回计划存款 = 10000 - 3000 - 3000 = 4000
|
||||
|
||||
#### Scenario: 年度预算未在本月发生
|
||||
- **WHEN** 用户查询 2026年2月的月度存款,月度预算(工资10000、房租3000),年度年终奖预算50000但本月实际为0
|
||||
- **THEN** 系统返回计划存款 = 10000 - 3000 = 7000(年终奖不计入)
|
||||
|
||||
### Requirement: 年度计划存款计算公式
|
||||
系统 SHALL 使用以下公式计算年度计划存款:
|
||||
|
||||
**年度计划存款 = 归档月已实收 + 未来月(包含本月)收入预算 - 归档月已实支 - 未来月(包含本月)支出预算**
|
||||
|
||||
其中:
|
||||
- **归档月已实收**:已归档月份(1月~当前月-1)的实际收入金额之和,从 `BudgetArchive` 读取
|
||||
- **未来月收入预算**:当前月及未来月份的月度收入预算 × 剩余月数
|
||||
- **归档月已实支**:已归档月份的实际支出金额之和,从 `BudgetArchive` 读取
|
||||
- **未来月支出预算**:当前月及未来月份的月度支出预算 × 剩余月数
|
||||
|
||||
#### Scenario: 年初无归档数据
|
||||
- **WHEN** 用户在 2026年1月查询年度存款,月度预算(工资10000/月、房租3000/月),无归档数据
|
||||
- **THEN** 系统返回计划存款 = (10000 - 3000) × 12 = 84000
|
||||
|
||||
#### Scenario: 年中有归档数据
|
||||
- **WHEN** 用户在 2026年3月查询年度存款,1月归档已实收15000、已实支4800,2月归档已实收14000、已实支5200,3~12月月度预算(工资10000、房租3000)
|
||||
- **THEN** 系统返回计划存款 = (15000 + 14000) + (10000 × 10) - (4800 + 5200) - (3000 × 10) = 129000
|
||||
|
||||
#### Scenario: 归档数据包含年度预算
|
||||
- **WHEN** 归档数据中包含年度预算的实际发生金额(如1月旅游支出3000)
|
||||
- **THEN** 系统将其计入"归档月已实支",不重复计算
|
||||
|
||||
### Requirement: 明细项计算用金额 - 收入规则
|
||||
对于收入类预算项,系统 SHALL 根据以下规则计算"计算用金额":
|
||||
- 如果实际金额 > 0,计算用金额 = 实际金额
|
||||
- 如果实际金额 = 0,计算用金额 = 预算金额
|
||||
|
||||
#### Scenario: 收入已发生
|
||||
- **WHEN** 工资预算10000,实际发生9500
|
||||
- **THEN** 计算用金额 = 9500,标注为"使用实际"
|
||||
|
||||
#### Scenario: 收入未发生
|
||||
- **WHEN** 奖金预算5000,实际发生0
|
||||
- **THEN** 计算用金额 = 5000,标注为"使用预算"
|
||||
|
||||
### Requirement: 明细项计算用金额 - 支出规则(普通)
|
||||
对于非硬性支出类预算项,系统 SHALL 计算用金额 = MAX(预算金额, 实际金额)
|
||||
|
||||
#### Scenario: 支出未超预算
|
||||
- **WHEN** 餐饮预算2000,实际发生1800
|
||||
- **THEN** 计算用金额 = 2000,标注为"使用预算"
|
||||
|
||||
#### Scenario: 支出超预算
|
||||
- **WHEN** 餐饮预算2000,实际发生2500
|
||||
- **THEN** 计算用金额 = 2500,标注为"使用实际(超支)",高亮显示
|
||||
|
||||
### Requirement: 明细项计算用金额 - 支出规则(硬性)
|
||||
对于硬性支出(`IsMandatoryExpense = true`)且实际金额 = 0 的预算项,系统 SHALL 按天数折算计算用金额,不进行 MAX 比较。
|
||||
|
||||
**月度折算公式**:计算用金额 = 预算金额 / 当月天数 × 当前日期
|
||||
|
||||
**年度折算公式**:计算用金额 = 预算金额 / 当年天数 × 当前天数
|
||||
|
||||
#### Scenario: 硬性支出未发生(月度)
|
||||
- **WHEN** 房租预算3000(硬性),实际为0,当前日期为2月15日,2月共28天
|
||||
- **THEN** 计算用金额 = 3000 / 28 × 15 ≈ 1607.14,标注为"按天折算"
|
||||
|
||||
#### Scenario: 硬性支出已发生
|
||||
- **WHEN** 房租预算3000(硬性),实际发生3000
|
||||
- **THEN** 计算用金额 = MAX(3000, 3000) = 3000,标注为"使用实际"
|
||||
|
||||
#### Scenario: 硬性支出超预算
|
||||
- **WHEN** 水电预算500(硬性),实际发生600
|
||||
- **THEN** 计算用金额 = MAX(500, 600) = 600,标注为"使用实际(超支)"
|
||||
|
||||
#### Scenario: 硬性支出按天折算可能超预算
|
||||
- **WHEN** 房租预算3000(硬性),实际为0,当前日期为2月29日,2月共28天
|
||||
- **THEN** 计算用金额 = 3000 / 28 × 29 ≈ 3107.14(大于预算),标注为"按天折算"
|
||||
|
||||
### Requirement: 归档月份数据处理
|
||||
对于已归档月份的预算数据,系统 SHALL 直接使用归档中的实际金额(`BudgetArchive.Content[].Actual`),不重新计算。
|
||||
|
||||
#### Scenario: 读取归档数据
|
||||
- **WHEN** 用户在3月查询年度存款,1月归档中工资实际10000、房租实际3000
|
||||
- **THEN** 系统使用归档实际值10000和3000,不根据当前预算重新计算
|
||||
|
||||
#### Scenario: 归档后预算调整
|
||||
- **WHEN** 1月归档时工资预算10000,实际10000;2月将工资预算调整为12000;用户在3月查询年度存款
|
||||
- **THEN** 1月仍使用归档的实际10000,2月及以后使用新预算12000
|
||||
|
||||
### Requirement: 闰年和月末边界处理
|
||||
系统 SHALL 正确处理闰年和月末边界情况:
|
||||
- 闰年判断:使用 `DateTime.IsLeapYear(year)` 判断,闰年366天,平年365天
|
||||
- 月末天数:使用 `DateTime.DaysInMonth(year, month)` 获取
|
||||
|
||||
#### Scenario: 闰年2月硬性支出折算
|
||||
- **WHEN** 2024年2月29日(闰年),房租预算3000(硬性),实际为0
|
||||
- **THEN** 计算用金额 = 3000 / 29 × 29 = 3000
|
||||
|
||||
#### Scenario: 平年2月硬性支出折算
|
||||
- **WHEN** 2026年2月28日(平年),房租预算3000(硬性),实际为0
|
||||
- **THEN** 计算用金额 = 3000 / 28 × 28 = 3000
|
||||
|
||||
#### Scenario: 年度硬性支出闰年折算
|
||||
- **WHEN** 2024年(闰年)第100天,年度保险预算12000(硬性),实际为0
|
||||
- **THEN** 计算用金额 = 12000 / 366 × 100 ≈ 3278.69
|
||||
|
||||
### Requirement: 不记额预算处理
|
||||
对于不记额预算(`NoLimit = true`)的预算项,系统 SHALL 排除在计划存款计算之外,但在明细中显示为"不限额"。
|
||||
|
||||
#### Scenario: 不记额收入
|
||||
- **WHEN** 存在不记额收入预算项(如意外收入),实际发生1000
|
||||
- **THEN** 该项不计入"收入预算",明细中显示预算为"不限额",实际为1000
|
||||
@@ -0,0 +1,141 @@
|
||||
# Spec: 存款明细前端展示组件
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 明细数据结构返回
|
||||
系统 SHALL 返回结构化的明细数据,包含以下信息:
|
||||
- 收入明细列表(`IncomeItems`)
|
||||
- 支出明细列表(`ExpenseItems`)
|
||||
- 计算汇总(`Summary`)
|
||||
|
||||
每个明细项包含:
|
||||
- `Id`: 预算ID
|
||||
- `Name`: 预算名称
|
||||
- `Type`: 预算类型(月度/年度)
|
||||
- `BudgetLimit`: 预算金额
|
||||
- `ActualAmount`: 实际金额
|
||||
- `EffectiveAmount`: 计算用金额
|
||||
- `CalculationNote`: 计算说明("使用预算"/"使用实际"/"按天折算"/"使用实际(超支)")
|
||||
- `IsOverBudget`: 是否超支/未达标
|
||||
|
||||
#### Scenario: 月度明细数据结构
|
||||
- **WHEN** 用户查询2月月度存款明细
|
||||
- **THEN** 系统返回 JSON 结构包含 `IncomeItems`(工资、奖金等)、`ExpenseItems`(房租、餐饮等)和 `Summary`(收入合计、支出合计、计划存款)
|
||||
|
||||
#### Scenario: 年度明细数据结构
|
||||
- **WHEN** 用户查询2026年度存款明细
|
||||
- **THEN** 系统返回包含归档月份明细和未来月份明细的完整数据结构
|
||||
|
||||
### Requirement: 明细表格展示列
|
||||
明细表格 SHALL 包含以下列:
|
||||
- 名称(Name)
|
||||
- 类型(月度/年度)
|
||||
- 预算金额(BudgetLimit)
|
||||
- 实际金额(ActualAmount)
|
||||
- 计算用金额(EffectiveAmount)
|
||||
- 计算说明(CalculationNote)
|
||||
|
||||
#### Scenario: 明细表格基本展示
|
||||
- **WHEN** 用户打开存款明细页面
|
||||
- **THEN** 表格显示所有收入和支出项的上述6列信息
|
||||
|
||||
#### Scenario: 不限额预算显示
|
||||
- **WHEN** 预算项为不记额(NoLimit = true)
|
||||
- **THEN** 预算金额列显示"不限额"
|
||||
|
||||
### Requirement: 超支/未达标高亮显示
|
||||
系统 SHALL 对超支或未达标的预算项进行高亮显示:
|
||||
- 支出超预算:实际 > 预算,高亮显示为红色/警告色
|
||||
- 收入未达标:实际 > 0 且 实际 < 预算,高亮显示为橙色/提示色
|
||||
|
||||
#### Scenario: 支出超预算高亮
|
||||
- **WHEN** 餐饮预算2000,实际2500(超支)
|
||||
- **THEN** 该行背景色为浅红色,计算说明显示"使用实际(超支)"
|
||||
|
||||
#### Scenario: 收入未达标高亮
|
||||
- **WHEN** 工资预算10000,实际9500(未达标)
|
||||
- **THEN** 该行背景色为浅橙色,计算说明显示"使用实际"
|
||||
|
||||
#### Scenario: 正常范围不高亮
|
||||
- **WHEN** 房租预算3000,实际3000(正常)
|
||||
- **THEN** 该行无特殊背景色
|
||||
|
||||
### Requirement: 计算过程说明展示
|
||||
系统 SHALL 在明细下方展示计算过程的文字说明,包括:
|
||||
- 收入合计的计算公式(如:工资10000 + 奖金5000 = 15000)
|
||||
- 支出合计的计算公式(如:房租3000 + 餐饮2000 = 5000)
|
||||
- 计划存款的计算公式(如:15000 - 5000 = 10000)
|
||||
|
||||
#### Scenario: 月度计算过程说明
|
||||
- **WHEN** 用户查看2月月度存款明细
|
||||
- **THEN** 页面底部显示:
|
||||
```
|
||||
收入合计 = 工资10000 + 奖金5000 = 15000
|
||||
支出合计 = 房租3000 + 餐饮2000 = 5000
|
||||
本月发生的年度支出 = 旅游3000
|
||||
月度计划存款 = 15000 - 5000 - 3000 = 7000
|
||||
```
|
||||
|
||||
#### Scenario: 年度计算过程说明
|
||||
- **WHEN** 用户查看2026年度存款明细
|
||||
- **THEN** 页面底部显示:
|
||||
```
|
||||
归档月已实收 = 1月15000 + 2月14000 = 29000
|
||||
未来月收入预算 = (工资10000 + 奖金5000) × 10月 = 150000
|
||||
归档月已实支 = 1月4800 + 2月5200 = 10000
|
||||
未来月支出预算 = (房租3000 + 餐饮2000) × 10月 = 50000
|
||||
年度计划存款 = 29000 + 150000 - 10000 - 50000 = 119000
|
||||
```
|
||||
|
||||
### Requirement: 归档月份和未来月份分组展示
|
||||
在年度明细中,系统 SHALL 将数据分为两组展示:
|
||||
- **已归档月份明细**:显示各归档月的实际收支
|
||||
- **未来月份预算明细**:显示当前及未来月份的预算和预测
|
||||
|
||||
#### Scenario: 年度明细分组
|
||||
- **WHEN** 用户在3月查询年度存款明细
|
||||
- **THEN** 页面分为两个表格:
|
||||
- 表格1:已归档明细(1月、2月)
|
||||
- 表格2:未来月份预算(3~12月)
|
||||
|
||||
#### Scenario: 归档月份合并显示
|
||||
- **WHEN** 同一预算项在多个归档月出现(如工资1月10000、2月10000)
|
||||
- **THEN** 可选择合并显示为"工资(1~2月):预算10000/月,实际合计20000"
|
||||
|
||||
### Requirement: 响应式布局支持
|
||||
明细表格 SHALL 支持移动端响应式布局:
|
||||
- 桌面端:完整表格展示
|
||||
- 移动端:卡片式折叠展示,点击展开详情
|
||||
|
||||
#### Scenario: 移动端卡片展示
|
||||
- **WHEN** 用户在手机上打开存款明细页面
|
||||
- **THEN** 每个预算项以卡片形式展示,显示名称、计算用金额和计算说明,点击展开显示完整信息
|
||||
|
||||
#### Scenario: 桌面端表格展示
|
||||
- **WHEN** 用户在桌面浏览器打开存款明细页面
|
||||
- **THEN** 以完整表格形式展示所有列
|
||||
|
||||
### Requirement: 排序和筛选功能
|
||||
系统 SHALL 支持明细列表的排序和筛选:
|
||||
- 按预算金额排序(降序/升序)
|
||||
- 按实际金额排序
|
||||
- 筛选显示:全部/仅超支/仅未达标
|
||||
|
||||
#### Scenario: 按预算金额降序排序
|
||||
- **WHEN** 用户点击"预算金额"列标题
|
||||
- **THEN** 列表按预算金额从高到低排序
|
||||
|
||||
#### Scenario: 仅显示超支项目
|
||||
- **WHEN** 用户选择"仅超支"筛选
|
||||
- **THEN** 列表仅显示 `IsOverBudget = true` 且为支出类的项目
|
||||
|
||||
### Requirement: 导出功能
|
||||
系统 SHALL 支持将明细数据导出为 CSV 或 Excel 格式。
|
||||
|
||||
#### Scenario: 导出为 CSV
|
||||
- **WHEN** 用户点击"导出 CSV"按钮
|
||||
- **THEN** 浏览器下载包含所有明细数据的 CSV 文件,文件名为"存款明细_YYYYMM.csv"
|
||||
|
||||
#### Scenario: 导出为 Excel
|
||||
- **WHEN** 用户点击"导出 Excel"按钮
|
||||
- **THEN** 浏览器下载包含所有明细数据的 Excel 文件,保留表格格式和高亮样式
|
||||
136
openspec/changes/saving-detail-calculation/tasks.md
Normal file
136
openspec/changes/saving-detail-calculation/tasks.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# Tasks: 存款明细计算优化实施清单
|
||||
|
||||
## 1. 数据结构定义
|
||||
|
||||
- [x] 1.1 在 `Application/Dto/BudgetDto.cs` 中定义 `SavingsDetail` record 类型
|
||||
- [x] 1.2 在 `Application/Dto/BudgetDto.cs` 中定义 `BudgetDetailItem` record 类型
|
||||
- [x] 1.3 在 `Application/Dto/BudgetDto.cs` 中定义 `SavingsCalculationSummary` record 类型
|
||||
- [x] 1.4 在 `BudgetResult` 中添加 `Details` 属性(类型为 `SavingsDetail?`)
|
||||
|
||||
## 2. 核心计算辅助类 - TDD 红灯阶段
|
||||
|
||||
- [x] 2.1 创建 `WebApi.Test/Budget/BudgetItemCalculatorTest.cs` 测试文件
|
||||
- [x] 2.2 编写测试:收入项实际已发生(actual > 0)应返回实际值
|
||||
- [x] 2.3 编写测试:收入项实际未发生(actual = 0)应返回预算值
|
||||
- [x] 2.4 编写测试:支出项普通情况应返回 MAX(预算, 实际)
|
||||
- [x] 2.5 编写测试:支出项未超预算应返回预算值
|
||||
- [x] 2.6 编写测试:支出项超预算应返回实际值
|
||||
- [x] 2.7 编写测试:支出项硬性且实际为0(月度)应按天数折算
|
||||
- [x] 2.8 编写测试:支出项硬性且实际为0(年度)应按天数折算
|
||||
- [x] 2.9 编写测试:支出项硬性且实际>0应返回MAX值
|
||||
- [x] 2.10 编写测试:归档数据应直接返回实际值
|
||||
- [x] 2.11 编写测试:闰年2月按天折算边界情况
|
||||
- [x] 2.12 编写测试:平年2月按天折算边界情况
|
||||
- [x] 2.13 运行所有测试,确认红灯(测试失败)
|
||||
|
||||
## 3. 核心计算辅助类 - TDD 绿灯阶段
|
||||
|
||||
- [x] 3.1 创建 `Service/Budget/BudgetItemCalculator.cs` 静态类
|
||||
- [x] 3.2 实现 `CalculateEffectiveAmount` 方法(包含所有计算规则)
|
||||
- [x] 3.3 实现 `CalculateMandatoryAmount` 私有方法(硬性消费按天折算)
|
||||
- [x] 3.4 实现 `GenerateCalculationNote` 方法(生成计算说明)
|
||||
- [x] 3.5 运行所有测试,确认绿灯(测试通过)
|
||||
|
||||
## 4. 月度存款核心公式 - TDD 红灯阶段
|
||||
|
||||
- [x] 4.1 创建 `WebApi.Test/Budget/BudgetSavingsCalculationTest.cs` 测试文件
|
||||
- [x] 4.2 编写测试:月度计划存款公式 - 纯月度预算场景
|
||||
- [x] 4.3 编写测试:月度计划存款公式 - 月度预算 + 本月发生的年度预算
|
||||
- [x] 4.4 编写测试:月度计划存款公式 - 年度预算未在本月发生应不计入
|
||||
- [x] 4.5 运行测试,确认红灯
|
||||
|
||||
## 5. 月度存款核心公式 - TDD 绿灯阶段
|
||||
|
||||
- [x] 5.1 在 `BudgetSavingsService` 中添加 `CalculateMonthlyPlannedSavings` 私有方法
|
||||
- [x] 5.2 实现月度计划存款公式:收入预算 + 本月年度收入 - 支出预算 - 本月年度支出
|
||||
- [x] 5.3 运行测试,确认绿灯
|
||||
|
||||
## 6. 年度存款核心公式 - TDD 红灯阶段
|
||||
|
||||
- [x] 6.1 编写测试:年度计划存款公式 - 年初无归档数据场景
|
||||
- [x] 6.2 编写测试:年度计划存款公式 - 年中有归档数据场景
|
||||
- [x] 6.3 编写测试:年度计划存款公式 - 归档数据包含年度预算
|
||||
- [x] 6.4 运行测试,确认红灯
|
||||
|
||||
## 7. 年度存款核心公式 - TDD 绿灯阶段
|
||||
|
||||
- [x] 7.1 在 `BudgetSavingsService` 中添加 `CalculateYearlyPlannedSavings` 私有方法
|
||||
- [x] 7.2 实现年度计划存款公式:归档已实收 + 未来收入预算 - 归档已实支 - 未来支出预算
|
||||
- [x] 7.3 运行测试,确认绿灯
|
||||
|
||||
## 8. 重构 GetForMonthAsync - TDD 红灯阶段
|
||||
|
||||
- [x] 8.1 在 `WebApi.Test/Budget/BudgetSavingsTest.cs` 中编写测试:月度查询应返回 Details 字段
|
||||
- [x] 8.2 编写测试:月度明细应包含所有月度预算项
|
||||
- [x] 8.3 编写测试:月度明细应包含本月发生的年度预算项
|
||||
- [x] 8.4 编写测试:月度明细中每项应包含计算用金额和计算说明
|
||||
- [x] 8.5 编写测试:超支项目应标记 IsOverBudget = true
|
||||
- [x] 8.6 编写测试:不记额预算应排除在汇总之外
|
||||
- [x] 8.7 运行测试,确认红灯
|
||||
|
||||
## 9. 重构 GetForMonthAsync - TDD 绿灯阶段
|
||||
|
||||
- [x] 9.1 重构 `GetForMonthAsync` 方法,使用 `CalculateMonthlyPlannedSavings`
|
||||
- [x] 9.2 添加明细数据收集逻辑(创建 `BudgetDetailItem` 列表)
|
||||
- [x] 9.3 为每个预算项调用 `BudgetItemCalculator.CalculateEffectiveAmount`
|
||||
- [x] 9.4 生成 `SavingsDetail` 对象并填充到 `BudgetResult.Details`
|
||||
- [x] 9.5 生成 `SavingsCalculationSummary` 汇总信息
|
||||
- [x] 9.6 保留原有的 HTML `Description` 生成逻辑(向后兼容)
|
||||
- [x] 9.7 运行测试,确认绿灯
|
||||
|
||||
## 10. 重构 GetForYearAsync - TDD 红灯阶段
|
||||
|
||||
- [x] 10.1 编写测试:年度查询应返回 Details 字段
|
||||
- [x] 10.2 编写测试:年度明细应包含归档月份标注(IsArchived = true)
|
||||
- [x] 10.3 编写测试:年度明细应包含 ArchivedMonths 字段
|
||||
- [x] 10.4 编写测试:归档数据应使用归档的实际值
|
||||
- [x] 10.5 编写测试:未来月份预算应正确折算
|
||||
- [x] 10.6 编写测试:年度预算项不应重复计算
|
||||
- [x] 10.7 运行测试,确认红灯
|
||||
|
||||
## 11. 重构 GetForYearAsync - TDD 绿灯阶段
|
||||
|
||||
- [x] 11.1 重构 `GetForYearAsync` 方法,使用 `CalculateYearlyPlannedSavings`
|
||||
- [x] 11.2 添加归档数据读取和明细项创建逻辑
|
||||
- [x] 11.3 为归档数据标注 `IsArchived = true` 和 `ArchivedMonths`
|
||||
- [x] 11.4 添加未来月份预算的明细项创建逻辑
|
||||
- [x] 11.5 生成 `SavingsDetail` 对象并填充到 `BudgetResult.Details`
|
||||
- [x] 11.6 保留原有的 HTML `Description` 生成逻辑(向后兼容)
|
||||
- [x] 11.7 运行测试,确认绿灯
|
||||
|
||||
## 12. 边界情况测试
|
||||
|
||||
- [x] 12.1 编写测试:闰年年度硬性支出按天折算
|
||||
- [x] 12.2 编写测试:平年年度硬性支出按天折算
|
||||
- [x] 12.3 编写测试:月初(1号)硬性支出折算
|
||||
- [x] 12.4 编写测试:月末(28/29/30/31号)硬性支出折算
|
||||
- [x] 12.5 编写测试:不记额预算的处理
|
||||
- [x] 12.6 编写测试:无预算项时的空列表处理
|
||||
- [x] 12.7 编写测试:所有预算项实际为0的情况
|
||||
- [x] 12.8 运行所有边界测试,确认通过
|
||||
|
||||
## 13. 集成测试
|
||||
|
||||
- [x] 13.1 编写完整场景集成测试:月度查询包含月度+年度混合
|
||||
- [x] 13.2 编写完整场景集成测试:年度查询包含归档+未来混合
|
||||
- [x] 13.3 编写集成测试:验证 HTML Description 和 Details 数据一致性
|
||||
- [x] 13.4 编写集成测试:验证与 BudgetArchiveRepository 的集成
|
||||
- [x] 13.5 编写集成测试:验证与 TransactionStatisticsService 的集成
|
||||
- [x] 13.6 运行所有集成测试,确认通过
|
||||
|
||||
## 14. 代码审查与重构
|
||||
|
||||
- [x] 14.1 审查所有新增代码,确保符合项目编码规范
|
||||
- [x] 14.2 检查中文注释是否完整清晰
|
||||
- [x] 14.3 重构重复代码,提取共用方法
|
||||
- [x] 14.4 优化变量命名,确保语义清晰
|
||||
- [x] 14.5 运行所有测试,确保重构后测试仍然通过
|
||||
|
||||
## 15. 文档与验收
|
||||
|
||||
- [x] 15.1 更新 `BudgetSavingsService` 相关方法的 XML 文档注释
|
||||
- [x] 15.2 添加 `BudgetItemCalculator` 的使用示例注释
|
||||
- [x] 15.3 运行完整测试套件:`dotnet test WebApi.Test/WebApi.Test.csproj`
|
||||
- [x] 15.4 验证所有测试通过(0 failed)
|
||||
- [x] 15.5 手动验证:通过 API 调用验证返回数据格式正确
|
||||
- [x] 15.6 确认向后兼容:旧版前端仍可正常使用 Description 字段
|
||||
45
openspec/specs/savings-plan-detail-view/spec.md
Normal file
45
openspec/specs/savings-plan-detail-view/spec.md
Normal file
@@ -0,0 +1,45 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Display income and expense budget in savings detail popup
|
||||
The savings detail popup SHALL display the associated income budget and expense budget information for the selected savings plan, including both budget limits and current amounts.
|
||||
|
||||
#### Scenario: User opens savings detail popup with matched budgets
|
||||
- **WHEN** user clicks the detail button on a savings plan card
|
||||
- **AND** there exist income and expense budgets for the same period and type
|
||||
- **THEN** the popup SHALL display the income budget limit and current amount
|
||||
- **AND** the popup SHALL display the expense budget limit and current amount
|
||||
- **AND** the popup SHALL display the savings formula (Income Limit - Expense Limit = Planned Savings)
|
||||
- **AND** the popup SHALL display the savings result (Planned Savings, Actual Savings, Remaining)
|
||||
|
||||
#### Scenario: User opens savings detail popup without matched budgets
|
||||
- **WHEN** user clicks the detail button on a savings plan card
|
||||
- **AND** there are no income or expense budgets for the same period and type
|
||||
- **THEN** the popup SHALL display 0 for income budget limit and current amount
|
||||
- **AND** the popup SHALL display 0 for expense budget limit and current amount
|
||||
- **AND** the popup SHALL still display the savings formula and result with these values
|
||||
|
||||
### Requirement: Pass budget data to savings component
|
||||
The parent component (Index.vue) SHALL pass income budgets and expense budgets to the SavingsBudgetContent component to enable detail popup display.
|
||||
|
||||
#### Scenario: Budget data is loaded successfully
|
||||
- **WHEN** the budget data is loaded from the API
|
||||
- **THEN** the income budgets SHALL be passed to SavingsBudgetContent via props
|
||||
- **AND** the expense budgets SHALL be passed to SavingsBudgetContent via props
|
||||
- **AND** the savings budgets SHALL be passed to SavingsBudgetContent via props (existing behavior)
|
||||
|
||||
### Requirement: Match income and expense budgets to savings plan
|
||||
The SavingsBudgetContent component SHALL match income and expense budgets to the current savings plan based on periodStart and type fields.
|
||||
|
||||
#### Scenario: Match budgets with same period and type
|
||||
- **WHEN** displaying savings plan details
|
||||
- **AND** the component searches for matching budgets
|
||||
- **THEN** the component SHALL find income budgets where periodStart and type match the savings plan
|
||||
- **AND** the component SHALL find expense budgets where periodStart and type match the savings plan
|
||||
- **AND** if multiple matches exist, the component SHALL use the first match
|
||||
|
||||
#### Scenario: No matching budgets found
|
||||
- **WHEN** displaying savings plan details
|
||||
- **AND** no income budget matches the savings plan's periodStart and type
|
||||
- **OR** no expense budget matches the savings plan's periodStart and type
|
||||
- **THEN** the component SHALL use 0 as the default value for unmatched budget fields
|
||||
- **AND** the popup SHALL still render without errors
|
||||
Reference in New Issue
Block a user