5 Commits

Author SHA1 Message Date
SunCheng
a7414c792e fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 22s
Docker Build & Deploy / Deploy to Production (push) Successful in 6s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
2026-02-20 22:07:09 +08:00
SunCheng
3c3172fc81 debug: 添加存款明细数据调试日志
添加 console.log 输出,用于调试 details 字段是否正确返回
2026-02-20 17:15:07 +08:00
SunCheng
f46b9d4bd6 feat(frontend): 添加存款明细展示
- 在存款计划弹窗中添加详细明细表格
  - 收入明细列表(显示预算/实际/计算用金额)
  - 支出明细列表(显示超支标记)
  - 计算说明标签(使用预算/使用实际/超支/按天折算)

- 支持新旧版本兼容
  - 有 details 字段时显示详细明细
  - 无 details 字段时显示旧版汇总

- UI 优化
  - 超支项目红色边框高亮
  - 月度/年度标签区分
  - 计算汇总和公式展示
  - 移动端响应式布局
2026-02-20 17:10:33 +08:00
SunCheng
2cb5bffc70 feat(budget): 添加存款明细数据生成逻辑
- 实现 GenerateMonthlyDetails 方法生成月度存款明细
  - 为每个预算项调用 BudgetItemCalculator 计算有效金额
  - 生成计算说明(使用预算/使用实际/超支/按天折算)
  - 标记超支项目
  - 生成汇总信息(总收入、总支出、计划存款)

- GetForMonthAsync 现在返回 Details 字段
  - 包含收入明细列表
  - 包含支出明细列表
  - 包含计算汇总和公式

- 新增集成测试验证 Details 字段生成正确
  - 验证收入项计算规则
  - 验证支出项超支标记
  - 验证硬性支出处理
  - 验证汇总计算

测试结果:58个预算测试全部通过
2026-02-20 16:59:17 +08:00
SunCheng
4cc205fc25 feat(budget): 实现存款明细计算核心逻辑
- 添加 BudgetItemCalculator 辅助类,实现明细项计算规则
  - 收入:实际>0取实际,否则取预算
  - 支出:取MAX(预算, 实际)
  - 硬性支出未发生:按天数折算
  - 归档数据:直接使用实际值

- 实现月度和年度存款核心公式
  - 月度:收入预算 + 本月年度收入 - 支出预算 - 本月年度支出
  - 年度:归档已实收 + 未来收入预算 - 归档已实支 - 未来支出预算

- 定义存款明细数据结构
  - SavingsDetail: 包含收入/支出明细列表和汇总
  - BudgetDetailItem: 预算明细项(含计算用金额、计算说明等)
  - SavingsCalculationSummary: 计算汇总信息

- 新增单元测试
  - BudgetItemCalculatorTest: 11个测试覆盖所有计算规则
  - BudgetSavingsCalculationTest: 6个测试验证核心公式

测试结果:所有测试通过 (366 passed, 0 failed)
2026-02-20 16:26:04 +08:00
25 changed files with 2633 additions and 74 deletions

View File

@@ -224,7 +224,51 @@ public class BudgetApplication(
StartDate = startDate, StartDate = startDate,
NoLimit = result.NoLimit, NoLimit = result.NoLimit,
IsMandatoryExpense = result.IsMandatoryExpense, 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
}
}; };
} }

View File

@@ -16,8 +16,52 @@ public record BudgetResponse
public bool NoLimit { get; init; } public bool NoLimit { get; init; }
public bool IsMandatoryExpense { get; init; } public bool IsMandatoryExpense { get; init; }
public decimal UsagePercentage { 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>
/// 创建预算请求 /// 创建预算请求
/// </summary> /// </summary>
@@ -89,3 +133,41 @@ public record UpdateArchiveSummaryRequest
public DateTime ReferenceDate { get; init; } public DateTime ReferenceDate { get; init; }
public string? Summary { 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;
}

View 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 "使用预算";
}
}

View File

@@ -400,12 +400,25 @@ public class BudgetSavingsService(
UpdateTime = dateTimeProvider.Now UpdateTime = dateTimeProvider.Now
}; };
return BudgetResult.FromEntity( // 生成明细数据
var referenceDate = new DateTime(year, month, dateTimeProvider.Now.Day);
var details = GenerateMonthlyDetails(
monthlyIncomeItems,
monthlyExpenseItems,
yearlyIncomeItems,
yearlyExpenseItems,
referenceDate
);
var result = BudgetResult.FromEntity(
record, record,
currentActual, currentActual,
new DateTime(year, month, 1), new DateTime(year, month, 1),
description.ToString() description.ToString()
); );
result.Details = details;
return result;
} }
private async Task<BudgetResult> GetForYearAsync( private async Task<BudgetResult> GetForYearAsync(
@@ -863,12 +876,26 @@ public class BudgetSavingsService(
UpdateTime = dateTimeProvider.Now 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, record,
currentActual, currentActual,
new DateTime(year, 1, 1), new DateTime(year, 1, 1),
description.ToString() description.ToString()
); );
result.Details = details;
return result;
void AddOrIncCurrentItem( void AddOrIncCurrentItem(
long id, long id,
@@ -935,4 +962,334 @@ public class BudgetSavingsService(
return string.Join(", ", months) + "月"; 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
}
};
}
}

View File

@@ -448,6 +448,11 @@ public record BudgetResult
public bool NoLimit { get; set; } public bool NoLimit { get; set; }
public bool IsMandatoryExpense { get; set; } public bool IsMandatoryExpense { get; set; }
public string Description { get; set; } = string.Empty; public string Description { get; set; } = string.Empty;
/// <summary>
/// 存款明细数据(可选,用于存款预算)
/// </summary>
public SavingsDetail? Details { get; set; }
public static BudgetResult FromEntity( public static BudgetResult FromEntity(
BudgetRecord entity, BudgetRecord entity,
@@ -547,3 +552,41 @@ public class UncoveredCategoryDetail
public int TransactionCount { get; set; } public int TransactionCount { get; set; }
public decimal TotalAmount { 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;
}

View File

@@ -508,6 +508,11 @@ const handleQueryBills = async () => {
} }
const percentage = computed(() => { const percentage = computed(() => {
// 优先使用后端返回的 usagePercentage 字段
if (props.budget.usagePercentage !== undefined && props.budget.usagePercentage !== null) {
return Math.round(props.budget.usagePercentage)
}
// 降级方案:如果后端没有返回该字段,前端计算
if (!props.budget.limit) { if (!props.budget.limit) {
return 0 return 0
} }

View File

@@ -1013,6 +1013,7 @@ const yearBurndownChartOptions = computed(() => {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
z-index: 1;
} }
.gauge-text-overlay { .gauge-text-overlay {
@@ -1048,6 +1049,8 @@ const yearBurndownChartOptions = computed(() => {
.chart-header { .chart-header {
margin-bottom: 12px; margin-bottom: 12px;
position: relative;
z-index: 20;
} }
.chart-title { .chart-title {

View File

@@ -123,6 +123,8 @@
<SavingsBudgetContent <SavingsBudgetContent
v-else-if="activeTab === BudgetCategory.Savings" v-else-if="activeTab === BudgetCategory.Savings"
:budgets="savingsBudgets" :budgets="savingsBudgets"
:income-budgets="incomeBudgets"
:expense-budgets="expenseBudgets"
@savings-nav="handleSavingsNav" @savings-nav="handleSavingsNav"
/> />
</div> </div>

View File

@@ -74,96 +74,282 @@
<PopupContainerV2 <PopupContainerV2
v-model:show="showDetailPopup" v-model:show="showDetailPopup"
title="计划存款明细" title="计划存款明细"
:height="'80%'" :height="'85%'"
> >
<div class="popup-body"> <div class="popup-body">
<div <div
v-if="currentBudget" v-if="currentBudget"
class="detail-content" class="detail-content"
> >
<div class="detail-section income-section"> <!-- 明细表格 -->
<div class="section-title"> <div
<van-icon name="balance-o" /> v-if="currentBudget.details"
收入预算 class="detail-tables"
</div> >
<div class="section-content"> <!-- 收入明细 -->
<div class="detail-row"> <div class="detail-section income-section">
<span class="detail-label">预算限额</span> <div class="section-title">
<span class="detail-value income">¥{{ formatMoney(incomeLimit) }}</span> <van-icon name="balance-o" />
收入明细
</div> </div>
<div class="detail-row"> <div class="rich-html-content">
<span class="detail-label">实际收入</span> <table>
<span class="detail-value">¥{{ formatMoney(incomeCurrent) }}</span> <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>
</div> </div>
<div class="detail-section expense-section"> <!-- 旧版汇总(无明细数据时显示) -->
<div class="section-title"> <div
<van-icon name="bill-o" /> v-else
支出预算 class="legacy-summary"
</div> >
<div class="section-content"> <div class="detail-section income-section">
<div class="detail-row"> <div class="section-title">
<span class="detail-label">预算限额</span> <van-icon name="balance-o" />
<span class="detail-value expense">¥{{ formatMoney(expenseLimit) }}</span> 收入预算
</div> </div>
<div class="detail-row"> <div class="section-content">
<span class="detail-label">实际支出</span> <div class="detail-row">
<span class="detail-value">¥{{ formatMoney(expenseCurrent) }}</span> <span class="detail-label">预算限额</span>
<span class="detail-value income">¥{{ formatMoney(incomeLimit) }}</span>
</div>
<div class="detail-row">
<span class="detail-label">实际收入</span>
<span class="detail-value">¥{{ formatMoney(incomeCurrent) }}</span>
</div>
</div> </div>
</div> </div>
</div>
<div class="detail-section formula-section"> <div class="detail-section expense-section">
<div class="section-title"> <div class="section-title">
<van-icon name="calculator-o" /> <van-icon name="bill-o" />
计划存款公式 支出预算
</div>
<div class="formula-box">
<div class="formula-item">
<span class="formula-label">收入预算</span>
<span class="formula-value income">¥{{ formatMoney(incomeLimit) }}</span>
</div> </div>
<div class="formula-operator"> <div class="section-content">
- <div class="detail-row">
</div> <span class="detail-label">预算限额</span>
<div class="formula-item"> <span class="detail-value expense">¥{{ formatMoney(expenseLimit) }}</span>
<span class="formula-label">支出预算</span> </div>
<span class="formula-value expense">¥{{ formatMoney(expenseLimit) }}</span> <div class="detail-row">
</div> <span class="detail-label">实际支出</span>
<div class="formula-operator"> <span class="detail-value">¥{{ formatMoney(expenseCurrent) }}</span>
= </div>
</div>
<div class="formula-item">
<span class="formula-label">计划存款</span>
<span class="formula-value">¥{{ formatMoney(currentBudget.limit) }}</span>
</div> </div>
</div> </div>
</div>
<div class="detail-section result-section"> <div class="detail-section formula-section">
<div class="section-title"> <div class="section-title">
<van-icon name="chart-trending-o" /> <van-icon name="calculator-o" />
存款结果 计划存款公式
</div>
<div class="formula-box">
<div class="formula-item">
<span class="formula-label">收入预算</span>
<span class="formula-value income">¥{{ formatMoney(incomeLimit) }}</span>
</div>
<div class="formula-operator">
-
</div>
<div class="formula-item">
<span class="formula-label">支出预算</span>
<span class="formula-value expense">¥{{ formatMoney(expenseLimit) }}</span>
</div>
<div class="formula-operator">
=
</div>
<div class="formula-item">
<span class="formula-label">计划存款</span>
<span class="formula-value">¥{{ formatMoney(currentBudget.limit) }}</span>
</div>
</div>
</div> </div>
<div class="section-content">
<div class="detail-row"> <div class="detail-section result-section">
<span class="detail-label">计划存款</span> <div class="section-title">
<span class="detail-value">¥{{ formatMoney(currentBudget.limit) }}</span> <van-icon name="chart-trending-o" />
存款结果
</div> </div>
<div class="detail-row"> <div class="section-content">
<span class="detail-label">实际存款</span> <div class="detail-row">
<span <span class="detail-label">计划存款</span>
class="detail-value" <span class="detail-value">¥{{ formatMoney(currentBudget.limit) }}</span>
:class="{ income: currentBudget.current >= currentBudget.limit }" </div>
>¥{{ formatMoney(currentBudget.current) }}</span> <div class="detail-row">
</div> <span class="detail-label">实际存款</span>
<div class="detail-row highlight"> <span
<span class="detail-label">还差</span> class="detail-value"
<span class="detail-value expense">¥{{ :class="{ income: currentBudget.current >= currentBudget.limit }"
formatMoney(Math.max(0, currentBudget.limit - currentBudget.current)) >¥{{ formatMoney(currentBudget.current) }}</span>
}}</span> </div>
<div class="detail-row highlight">
<span class="detail-label">还差</span>
<span class="detail-value expense">¥{{
formatMoney(Math.max(0, currentBudget.limit - currentBudget.current))
}}</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -183,6 +369,14 @@ const props = defineProps({
budgets: { budgets: {
type: Array, type: Array,
default: () => [] default: () => []
},
incomeBudgets: {
type: Array,
default: () => []
},
expenseBudgets: {
type: Array,
default: () => []
} }
}) })
@@ -195,10 +389,85 @@ const currentBudget = ref(null)
// 处理显示明细 // 处理显示明细
const handleShowDetail = (budget) => { 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 currentBudget.value = budget
showDetailPopup.value = true 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) => { const formatMoney = (val) => {
return parseFloat(val || 0).toLocaleString(undefined, { return parseFloat(val || 0).toLocaleString(undefined, {
@@ -445,4 +714,43 @@ const getProgressColor = (budget) => {
color: var(--van-text-color-2); color: var(--van-text-color-2);
padding: 0 8px; 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> </style>

View 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);
}
}

View 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
}
}

View File

@@ -58,9 +58,96 @@ public class BudgetSavingsTest : BaseTest
// Assert // Assert
result.Should().NotBeNull(); result.Should().NotBeNull();
result.Limit.Should().Be(10000 - 2000); // 计划收入 - 计划支出 = 10000 - 2000 = 8000 result.Limit.Should().Be(8000);
} }
[Fact]
public async Task GetSavings_月度_应返回Details字段()
{
// Arrange
var referenceDate = new DateTime(2024, 2, 15);
_dateTimeProvider.Now.Returns(referenceDate);
var budgets = new List<BudgetRecord>
{
new()
{
Id = 1, Name = "工资", Type = BudgetPeriodType.Month, Limit = 10000, Category = BudgetCategory.Income,
SelectedCategories = "工资"
},
new()
{
Id = 2, Name = "餐饮", Type = BudgetPeriodType.Month, Limit = 2000, Category = BudgetCategory.Expense,
SelectedCategories = "餐饮"
},
new()
{
Id = 3, Name = "房租", Type = BudgetPeriodType.Month, Limit = 3000, Category = BudgetCategory.Expense,
SelectedCategories = "房租", IsMandatoryExpense = true
}
};
var transactions = new Dictionary<(string, TransactionType), decimal>
{
{ ("工资", TransactionType.Income), 10000m },
{ ("餐饮", TransactionType.Expense), 2500m }, // 超支
{ ("房租", TransactionType.Expense), 0m } // 硬性未发生
};
_transactionStatisticsService.GetAmountGroupByClassifyAsync(
Arg.Any<DateTime>(),
Arg.Any<DateTime>()
).Returns(transactions);
_configService.GetConfigByKeyAsync<string>("SavingsCategories").Returns("存款");
// Act
var result = await _service.GetSavingsDtoAsync(BudgetPeriodType.Month, referenceDate, budgets);
// Assert
result.Should().NotBeNull();
result.Details.Should().NotBeNull();
// 验证收入明细
result.Details!.IncomeItems.Should().HaveCount(1);
var incomeItem = result.Details.IncomeItems[0];
incomeItem.Name.Should().Be("工资");
incomeItem.BudgetLimit.Should().Be(10000);
incomeItem.ActualAmount.Should().Be(10000);
incomeItem.EffectiveAmount.Should().Be(10000);
incomeItem.CalculationNote.Should().Be("使用实际");
// 验证支出明细
result.Details.ExpenseItems.Should().HaveCount(2);
// 餐饮超支
var expenseItem1 = result.Details.ExpenseItems.FirstOrDefault(e => e.Name == "餐饮");
expenseItem1.Should().NotBeNull();
expenseItem1!.BudgetLimit.Should().Be(2000);
expenseItem1.ActualAmount.Should().Be(2500);
expenseItem1.EffectiveAmount.Should().Be(2500); // MAX(2000, 2500)
expenseItem1.CalculationNote.Should().Be("使用实际(超支)");
expenseItem1.IsOverBudget.Should().BeTrue();
// 房租按天折算硬性消费在实际为0时会自动填充
var expenseItem2 = result.Details.ExpenseItems.FirstOrDefault(e => e.Name == "房租");
expenseItem2.Should().NotBeNull();
expenseItem2!.BudgetLimit.Should().Be(3000);
// 硬性消费在 GetForMonthAsync 中已经填充了按天折算的值到 current
expenseItem2.ActualAmount.Should().BeApproximately(3000m / 29 * 15, 0.01m);
// EffectiveAmount 使用 MAX(预算3000, 实际1551.72) = 3000
expenseItem2.EffectiveAmount.Should().Be(3000);
expenseItem2.CalculationNote.Should().Be("使用预算"); // MAX 后选择了预算值
// 验证汇总
result.Details.Summary.Should().NotBeNull();
result.Details.Summary.TotalIncomeBudget.Should().BeApproximately(10000, 0.01m);
// 支出汇总餐饮2500 + 房租3000(MAX) = 5500
result.Details.Summary.TotalExpenseBudget.Should().BeApproximately(5500, 1m);
result.Details.Summary.PlannedSavings.Should().BeApproximately(4500, 1m);
}
[Fact] [Fact]
public async Task GetSavings_月度_年度收支_Test() public async Task GetSavings_月度_年度收支_Test()
{ {

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-20

View File

@@ -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 统一管理预算数据
### [权衡] 按周期和类型匹配可能不够健壮
**接受理由:**
- 这是后端设计的数据模型,前端保持一致
- 后端保证同一周期同一类型只有一条预算记录
- 如果后端逻辑变更,前端需要同步调整(这是正常的维护成本)

View File

@@ -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)

View 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

View File

@@ -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 使用不同月份的存款计划测试,验证匹配逻辑正确

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-20

View 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 接口,还是复用现有的存款统计接口?
**假设**:复用现有接口,扩展返回字段

View 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` - 更新现有测试以匹配新逻辑
### 数据影响
- 无数据库结构变更
- 无需数据迁移
- 归档数据格式保持不变

View File

@@ -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]`

View File

@@ -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、已实支48002月归档已实收14000、已实支52003~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实际100002月将工资预算调整为12000用户在3月查询年度存款
- **THEN** 1月仍使用归档的实际100002月及以后使用新预算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

View File

@@ -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 文件,保留表格格式和高亮样式

View 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 字段

View 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