2026-02-10 17:49:19 +08:00
|
|
|
|
using Service.Budget;
|
|
|
|
|
|
|
|
|
|
|
|
namespace Application;
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 预算应用服务接口
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public interface IBudgetApplication
|
|
|
|
|
|
{
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 获取预算列表
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
Task<List<BudgetResponse>> GetListAsync(DateTime referenceDate);
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 获取分类统计信息(月度和年度)
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
Task<BudgetCategoryStatsResponse> GetCategoryStatsAsync(BudgetCategory category, DateTime referenceDate);
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 获取未被预算覆盖的分类统计信息
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
Task<List<UncoveredCategoryResponse>> GetUncoveredCategoriesAsync(BudgetCategory category, DateTime? referenceDate = null);
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 获取归档总结
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
Task<string?> GetArchiveSummaryAsync(DateTime referenceDate);
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 获取指定周期的存款预算信息
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
Task<BudgetResponse?> GetSavingsBudgetAsync(int year, int month, BudgetPeriodType type);
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 删除预算
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
Task DeleteByIdAsync(long id);
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 创建预算
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
Task<long> CreateAsync(CreateBudgetRequest request);
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 更新预算
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
Task UpdateAsync(UpdateBudgetRequest request);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 预算应用服务实现
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public class BudgetApplication(
|
|
|
|
|
|
IBudgetService budgetService,
|
|
|
|
|
|
IBudgetRepository budgetRepository
|
|
|
|
|
|
) : IBudgetApplication
|
|
|
|
|
|
{
|
|
|
|
|
|
public async Task<List<BudgetResponse>> GetListAsync(DateTime referenceDate)
|
|
|
|
|
|
{
|
|
|
|
|
|
var results = await budgetService.GetListAsync(referenceDate);
|
|
|
|
|
|
|
|
|
|
|
|
// 排序: 刚性支出优先 → 按分类 → 按类型 → 按使用率 → 按名称
|
|
|
|
|
|
return results
|
|
|
|
|
|
.OrderByDescending(b => b.IsMandatoryExpense)
|
|
|
|
|
|
.ThenBy(b => b.Category)
|
|
|
|
|
|
.ThenBy(b => b.Type)
|
|
|
|
|
|
.ThenByDescending(b => b.Limit > 0 ? b.Current / b.Limit : 0)
|
|
|
|
|
|
.ThenBy(b => b.Name)
|
|
|
|
|
|
.Select(MapToResponse)
|
|
|
|
|
|
.ToList();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public async Task<BudgetCategoryStatsResponse> GetCategoryStatsAsync(
|
|
|
|
|
|
BudgetCategory category,
|
|
|
|
|
|
DateTime referenceDate)
|
|
|
|
|
|
{
|
|
|
|
|
|
var result = await budgetService.GetCategoryStatsAsync(category, referenceDate);
|
|
|
|
|
|
|
|
|
|
|
|
return new BudgetCategoryStatsResponse
|
|
|
|
|
|
{
|
|
|
|
|
|
Month = new BudgetStatsDetail
|
|
|
|
|
|
{
|
|
|
|
|
|
Limit = result.Month.Limit,
|
|
|
|
|
|
Current = result.Month.Current,
|
|
|
|
|
|
Remaining = result.Month.Limit - result.Month.Current,
|
2026-02-15 10:10:28 +08:00
|
|
|
|
UsagePercentage = result.Month.Rate,
|
|
|
|
|
|
Trend = result.Month.Trend,
|
|
|
|
|
|
Description = result.Month.Description
|
2026-02-10 17:49:19 +08:00
|
|
|
|
},
|
|
|
|
|
|
Year = new BudgetStatsDetail
|
|
|
|
|
|
{
|
|
|
|
|
|
Limit = result.Year.Limit,
|
|
|
|
|
|
Current = result.Year.Current,
|
|
|
|
|
|
Remaining = result.Year.Limit - result.Year.Current,
|
2026-02-15 10:10:28 +08:00
|
|
|
|
UsagePercentage = result.Year.Rate,
|
|
|
|
|
|
Trend = result.Year.Trend,
|
|
|
|
|
|
Description = result.Year.Description
|
2026-02-10 17:49:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public async Task<List<UncoveredCategoryResponse>> GetUncoveredCategoriesAsync(
|
|
|
|
|
|
BudgetCategory category,
|
|
|
|
|
|
DateTime? referenceDate = null)
|
|
|
|
|
|
{
|
|
|
|
|
|
var results = await budgetService.GetUncoveredCategoriesAsync(category, referenceDate);
|
|
|
|
|
|
|
|
|
|
|
|
return results.Select(r => new UncoveredCategoryResponse
|
|
|
|
|
|
{
|
|
|
|
|
|
Category = r.Category,
|
|
|
|
|
|
Amount = r.TotalAmount,
|
|
|
|
|
|
Count = r.TransactionCount
|
|
|
|
|
|
}).ToList();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public async Task<string?> GetArchiveSummaryAsync(DateTime referenceDate)
|
|
|
|
|
|
{
|
|
|
|
|
|
return await budgetService.GetArchiveSummaryAsync(referenceDate.Year, referenceDate.Month);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public async Task<BudgetResponse?> GetSavingsBudgetAsync(int year, int month, BudgetPeriodType type)
|
|
|
|
|
|
{
|
|
|
|
|
|
var result = await budgetService.GetSavingsBudgetAsync(year, month, type);
|
|
|
|
|
|
return result == null ? null : MapToResponse(result);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public async Task DeleteByIdAsync(long id)
|
|
|
|
|
|
{
|
|
|
|
|
|
var success = await budgetRepository.DeleteAsync(id);
|
|
|
|
|
|
if (!success)
|
|
|
|
|
|
{
|
|
|
|
|
|
throw new BusinessException("删除预算失败,记录不存在");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public async Task<long> CreateAsync(CreateBudgetRequest request)
|
|
|
|
|
|
{
|
|
|
|
|
|
// 业务验证
|
|
|
|
|
|
await ValidateCreateRequestAsync(request);
|
|
|
|
|
|
|
|
|
|
|
|
// 不记额预算的金额强制设为0
|
|
|
|
|
|
var limit = request.NoLimit ? 0 : request.Limit;
|
|
|
|
|
|
|
|
|
|
|
|
var budget = new BudgetRecord
|
|
|
|
|
|
{
|
|
|
|
|
|
Name = request.Name,
|
|
|
|
|
|
Type = request.Type,
|
|
|
|
|
|
Limit = limit,
|
|
|
|
|
|
Category = request.Category,
|
|
|
|
|
|
SelectedCategories = string.Join(",", request.SelectedCategories),
|
|
|
|
|
|
StartDate = request.StartDate ?? DateTime.Now,
|
|
|
|
|
|
NoLimit = request.NoLimit,
|
|
|
|
|
|
IsMandatoryExpense = request.IsMandatoryExpense
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 验证分类冲突
|
|
|
|
|
|
await ValidateBudgetCategoriesAsync(budget);
|
|
|
|
|
|
|
|
|
|
|
|
var success = await budgetRepository.AddAsync(budget);
|
|
|
|
|
|
if (!success)
|
|
|
|
|
|
{
|
|
|
|
|
|
throw new BusinessException("创建预算失败");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return budget.Id;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public async Task UpdateAsync(UpdateBudgetRequest request)
|
|
|
|
|
|
{
|
|
|
|
|
|
var budget = await budgetRepository.GetByIdAsync(request.Id);
|
|
|
|
|
|
if (budget == null)
|
|
|
|
|
|
{
|
|
|
|
|
|
throw new NotFoundException("预算不存在");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 业务验证
|
|
|
|
|
|
await ValidateUpdateRequestAsync(request);
|
|
|
|
|
|
|
|
|
|
|
|
// 不记额预算的金额强制设为0
|
|
|
|
|
|
var limit = request.NoLimit ? 0 : request.Limit;
|
|
|
|
|
|
|
|
|
|
|
|
budget.Name = request.Name;
|
|
|
|
|
|
budget.Type = request.Type;
|
|
|
|
|
|
budget.Limit = limit;
|
|
|
|
|
|
budget.Category = request.Category;
|
|
|
|
|
|
budget.SelectedCategories = string.Join(",", request.SelectedCategories);
|
|
|
|
|
|
budget.NoLimit = request.NoLimit;
|
|
|
|
|
|
budget.IsMandatoryExpense = request.IsMandatoryExpense;
|
|
|
|
|
|
if (request.StartDate.HasValue)
|
|
|
|
|
|
{
|
|
|
|
|
|
budget.StartDate = request.StartDate.Value;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 验证分类冲突
|
|
|
|
|
|
await ValidateBudgetCategoriesAsync(budget);
|
|
|
|
|
|
|
|
|
|
|
|
var success = await budgetRepository.UpdateAsync(budget);
|
|
|
|
|
|
if (!success)
|
|
|
|
|
|
{
|
|
|
|
|
|
throw new BusinessException("更新预算失败");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#region Private Methods
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 映射到响应DTO
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
private static BudgetResponse MapToResponse(BudgetResult result)
|
|
|
|
|
|
{
|
|
|
|
|
|
// 解析StartDate字符串为DateTime
|
|
|
|
|
|
DateTime.TryParse(result.StartDate, out var startDate);
|
|
|
|
|
|
|
|
|
|
|
|
return new BudgetResponse
|
|
|
|
|
|
{
|
|
|
|
|
|
Id = result.Id,
|
|
|
|
|
|
Name = result.Name,
|
|
|
|
|
|
Type = result.Type,
|
|
|
|
|
|
Limit = result.Limit,
|
|
|
|
|
|
Current = result.Current,
|
|
|
|
|
|
Category = result.Category,
|
|
|
|
|
|
SelectedCategories = result.SelectedCategories,
|
|
|
|
|
|
StartDate = startDate,
|
|
|
|
|
|
NoLimit = result.NoLimit,
|
|
|
|
|
|
IsMandatoryExpense = result.IsMandatoryExpense,
|
2026-02-20 22:07:09 +08:00
|
|
|
|
UsagePercentage = result.Limit > 0 ? result.Current / result.Limit * 100 : 0,
|
|
|
|
|
|
Details = result.Details != null ? MapToSavingsDetailDto(result.Details) : null
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 映射存款明细数据到DTO
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
private static SavingsDetailDto MapToSavingsDetailDto(Service.Budget.SavingsDetail details)
|
|
|
|
|
|
{
|
|
|
|
|
|
return new SavingsDetailDto
|
|
|
|
|
|
{
|
|
|
|
|
|
IncomeItems = details.IncomeItems.Select(item => new BudgetDetailItemDto
|
|
|
|
|
|
{
|
|
|
|
|
|
Id = item.Id,
|
|
|
|
|
|
Name = item.Name,
|
|
|
|
|
|
Type = item.Type,
|
|
|
|
|
|
BudgetLimit = item.BudgetLimit,
|
|
|
|
|
|
ActualAmount = item.ActualAmount,
|
|
|
|
|
|
EffectiveAmount = item.EffectiveAmount,
|
|
|
|
|
|
CalculationNote = item.CalculationNote,
|
|
|
|
|
|
IsOverBudget = item.IsOverBudget,
|
|
|
|
|
|
IsArchived = item.IsArchived,
|
|
|
|
|
|
ArchivedMonths = item.ArchivedMonths
|
|
|
|
|
|
}).ToList(),
|
|
|
|
|
|
ExpenseItems = details.ExpenseItems.Select(item => new BudgetDetailItemDto
|
|
|
|
|
|
{
|
|
|
|
|
|
Id = item.Id,
|
|
|
|
|
|
Name = item.Name,
|
|
|
|
|
|
Type = item.Type,
|
|
|
|
|
|
BudgetLimit = item.BudgetLimit,
|
|
|
|
|
|
ActualAmount = item.ActualAmount,
|
|
|
|
|
|
EffectiveAmount = item.EffectiveAmount,
|
|
|
|
|
|
CalculationNote = item.CalculationNote,
|
|
|
|
|
|
IsOverBudget = item.IsOverBudget,
|
|
|
|
|
|
IsArchived = item.IsArchived,
|
|
|
|
|
|
ArchivedMonths = item.ArchivedMonths
|
|
|
|
|
|
}).ToList(),
|
|
|
|
|
|
Summary = new SavingsCalculationSummaryDto
|
|
|
|
|
|
{
|
|
|
|
|
|
TotalIncomeBudget = details.Summary.TotalIncomeBudget,
|
|
|
|
|
|
TotalExpenseBudget = details.Summary.TotalExpenseBudget,
|
|
|
|
|
|
PlannedSavings = details.Summary.PlannedSavings,
|
|
|
|
|
|
CalculationFormula = details.Summary.CalculationFormula
|
|
|
|
|
|
}
|
2026-02-10 17:49:19 +08:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 验证创建请求
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
private static Task ValidateCreateRequestAsync(CreateBudgetRequest request)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(request.Name))
|
|
|
|
|
|
{
|
|
|
|
|
|
throw new ValidationException("预算名称不能为空");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!request.NoLimit && request.Limit <= 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
throw new ValidationException("预算金额必须大于0");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (request.SelectedCategories.Length == 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
throw new ValidationException("请至少选择一个分类");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 验证更新请求
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
private static Task ValidateUpdateRequestAsync(UpdateBudgetRequest request)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(request.Name))
|
|
|
|
|
|
{
|
|
|
|
|
|
throw new ValidationException("预算名称不能为空");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!request.NoLimit && request.Limit <= 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
throw new ValidationException("预算金额必须大于0");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (request.SelectedCategories.Length == 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
throw new ValidationException("请至少选择一个分类");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 验证预算分类(从Controller迁移的业务逻辑)
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
private async Task ValidateBudgetCategoriesAsync(BudgetRecord record)
|
|
|
|
|
|
{
|
|
|
|
|
|
// 验证不记额预算必须是年度预算
|
|
|
|
|
|
if (record.NoLimit && record.Type != BudgetPeriodType.Year)
|
|
|
|
|
|
{
|
|
|
|
|
|
throw new ValidationException("不记额预算只能设置为年度预算。");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var allBudgets = await budgetRepository.GetAllAsync();
|
|
|
|
|
|
var recordSelectedCategories = record.SelectedCategories.Split(',', StringSplitOptions.RemoveEmptyEntries);
|
|
|
|
|
|
|
|
|
|
|
|
foreach (var budget in allBudgets)
|
|
|
|
|
|
{
|
|
|
|
|
|
var selectedCategories = budget.SelectedCategories.Split(',', StringSplitOptions.RemoveEmptyEntries);
|
|
|
|
|
|
if (budget.Id != record.Id)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (budget.Category == record.Category &&
|
|
|
|
|
|
recordSelectedCategories.Intersect(selectedCategories).Any())
|
|
|
|
|
|
{
|
|
|
|
|
|
throw new ValidationException($"和 {budget.Name} 存在分类冲突,请调整相关分类。");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#endregion
|
|
|
|
|
|
}
|