Files
EmailBill/openspec/changes/saving-detail-calculation/design.md
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

9.1 KiB
Raw Blame History

Design: 存款明细计算优化

Context

当前 BudgetSavingsService 在计算存款时使用了复杂的逻辑,包括归档数据读取、月度/年度预算折算、硬性消费的天数折算等。但核心计算公式不明确,导致代码可读性差,且难以验证计算结果的正确性。

现状

  • GetForMonthAsync: 计算月度存款,需要处理月度预算和发生在本月的年度预算
  • GetForYearAsync: 计算年度存款,需要整合归档数据和未来月份预算
  • 归档数据存储在 BudgetArchive 表中,每月的实际收支被固化
  • 硬性消费(IsMandatoryExpense在实际为0时按天数比例折算

约束

  • 不改变数据库结构和归档格式
  • 保持与现有 BudgetArchiveRepositoryTransactionStatisticsService 的兼容性
  • 必须通过 TDD 方式开发,先写测试再实现

Goals / Non-Goals

Goals:

  • 明确定义月度和年度存款的计算公式
  • 重构 BudgetSavingsService 以提高代码可读性和可维护性
  • 提供详细的明细数据结构,支持前端展示计算过程
  • 确保所有计算场景都有单元测试覆盖

Non-Goals:

  • 修改前端展示逻辑(仅提供数据结构)
  • 改变归档任务的行为
  • 优化数据库查询性能(保持现有逻辑)

Decisions

决策1核心计算公式明确化

选择:将核心公式提取为独立方法

// 月度计划存款
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 辅助类

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明细数据结构设计

选择:返回结构化的明细对象

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. 实现 CalculateMonthlyPlannedSavingsCalculateYearlyPlannedSavings(绿灯)
  3. 编写 BudgetItemCalculator 的测试(红灯)
  4. 实现 BudgetItemCalculator(绿灯)
  5. 重构 GetForMonthAsyncGetForYearAsync,使用新方法
  6. 运行所有测试,确保通过

阶段2明细数据结构

  1. 定义 SavingsDetail 相关的 record 类型
  2. 修改 GetForMonthAsyncGetForYearAsync 返回明细
  3. 更新 API 响应(如果需要)

阶段3前端适配后续变更

  • 本次变更不涉及前端,仅提供数据结构

Rollback 策略

  • 如果新逻辑导致计算错误,可以通过 Git 回滚到旧版本
  • 由于不涉及数据库变更,回滚无副作用

Open Questions

  1. 年度预算在月度计算中的处理
    "发生在本月的年度收入/支出"是否仅指实际发生金额actual还是也要考虑预算
    假设:仅使用实际发生金额(与现有逻辑一致)

  2. 明细展示的优先级
    收入/支出项在明细表格中的排序规则?
    假设:按预算金额降序排列

  3. 不记额预算NoLimit的处理
    在明细中如何展示?
    假设:显示为"不限额",不参与预算汇总

  4. 前端 API 契约
    是否需要新增 API 接口,还是复用现有的存款统计接口?
    假设:复用现有接口,扩展返回字段