Files
EmailBill/openspec/changes/saving-detail-calculation/design.md

256 lines
9.1 KiB
Markdown
Raw Normal View History

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