Files
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

256 lines
9.1 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 接口,还是复用现有的存款统计接口?
**假设**:复用现有接口,扩展返回字段