Files
Novault-backend/internal/service/allocation_rule_service.go
2026-01-25 21:59:00 +08:00

588 lines
17 KiB
Go
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.
package service
import (
"errors"
"fmt"
"accounting-app/internal/models"
"accounting-app/internal/repository"
"gorm.io/gorm"
)
// 分配规则服务层错误定义
var (
ErrAllocationRuleNotFound = errors.New("分配规则不存在")
ErrAllocationRuleInUse = errors.New("分配规则正在使用中,无法删除")
ErrInvalidTriggerType = errors.New("无效的触发类型")
ErrInvalidTargetType = errors.New("无效的目标类型")
ErrInvalidAllocationPercentage = errors.New("分配百分比必须在0-100之间")
ErrInvalidAllocationAmount = errors.New("分配金额必须为正数")
ErrInvalidAllocationTarget = errors.New("分配目标必须有百分比或固定金额")
ErrTotalPercentageExceeds100 = errors.New("分配百分比总和超过100%")
ErrTargetNotFound = errors.New("目标账户或存钱罐不存在")
ErrInsufficientAmount = errors.New("分配金额不足")
)
// AllocationRuleInput 创建或更新分配规则的输入数据
type AllocationRuleInput struct {
UserID uint `json:"user_id"`
Name string `json:"name" binding:"required"`
TriggerType models.TriggerType `json:"trigger_type" binding:"required"`
SourceAccountID *uint `json:"source_account_id,omitempty"` // 触发分配的源账户
IsActive bool `json:"is_active"`
Targets []AllocationTargetInput `json:"targets" binding:"required,min=1"`
}
// AllocationTargetInput 分配目标的输入数据
type AllocationTargetInput struct {
TargetType models.TargetType `json:"target_type" binding:"required"`
TargetID uint `json:"target_id" binding:"required"`
Percentage *float64 `json:"percentage,omitempty"`
FixedAmount *float64 `json:"fixed_amount,omitempty"`
}
// AllocationResult 应用分配规则的结果
type AllocationResult struct {
RuleID uint `json:"rule_id"`
RuleName string `json:"rule_name"`
TotalAmount float64 `json:"total_amount"`
AllocatedAmount float64 `json:"allocated_amount"`
Remaining float64 `json:"remaining"`
Allocations []AllocationDetail `json:"allocations"`
}
// AllocationDetail 单个分配目标的详情
type AllocationDetail struct {
TargetType models.TargetType `json:"target_type"`
TargetID uint `json:"target_id"`
TargetName string `json:"target_name"`
Amount float64 `json:"amount"`
Percentage *float64 `json:"percentage,omitempty"`
FixedAmount *float64 `json:"fixed_amount,omitempty"`
}
// ApplyAllocationInput 应用分配规则的输入数据
type ApplyAllocationInput struct {
Amount float64 `json:"amount" binding:"required,gt=0"`
FromAccountID *uint `json:"from_account_id,omitempty"`
Note string `json:"note,omitempty"`
}
// AllocationRuleService 分配规则业务逻辑服务
type AllocationRuleService struct {
repo *repository.AllocationRuleRepository
recordRepo *repository.AllocationRecordRepository
accountRepo *repository.AccountRepository
piggyBankRepo *repository.PiggyBankRepository
db *gorm.DB
}
// NewAllocationRuleService 创建分配规则服务实例
func NewAllocationRuleService(
repo *repository.AllocationRuleRepository,
recordRepo *repository.AllocationRecordRepository,
accountRepo *repository.AccountRepository,
piggyBankRepo *repository.PiggyBankRepository,
db *gorm.DB,
) *AllocationRuleService {
return &AllocationRuleService{
repo: repo,
recordRepo: recordRepo,
accountRepo: accountRepo,
piggyBankRepo: piggyBankRepo,
db: db,
}
}
// CreateAllocationRule 创建新的分配规则(带业务逻辑验证)
func (s *AllocationRuleService) CreateAllocationRule(input AllocationRuleInput) (*models.AllocationRule, error) {
// 验证触发类型
if !isValidTriggerType(input.TriggerType) {
return nil, ErrInvalidTriggerType
}
// 验证分配目标
if err := s.validateTargets(input.UserID, input.Targets); err != nil {
return nil, err
}
// 创建分配规则模型
rule := &models.AllocationRule{
UserID: input.UserID,
Name: input.Name,
TriggerType: input.TriggerType,
SourceAccountID: input.SourceAccountID,
IsActive: input.IsActive,
}
// 开始数据库事务
tx := s.db.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
// 保存规则到数据库
if err := tx.Create(rule).Error; err != nil {
tx.Rollback()
return nil, fmt.Errorf("创建分配规则失败: %w", err)
}
// 创建分配目标
for _, targetInput := range input.Targets {
target := &models.AllocationTarget{
RuleID: rule.ID,
TargetType: targetInput.TargetType,
TargetID: targetInput.TargetID,
Percentage: targetInput.Percentage,
FixedAmount: targetInput.FixedAmount,
}
if err := tx.Create(target).Error; err != nil {
tx.Rollback()
return nil, fmt.Errorf("创建分配目标失败: %w", err)
}
}
// 提交事务
if err := tx.Commit().Error; err != nil {
return nil, fmt.Errorf("提交事务失败: %w", err)
}
// 重新加载规则(包含目标)
// Re-fetch the rule to include targets
var err error
rule, err = s.repo.GetByID(input.UserID, rule.ID)
if err != nil {
return nil, fmt.Errorf("重新加载分配规则失败: %w", err)
}
return rule, nil
}
// GetAllocationRule 根据ID获取分配规则
func (s *AllocationRuleService) GetAllocationRule(userID, id uint) (*models.AllocationRule, error) {
rule, err := s.repo.GetByID(userID, id)
if err != nil {
if errors.Is(err, repository.ErrAllocationRuleNotFound) {
return nil, ErrAllocationRuleNotFound
}
return nil, fmt.Errorf("获取分配规则失败: %w", err)
}
// userID check handled by repo
return rule, nil
}
// GetAllAllocationRules 获取所有分配规则
func (s *AllocationRuleService) GetAllAllocationRules(userID uint) ([]models.AllocationRule, error) {
rules, err := s.repo.GetAll(userID)
if err != nil {
return nil, fmt.Errorf("获取分配规则列表失败: %w", err)
}
return rules, nil
}
// GetActiveAllocationRules 获取所有启用的分配规则
func (s *AllocationRuleService) GetActiveAllocationRules(userID uint) ([]models.AllocationRule, error) {
rules, err := s.repo.GetActive(userID)
if err != nil {
return nil, fmt.Errorf("获取启用的分配规则失败: %w", err)
}
return rules, nil
}
// UpdateAllocationRule 更新现有的分配规则
func (s *AllocationRuleService) UpdateAllocationRule(userID, id uint, input AllocationRuleInput) (*models.AllocationRule, error) {
// 获取现有规则
rule, err := s.repo.GetByID(userID, id)
if err != nil {
if errors.Is(err, repository.ErrAllocationRuleNotFound) {
return nil, ErrAllocationRuleNotFound
}
return nil, fmt.Errorf("获取分配规则失败: %w", err)
}
// userID check handled by repo
// 验证触发类型
if !isValidTriggerType(input.TriggerType) {
return nil, ErrInvalidTriggerType
}
// 验证分配目标
if err := s.validateTargets(userID, input.Targets); err != nil {
return nil, err
}
// 开始数据库事务
tx := s.db.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
// 更新规则字段
rule.Name = input.Name
rule.TriggerType = input.TriggerType
rule.SourceAccountID = input.SourceAccountID
rule.IsActive = input.IsActive
// 保存规则
if err := tx.Save(rule).Error; err != nil {
tx.Rollback()
return nil, fmt.Errorf("更新分配规则失败: %w", err)
}
// 删除现有目标
if err := tx.Where("rule_id = ?", id).Delete(&models.AllocationTarget{}).Error; err != nil {
tx.Rollback()
return nil, fmt.Errorf("删除现有目标失败: %w", err)
}
// 创建新目标
for _, targetInput := range input.Targets {
target := &models.AllocationTarget{
RuleID: rule.ID,
TargetType: targetInput.TargetType,
TargetID: targetInput.TargetID,
Percentage: targetInput.Percentage,
FixedAmount: targetInput.FixedAmount,
}
if err := tx.Create(target).Error; err != nil {
tx.Rollback()
return nil, fmt.Errorf("创建分配目标失败: %w", err)
}
}
// 提交事务
if err := tx.Commit().Error; err != nil {
return nil, fmt.Errorf("提交事务失败: %w", err)
}
// 重新加载规则(包含目标)
rule, err = s.repo.GetByID(userID, rule.ID)
if err != nil {
return nil, fmt.Errorf("重新加载分配规则失败: %w", err)
}
return rule, nil
}
// DeleteAllocationRule 根据ID删除分配规则
func (s *AllocationRuleService) DeleteAllocationRule(userID, id uint) error {
_, err := s.repo.GetByID(userID, id)
if err != nil {
if errors.Is(err, repository.ErrAllocationRuleNotFound) {
return ErrAllocationRuleNotFound
}
return err
}
// userID check handled by repo
err = s.repo.Delete(userID, id)
if err != nil {
if errors.Is(err, repository.ErrAllocationRuleNotFound) {
return ErrAllocationRuleNotFound
}
if errors.Is(err, repository.ErrAllocationRuleInUse) {
return ErrAllocationRuleInUse
}
return fmt.Errorf("删除分配规则失败: %w", err)
}
return nil
}
// ApplyAllocationRule 应用分配规则到指定金额
// 根据规则的目标分配金额
func (s *AllocationRuleService) ApplyAllocationRule(userID, id uint, input ApplyAllocationInput) (*AllocationResult, error) {
// 验证金额
if input.Amount <= 0 {
return nil, ErrInsufficientAmount
}
// 获取分配规则
rule, err := s.repo.GetByID(userID, id)
if err != nil {
if errors.Is(err, repository.ErrAllocationRuleNotFound) {
return nil, ErrAllocationRuleNotFound
}
return nil, fmt.Errorf("获取分配规则失败: %w", err)
}
// userID check handled by repo
// 检查规则是否启用
if !rule.IsActive {
return nil, errors.New("分配规则未启用")
}
// 开始数据库事务
tx := s.db.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
// 如果提供了源账户ID验证账户是否存在且余额充足
if input.FromAccountID != nil {
var account models.Account
if err := tx.First(&account, *input.FromAccountID).Error; err != nil {
tx.Rollback()
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrAccountNotFound
}
return nil, fmt.Errorf("获取账户失败: %w", err)
}
// 检查账户余额是否充足(非信用账户)
if !account.IsCredit && account.Balance < input.Amount {
tx.Rollback()
return nil, ErrInsufficientBalance
}
// 从源账户扣除金额
account.Balance -= input.Amount
if err := tx.Save(&account).Error; err != nil {
tx.Rollback()
return nil, fmt.Errorf("更新源账户余额失败: %w", err)
}
}
// 计算分配
result := &AllocationResult{
RuleID: rule.ID,
RuleName: rule.Name,
TotalAmount: input.Amount,
Allocations: []AllocationDetail{},
}
totalAllocated := 0.0
// 处理每个目标
for _, target := range rule.Targets {
var allocatedAmount float64
// 计算分配金额
if target.Percentage != nil {
allocatedAmount = input.Amount * (*target.Percentage / 100.0)
} else if target.FixedAmount != nil {
allocatedAmount = *target.FixedAmount
} else {
tx.Rollback()
return nil, ErrInvalidAllocationTarget
}
// 四舍五入到2位小数
allocatedAmount = float64(int(allocatedAmount*100+0.5)) / 100
// 获取目标名称
targetName := ""
// 根据目标类型执行分配
switch target.TargetType {
case models.TargetTypeAccount:
var account models.Account
if err := tx.First(&account, target.TargetID).Error; err != nil {
tx.Rollback()
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrTargetNotFound
}
return nil, fmt.Errorf("获取目标账户失败: %w", err)
}
targetName = account.Name
// 增加目标账户余额
account.Balance += allocatedAmount
if err := tx.Save(&account).Error; err != nil {
tx.Rollback()
return nil, fmt.Errorf("更新目标账户余额失败: %w", err)
}
case models.TargetTypePiggyBank:
var piggyBank models.PiggyBank
if err := tx.First(&piggyBank, target.TargetID).Error; err != nil {
tx.Rollback()
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrTargetNotFound
}
return nil, fmt.Errorf("获取目标存钱罐失败: %w", err)
}
targetName = piggyBank.Name
// 增加存钱罐金额
piggyBank.CurrentAmount += allocatedAmount
if err := tx.Save(&piggyBank).Error; err != nil {
tx.Rollback()
return nil, fmt.Errorf("更新存钱罐余额失败: %w", err)
}
default:
tx.Rollback()
return nil, ErrInvalidTargetType
}
// 添加到结果
result.Allocations = append(result.Allocations, AllocationDetail{
TargetType: target.TargetType,
TargetID: target.TargetID,
TargetName: targetName,
Amount: allocatedAmount,
Percentage: target.Percentage,
FixedAmount: target.FixedAmount,
})
totalAllocated += allocatedAmount
}
result.AllocatedAmount = totalAllocated
result.Remaining = input.Amount - totalAllocated
// 确定分配记录的源账户ID
var sourceAccountID uint
if input.FromAccountID != nil {
sourceAccountID = *input.FromAccountID
} else {
// 如果未指定源账户使用0或适当处理
// 正常流程中不应该发生这种情况,但需要处理
sourceAccountID = 0
}
// 保存分配记录
allocationRecord := &models.AllocationRecord{
UserID: userID,
RuleID: rule.ID,
RuleName: rule.Name,
SourceAccountID: sourceAccountID,
TotalAmount: input.Amount,
AllocatedAmount: totalAllocated,
RemainingAmount: result.Remaining,
Note: input.Note,
}
if err := tx.Create(allocationRecord).Error; err != nil {
tx.Rollback()
return nil, fmt.Errorf("创建分配记录失败: %w", err)
}
// 保存分配记录详情
for _, allocation := range result.Allocations {
detail := &models.AllocationRecordDetail{
RecordID: allocationRecord.ID,
TargetType: allocation.TargetType,
TargetID: allocation.TargetID,
TargetName: allocation.TargetName,
Amount: allocation.Amount,
Percentage: allocation.Percentage,
FixedAmount: allocation.FixedAmount,
}
if err := tx.Create(detail).Error; err != nil {
tx.Rollback()
return nil, fmt.Errorf("创建分配记录详情失败: %w", err)
}
}
// 提交事务
if err := tx.Commit().Error; err != nil {
return nil, fmt.Errorf("提交事务失败: %w", err)
}
return result, nil
}
// SuggestAllocationForIncome 为指定收入金额和账户建议分配规则
// 返回所有匹配源账户的已启用收入触发分配规则
func (s *AllocationRuleService) SuggestAllocationForIncome(userID uint, amount float64, accountID uint) ([]models.AllocationRule, error) {
rules, err := s.repo.GetActiveByTriggerTypeAndAccount(userID, models.TriggerTypeIncome, accountID)
if err != nil {
return nil, fmt.Errorf("获取收入分配规则失败: %w", err)
}
return rules, nil
}
// validateTargets 验证分配目标
func (s *AllocationRuleService) validateTargets(userID uint, targets []AllocationTargetInput) error {
if len(targets) == 0 {
return errors.New("至少需要一个分配目标")
}
totalPercentage := 0.0
for _, target := range targets {
// 验证目标类型
if !isValidTargetType(target.TargetType) {
return ErrInvalidTargetType
}
// 验证目标必须有百分比或固定金额,但不能同时有
if target.Percentage == nil && target.FixedAmount == nil {
return ErrInvalidAllocationTarget
}
if target.Percentage != nil && target.FixedAmount != nil {
return errors.New("分配目标不能同时有百分比和固定金额")
}
// 验证百分比
if target.Percentage != nil {
if *target.Percentage < 0 || *target.Percentage > 100 {
return ErrInvalidAllocationPercentage
}
totalPercentage += *target.Percentage
}
// 验证固定金额
if target.FixedAmount != nil {
if *target.FixedAmount <= 0 {
return ErrInvalidAllocationAmount
}
}
// 验证目标是否存在
switch target.TargetType {
case models.TargetTypeAccount:
exists, err := s.accountRepo.ExistsByID(userID, target.TargetID)
if err != nil {
return fmt.Errorf("检查账户是否存在失败: %w", err)
}
if !exists {
return ErrTargetNotFound
}
case models.TargetTypePiggyBank:
exists, err := s.piggyBankRepo.ExistsByID(userID, target.TargetID)
if err != nil {
return fmt.Errorf("检查存钱罐是否存在失败: %w", err)
}
if !exists {
return ErrTargetNotFound
}
}
}
// 检查百分比总和是否超过100%
if totalPercentage > 100 {
return ErrTotalPercentageExceeds100
}
return nil
}
// isValidTriggerType 检查触发类型是否有效
func isValidTriggerType(triggerType models.TriggerType) bool {
switch triggerType {
case models.TriggerTypeIncome, models.TriggerTypeManual:
return true
default:
return false
}
}
// isValidTargetType 检查目标类型是否有效
func isValidTargetType(targetType models.TargetType) bool {
switch targetType {
case models.TargetTypeAccount, models.TargetTypePiggyBank:
return true
default:
return false
}
}