归档
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 18s
Docker Build & Deploy / Deploy to Production (push) Successful in 6s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 18s
Docker Build & Deploy / Deploy to Production (push) Successful in 6s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
# 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]`
|
||||
@@ -0,0 +1,131 @@
|
||||
# Spec: 存款明细计算核心算法
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 月度计划存款计算公式
|
||||
系统 SHALL 使用以下公式计算月度计划存款:
|
||||
|
||||
**月度计划存款 = 收入预算 + 发生在本月的年度收入 - 支出预算 - 发生在本月的年度支出**
|
||||
|
||||
其中:
|
||||
- **收入预算**:所有月度收入预算项的预算金额之和
|
||||
- **发生在本月的年度收入**:年度收入预算项在本月实际发生的金额(actual > 0)
|
||||
- **支出预算**:所有月度支出预算项的预算金额之和
|
||||
- **发生在本月的年度支出**:年度支出预算项在本月实际发生的金额(actual > 0)
|
||||
|
||||
#### Scenario: 纯月度预算计算
|
||||
- **WHEN** 用户查询 2026年2月的月度存款,且只有月度预算(工资10000、奖金5000、房租3000、餐饮2000)
|
||||
- **THEN** 系统返回计划存款 = 10000 + 5000 - 3000 - 2000 = 10000
|
||||
|
||||
#### Scenario: 月度预算 + 本月发生的年度预算
|
||||
- **WHEN** 用户查询 2026年2月的月度存款,月度预算(工资10000、房租3000),且年度旅游支出在本月实际发生3000元
|
||||
- **THEN** 系统返回计划存款 = 10000 - 3000 - 3000 = 4000
|
||||
|
||||
#### Scenario: 年度预算未在本月发生
|
||||
- **WHEN** 用户查询 2026年2月的月度存款,月度预算(工资10000、房租3000),年度年终奖预算50000但本月实际为0
|
||||
- **THEN** 系统返回计划存款 = 10000 - 3000 = 7000(年终奖不计入)
|
||||
|
||||
### Requirement: 年度计划存款计算公式
|
||||
系统 SHALL 使用以下公式计算年度计划存款:
|
||||
|
||||
**年度计划存款 = 归档月已实收 + 未来月(包含本月)收入预算 - 归档月已实支 - 未来月(包含本月)支出预算**
|
||||
|
||||
其中:
|
||||
- **归档月已实收**:已归档月份(1月~当前月-1)的实际收入金额之和,从 `BudgetArchive` 读取
|
||||
- **未来月收入预算**:当前月及未来月份的月度收入预算 × 剩余月数
|
||||
- **归档月已实支**:已归档月份的实际支出金额之和,从 `BudgetArchive` 读取
|
||||
- **未来月支出预算**:当前月及未来月份的月度支出预算 × 剩余月数
|
||||
|
||||
#### Scenario: 年初无归档数据
|
||||
- **WHEN** 用户在 2026年1月查询年度存款,月度预算(工资10000/月、房租3000/月),无归档数据
|
||||
- **THEN** 系统返回计划存款 = (10000 - 3000) × 12 = 84000
|
||||
|
||||
#### Scenario: 年中有归档数据
|
||||
- **WHEN** 用户在 2026年3月查询年度存款,1月归档已实收15000、已实支4800,2月归档已实收14000、已实支5200,3~12月月度预算(工资10000、房租3000)
|
||||
- **THEN** 系统返回计划存款 = (15000 + 14000) + (10000 × 10) - (4800 + 5200) - (3000 × 10) = 129000
|
||||
|
||||
#### Scenario: 归档数据包含年度预算
|
||||
- **WHEN** 归档数据中包含年度预算的实际发生金额(如1月旅游支出3000)
|
||||
- **THEN** 系统将其计入"归档月已实支",不重复计算
|
||||
|
||||
### Requirement: 明细项计算用金额 - 收入规则
|
||||
对于收入类预算项,系统 SHALL 根据以下规则计算"计算用金额":
|
||||
- 如果实际金额 > 0,计算用金额 = 实际金额
|
||||
- 如果实际金额 = 0,计算用金额 = 预算金额
|
||||
|
||||
#### Scenario: 收入已发生
|
||||
- **WHEN** 工资预算10000,实际发生9500
|
||||
- **THEN** 计算用金额 = 9500,标注为"使用实际"
|
||||
|
||||
#### Scenario: 收入未发生
|
||||
- **WHEN** 奖金预算5000,实际发生0
|
||||
- **THEN** 计算用金额 = 5000,标注为"使用预算"
|
||||
|
||||
### Requirement: 明细项计算用金额 - 支出规则(普通)
|
||||
对于非硬性支出类预算项,系统 SHALL 计算用金额 = MAX(预算金额, 实际金额)
|
||||
|
||||
#### Scenario: 支出未超预算
|
||||
- **WHEN** 餐饮预算2000,实际发生1800
|
||||
- **THEN** 计算用金额 = 2000,标注为"使用预算"
|
||||
|
||||
#### Scenario: 支出超预算
|
||||
- **WHEN** 餐饮预算2000,实际发生2500
|
||||
- **THEN** 计算用金额 = 2500,标注为"使用实际(超支)",高亮显示
|
||||
|
||||
### Requirement: 明细项计算用金额 - 支出规则(硬性)
|
||||
对于硬性支出(`IsMandatoryExpense = true`)且实际金额 = 0 的预算项,系统 SHALL 按天数折算计算用金额,不进行 MAX 比较。
|
||||
|
||||
**月度折算公式**:计算用金额 = 预算金额 / 当月天数 × 当前日期
|
||||
|
||||
**年度折算公式**:计算用金额 = 预算金额 / 当年天数 × 当前天数
|
||||
|
||||
#### Scenario: 硬性支出未发生(月度)
|
||||
- **WHEN** 房租预算3000(硬性),实际为0,当前日期为2月15日,2月共28天
|
||||
- **THEN** 计算用金额 = 3000 / 28 × 15 ≈ 1607.14,标注为"按天折算"
|
||||
|
||||
#### Scenario: 硬性支出已发生
|
||||
- **WHEN** 房租预算3000(硬性),实际发生3000
|
||||
- **THEN** 计算用金额 = MAX(3000, 3000) = 3000,标注为"使用实际"
|
||||
|
||||
#### Scenario: 硬性支出超预算
|
||||
- **WHEN** 水电预算500(硬性),实际发生600
|
||||
- **THEN** 计算用金额 = MAX(500, 600) = 600,标注为"使用实际(超支)"
|
||||
|
||||
#### Scenario: 硬性支出按天折算可能超预算
|
||||
- **WHEN** 房租预算3000(硬性),实际为0,当前日期为2月29日,2月共28天
|
||||
- **THEN** 计算用金额 = 3000 / 28 × 29 ≈ 3107.14(大于预算),标注为"按天折算"
|
||||
|
||||
### Requirement: 归档月份数据处理
|
||||
对于已归档月份的预算数据,系统 SHALL 直接使用归档中的实际金额(`BudgetArchive.Content[].Actual`),不重新计算。
|
||||
|
||||
#### Scenario: 读取归档数据
|
||||
- **WHEN** 用户在3月查询年度存款,1月归档中工资实际10000、房租实际3000
|
||||
- **THEN** 系统使用归档实际值10000和3000,不根据当前预算重新计算
|
||||
|
||||
#### Scenario: 归档后预算调整
|
||||
- **WHEN** 1月归档时工资预算10000,实际10000;2月将工资预算调整为12000;用户在3月查询年度存款
|
||||
- **THEN** 1月仍使用归档的实际10000,2月及以后使用新预算12000
|
||||
|
||||
### Requirement: 闰年和月末边界处理
|
||||
系统 SHALL 正确处理闰年和月末边界情况:
|
||||
- 闰年判断:使用 `DateTime.IsLeapYear(year)` 判断,闰年366天,平年365天
|
||||
- 月末天数:使用 `DateTime.DaysInMonth(year, month)` 获取
|
||||
|
||||
#### Scenario: 闰年2月硬性支出折算
|
||||
- **WHEN** 2024年2月29日(闰年),房租预算3000(硬性),实际为0
|
||||
- **THEN** 计算用金额 = 3000 / 29 × 29 = 3000
|
||||
|
||||
#### Scenario: 平年2月硬性支出折算
|
||||
- **WHEN** 2026年2月28日(平年),房租预算3000(硬性),实际为0
|
||||
- **THEN** 计算用金额 = 3000 / 28 × 28 = 3000
|
||||
|
||||
#### Scenario: 年度硬性支出闰年折算
|
||||
- **WHEN** 2024年(闰年)第100天,年度保险预算12000(硬性),实际为0
|
||||
- **THEN** 计算用金额 = 12000 / 366 × 100 ≈ 3278.69
|
||||
|
||||
### Requirement: 不记额预算处理
|
||||
对于不记额预算(`NoLimit = true`)的预算项,系统 SHALL 排除在计划存款计算之外,但在明细中显示为"不限额"。
|
||||
|
||||
#### Scenario: 不记额收入
|
||||
- **WHEN** 存在不记额收入预算项(如意外收入),实际发生1000
|
||||
- **THEN** 该项不计入"收入预算",明细中显示预算为"不限额",实际为1000
|
||||
@@ -0,0 +1,141 @@
|
||||
# Spec: 存款明细前端展示组件
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 明细数据结构返回
|
||||
系统 SHALL 返回结构化的明细数据,包含以下信息:
|
||||
- 收入明细列表(`IncomeItems`)
|
||||
- 支出明细列表(`ExpenseItems`)
|
||||
- 计算汇总(`Summary`)
|
||||
|
||||
每个明细项包含:
|
||||
- `Id`: 预算ID
|
||||
- `Name`: 预算名称
|
||||
- `Type`: 预算类型(月度/年度)
|
||||
- `BudgetLimit`: 预算金额
|
||||
- `ActualAmount`: 实际金额
|
||||
- `EffectiveAmount`: 计算用金额
|
||||
- `CalculationNote`: 计算说明("使用预算"/"使用实际"/"按天折算"/"使用实际(超支)")
|
||||
- `IsOverBudget`: 是否超支/未达标
|
||||
|
||||
#### Scenario: 月度明细数据结构
|
||||
- **WHEN** 用户查询2月月度存款明细
|
||||
- **THEN** 系统返回 JSON 结构包含 `IncomeItems`(工资、奖金等)、`ExpenseItems`(房租、餐饮等)和 `Summary`(收入合计、支出合计、计划存款)
|
||||
|
||||
#### Scenario: 年度明细数据结构
|
||||
- **WHEN** 用户查询2026年度存款明细
|
||||
- **THEN** 系统返回包含归档月份明细和未来月份明细的完整数据结构
|
||||
|
||||
### Requirement: 明细表格展示列
|
||||
明细表格 SHALL 包含以下列:
|
||||
- 名称(Name)
|
||||
- 类型(月度/年度)
|
||||
- 预算金额(BudgetLimit)
|
||||
- 实际金额(ActualAmount)
|
||||
- 计算用金额(EffectiveAmount)
|
||||
- 计算说明(CalculationNote)
|
||||
|
||||
#### Scenario: 明细表格基本展示
|
||||
- **WHEN** 用户打开存款明细页面
|
||||
- **THEN** 表格显示所有收入和支出项的上述6列信息
|
||||
|
||||
#### Scenario: 不限额预算显示
|
||||
- **WHEN** 预算项为不记额(NoLimit = true)
|
||||
- **THEN** 预算金额列显示"不限额"
|
||||
|
||||
### Requirement: 超支/未达标高亮显示
|
||||
系统 SHALL 对超支或未达标的预算项进行高亮显示:
|
||||
- 支出超预算:实际 > 预算,高亮显示为红色/警告色
|
||||
- 收入未达标:实际 > 0 且 实际 < 预算,高亮显示为橙色/提示色
|
||||
|
||||
#### Scenario: 支出超预算高亮
|
||||
- **WHEN** 餐饮预算2000,实际2500(超支)
|
||||
- **THEN** 该行背景色为浅红色,计算说明显示"使用实际(超支)"
|
||||
|
||||
#### Scenario: 收入未达标高亮
|
||||
- **WHEN** 工资预算10000,实际9500(未达标)
|
||||
- **THEN** 该行背景色为浅橙色,计算说明显示"使用实际"
|
||||
|
||||
#### Scenario: 正常范围不高亮
|
||||
- **WHEN** 房租预算3000,实际3000(正常)
|
||||
- **THEN** 该行无特殊背景色
|
||||
|
||||
### Requirement: 计算过程说明展示
|
||||
系统 SHALL 在明细下方展示计算过程的文字说明,包括:
|
||||
- 收入合计的计算公式(如:工资10000 + 奖金5000 = 15000)
|
||||
- 支出合计的计算公式(如:房租3000 + 餐饮2000 = 5000)
|
||||
- 计划存款的计算公式(如:15000 - 5000 = 10000)
|
||||
|
||||
#### Scenario: 月度计算过程说明
|
||||
- **WHEN** 用户查看2月月度存款明细
|
||||
- **THEN** 页面底部显示:
|
||||
```
|
||||
收入合计 = 工资10000 + 奖金5000 = 15000
|
||||
支出合计 = 房租3000 + 餐饮2000 = 5000
|
||||
本月发生的年度支出 = 旅游3000
|
||||
月度计划存款 = 15000 - 5000 - 3000 = 7000
|
||||
```
|
||||
|
||||
#### Scenario: 年度计算过程说明
|
||||
- **WHEN** 用户查看2026年度存款明细
|
||||
- **THEN** 页面底部显示:
|
||||
```
|
||||
归档月已实收 = 1月15000 + 2月14000 = 29000
|
||||
未来月收入预算 = (工资10000 + 奖金5000) × 10月 = 150000
|
||||
归档月已实支 = 1月4800 + 2月5200 = 10000
|
||||
未来月支出预算 = (房租3000 + 餐饮2000) × 10月 = 50000
|
||||
年度计划存款 = 29000 + 150000 - 10000 - 50000 = 119000
|
||||
```
|
||||
|
||||
### Requirement: 归档月份和未来月份分组展示
|
||||
在年度明细中,系统 SHALL 将数据分为两组展示:
|
||||
- **已归档月份明细**:显示各归档月的实际收支
|
||||
- **未来月份预算明细**:显示当前及未来月份的预算和预测
|
||||
|
||||
#### Scenario: 年度明细分组
|
||||
- **WHEN** 用户在3月查询年度存款明细
|
||||
- **THEN** 页面分为两个表格:
|
||||
- 表格1:已归档明细(1月、2月)
|
||||
- 表格2:未来月份预算(3~12月)
|
||||
|
||||
#### Scenario: 归档月份合并显示
|
||||
- **WHEN** 同一预算项在多个归档月出现(如工资1月10000、2月10000)
|
||||
- **THEN** 可选择合并显示为"工资(1~2月):预算10000/月,实际合计20000"
|
||||
|
||||
### Requirement: 响应式布局支持
|
||||
明细表格 SHALL 支持移动端响应式布局:
|
||||
- 桌面端:完整表格展示
|
||||
- 移动端:卡片式折叠展示,点击展开详情
|
||||
|
||||
#### Scenario: 移动端卡片展示
|
||||
- **WHEN** 用户在手机上打开存款明细页面
|
||||
- **THEN** 每个预算项以卡片形式展示,显示名称、计算用金额和计算说明,点击展开显示完整信息
|
||||
|
||||
#### Scenario: 桌面端表格展示
|
||||
- **WHEN** 用户在桌面浏览器打开存款明细页面
|
||||
- **THEN** 以完整表格形式展示所有列
|
||||
|
||||
### Requirement: 排序和筛选功能
|
||||
系统 SHALL 支持明细列表的排序和筛选:
|
||||
- 按预算金额排序(降序/升序)
|
||||
- 按实际金额排序
|
||||
- 筛选显示:全部/仅超支/仅未达标
|
||||
|
||||
#### Scenario: 按预算金额降序排序
|
||||
- **WHEN** 用户点击"预算金额"列标题
|
||||
- **THEN** 列表按预算金额从高到低排序
|
||||
|
||||
#### Scenario: 仅显示超支项目
|
||||
- **WHEN** 用户选择"仅超支"筛选
|
||||
- **THEN** 列表仅显示 `IsOverBudget = true` 且为支出类的项目
|
||||
|
||||
### Requirement: 导出功能
|
||||
系统 SHALL 支持将明细数据导出为 CSV 或 Excel 格式。
|
||||
|
||||
#### Scenario: 导出为 CSV
|
||||
- **WHEN** 用户点击"导出 CSV"按钮
|
||||
- **THEN** 浏览器下载包含所有明细数据的 CSV 文件,文件名为"存款明细_YYYYMM.csv"
|
||||
|
||||
#### Scenario: 导出为 Excel
|
||||
- **WHEN** 用户点击"导出 Excel"按钮
|
||||
- **THEN** 浏览器下载包含所有明细数据的 Excel 文件,保留表格格式和高亮样式
|
||||
Reference in New Issue
Block a user