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