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

127 lines
5.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` 字段,其中 `IncomeItems``ExpenseItems` 包含所有月度预算项和本月发生的年度预算项
#### 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.IncomeItems``Details.ExpenseItems` 中,归档月份的项目标记为 `IsArchived = true`
#### Scenario: 年初无归档数据
- **WHEN** 调用 `GetForYearAsync(BudgetPeriodType.Year, new DateTime(2026, 1, 1))`,无归档数据
- **THEN** 返回的明细中所有项目 `IsArchived = false`,未来月份数 = 12
## ADDED Requirements
### Requirement: BudgetItemCalculator 辅助类
系统 SHALL 提供 `BudgetItemCalculator` 静态类,用于计算单个预算项的计算用金额。
方法签名:
```csharp
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 类型用于存储明细数据:
```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 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` 对象,填充 `IncomeItems``ExpenseItems``Summary`
### Requirement: 核心计算公式方法提取
系统 SHALL 将核心计算公式提取为独立的私有方法:
- `CalculateMonthlyPlannedSavings`: 月度计划存款计算
- `CalculateYearlyPlannedSavings`: 年度计划存款计算
#### Scenario: 单元测试可测试性
- **WHEN** 开发人员编写单元测试
- **THEN** 可以通过反射或测试友好的设计(如 internal 可见性)测试核心计算公式
### Requirement: 计算说明生成
系统 SHALL 为每个明细项生成 `CalculationNote` 字段,说明使用了哪种计算规则:
- "使用预算"
- "使用实际"
- "使用实际(超支)"
- "按天折算"
- "归档实际"
#### Scenario: 生成计算说明
- **WHEN** 餐饮预算2000实际2500
- **THEN** `CalculationNote = "使用实际(超支)"``IsOverBudget = true`
### Requirement: 年度归档月份标注
对于年度查询中的归档月份数据,系统 SHALL 标注 `IsArchived = true``ArchivedMonths` 字段。
#### Scenario: 标注归档月份
- **WHEN** 工资预算在1月和2月都有归档数据
- **THEN** 返回的明细项中 `IsArchived = true``ArchivedMonths = [1, 2]`