- 添加 BudgetItemCalculator 辅助类,实现明细项计算规则 - 收入:实际>0取实际,否则取预算 - 支出:取MAX(预算, 实际) - 硬性支出未发生:按天数折算 - 归档数据:直接使用实际值 - 实现月度和年度存款核心公式 - 月度:收入预算 + 本月年度收入 - 支出预算 - 本月年度支出 - 年度:归档已实收 + 未来收入预算 - 归档已实支 - 未来支出预算 - 定义存款明细数据结构 - SavingsDetail: 包含收入/支出明细列表和汇总 - BudgetDetailItem: 预算明细项(含计算用金额、计算说明等) - SavingsCalculationSummary: 计算汇总信息 - 新增单元测试 - BudgetItemCalculatorTest: 11个测试覆盖所有计算规则 - BudgetSavingsCalculationTest: 6个测试验证核心公式 测试结果:所有测试通过 (366 passed, 0 failed)
9.1 KiB
Design: 存款明细计算优化
Context
当前 BudgetSavingsService 在计算存款时使用了复杂的逻辑,包括归档数据读取、月度/年度预算折算、硬性消费的天数折算等。但核心计算公式不明确,导致代码可读性差,且难以验证计算结果的正确性。
现状
GetForMonthAsync: 计算月度存款,需要处理月度预算和发生在本月的年度预算GetForYearAsync: 计算年度存款,需要整合归档数据和未来月份预算- 归档数据存储在
BudgetArchive表中,每月的实际收支被固化 - 硬性消费(
IsMandatoryExpense)在实际为0时按天数比例折算
约束
- 不改变数据库结构和归档格式
- 保持与现有
BudgetArchiveRepository和TransactionStatisticsService的兼容性 - 必须通过 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
测试覆盖场景:
- 月度计算:纯月度预算、月度+年度混合
- 年度计算:有归档数据、无归档数据、部分归档
- 收入项:实际>0、实际=0
- 支出项:普通、硬性且实际=0、硬性且实际>0
- 边界情况:闰年、月初、月末
Risks / Trade-offs
风险1:归档数据不一致
风险: 历史归档数据可能因旧逻辑生成,导致与新逻辑不兼容
缓解: 在单元测试中使用实际的归档数据结构,验证兼容性
风险2:硬性消费按天折算的边界问题
风险: 月初/月末、闰年等边界情况可能导致计算偏差
缓解: 针对边界情况编写专门的单元测试
风险3:年度预算的月份分配
风险: 年度预算如何分配到未来月份不明确(是平均分配还是一次性计入?)
缓解: 根据现有逻辑,年度预算的"发生在本月"部分使用实际发生金额,未来月份不折算
Trade-off:明细数据结构复杂度
权衡: 返回结构化对象增加了 DTO 复杂度,但提高了前端灵活性
选择: 接受复杂度,因为可维护性和用户体验更重要
Migration Plan
阶段1:后端重构(TDD)
- 编写
BudgetSavingsCalculationTest.cs中的核心公式测试(红灯) - 实现
CalculateMonthlyPlannedSavings和CalculateYearlyPlannedSavings(绿灯) - 编写
BudgetItemCalculator的测试(红灯) - 实现
BudgetItemCalculator(绿灯) - 重构
GetForMonthAsync和GetForYearAsync,使用新方法 - 运行所有测试,确保通过
阶段2:明细数据结构
- 定义
SavingsDetail相关的 record 类型 - 修改
GetForMonthAsync和GetForYearAsync返回明细 - 更新 API 响应(如果需要)
阶段3:前端适配(后续变更)
- 本次变更不涉及前端,仅提供数据结构
Rollback 策略
- 如果新逻辑导致计算错误,可以通过 Git 回滚到旧版本
- 由于不涉及数据库变更,回滚无副作用
Open Questions
-
年度预算在月度计算中的处理:
"发生在本月的年度收入/支出"是否仅指实际发生金额(actual),还是也要考虑预算?
假设:仅使用实际发生金额(与现有逻辑一致) -
明细展示的优先级:
收入/支出项在明细表格中的排序规则?
假设:按预算金额降序排列 -
不记额预算(NoLimit)的处理:
在明细中如何展示?
假设:显示为"不限额",不参与预算汇总 -
前端 API 契约:
是否需要新增 API 接口,还是复用现有的存款统计接口?
假设:复用现有接口,扩展返回字段