feat: 添加预算管理功能,包括预算的创建、查询、更新、删除及进度计算。

This commit is contained in:
2026-01-29 21:43:35 +08:00
parent f1fa7b6c54
commit cf34f8b3d0
4 changed files with 112 additions and 35 deletions

View File

@@ -13,12 +13,11 @@ import (
// Service layer errors for budgets
var (
ErrBudgetNotFound = errors.New("budget not found")
ErrBudgetInUse = errors.New("budget is in use and cannot be deleted")
ErrInvalidBudgetAmount = errors.New("budget amount must be positive")
ErrInvalidDateRange = errors.New("end date must be after start date")
ErrInvalidPeriodType = errors.New("invalid period type")
ErrCategoryOrAccountRequired = errors.New("either category or account must be specified")
ErrBudgetNotFound = errors.New("budget not found")
ErrBudgetInUse = errors.New("budget is in use and cannot be deleted")
ErrInvalidBudgetAmount = errors.New("budget amount must be positive")
ErrInvalidDateRange = errors.New("end date must be after start date")
ErrInvalidPeriodType = errors.New("invalid period type")
)
// BudgetInput represents the input data for creating or updating a budget
@@ -72,10 +71,7 @@ func (s *BudgetService) CreateBudget(input BudgetInput) (*models.Budget, error)
return nil, ErrInvalidBudgetAmount
}
// Validate that at least category or account is specified
if input.CategoryID == nil && input.AccountID == nil {
return nil, ErrCategoryOrAccountRequired
}
// 分类和账户都可选,支持全局预算
// Validate date range
if input.EndDate != nil && input.EndDate.Before(input.StartDate) {
@@ -147,10 +143,7 @@ func (s *BudgetService) UpdateBudget(userID, id uint, input BudgetInput) (*model
return nil, ErrInvalidBudgetAmount
}
// Validate that at least category or account is specified
if input.CategoryID == nil && input.AccountID == nil {
return nil, ErrCategoryOrAccountRequired
}
// 分类和账户都可选,支持全局预算
// Validate date range
if input.EndDate != nil && input.EndDate.Before(input.StartDate) {
@@ -222,41 +215,55 @@ func (s *BudgetService) GetBudgetProgress(userID, id uint) (*BudgetProgress, err
startDate, endDate := s.calculateCurrentPeriod(budget, now)
// Get spent amount for the current period
spent, err := s.repo.GetSpentAmount(budget, startDate, endDate)
currentSpent, err := s.repo.GetSpentAmount(budget, startDate, endDate)
if err != nil {
return nil, fmt.Errorf("failed to calculate spent amount: %w", err)
}
// Calculate effective budget amount (considering rolling budget)
// Calculate effective budget amount
effectiveAmount := budget.Amount
totalSpent := currentSpent
if budget.IsRolling {
// For rolling budgets, add the previous period's remaining balance
prevStartDate, prevEndDate := s.calculatePreviousPeriod(budget, now)
prevSpent, err := s.repo.GetSpentAmount(budget, prevStartDate, prevEndDate)
if err != nil {
return nil, fmt.Errorf("failed to calculate previous period spent: %w", err)
}
prevRemaining := budget.Amount - prevSpent
if prevRemaining > 0 {
effectiveAmount += prevRemaining
// 滚动预算:结余自动累加到下一周期
// 当期可用额度 = 总额度 - 历史支出
// 计算已过的完整周期数(不含当期)
periodsElapsed := s.calculatePeriodsElapsed(budget, startDate)
// 总额度 = (已过周期数 + 当期) × 单期额度
totalBudget := budget.Amount * float64(periodsElapsed+1)
// 获取历史支出(从预算开始到当期开始前一秒)
historyEnd := startDate.Add(-time.Second)
historySpent := 0.0
if historyEnd.After(budget.StartDate) {
historySpent, err = s.repo.GetSpentAmount(budget, budget.StartDate, historyEnd)
if err != nil {
return nil, fmt.Errorf("failed to calculate history spent: %w", err)
}
}
// 当期可用额度 = 总额度 - 历史支出
effectiveAmount = totalBudget - historySpent
totalSpent = currentSpent
}
// Calculate progress metrics
remaining := effectiveAmount - spent
remaining := effectiveAmount - totalSpent
progress := 0.0
if effectiveAmount > 0 {
progress = (spent / effectiveAmount) * 100
progress = (totalSpent / effectiveAmount) * 100
}
isOverBudget := spent > effectiveAmount
isOverBudget := totalSpent > effectiveAmount
isNearLimit := progress >= 80.0 && !isOverBudget
return &BudgetProgress{
BudgetID: budget.ID,
Name: budget.Name,
Amount: effectiveAmount,
Spent: spent,
Spent: totalSpent,
Remaining: remaining,
Progress: progress,
PeriodType: budget.PeriodType,
@@ -353,6 +360,41 @@ func (s *BudgetService) calculatePreviousPeriod(budget *models.Budget, reference
}
}
// calculatePeriodsElapsed 计算从预算开始日期到当前周期开始日期之间的完整周期数
func (s *BudgetService) calculatePeriodsElapsed(budget *models.Budget, currentPeriodStart time.Time) int {
if currentPeriodStart.Before(budget.StartDate) || currentPeriodStart.Equal(budget.StartDate) {
return 0
}
var periods int
switch budget.PeriodType {
case models.PeriodTypeDaily:
periods = int(currentPeriodStart.Sub(budget.StartDate).Hours() / 24)
case models.PeriodTypeWeekly:
periods = int(currentPeriodStart.Sub(budget.StartDate).Hours() / (24 * 7))
case models.PeriodTypeMonthly:
yearDiff := currentPeriodStart.Year() - budget.StartDate.Year()
monthDiff := int(currentPeriodStart.Month()) - int(budget.StartDate.Month())
periods = yearDiff*12 + monthDiff
case models.PeriodTypeYearly:
periods = currentPeriodStart.Year() - budget.StartDate.Year()
default:
yearDiff := currentPeriodStart.Year() - budget.StartDate.Year()
monthDiff := int(currentPeriodStart.Month()) - int(budget.StartDate.Month())
periods = yearDiff*12 + monthDiff
}
// 确保返回非负数
if periods < 0 {
return 0
}
return periods
}
// isValidPeriodType checks if a period type is valid
func isValidPeriodType(periodType models.PeriodType) bool {
switch periodType {