feat(budget): 实现存款明细计算核心逻辑

- 添加 BudgetItemCalculator 辅助类,实现明细项计算规则
  - 收入:实际>0取实际,否则取预算
  - 支出:取MAX(预算, 实际)
  - 硬性支出未发生:按天数折算
  - 归档数据:直接使用实际值

- 实现月度和年度存款核心公式
  - 月度:收入预算 + 本月年度收入 - 支出预算 - 本月年度支出
  - 年度:归档已实收 + 未来收入预算 - 归档已实支 - 未来支出预算

- 定义存款明细数据结构
  - SavingsDetail: 包含收入/支出明细列表和汇总
  - BudgetDetailItem: 预算明细项(含计算用金额、计算说明等)
  - SavingsCalculationSummary: 计算汇总信息

- 新增单元测试
  - BudgetItemCalculatorTest: 11个测试覆盖所有计算规则
  - BudgetSavingsCalculationTest: 6个测试验证核心公式

测试结果:所有测试通过 (366 passed, 0 failed)
This commit is contained in:
SunCheng
2026-02-20 16:26:04 +08:00
parent 32d5ed62d0
commit 4cc205fc25
21 changed files with 1730 additions and 1 deletions

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-20

View File

@@ -0,0 +1,117 @@
## Context
`SavingsBudgetContent.vue` 是一个显示存款计划列表和明细的组件。每个存款计划卡片可以点击查看详细信息,弹窗会显示:
1. 收入预算(预算限额和实际收入)
2. 支出预算(预算限额和实际支出)
3. 计划存款公式(收入预算 - 支出预算 = 计划存款)
4. 存款结果(计划存款、实际存款、还差)
问题在于弹窗模板引用了 `incomeLimit``incomeCurrent``expenseLimit``expenseCurrent` 这些计算属性,但在 `<script setup>` 中并未定义,导致弹窗内容为空。
父组件 `Index.vue` 维护了三个独立的预算数组:
- `expenseBudgets` - 支出预算列表
- `incomeBudgets` - 收入预算列表
- `savingsBudgets` - 存款计划列表
当前 `SavingsBudgetContent.vue` 只接收 `savingsBudgets` 数组,无法访问收入和支出预算数据。
## Goals / Non-Goals
**Goals:**
- 修复存款明细弹窗显示为空的问题
- 正确显示收入预算和支出预算的限额及实际值
- 确保计算逻辑与后端逻辑一致
- 保持组件的单一职责,不引入不必要的依赖
**Non-Goals:**
- 不修改存款计划的计算逻辑(后端已有)
- 不改变预算数据的加载方式
- 不重构整个预算页面的架构
## Decisions
### 决策 1: 数据传递方式
**选择:** 通过 props 传递收入和支出预算数据
**理由:**
- **为什么不用 provide/inject?** 数据流更清晰,易于追踪和调试
- **为什么不在子组件中调用 API?** 违反单一数据源原则,会导致重复加载和不一致
- **为什么不用 Pinia store?** 这是页面级别的临时数据,不需要全局状态管理
**实现:**
`Index.vue` 中传递 `incomeBudgets``expenseBudgets``SavingsBudgetContent.vue`:
```vue
<SavingsBudgetContent
:budgets="savingsBudgets"
:income-budgets="incomeBudgets"
:expense-budgets="expenseBudgets"
@savings-nav="handleSavingsNav"
/>
```
### 决策 2: 匹配收入/支出预算的逻辑
**选择:** 根据 `periodStart``type` 进行匹配
**理由:**
- 存款计划、收入预算、支出预算都有相同的 `periodStart`(周期开始时间)和 `type`(月度/年度)字段
- 同一周期、同一类型的预算应该对应同一个存款计划
- 后端逻辑中存款 = 收入预算限额 - 支出预算限额
**实现:**
`SavingsBudgetContent.vue` 中添加计算属性:
```javascript
const matchedIncomeBudget = computed(() => {
if (!currentBudget.value) return null
return props.incomeBudgets?.find(
b => b.periodStart === currentBudget.value.periodStart
&& b.type === currentBudget.value.type
)
})
const matchedExpenseBudget = computed(() => {
if (!currentBudget.value) return null
return props.expenseBudgets?.find(
b => b.periodStart === currentBudget.value.periodStart
&& b.type === currentBudget.value.type
)
})
const incomeLimit = computed(() => matchedIncomeBudget.value?.limit || 0)
const incomeCurrent = computed(() => matchedIncomeBudget.value?.current || 0)
const expenseLimit = computed(() => matchedExpenseBudget.value?.limit || 0)
const expenseCurrent = computed(() => matchedExpenseBudget.value?.current || 0)
```
### 决策 3: 处理数据缺失情况
**选择:** 使用默认值 0,不显示错误提示
**理由:**
- 如果找不到对应的收入或支出预算,说明用户可能还未设置
- 显示 0 比显示错误信息更友好,符合"计划存款 = 收入 - 支出"的语义
- 用户可以在主界面看到预算设置情况,弹窗只是详情展示
## Risks / Trade-offs
### [风险] 如果收入/支出预算数据未加载完成
**缓解措施:**
- 父组件 `Index.vue` 已经统一加载所有预算数据
- 弹窗在用户点击时打开,此时数据应已加载完成
- 使用可选链和默认值避免运行时错误
### [权衡] Props 增加导致组件耦合度提高
**接受理由:**
- 父组件本身就维护了所有预算数据,传递给子组件是合理的
- 子组件不负责数据加载,只负责展示,职责依然清晰
- 如果未来需要重构,可以考虑引入 Pinia store 统一管理预算数据
### [权衡] 按周期和类型匹配可能不够健壮
**接受理由:**
- 这是后端设计的数据模型,前端保持一致
- 后端保证同一周期同一类型只有一条预算记录
- 如果后端逻辑变更,前端需要同步调整(这是正常的维护成本)

View File

@@ -0,0 +1,33 @@
## Why
存款明细弹窗显示为空白内容,因为 `SavingsBudgetContent.vue` 组件中引用了未定义的计算属性 `incomeLimit``incomeCurrent``expenseLimit``expenseCurrent`,导致用户无法查看存款计划的详细构成。这影响了用户理解存款目标的计算逻辑和追踪存款进度的能力。
## What Changes
- 修复 `SavingsBudgetContent.vue` 组件中缺失的计算属性
- 添加从父组件获取收入和支出预算数据的逻辑
- 确保存款明细弹窗正确显示收入预算、支出预算、计划存款公式和存款结果
## Capabilities
### New Capabilities
无新增能力。
### Modified Capabilities
- `savings-budget-display`: 修复存款明细弹窗内容显示功能,确保收入预算和支出预算数据正确传递和渲染
## Impact
**受影响文件:**
- `Web/src/views/budgetV2/modules/SavingsBudgetContent.vue` - 添加缺失的计算属性
- `Web/src/views/budgetV2/Index.vue` - 可能需要传递额外的收入/支出预算数据给子组件
**受影响功能:**
- 存款计划明细查看功能
- 用户对存款目标计算逻辑的理解
**依赖:**
- Vue 3 computed API
- 组件间数据传递(props 或 provide/inject)

View File

@@ -0,0 +1,45 @@
## MODIFIED Requirements
### Requirement: Display income and expense budget in savings detail popup
The savings detail popup SHALL display the associated income budget and expense budget information for the selected savings plan, including both budget limits and current amounts.
#### Scenario: User opens savings detail popup with matched budgets
- **WHEN** user clicks the detail button on a savings plan card
- **AND** there exist income and expense budgets for the same period and type
- **THEN** the popup SHALL display the income budget limit and current amount
- **AND** the popup SHALL display the expense budget limit and current amount
- **AND** the popup SHALL display the savings formula (Income Limit - Expense Limit = Planned Savings)
- **AND** the popup SHALL display the savings result (Planned Savings, Actual Savings, Remaining)
#### Scenario: User opens savings detail popup without matched budgets
- **WHEN** user clicks the detail button on a savings plan card
- **AND** there are no income or expense budgets for the same period and type
- **THEN** the popup SHALL display 0 for income budget limit and current amount
- **AND** the popup SHALL display 0 for expense budget limit and current amount
- **AND** the popup SHALL still display the savings formula and result with these values
### Requirement: Pass budget data to savings component
The parent component (Index.vue) SHALL pass income budgets and expense budgets to the SavingsBudgetContent component to enable detail popup display.
#### Scenario: Budget data is loaded successfully
- **WHEN** the budget data is loaded from the API
- **THEN** the income budgets SHALL be passed to SavingsBudgetContent via props
- **AND** the expense budgets SHALL be passed to SavingsBudgetContent via props
- **AND** the savings budgets SHALL be passed to SavingsBudgetContent via props (existing behavior)
### Requirement: Match income and expense budgets to savings plan
The SavingsBudgetContent component SHALL match income and expense budgets to the current savings plan based on periodStart and type fields.
#### Scenario: Match budgets with same period and type
- **WHEN** displaying savings plan details
- **AND** the component searches for matching budgets
- **THEN** the component SHALL find income budgets where periodStart and type match the savings plan
- **AND** the component SHALL find expense budgets where periodStart and type match the savings plan
- **AND** if multiple matches exist, the component SHALL use the first match
#### Scenario: No matching budgets found
- **WHEN** displaying savings plan details
- **AND** no income budget matches the savings plan's periodStart and type
- **OR** no expense budget matches the savings plan's periodStart and type
- **THEN** the component SHALL use 0 as the default value for unmatched budget fields
- **AND** the popup SHALL still render without errors

View File

@@ -0,0 +1,22 @@
## 1. 修改父组件传递数据
- [x] 1.1 在 Index.vue 中修改 SavingsBudgetContent 组件调用,添加 income-budgets 和 expense-budgets props
- [x] 1.2 验证数据传递正确(通过 Vue DevTools 检查 props)
## 2. 修改 SavingsBudgetContent 组件
- [x] 2.1 在 props 定义中添加 incomeBudgets 和 expenseBudgets 数组
- [x] 2.2 添加 matchedIncomeBudget 计算属性(根据 periodStart 和 type 匹配)
- [x] 2.3 添加 matchedExpenseBudget 计算属性(根据 periodStart 和 type 匹配)
- [x] 2.4 添加 incomeLimit 计算属性(从 matchedIncomeBudget 获取或默认 0)
- [x] 2.5 添加 incomeCurrent 计算属性(从 matchedIncomeBudget 获取或默认 0)
- [x] 2.6 添加 expenseLimit 计算属性(从 matchedExpenseBudget 获取或默认 0)
- [x] 2.7 添加 expenseCurrent 计算属性(从 matchedExpenseBudget 获取或默认 0)
## 3. 测试验证
- [ ] 3.1 测试有对应收入和支出预算的存款计划,打开明细弹窗验证数据显示正确
- [ ] 3.2 测试没有对应收入或支出预算的存款计划,验证弹窗显示 0 且不报错
- [ ] 3.3 验证计划存款公式计算正确(收入预算 - 支出预算 = 计划存款)
- [ ] 3.4 测试月度和年度两种类型的存款计划明细
- [ ] 3.5 使用不同月份的存款计划测试,验证匹配逻辑正确