# 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 IncomeItems { get; init; } = new(); public List 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 接口,还是复用现有的存款统计接口? **假设**:复用现有接口,扩展返回字段