Files
EmailBill/openspec/changes/saving-detail-calculation/specs/budget-savings/spec.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

5.1 KiB
Raw Blame History

Spec: 预算存款服务重构

MODIFIED Requirements

Requirement: GetForMonthAsync 返回明细数据

BudgetSavingsService.GetForMonthAsync 方法 SHALL 返回包含明细数据的 BudgetResult 对象,除了原有的 HTML 描述外,还包括结构化的明细数据。

返回对象应包含:

  • Limit: 计划存款金额(使用新公式计算)
  • Current: 实际存款金额(从配置的存款分类中累加)
  • Description: HTML 格式的详细说明(保留兼容性)
  • Details: 新增的结构化明细数据(SavingsDetail 对象)

Scenario: 月度查询返回明细

  • WHEN 调用 GetForMonthAsync(BudgetPeriodType.Month, new DateTime(2026, 2, 1))
  • THEN 返回的 BudgetResult 对象包含 Details 字段,其中 IncomeItemsExpenseItems 包含所有月度预算项和本月发生的年度预算项

Scenario: 向后兼容 HTML 描述

  • WHEN 调用 GetForMonthAsync 方法
  • THEN 返回的 Description 字段仍包含原有的 HTML 表格格式说明,确保旧版前端不受影响

Requirement: GetForYearAsync 返回明细数据

BudgetSavingsService.GetForYearAsync 方法 SHALL 返回包含归档月份和未来月份明细的完整数据结构。

归档月份明细应标注:

  • IsArchived: true
  • ArchivedMonths: 归档月份列表(如 [1, 2]

Scenario: 年度查询包含归档明细

  • WHEN 调用 GetForYearAsync(BudgetPeriodType.Year, new DateTime(2026, 3, 1))且1月、2月已归档
  • THEN 返回的 Details.IncomeItemsDetails.ExpenseItems 中,归档月份的项目标记为 IsArchived = true

Scenario: 年初无归档数据

  • WHEN 调用 GetForYearAsync(BudgetPeriodType.Year, new DateTime(2026, 1, 1)),无归档数据
  • THEN 返回的明细中所有项目 IsArchived = false,未来月份数 = 12

ADDED Requirements

Requirement: BudgetItemCalculator 辅助类

系统 SHALL 提供 BudgetItemCalculator 静态类,用于计算单个预算项的计算用金额。

方法签名:

public static decimal CalculateEffectiveAmount(
    BudgetCategory category,
    decimal budgetLimit,
    decimal actualAmount,
    bool isMandatory,
    bool isArchived,
    DateTime referenceDate,
    BudgetPeriodType periodType)

Scenario: 调用计算器计算收入项

  • WHEN 调用 CalculateEffectiveAmount(BudgetCategory.Income, 10000, 9500, false, false, date, Month)
  • THEN 返回 9500使用实际值

Scenario: 调用计算器计算硬性支出

  • WHEN 调用 CalculateEffectiveAmount(BudgetCategory.Expense, 3000, 0, true, false, new DateTime(2026, 2, 15), Month)
  • THEN 返回 ≈ 1607.14(按天数折算)

Requirement: SavingsDetail 数据结构定义

系统 SHALL 定义以下 record 类型用于存储明细数据:

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 bool IsArchived { get; init; }
    public int[]? ArchivedMonths { 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;
}

Scenario: 创建明细对象

  • WHEN 系统计算完月度存款明细
  • THEN 创建 SavingsDetail 对象,填充 IncomeItemsExpenseItemsSummary

Requirement: 核心计算公式方法提取

系统 SHALL 将核心计算公式提取为独立的私有方法:

  • CalculateMonthlyPlannedSavings: 月度计划存款计算
  • CalculateYearlyPlannedSavings: 年度计划存款计算

Scenario: 单元测试可测试性

  • WHEN 开发人员编写单元测试
  • THEN 可以通过反射或测试友好的设计(如 internal 可见性)测试核心计算公式

Requirement: 计算说明生成

系统 SHALL 为每个明细项生成 CalculationNote 字段,说明使用了哪种计算规则:

  • "使用预算"
  • "使用实际"
  • "使用实际(超支)"
  • "按天折算"
  • "归档实际"

Scenario: 生成计算说明

  • WHEN 餐饮预算2000实际2500
  • THEN CalculationNote = "使用实际(超支)"IsOverBudget = true

Requirement: 年度归档月份标注

对于年度查询中的归档月份数据,系统 SHALL 标注 IsArchived = trueArchivedMonths 字段。

Scenario: 标注归档月份

  • WHEN 工资预算在1月和2月都有归档数据
  • THEN 返回的明细项中 IsArchived = trueArchivedMonths = [1, 2]