2026-01-19 13:39:59 +08:00
|
|
|
|
namespace Service.Budget;
|
2026-01-06 21:15:02 +08:00
|
|
|
|
|
|
|
|
|
|
public interface IBudgetService
|
|
|
|
|
|
{
|
2026-01-12 22:29:39 +08:00
|
|
|
|
Task<List<BudgetResult>> GetListAsync(DateTime referenceDate);
|
2026-01-09 14:03:01 +08:00
|
|
|
|
|
|
|
|
|
|
Task<string> ArchiveBudgetsAsync(int year, int month);
|
2026-01-09 15:42:59 +08:00
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 获取指定分类的统计信息(月度和年度)
|
|
|
|
|
|
/// </summary>
|
2026-01-12 22:29:39 +08:00
|
|
|
|
Task<BudgetCategoryStats> GetCategoryStatsAsync(BudgetCategory category, DateTime referenceDate);
|
2026-01-11 12:33:12 +08:00
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 获取未被预算覆盖的分类统计信息
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
Task<List<UncoveredCategoryDetail>> GetUncoveredCategoriesAsync(BudgetCategory category, DateTime? referenceDate = null);
|
2026-01-12 22:29:39 +08:00
|
|
|
|
|
|
|
|
|
|
Task<string?> GetArchiveSummaryAsync(int year, int month);
|
|
|
|
|
|
|
2026-01-15 20:00:41 +08:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 获取指定周期的存款预算信息
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
Task<BudgetResult?> GetSavingsBudgetAsync(int year, int month, BudgetPeriodType type);
|
2026-01-06 21:15:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 22:04:56 +08:00
|
|
|
|
[UsedImplicitly]
|
2026-01-06 21:15:02 +08:00
|
|
|
|
public class BudgetService(
|
2026-01-09 14:03:01 +08:00
|
|
|
|
IBudgetRepository budgetRepository,
|
|
|
|
|
|
IBudgetArchiveRepository budgetArchiveRepository,
|
|
|
|
|
|
ITransactionRecordRepository transactionRecordRepository,
|
|
|
|
|
|
IOpenAiService openAiService,
|
|
|
|
|
|
IConfigService configService,
|
2026-01-10 17:47:09 +08:00
|
|
|
|
IMessageService messageService,
|
2026-01-09 14:03:01 +08:00
|
|
|
|
ILogger<BudgetService> logger
|
2026-01-07 17:33:50 +08:00
|
|
|
|
) : IBudgetService
|
2026-01-06 21:15:02 +08:00
|
|
|
|
{
|
2026-01-12 22:29:39 +08:00
|
|
|
|
public async Task<List<BudgetResult>> GetListAsync(DateTime referenceDate)
|
2026-01-06 21:15:02 +08:00
|
|
|
|
{
|
2026-01-12 22:29:39 +08:00
|
|
|
|
var year = referenceDate.Year;
|
|
|
|
|
|
var month = referenceDate.Month;
|
|
|
|
|
|
|
|
|
|
|
|
var isArchive = year < DateTime.Now.Year
|
|
|
|
|
|
|| (year == DateTime.Now.Year && month < DateTime.Now.Month);
|
|
|
|
|
|
|
|
|
|
|
|
if (isArchive)
|
|
|
|
|
|
{
|
|
|
|
|
|
var archive = await budgetArchiveRepository.GetArchiveAsync(year, month);
|
|
|
|
|
|
|
|
|
|
|
|
if (archive != null)
|
|
|
|
|
|
{
|
2026-01-17 15:55:46 +08:00
|
|
|
|
var (start, end) = GetPeriodRange(DateTime.Now, BudgetPeriodType.Month, referenceDate);
|
|
|
|
|
|
return [.. archive.Content.Select(c => new BudgetResult
|
2026-01-12 22:29:39 +08:00
|
|
|
|
{
|
2026-01-18 13:32:10 +08:00
|
|
|
|
Id = c.Id,
|
2026-01-12 22:29:39 +08:00
|
|
|
|
Name = c.Name,
|
|
|
|
|
|
Type = c.Type,
|
|
|
|
|
|
Limit = c.Limit,
|
|
|
|
|
|
Current = c.Actual,
|
|
|
|
|
|
Category = c.Category,
|
|
|
|
|
|
SelectedCategories = c.SelectedCategories,
|
2026-01-15 10:53:05 +08:00
|
|
|
|
NoLimit = c.NoLimit,
|
2026-01-16 23:18:04 +08:00
|
|
|
|
IsMandatoryExpense = c.IsMandatoryExpense,
|
2026-01-12 22:29:39 +08:00
|
|
|
|
Description = c.Description,
|
2026-01-17 15:55:46 +08:00
|
|
|
|
PeriodStart = start,
|
|
|
|
|
|
PeriodEnd = end,
|
|
|
|
|
|
})];
|
2026-01-12 22:29:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
logger.LogWarning("获取预算列表时发现归档数据缺失,Year: {Year}, Month: {Month}", year, month);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 14:03:01 +08:00
|
|
|
|
var budgets = await budgetRepository.GetAllAsync();
|
|
|
|
|
|
var dtos = new List<BudgetResult?>();
|
2026-01-06 21:15:02 +08:00
|
|
|
|
|
2026-01-09 14:03:01 +08:00
|
|
|
|
foreach (var budget in budgets)
|
|
|
|
|
|
{
|
|
|
|
|
|
var currentAmount = await CalculateCurrentAmountAsync(budget, referenceDate);
|
|
|
|
|
|
dtos.Add(BudgetResult.FromEntity(budget, currentAmount, referenceDate));
|
|
|
|
|
|
}
|
2026-01-06 21:15:02 +08:00
|
|
|
|
|
2026-01-09 14:03:01 +08:00
|
|
|
|
// 创造虚拟的存款预算
|
2026-01-18 22:04:56 +08:00
|
|
|
|
dtos.Add(await GetSavingsDtoAsync(
|
2026-01-09 14:03:01 +08:00
|
|
|
|
BudgetPeriodType.Month,
|
|
|
|
|
|
referenceDate,
|
|
|
|
|
|
budgets));
|
2026-01-18 22:04:56 +08:00
|
|
|
|
dtos.Add(await GetSavingsDtoAsync(
|
2026-01-09 14:03:01 +08:00
|
|
|
|
BudgetPeriodType.Year,
|
|
|
|
|
|
referenceDate,
|
|
|
|
|
|
budgets));
|
|
|
|
|
|
|
2026-01-17 15:55:46 +08:00
|
|
|
|
dtos = dtos
|
2026-01-18 22:04:56 +08:00
|
|
|
|
.Where(x => x != null)
|
|
|
|
|
|
.Cast<BudgetResult>()
|
2026-01-17 15:55:46 +08:00
|
|
|
|
.OrderByDescending(x => x.IsMandatoryExpense)
|
|
|
|
|
|
.ThenBy(x => x.Type)
|
|
|
|
|
|
.ThenByDescending(x => x.Current)
|
2026-01-18 22:04:56 +08:00
|
|
|
|
.ToList()!;
|
2026-01-17 15:55:46 +08:00
|
|
|
|
|
|
|
|
|
|
return [.. dtos.Where(dto => dto != null).Cast<BudgetResult>()];
|
2026-01-06 21:15:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 20:00:41 +08:00
|
|
|
|
public async Task<BudgetResult?> GetSavingsBudgetAsync(int year, int month, BudgetPeriodType type)
|
|
|
|
|
|
{
|
|
|
|
|
|
var referenceDate = new DateTime(year, month, 1);
|
2026-01-18 22:04:56 +08:00
|
|
|
|
return await GetSavingsDtoAsync(type, referenceDate);
|
2026-01-15 20:00:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-12 22:29:39 +08:00
|
|
|
|
public async Task<BudgetCategoryStats> GetCategoryStatsAsync(BudgetCategory category, DateTime referenceDate)
|
2026-01-09 15:42:59 +08:00
|
|
|
|
{
|
2026-01-12 22:29:39 +08:00
|
|
|
|
var budgets = await GetListAsync(referenceDate);
|
2026-01-09 15:42:59 +08:00
|
|
|
|
|
|
|
|
|
|
var result = new BudgetCategoryStats();
|
|
|
|
|
|
|
|
|
|
|
|
// 获取月度统计
|
2026-01-12 22:29:39 +08:00
|
|
|
|
result.Month = await CalculateCategoryStatsAsync(budgets, category, BudgetPeriodType.Month, referenceDate);
|
2026-01-09 15:42:59 +08:00
|
|
|
|
|
|
|
|
|
|
// 获取年度统计
|
2026-01-12 22:29:39 +08:00
|
|
|
|
result.Year = await CalculateCategoryStatsAsync(budgets, category, BudgetPeriodType.Year, referenceDate);
|
2026-01-09 15:42:59 +08:00
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-11 12:33:12 +08:00
|
|
|
|
public async Task<List<UncoveredCategoryDetail>> GetUncoveredCategoriesAsync(BudgetCategory category, DateTime? referenceDate = null)
|
|
|
|
|
|
{
|
|
|
|
|
|
var date = referenceDate ?? DateTime.Now;
|
|
|
|
|
|
var transactionType = category switch
|
|
|
|
|
|
{
|
|
|
|
|
|
BudgetCategory.Expense => TransactionType.Expense,
|
|
|
|
|
|
BudgetCategory.Income => TransactionType.Income,
|
|
|
|
|
|
_ => TransactionType.None
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-18 22:04:56 +08:00
|
|
|
|
if (transactionType == TransactionType.None) return [];
|
2026-01-11 12:33:12 +08:00
|
|
|
|
|
|
|
|
|
|
// 1. 获取所有预算
|
|
|
|
|
|
var budgets = (await budgetRepository.GetAllAsync()).ToList();
|
|
|
|
|
|
var coveredCategories = budgets
|
|
|
|
|
|
.Where(b => b.Category == category)
|
|
|
|
|
|
.SelectMany(b => b.SelectedCategories.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
|
|
|
|
|
.ToHashSet();
|
|
|
|
|
|
|
|
|
|
|
|
// 2. 获取分类统计
|
|
|
|
|
|
var stats = await transactionRecordRepository.GetCategoryStatisticsAsync(date.Year, date.Month, transactionType);
|
|
|
|
|
|
|
|
|
|
|
|
// 3. 过滤未覆盖的
|
|
|
|
|
|
return stats
|
|
|
|
|
|
.Where(s => !coveredCategories.Contains(s.Classify))
|
|
|
|
|
|
.Select(s => new UncoveredCategoryDetail
|
|
|
|
|
|
{
|
|
|
|
|
|
Category = s.Classify,
|
|
|
|
|
|
TransactionCount = s.Count,
|
|
|
|
|
|
TotalAmount = s.Amount
|
|
|
|
|
|
})
|
|
|
|
|
|
.OrderByDescending(x => x.TotalAmount)
|
|
|
|
|
|
.ToList();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-12 22:29:39 +08:00
|
|
|
|
public async Task<string?> GetArchiveSummaryAsync(int year, int month)
|
|
|
|
|
|
{
|
|
|
|
|
|
var archive = await budgetArchiveRepository.GetArchiveAsync(year, month);
|
|
|
|
|
|
return archive?.Summary;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 15:42:59 +08:00
|
|
|
|
private async Task<BudgetStatsDto> CalculateCategoryStatsAsync(
|
2026-01-12 22:29:39 +08:00
|
|
|
|
List<BudgetResult> budgets,
|
2026-01-09 15:42:59 +08:00
|
|
|
|
BudgetCategory category,
|
|
|
|
|
|
BudgetPeriodType statType,
|
|
|
|
|
|
DateTime referenceDate)
|
|
|
|
|
|
{
|
|
|
|
|
|
var result = new BudgetStatsDto
|
|
|
|
|
|
{
|
|
|
|
|
|
PeriodType = statType,
|
|
|
|
|
|
Rate = 0,
|
|
|
|
|
|
Current = 0,
|
|
|
|
|
|
Limit = 0,
|
|
|
|
|
|
Count = 0
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-15 10:53:05 +08:00
|
|
|
|
// 获取当前分类下所有预算,排除不记额预算
|
2026-01-09 15:42:59 +08:00
|
|
|
|
var relevant = budgets
|
2026-01-15 10:53:05 +08:00
|
|
|
|
.Where(b => b.Category == category && !b.NoLimit)
|
2026-01-09 15:42:59 +08:00
|
|
|
|
.ToList();
|
|
|
|
|
|
|
|
|
|
|
|
if (relevant.Count == 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
result.Count = relevant.Count;
|
|
|
|
|
|
decimal totalCurrent = 0;
|
|
|
|
|
|
decimal totalLimit = 0;
|
|
|
|
|
|
|
|
|
|
|
|
foreach (var budget in relevant)
|
|
|
|
|
|
{
|
|
|
|
|
|
// 限额折算
|
|
|
|
|
|
var itemLimit = budget.Limit;
|
|
|
|
|
|
if (statType == BudgetPeriodType.Month && budget.Type == BudgetPeriodType.Year)
|
|
|
|
|
|
{
|
|
|
|
|
|
// 月度视图下,年度预算不参与限额计算
|
|
|
|
|
|
itemLimit = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
else if (statType == BudgetPeriodType.Year && budget.Type == BudgetPeriodType.Month)
|
|
|
|
|
|
{
|
|
|
|
|
|
// 年度视图下,月度预算折算为年度
|
|
|
|
|
|
itemLimit = budget.Limit * 12;
|
|
|
|
|
|
}
|
|
|
|
|
|
totalLimit += itemLimit;
|
|
|
|
|
|
|
|
|
|
|
|
// 当前值累加
|
2026-01-18 22:04:56 +08:00
|
|
|
|
var selectedCategories = string.Join(',', budget.SelectedCategories);
|
2026-01-12 22:29:39 +08:00
|
|
|
|
var currentAmount = await CalculateCurrentAmountAsync(new()
|
|
|
|
|
|
{
|
|
|
|
|
|
Name = budget.Name,
|
|
|
|
|
|
Type = budget.Type,
|
|
|
|
|
|
Limit = budget.Limit,
|
|
|
|
|
|
Category = budget.Category,
|
2026-01-15 20:00:41 +08:00
|
|
|
|
SelectedCategories = selectedCategories,
|
2026-01-16 23:18:04 +08:00
|
|
|
|
StartDate = new DateTime(referenceDate.Year, referenceDate.Month, 1),
|
|
|
|
|
|
IsMandatoryExpense = budget.IsMandatoryExpense
|
2026-01-12 22:29:39 +08:00
|
|
|
|
}, referenceDate);
|
2026-01-09 15:42:59 +08:00
|
|
|
|
if (budget.Type == statType)
|
|
|
|
|
|
{
|
|
|
|
|
|
totalCurrent += currentAmount;
|
|
|
|
|
|
}
|
|
|
|
|
|
else
|
|
|
|
|
|
{
|
|
|
|
|
|
// 如果周期不匹配
|
|
|
|
|
|
if (statType == BudgetPeriodType.Year && budget.Type == BudgetPeriodType.Month)
|
|
|
|
|
|
{
|
|
|
|
|
|
// 在年度视图下,月度预算计入其当前值(作为对年度目前的贡献)
|
|
|
|
|
|
totalCurrent += currentAmount;
|
|
|
|
|
|
}
|
|
|
|
|
|
// 月度视图下,年度预算的 current 不计入
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
result.Limit = totalLimit;
|
|
|
|
|
|
result.Current = totalCurrent;
|
2026-01-11 11:21:13 +08:00
|
|
|
|
result.Rate = totalLimit > 0 ? totalCurrent / totalLimit * 100 : 0;
|
2026-01-09 15:42:59 +08:00
|
|
|
|
|
2026-01-17 14:38:40 +08:00
|
|
|
|
// 计算每日/每月趋势
|
|
|
|
|
|
var transactionType = category switch
|
|
|
|
|
|
{
|
|
|
|
|
|
BudgetCategory.Expense => TransactionType.Expense,
|
|
|
|
|
|
BudgetCategory.Income => TransactionType.Income,
|
|
|
|
|
|
_ => TransactionType.None
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (transactionType != TransactionType.None)
|
|
|
|
|
|
{
|
2026-01-18 22:04:56 +08:00
|
|
|
|
var hasGlobalBudget = relevant.Any(b => b.SelectedCategories.Length == 0);
|
2026-01-17 14:38:40 +08:00
|
|
|
|
|
|
|
|
|
|
var allClassifies = hasGlobalBudget
|
2026-01-17 15:03:19 +08:00
|
|
|
|
? []
|
2026-01-17 14:38:40 +08:00
|
|
|
|
: relevant
|
|
|
|
|
|
.SelectMany(b => b.SelectedCategories)
|
|
|
|
|
|
.Distinct()
|
|
|
|
|
|
.ToList();
|
|
|
|
|
|
|
|
|
|
|
|
DateTime startDate, endDate;
|
2026-01-18 22:04:56 +08:00
|
|
|
|
bool groupByMonth;
|
2026-01-17 14:38:40 +08:00
|
|
|
|
|
|
|
|
|
|
if (statType == BudgetPeriodType.Month)
|
|
|
|
|
|
{
|
|
|
|
|
|
startDate = new DateTime(referenceDate.Year, referenceDate.Month, 1);
|
|
|
|
|
|
endDate = startDate.AddMonths(1).AddDays(-1);
|
|
|
|
|
|
groupByMonth = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
else // Year
|
|
|
|
|
|
{
|
|
|
|
|
|
startDate = new DateTime(referenceDate.Year, 1, 1);
|
|
|
|
|
|
endDate = startDate.AddYears(1).AddDays(-1);
|
|
|
|
|
|
groupByMonth = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var dailyStats = await transactionRecordRepository.GetFilteredTrendStatisticsAsync(
|
|
|
|
|
|
startDate,
|
|
|
|
|
|
endDate,
|
|
|
|
|
|
transactionType,
|
|
|
|
|
|
allClassifies,
|
|
|
|
|
|
groupByMonth);
|
|
|
|
|
|
|
|
|
|
|
|
decimal accumulated = 0;
|
|
|
|
|
|
var now = DateTime.Now;
|
|
|
|
|
|
|
|
|
|
|
|
if (statType == BudgetPeriodType.Month)
|
|
|
|
|
|
{
|
|
|
|
|
|
var daysInMonth = DateTime.DaysInMonth(startDate.Year, startDate.Month);
|
|
|
|
|
|
for (int i = 1; i <= daysInMonth; i++)
|
|
|
|
|
|
{
|
|
|
|
|
|
var currentDate = new DateTime(startDate.Year, startDate.Month, i);
|
|
|
|
|
|
if (currentDate.Date > now.Date)
|
|
|
|
|
|
{
|
|
|
|
|
|
result.Trend.Add(null);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (dailyStats.TryGetValue(currentDate.Date, out var amount))
|
|
|
|
|
|
{
|
|
|
|
|
|
accumulated += amount;
|
|
|
|
|
|
}
|
|
|
|
|
|
result.Trend.Add(accumulated);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
else // Year
|
|
|
|
|
|
{
|
|
|
|
|
|
for (int i = 1; i <= 12; i++)
|
|
|
|
|
|
{
|
|
|
|
|
|
var currentMonthDate = new DateTime(startDate.Year, i, 1);
|
2026-01-17 15:55:46 +08:00
|
|
|
|
|
2026-01-17 14:38:40 +08:00
|
|
|
|
if (currentMonthDate.Year > now.Year || (currentMonthDate.Year == now.Year && i > now.Month))
|
|
|
|
|
|
{
|
|
|
|
|
|
result.Trend.Add(null);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (dailyStats.TryGetValue(currentMonthDate, out var amount))
|
|
|
|
|
|
{
|
|
|
|
|
|
accumulated += amount;
|
|
|
|
|
|
}
|
|
|
|
|
|
result.Trend.Add(accumulated);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 15:42:59 +08:00
|
|
|
|
return result;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 14:03:01 +08:00
|
|
|
|
public async Task<string> ArchiveBudgetsAsync(int year, int month)
|
2026-01-06 21:15:02 +08:00
|
|
|
|
{
|
2026-01-09 14:03:01 +08:00
|
|
|
|
var referenceDate = new DateTime(year, month, 1);
|
2026-01-12 22:29:39 +08:00
|
|
|
|
|
2026-01-09 14:03:01 +08:00
|
|
|
|
var budgets = await GetListAsync(referenceDate);
|
|
|
|
|
|
|
2026-01-15 20:00:41 +08:00
|
|
|
|
var expenseSurplus = budgets
|
|
|
|
|
|
.Where(b => b.Category == BudgetCategory.Expense && !b.NoLimit && b.Type == BudgetPeriodType.Month)
|
|
|
|
|
|
.Sum(b => b.Limit - b.Current);
|
|
|
|
|
|
|
|
|
|
|
|
var incomeSurplus = budgets
|
|
|
|
|
|
.Where(b => b.Category == BudgetCategory.Income && !b.NoLimit && b.Type == BudgetPeriodType.Month)
|
|
|
|
|
|
.Sum(b => b.Current - b.Limit);
|
|
|
|
|
|
|
2026-01-12 22:29:39 +08:00
|
|
|
|
var content = budgets.Select(b => new BudgetArchiveContent
|
2026-01-09 14:03:01 +08:00
|
|
|
|
{
|
2026-01-18 13:32:10 +08:00
|
|
|
|
Id = b.Id,
|
2026-01-12 22:29:39 +08:00
|
|
|
|
Name = b.Name,
|
|
|
|
|
|
Type = b.Type,
|
|
|
|
|
|
Limit = b.Limit,
|
|
|
|
|
|
Actual = b.Current,
|
|
|
|
|
|
Category = b.Category,
|
|
|
|
|
|
SelectedCategories = b.SelectedCategories,
|
2026-01-15 10:53:05 +08:00
|
|
|
|
NoLimit = b.NoLimit,
|
2026-01-16 23:18:04 +08:00
|
|
|
|
IsMandatoryExpense = b.IsMandatoryExpense,
|
2026-01-12 22:29:39 +08:00
|
|
|
|
Description = b.Description
|
|
|
|
|
|
}).ToArray();
|
|
|
|
|
|
|
|
|
|
|
|
var archive = await budgetArchiveRepository.GetArchiveAsync(year, month);
|
|
|
|
|
|
|
|
|
|
|
|
if (archive != null)
|
2026-01-09 14:03:01 +08:00
|
|
|
|
{
|
2026-01-12 22:29:39 +08:00
|
|
|
|
archive.Content = content;
|
|
|
|
|
|
archive.ArchiveDate = DateTime.Now;
|
2026-01-15 20:00:41 +08:00
|
|
|
|
archive.ExpenseSurplus = expenseSurplus;
|
|
|
|
|
|
archive.IncomeSurplus = incomeSurplus;
|
2026-01-12 22:29:39 +08:00
|
|
|
|
if (!await budgetArchiveRepository.UpdateAsync(archive))
|
2026-01-09 14:03:01 +08:00
|
|
|
|
{
|
2026-01-12 22:29:39 +08:00
|
|
|
|
return "更新预算归档失败";
|
2026-01-09 14:03:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-01-12 22:29:39 +08:00
|
|
|
|
else
|
2026-01-09 14:03:01 +08:00
|
|
|
|
{
|
2026-01-12 22:29:39 +08:00
|
|
|
|
archive = new BudgetArchive
|
2026-01-09 14:03:01 +08:00
|
|
|
|
{
|
2026-01-12 22:29:39 +08:00
|
|
|
|
Year = year,
|
|
|
|
|
|
Month = month,
|
|
|
|
|
|
Content = content,
|
2026-01-15 20:00:41 +08:00
|
|
|
|
ArchiveDate = DateTime.Now,
|
|
|
|
|
|
ExpenseSurplus = expenseSurplus,
|
|
|
|
|
|
IncomeSurplus = incomeSurplus
|
2026-01-12 22:29:39 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (!await budgetArchiveRepository.AddAsync(archive))
|
|
|
|
|
|
{
|
|
|
|
|
|
return "保存预算归档失败";
|
2026-01-09 14:03:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_ = NotifyAsync(year, month);
|
2026-01-12 22:29:39 +08:00
|
|
|
|
|
2026-01-09 14:03:01 +08:00
|
|
|
|
return string.Empty;
|
2026-01-06 21:15:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 14:03:01 +08:00
|
|
|
|
private async Task NotifyAsync(int year, int month)
|
2026-01-06 21:15:02 +08:00
|
|
|
|
{
|
2026-01-09 14:03:01 +08:00
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
var archives = await budgetArchiveRepository.GetListAsync(year, month);
|
|
|
|
|
|
|
2026-01-12 22:29:39 +08:00
|
|
|
|
var archiveData = archives.SelectMany(a => a.Content.Select(c => new
|
2026-01-09 14:03:01 +08:00
|
|
|
|
{
|
2026-01-12 22:29:39 +08:00
|
|
|
|
c.Name,
|
|
|
|
|
|
Type = c.Type.ToString(),
|
|
|
|
|
|
c.Limit,
|
|
|
|
|
|
c.Actual,
|
|
|
|
|
|
Category = c.Category.ToString(),
|
|
|
|
|
|
c.SelectedCategories
|
|
|
|
|
|
})).ToList();
|
2026-01-09 14:03:01 +08:00
|
|
|
|
|
|
|
|
|
|
var yearTransactions = await transactionRecordRepository.ExecuteDynamicSqlAsync(
|
|
|
|
|
|
$"""
|
|
|
|
|
|
SELECT
|
|
|
|
|
|
COUNT(*) AS TransactionCount,
|
|
|
|
|
|
SUM(ABS(Amount)) AS TotalAmount,
|
|
|
|
|
|
Type,
|
|
|
|
|
|
Classify
|
|
|
|
|
|
FROM TransactionRecord
|
|
|
|
|
|
WHERE OccurredAt >= '{year}-01-01'
|
|
|
|
|
|
AND OccurredAt < '{year + 1}-01-01'
|
|
|
|
|
|
GROUP BY Type, Classify
|
|
|
|
|
|
ORDER BY TotalAmount DESC
|
|
|
|
|
|
"""
|
|
|
|
|
|
);
|
|
|
|
|
|
var monthYear = new DateTime(year, month, 1).AddMonths(1);
|
|
|
|
|
|
var monthTransactions = await transactionRecordRepository.ExecuteDynamicSqlAsync(
|
|
|
|
|
|
$"""
|
|
|
|
|
|
SELECT
|
|
|
|
|
|
COUNT(*) AS TransactionCount,
|
|
|
|
|
|
SUM(ABS(Amount)) AS TotalAmount,
|
|
|
|
|
|
Type,
|
|
|
|
|
|
Classify
|
|
|
|
|
|
FROM TransactionRecord
|
|
|
|
|
|
WHERE OccurredAt >= '{year}-{month:00}-01'
|
|
|
|
|
|
AND OccurredAt < '{monthYear:yyyy-MM-dd}'
|
|
|
|
|
|
GROUP BY Type, Classify
|
|
|
|
|
|
ORDER BY TotalAmount DESC
|
|
|
|
|
|
"""
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// 分析未被预算覆盖的分类 (仅针对支出类型 Type=0)
|
2026-01-12 22:29:39 +08:00
|
|
|
|
var budgetedCategories = archiveData
|
|
|
|
|
|
.SelectMany(b => b.SelectedCategories)
|
|
|
|
|
|
.Where(c => !string.IsNullOrEmpty(c))
|
2026-01-09 14:03:01 +08:00
|
|
|
|
.Distinct()
|
|
|
|
|
|
.ToHashSet();
|
|
|
|
|
|
|
|
|
|
|
|
var uncovered = monthTransactions
|
|
|
|
|
|
.Where(t =>
|
|
|
|
|
|
{
|
|
|
|
|
|
var dict = (IDictionary<string, object>)t;
|
2026-01-18 22:04:56 +08:00
|
|
|
|
var classify = dict["Classify"].ToString() ?? "";
|
2026-01-09 14:03:01 +08:00
|
|
|
|
var type = Convert.ToInt32(dict["Type"]);
|
|
|
|
|
|
return type == 0 && !budgetedCategories.Contains(classify);
|
|
|
|
|
|
})
|
|
|
|
|
|
.ToList();
|
|
|
|
|
|
|
|
|
|
|
|
logger.LogInformation("预算执行数据{JSON}", JsonSerializer.Serialize(archiveData));
|
|
|
|
|
|
logger.LogInformation("本月消费明细{JSON}", JsonSerializer.Serialize(monthTransactions));
|
|
|
|
|
|
logger.LogInformation("全年累计消费概况{JSON}", JsonSerializer.Serialize(yearTransactions));
|
|
|
|
|
|
logger.LogInformation("未被预算覆盖的分类{JSON}", JsonSerializer.Serialize(uncovered));
|
|
|
|
|
|
|
|
|
|
|
|
var dataPrompt = $"""
|
|
|
|
|
|
报告周期:{year}年{month}月
|
|
|
|
|
|
|
|
|
|
|
|
1. 预算执行数据(JSON):
|
|
|
|
|
|
{JsonSerializer.Serialize(archiveData)}
|
|
|
|
|
|
|
2026-01-13 17:00:44 +08:00
|
|
|
|
2. 本月账单类目明细(按分类, JSON):
|
2026-01-09 14:03:01 +08:00
|
|
|
|
{JsonSerializer.Serialize(monthTransactions)}
|
|
|
|
|
|
|
2026-01-13 17:00:44 +08:00
|
|
|
|
3. 全年累计账单类目明细(按分类, JSON):
|
2026-01-09 14:03:01 +08:00
|
|
|
|
{JsonSerializer.Serialize(yearTransactions)}
|
|
|
|
|
|
|
|
|
|
|
|
4. 未被任何预算覆盖的支出分类(JSON):
|
|
|
|
|
|
{JsonSerializer.Serialize(uncovered)}
|
|
|
|
|
|
|
|
|
|
|
|
请生成一份专业且美观的预算执行分析报告,严格遵守以下要求:
|
|
|
|
|
|
|
|
|
|
|
|
【内容要求】
|
|
|
|
|
|
1. 概览:总结本月预算达成情况。
|
|
|
|
|
|
2. 预算详情:使用 HTML 表格展示预算执行明细(预算项、预算额、实际额、使用/达成率、状态)。
|
|
|
|
|
|
3. 超支/异常预警:重点分析超支项或支出异常的分类。
|
|
|
|
|
|
4. 消费透视:针对“未被预算覆盖的支出”提供分析建议。分析这些账单产生的合理性,并评估是否需要为其中的大额或频发分类建立新预算。
|
2026-01-09 16:21:03 +08:00
|
|
|
|
5. 改进建议:根据当前时间进度和预算完成进度,基于本月整体收入支出情况,给出下月预算调整或消费改进的专业化建议。
|
2026-01-09 14:03:01 +08:00
|
|
|
|
6. 语言风格:专业、清晰、简洁,适合财务报告阅读。
|
2026-01-13 17:00:44 +08:00
|
|
|
|
7. 如果报告月份是12月,需要报告年度预算的执行情况。
|
2026-01-09 14:03:01 +08:00
|
|
|
|
|
|
|
|
|
|
【格式要求】
|
|
|
|
|
|
1. 使用HTML格式(移动端H5页面风格)
|
|
|
|
|
|
2. 生成清晰的报告标题(基于用户问题)
|
2026-01-12 16:20:06 +08:00
|
|
|
|
3. 使用表格展示统计数据(table > thead/tbody > tr > th/td),
|
|
|
|
|
|
3.1 table要求不能超过屏幕宽度,尽可能简洁明了,避免冗余信息
|
|
|
|
|
|
3.2 预算金额精确到整数即可,实际金额精确到小数点后1位
|
2026-01-09 14:03:01 +08:00
|
|
|
|
4. 使用合适的HTML标签:h2(标题)、h3(小节)、p(段落)、table(表格)、ul/li(列表)、strong(强调)
|
|
|
|
|
|
5. 支出金额用 <span class='expense-value'>金额</span> 包裹
|
|
|
|
|
|
6. 收入金额用 <span class='income-value'>金额</span> 包裹
|
|
|
|
|
|
7. 重要结论用 <span class='highlight'>内容</span> 高亮
|
|
|
|
|
|
|
|
|
|
|
|
【样式限制(重要)】
|
|
|
|
|
|
8. 不要包含 html、body、head 标签
|
|
|
|
|
|
9. 不要使用任何 style 属性或 <style> 标签
|
|
|
|
|
|
10. 不要设置 background、background-color、color 等样式属性
|
|
|
|
|
|
11. 不要使用 div 包裹大段内容
|
2026-01-09 16:21:03 +08:00
|
|
|
|
|
|
|
|
|
|
【系统信息】
|
|
|
|
|
|
当前时间:{DateTime.Now:yyyy-MM-dd HH:mm:ss}
|
|
|
|
|
|
预算归档周期:{year}年{month}月
|
2026-01-09 14:03:01 +08:00
|
|
|
|
|
|
|
|
|
|
直接输出纯净 HTML 内容,不要带有 Markdown 代码块包裹。
|
|
|
|
|
|
""";
|
|
|
|
|
|
|
|
|
|
|
|
var htmlReport = await openAiService.ChatAsync(dataPrompt);
|
|
|
|
|
|
if (!string.IsNullOrEmpty(htmlReport))
|
|
|
|
|
|
{
|
|
|
|
|
|
await messageService.AddAsync(
|
|
|
|
|
|
title: $"{year}年{month}月 - 预算归档报告",
|
|
|
|
|
|
content: htmlReport,
|
2026-01-10 17:47:09 +08:00
|
|
|
|
type: MessageType.Html,
|
|
|
|
|
|
url: "/balance?tab=message");
|
2026-01-15 21:19:03 +08:00
|
|
|
|
|
|
|
|
|
|
// 同时保存到归档总结
|
|
|
|
|
|
var first = archives.First();
|
|
|
|
|
|
first.Summary = htmlReport;
|
|
|
|
|
|
await budgetArchiveRepository.UpdateAsync(first);
|
2026-01-09 14:03:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
|
{
|
|
|
|
|
|
logger.LogError(ex, "生成预算执行通知报告失败");
|
|
|
|
|
|
}
|
2026-01-06 21:15:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 14:03:01 +08:00
|
|
|
|
private async Task<decimal> CalculateCurrentAmountAsync(BudgetRecord budget, DateTime? now = null)
|
2026-01-06 21:15:02 +08:00
|
|
|
|
{
|
|
|
|
|
|
var referenceDate = now ?? DateTime.Now;
|
|
|
|
|
|
var (startDate, endDate) = GetPeriodRange(budget.StartDate, budget.Type, referenceDate);
|
|
|
|
|
|
|
2026-01-16 23:18:04 +08:00
|
|
|
|
var actualAmount = await budgetRepository.GetCurrentAmountAsync(budget, startDate, endDate);
|
|
|
|
|
|
|
|
|
|
|
|
// 如果是硬性消费,且是当前年当前月,则根据经过的天数累加
|
|
|
|
|
|
if (actualAmount == 0
|
|
|
|
|
|
&& budget.IsMandatoryExpense
|
|
|
|
|
|
&& referenceDate.Year == startDate.Year
|
|
|
|
|
|
&& referenceDate.Month == startDate.Month)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (budget.Type == BudgetPeriodType.Month)
|
|
|
|
|
|
{
|
|
|
|
|
|
// 计算本月的天数
|
|
|
|
|
|
var daysInMonth = DateTime.DaysInMonth(referenceDate.Year, referenceDate.Month);
|
|
|
|
|
|
// 计算当前已经过的天数(包括今天)
|
|
|
|
|
|
var daysElapsed = referenceDate.Day;
|
|
|
|
|
|
// 根据预算金额和经过天数计算应累加的金额
|
|
|
|
|
|
var mandatoryAccumulation = budget.Limit * daysElapsed / daysInMonth;
|
|
|
|
|
|
// 返回实际消费和硬性消费累加中的较大值
|
|
|
|
|
|
return mandatoryAccumulation;
|
|
|
|
|
|
}
|
2026-01-18 22:04:56 +08:00
|
|
|
|
|
|
|
|
|
|
if (budget.Type == BudgetPeriodType.Year)
|
2026-01-16 23:18:04 +08:00
|
|
|
|
{
|
|
|
|
|
|
// 计算本年的天数(考虑闰年)
|
|
|
|
|
|
var daysInYear = DateTime.IsLeapYear(referenceDate.Year) ? 366 : 365;
|
|
|
|
|
|
// 计算当前已经过的天数(包括今天)
|
|
|
|
|
|
var daysElapsed = referenceDate.DayOfYear;
|
|
|
|
|
|
// 根据预算金额和经过天数计算应累加的金额
|
|
|
|
|
|
var mandatoryAccumulation = budget.Limit * daysElapsed / daysInYear;
|
|
|
|
|
|
// 返回实际消费和硬性消费累加中的较大值
|
|
|
|
|
|
return mandatoryAccumulation;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return actualAmount;
|
2026-01-06 21:15:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 14:03:01 +08:00
|
|
|
|
internal static (DateTime start, DateTime end) GetPeriodRange(DateTime startDate, BudgetPeriodType type, DateTime referenceDate)
|
2026-01-06 21:15:02 +08:00
|
|
|
|
{
|
|
|
|
|
|
DateTime start;
|
|
|
|
|
|
DateTime end;
|
|
|
|
|
|
|
2026-01-08 14:41:50 +08:00
|
|
|
|
if (type == BudgetPeriodType.Month)
|
2026-01-06 21:15:02 +08:00
|
|
|
|
{
|
|
|
|
|
|
start = new DateTime(referenceDate.Year, referenceDate.Month, 1);
|
|
|
|
|
|
end = start.AddMonths(1).AddDays(-1).AddHours(23).AddMinutes(59).AddSeconds(59);
|
|
|
|
|
|
}
|
|
|
|
|
|
else if (type == BudgetPeriodType.Year)
|
|
|
|
|
|
{
|
|
|
|
|
|
start = new DateTime(referenceDate.Year, 1, 1);
|
|
|
|
|
|
end = new DateTime(referenceDate.Year, 12, 31).AddHours(23).AddMinutes(59).AddSeconds(59);
|
|
|
|
|
|
}
|
|
|
|
|
|
else
|
|
|
|
|
|
{
|
|
|
|
|
|
start = startDate;
|
|
|
|
|
|
end = DateTime.MaxValue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (start, end);
|
|
|
|
|
|
}
|
2026-01-09 14:03:01 +08:00
|
|
|
|
|
2026-01-18 22:04:56 +08:00
|
|
|
|
private async Task<BudgetResult?> GetSavingsDtoAsync(
|
2026-01-09 14:03:01 +08:00
|
|
|
|
BudgetPeriodType periodType,
|
|
|
|
|
|
DateTime? referenceDate = null,
|
|
|
|
|
|
IEnumerable<BudgetRecord>? existingBudgets = null)
|
|
|
|
|
|
{
|
|
|
|
|
|
var allBudgets = existingBudgets;
|
|
|
|
|
|
|
|
|
|
|
|
if (existingBudgets == null)
|
|
|
|
|
|
{
|
|
|
|
|
|
allBudgets = await budgetRepository.GetAllAsync();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (allBudgets == null)
|
|
|
|
|
|
{
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-17 15:03:19 +08:00
|
|
|
|
allBudgets = allBudgets
|
|
|
|
|
|
// 排序顺序 1.硬性预算 2.月度->年度 3.实际金额倒叙
|
|
|
|
|
|
.OrderBy(b => b.IsMandatoryExpense)
|
|
|
|
|
|
.ThenBy(b => b.Type)
|
|
|
|
|
|
.ThenByDescending(b => b.Limit);
|
|
|
|
|
|
|
2026-01-09 14:03:01 +08:00
|
|
|
|
var date = referenceDate ?? DateTime.Now;
|
|
|
|
|
|
|
|
|
|
|
|
decimal incomeLimitAtPeriod = 0;
|
|
|
|
|
|
decimal expenseLimitAtPeriod = 0;
|
2026-01-15 10:53:05 +08:00
|
|
|
|
decimal noLimitIncomeAtPeriod = 0; // 新增:不记额收入汇总
|
|
|
|
|
|
decimal noLimitExpenseAtPeriod = 0; // 新增:不记额支出汇总
|
2026-01-09 14:03:01 +08:00
|
|
|
|
|
2026-01-18 13:32:10 +08:00
|
|
|
|
var incomeItems = new List<(string Name, decimal Limit, decimal Factor, decimal Historical, decimal Total)>();
|
|
|
|
|
|
var expenseItems = new List<(string Name, decimal Limit, decimal Factor, decimal Historical, decimal Total)>();
|
2026-01-15 10:53:05 +08:00
|
|
|
|
var noLimitIncomeItems = new List<(string Name, decimal Amount)>(); // 新增
|
|
|
|
|
|
var noLimitExpenseItems = new List<(string Name, decimal Amount)>(); // 新增
|
2026-01-09 14:03:01 +08:00
|
|
|
|
|
2026-01-18 13:32:10 +08:00
|
|
|
|
// 如果是年度计算,先从归档中获取所有历史数据
|
|
|
|
|
|
Dictionary<(long Id, int Month), (decimal HistoricalLimit, BudgetCategory Category, string Name)> historicalData = new();
|
|
|
|
|
|
|
|
|
|
|
|
if (periodType == BudgetPeriodType.Year)
|
|
|
|
|
|
{
|
|
|
|
|
|
var yearArchives = await budgetArchiveRepository.GetArchivesByYearAsync(date.Year);
|
|
|
|
|
|
|
|
|
|
|
|
// 按预算ID和月份记录历史数据
|
|
|
|
|
|
foreach (var archive in yearArchives)
|
|
|
|
|
|
{
|
|
|
|
|
|
foreach (var content in archive.Content)
|
|
|
|
|
|
{
|
|
|
|
|
|
// 跳过存款类预算
|
|
|
|
|
|
if (content.Category == BudgetCategory.Savings) continue;
|
|
|
|
|
|
|
|
|
|
|
|
historicalData[(content.Id, archive.Month)] = (
|
|
|
|
|
|
content.Limit,
|
|
|
|
|
|
content.Category,
|
|
|
|
|
|
content.Name);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 处理当前预算
|
|
|
|
|
|
var processedIds = new HashSet<long>();
|
2026-01-09 14:03:01 +08:00
|
|
|
|
foreach (var b in allBudgets)
|
|
|
|
|
|
{
|
2026-01-10 17:38:22 +08:00
|
|
|
|
if (b.Category == BudgetCategory.Savings) continue;
|
2026-01-09 14:03:01 +08:00
|
|
|
|
|
2026-01-18 13:32:10 +08:00
|
|
|
|
processedIds.Add(b.Id);
|
2026-01-18 22:04:56 +08:00
|
|
|
|
decimal factor;
|
2026-01-18 13:32:10 +08:00
|
|
|
|
decimal historicalAmount = 0m;
|
|
|
|
|
|
var historicalMonths = new List<int>();
|
2026-01-09 14:03:01 +08:00
|
|
|
|
|
|
|
|
|
|
if (periodType == BudgetPeriodType.Year)
|
|
|
|
|
|
{
|
2026-01-18 13:32:10 +08:00
|
|
|
|
if (b.Type == BudgetPeriodType.Month)
|
2026-01-09 14:03:01 +08:00
|
|
|
|
{
|
2026-01-18 13:32:10 +08:00
|
|
|
|
// 月度预算在年度计算时:历史归档 + 剩余月份预算
|
|
|
|
|
|
// 收集该预算的所有历史月份数据
|
|
|
|
|
|
foreach (var ((id, month), (limit, _, _)) in historicalData)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (id == b.Id)
|
|
|
|
|
|
{
|
|
|
|
|
|
historicalAmount += limit;
|
|
|
|
|
|
historicalMonths.Add(month);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 计算剩余月份数(当前月到12月)
|
|
|
|
|
|
var remainingMonths = 12 - date.Month + 1;
|
|
|
|
|
|
factor = remainingMonths;
|
|
|
|
|
|
}
|
|
|
|
|
|
else if (b.Type == BudgetPeriodType.Year)
|
|
|
|
|
|
{
|
|
|
|
|
|
factor = 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
else
|
|
|
|
|
|
{
|
|
|
|
|
|
factor = 0;
|
|
|
|
|
|
}
|
2026-01-09 14:03:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
else if (periodType == BudgetPeriodType.Month)
|
|
|
|
|
|
{
|
|
|
|
|
|
factor = b.Type switch
|
|
|
|
|
|
{
|
|
|
|
|
|
BudgetPeriodType.Month => 1,
|
|
|
|
|
|
BudgetPeriodType.Year => 0,
|
|
|
|
|
|
_ => 0
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
else
|
|
|
|
|
|
{
|
|
|
|
|
|
factor = 0; // 其他周期暂不计算虚拟存款
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-18 13:32:10 +08:00
|
|
|
|
if (factor <= 0 && historicalAmount <= 0) continue;
|
2026-01-09 14:03:01 +08:00
|
|
|
|
|
2026-01-18 13:32:10 +08:00
|
|
|
|
// 处理不记额预算
|
2026-01-15 10:53:05 +08:00
|
|
|
|
if (b.NoLimit)
|
2026-01-09 14:03:01 +08:00
|
|
|
|
{
|
2026-01-15 10:53:05 +08:00
|
|
|
|
var actualAmount = await CalculateCurrentAmountAsync(b, date);
|
|
|
|
|
|
if (b.Category == BudgetCategory.Income)
|
|
|
|
|
|
{
|
|
|
|
|
|
noLimitIncomeAtPeriod += actualAmount;
|
|
|
|
|
|
noLimitIncomeItems.Add((b.Name, actualAmount));
|
|
|
|
|
|
}
|
|
|
|
|
|
else if (b.Category == BudgetCategory.Expense)
|
|
|
|
|
|
{
|
|
|
|
|
|
noLimitExpenseAtPeriod += actualAmount;
|
|
|
|
|
|
noLimitExpenseItems.Add((b.Name, actualAmount));
|
|
|
|
|
|
}
|
2026-01-09 14:03:01 +08:00
|
|
|
|
}
|
2026-01-15 10:53:05 +08:00
|
|
|
|
else
|
2026-01-09 14:03:01 +08:00
|
|
|
|
{
|
2026-01-18 13:32:10 +08:00
|
|
|
|
// 普通预算:历史金额 + 当前预算折算
|
|
|
|
|
|
var subtotal = historicalAmount + b.Limit * factor;
|
|
|
|
|
|
var displayName = b.Name;
|
|
|
|
|
|
|
|
|
|
|
|
// 如果有历史月份,添加月份范围显示
|
|
|
|
|
|
if (historicalMonths.Count > 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
historicalMonths.Sort();
|
|
|
|
|
|
var monthRange = historicalMonths.Count == 1
|
|
|
|
|
|
? $"{historicalMonths[0]}月"
|
|
|
|
|
|
: $"{historicalMonths[0]}~{historicalMonths[^1]}月";
|
|
|
|
|
|
displayName = $"{b.Name} ({monthRange})";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-15 10:53:05 +08:00
|
|
|
|
if (b.Category == BudgetCategory.Income)
|
|
|
|
|
|
{
|
|
|
|
|
|
incomeLimitAtPeriod += subtotal;
|
2026-01-18 13:32:10 +08:00
|
|
|
|
incomeItems.Add((displayName, b.Limit, factor, historicalAmount, subtotal));
|
2026-01-15 10:53:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
else if (b.Category == BudgetCategory.Expense)
|
|
|
|
|
|
{
|
|
|
|
|
|
expenseLimitAtPeriod += subtotal;
|
2026-01-18 13:32:10 +08:00
|
|
|
|
expenseItems.Add((displayName, b.Limit, factor, historicalAmount, subtotal));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 处理已删除的预算(只在归档中存在,但当前预算列表中不存在的)
|
|
|
|
|
|
if (periodType == BudgetPeriodType.Year)
|
|
|
|
|
|
{
|
|
|
|
|
|
// 按预算ID分组
|
|
|
|
|
|
var deletedBudgets = historicalData
|
|
|
|
|
|
.Where(kvp => !processedIds.Contains(kvp.Key.Id))
|
|
|
|
|
|
.GroupBy(kvp => kvp.Key.Id);
|
|
|
|
|
|
|
|
|
|
|
|
foreach (var group in deletedBudgets)
|
|
|
|
|
|
{
|
|
|
|
|
|
var months = group.Select(g => g.Key.Month).OrderBy(m => m).ToList();
|
|
|
|
|
|
var totalLimit = group.Sum(g => g.Value.HistoricalLimit);
|
|
|
|
|
|
var (_, category, name) = group.First().Value;
|
|
|
|
|
|
|
|
|
|
|
|
var monthRange = months.Count == 1
|
|
|
|
|
|
? $"{months[0]}月"
|
|
|
|
|
|
: $"{months[0]}~{months[^1]}月";
|
|
|
|
|
|
var displayName = $"{name} ({monthRange}, 已删除)";
|
|
|
|
|
|
|
|
|
|
|
|
// 这是一个已被删除的预算,但有历史数据
|
|
|
|
|
|
if (category == BudgetCategory.Income)
|
|
|
|
|
|
{
|
|
|
|
|
|
incomeLimitAtPeriod += totalLimit;
|
|
|
|
|
|
incomeItems.Add((displayName, 0, months.Count, totalLimit, totalLimit));
|
|
|
|
|
|
}
|
|
|
|
|
|
else if (category == BudgetCategory.Expense)
|
|
|
|
|
|
{
|
|
|
|
|
|
expenseLimitAtPeriod += totalLimit;
|
|
|
|
|
|
expenseItems.Add((displayName, 0, months.Count, totalLimit, totalLimit));
|
2026-01-15 10:53:05 +08:00
|
|
|
|
}
|
2026-01-09 14:03:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var description = new StringBuilder();
|
|
|
|
|
|
description.Append("<h3>预算收入明细</h3>");
|
|
|
|
|
|
if (incomeItems.Count == 0) description.Append("<p>无收入预算</p>");
|
|
|
|
|
|
else
|
|
|
|
|
|
{
|
2026-01-18 13:32:10 +08:00
|
|
|
|
// 根据是否有历史数据决定表格列
|
|
|
|
|
|
var hasHistoricalData = incomeItems.Any(i => i.Historical > 0);
|
|
|
|
|
|
|
|
|
|
|
|
if (hasHistoricalData)
|
|
|
|
|
|
{
|
|
|
|
|
|
description.Append("""
|
|
|
|
|
|
<table>
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>名称</th>
|
|
|
|
|
|
<th>当前预算</th>
|
|
|
|
|
|
<th>剩余月数</th>
|
|
|
|
|
|
<th>历史归档</th>
|
|
|
|
|
|
<th>合计</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
""");
|
|
|
|
|
|
foreach (var item in incomeItems)
|
|
|
|
|
|
{
|
|
|
|
|
|
description.Append($"""
|
2026-01-12 16:58:51 +08:00
|
|
|
|
<tr>
|
2026-01-18 13:32:10 +08:00
|
|
|
|
<td>{item.Name}</td>
|
|
|
|
|
|
<td>{item.Limit:N0}</td>
|
|
|
|
|
|
<td>{item.Factor:0.##}</td>
|
|
|
|
|
|
<td>{item.Historical:N0}</td>
|
|
|
|
|
|
<td><span class='income-value'>{item.Total:N0}</span></td>
|
2026-01-12 16:58:51 +08:00
|
|
|
|
</tr>
|
2026-01-18 13:32:10 +08:00
|
|
|
|
""");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
else
|
2026-01-09 14:03:01 +08:00
|
|
|
|
{
|
2026-01-18 13:32:10 +08:00
|
|
|
|
description.Append("""
|
|
|
|
|
|
<table>
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>名称</th>
|
|
|
|
|
|
<th>金额</th>
|
|
|
|
|
|
<th>折算</th>
|
|
|
|
|
|
<th>合计</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
2026-01-12 16:58:51 +08:00
|
|
|
|
""");
|
2026-01-18 13:32:10 +08:00
|
|
|
|
foreach (var item in incomeItems)
|
|
|
|
|
|
{
|
|
|
|
|
|
description.Append($"""
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<td>{item.Name}</td>
|
|
|
|
|
|
<td>{item.Limit:N0}</td>
|
|
|
|
|
|
<td>{item.Factor:0.##}</td>
|
|
|
|
|
|
<td><span class='income-value'>{item.Total:N0}</span></td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
""");
|
|
|
|
|
|
}
|
2026-01-09 14:03:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
description.Append("</tbody></table>");
|
|
|
|
|
|
}
|
|
|
|
|
|
description.Append($"<p>收入合计: <span class='income-value'><strong>{incomeLimitAtPeriod:N0}</strong></span></p>");
|
|
|
|
|
|
|
2026-01-15 20:50:14 +08:00
|
|
|
|
if (periodType == BudgetPeriodType.Year && noLimitIncomeItems.Count > 0)
|
2026-01-15 10:53:05 +08:00
|
|
|
|
{
|
2026-01-15 20:50:14 +08:00
|
|
|
|
description.Append("<h3>不记额收入明细</h3>");
|
2026-01-15 10:53:05 +08:00
|
|
|
|
description.Append("""
|
|
|
|
|
|
<table>
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>预算名称</th>
|
|
|
|
|
|
<th>实际发生</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
""");
|
2026-01-18 22:04:56 +08:00
|
|
|
|
foreach (var (name, amount) in noLimitIncomeItems)
|
2026-01-15 10:53:05 +08:00
|
|
|
|
{
|
|
|
|
|
|
description.Append($"""
|
|
|
|
|
|
<tr>
|
2026-01-18 22:04:56 +08:00
|
|
|
|
<td>{name}</td>
|
|
|
|
|
|
<td><span class='income-value'>{amount:N0}</span></td>
|
2026-01-15 10:53:05 +08:00
|
|
|
|
</tr>
|
|
|
|
|
|
""");
|
|
|
|
|
|
}
|
|
|
|
|
|
description.Append("</tbody></table>");
|
2026-01-15 20:50:14 +08:00
|
|
|
|
description.Append($"<p>不记额收入合计: <span class='income-value'><strong>{noLimitIncomeAtPeriod:N0}</strong></span></p>");
|
2026-01-15 10:53:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-09 14:03:01 +08:00
|
|
|
|
description.Append("<h3>预算支出明细</h3>");
|
|
|
|
|
|
if (expenseItems.Count == 0) description.Append("<p>无支出预算</p>");
|
|
|
|
|
|
else
|
|
|
|
|
|
{
|
2026-01-18 13:32:10 +08:00
|
|
|
|
// 根据是否有历史数据决定表格列
|
|
|
|
|
|
var hasHistoricalData = expenseItems.Any(i => i.Historical > 0);
|
|
|
|
|
|
|
|
|
|
|
|
if (hasHistoricalData)
|
|
|
|
|
|
{
|
|
|
|
|
|
description.Append("""
|
|
|
|
|
|
<table>
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>名称</th>
|
|
|
|
|
|
<th>当前预算</th>
|
|
|
|
|
|
<th>剩余月数</th>
|
|
|
|
|
|
<th>历史归档</th>
|
|
|
|
|
|
<th>合计</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
""");
|
|
|
|
|
|
foreach (var item in expenseItems)
|
|
|
|
|
|
{
|
|
|
|
|
|
description.Append($"""
|
2026-01-12 16:20:06 +08:00
|
|
|
|
<tr>
|
2026-01-18 13:32:10 +08:00
|
|
|
|
<td>{item.Name}</td>
|
|
|
|
|
|
<td>{item.Limit:N0}</td>
|
|
|
|
|
|
<td>{item.Factor:0.##}</td>
|
|
|
|
|
|
<td>{item.Historical:N0}</td>
|
|
|
|
|
|
<td><span class='expense-value'>{item.Total:N0}</span></td>
|
2026-01-12 16:20:06 +08:00
|
|
|
|
</tr>
|
2026-01-18 13:32:10 +08:00
|
|
|
|
""");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
else
|
2026-01-09 14:03:01 +08:00
|
|
|
|
{
|
2026-01-18 13:32:10 +08:00
|
|
|
|
description.Append("""
|
|
|
|
|
|
<table>
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>名称</th>
|
|
|
|
|
|
<th>金额</th>
|
|
|
|
|
|
<th>折算</th>
|
|
|
|
|
|
<th>合计</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
2026-01-12 16:58:51 +08:00
|
|
|
|
""");
|
2026-01-18 13:32:10 +08:00
|
|
|
|
foreach (var item in expenseItems)
|
|
|
|
|
|
{
|
|
|
|
|
|
description.Append($"""
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<td>{item.Name}</td>
|
|
|
|
|
|
<td>{item.Limit:N0}</td>
|
|
|
|
|
|
<td>{item.Factor:0.##}</td>
|
|
|
|
|
|
<td><span class='expense-value'>{item.Total:N0}</span></td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
""");
|
|
|
|
|
|
}
|
2026-01-09 14:03:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
description.Append("</tbody></table>");
|
|
|
|
|
|
}
|
|
|
|
|
|
description.Append($"<p>支出合计: <span class='expense-value'><strong>{expenseLimitAtPeriod:N0}</strong></span></p>");
|
|
|
|
|
|
|
2026-01-15 20:50:14 +08:00
|
|
|
|
if (periodType == BudgetPeriodType.Year && noLimitExpenseItems.Count > 0)
|
2026-01-15 10:53:05 +08:00
|
|
|
|
{
|
2026-01-15 20:00:41 +08:00
|
|
|
|
description.Append("<h3>不记额支出明细</h3>");
|
2026-01-15 10:53:05 +08:00
|
|
|
|
description.Append("""
|
|
|
|
|
|
<table>
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>预算名称</th>
|
|
|
|
|
|
<th>实际发生</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
""");
|
2026-01-18 22:04:56 +08:00
|
|
|
|
foreach (var (name, amount) in noLimitExpenseItems)
|
2026-01-15 10:53:05 +08:00
|
|
|
|
{
|
|
|
|
|
|
description.Append($"""
|
|
|
|
|
|
<tr>
|
2026-01-18 22:04:56 +08:00
|
|
|
|
<td>{name}</td>
|
|
|
|
|
|
<td><span class='expense-value'>{amount:N0}</span></td>
|
2026-01-15 10:53:05 +08:00
|
|
|
|
</tr>
|
|
|
|
|
|
""");
|
|
|
|
|
|
}
|
|
|
|
|
|
description.Append("</tbody></table>");
|
2026-01-15 20:50:14 +08:00
|
|
|
|
description.Append($"<p>不记额支出合计: <span class='expense-value'><strong>{noLimitExpenseAtPeriod:N0}</strong></span></p>");
|
2026-01-15 10:53:05 +08:00
|
|
|
|
}
|
2026-01-15 20:00:41 +08:00
|
|
|
|
|
2026-01-09 14:03:01 +08:00
|
|
|
|
description.Append("<h3>存款计划结论</h3>");
|
2026-01-15 10:53:05 +08:00
|
|
|
|
// 修改计算公式:包含不记额收入和支出
|
|
|
|
|
|
var totalIncome = incomeLimitAtPeriod + noLimitIncomeAtPeriod;
|
|
|
|
|
|
var totalExpense = expenseLimitAtPeriod + noLimitExpenseAtPeriod;
|
|
|
|
|
|
description.Append($"<p>计划收入 = 预算 <span class='income-value'>{incomeLimitAtPeriod:N0}</span> + 不记额 <span class='income-value'>{noLimitIncomeAtPeriod:N0}</span> = <span class='income-value'><strong>{totalIncome:N0}</strong></span></p>");
|
|
|
|
|
|
description.Append($"<p>计划支出 = 预算 <span class='expense-value'>{expenseLimitAtPeriod:N0}</span> + 不记额 <span class='expense-value'>{noLimitExpenseAtPeriod:N0}</span> = <span class='expense-value'><strong>{totalExpense:N0}</strong></span></p>");
|
2026-01-15 20:31:10 +08:00
|
|
|
|
description.Append($"<p>计划盈余 = 计划收入 <span class='income-value'>{totalIncome:N0}</span> - 计划支出 <span class='expense-value'>{totalExpense:N0}</span> = <span class='income-value'><strong>{totalIncome - totalExpense:N0}</strong></span></p>");
|
2026-01-15 20:00:41 +08:00
|
|
|
|
|
|
|
|
|
|
decimal historicalSurplus = 0;
|
|
|
|
|
|
if (periodType == BudgetPeriodType.Year)
|
|
|
|
|
|
{
|
|
|
|
|
|
var archives = await budgetArchiveRepository.GetArchivesByYearAsync(date.Year);
|
|
|
|
|
|
if (archives.Count > 0)
|
|
|
|
|
|
{
|
|
|
|
|
|
var expenseSurplus = archives.Sum(a => a.ExpenseSurplus);
|
|
|
|
|
|
var incomeSurplus = archives.Sum(a => a.IncomeSurplus);
|
|
|
|
|
|
historicalSurplus = expenseSurplus + incomeSurplus;
|
|
|
|
|
|
|
|
|
|
|
|
description.Append("<h3>历史月份盈亏</h3>");
|
|
|
|
|
|
description.Append("""
|
|
|
|
|
|
<table>
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>月份</th>
|
|
|
|
|
|
<th>支出结余</th>
|
|
|
|
|
|
<th>收入结余</th>
|
|
|
|
|
|
<th>合计</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
""");
|
|
|
|
|
|
|
|
|
|
|
|
foreach (var archive in archives)
|
|
|
|
|
|
{
|
|
|
|
|
|
var monthlyTotal = archive.ExpenseSurplus + archive.IncomeSurplus;
|
|
|
|
|
|
var monthlyClass = monthlyTotal >= 0 ? "income-value" : "expense-value";
|
|
|
|
|
|
description.Append($"""
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<td>{archive.Month}月</td>
|
|
|
|
|
|
<td><span class='{(archive.ExpenseSurplus >= 0 ? "income-value" : "expense-value")}'>{archive.ExpenseSurplus:N0}</span></td>
|
|
|
|
|
|
<td><span class='{(archive.IncomeSurplus >= 0 ? "income-value" : "expense-value")}'>{archive.IncomeSurplus:N0}</span></td>
|
|
|
|
|
|
<td><span class='{monthlyClass}'>{monthlyTotal:N0}</span></td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
""");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var totalClass = historicalSurplus >= 0 ? "income-value" : "expense-value";
|
|
|
|
|
|
description.Append($"""
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<td><strong>汇总</strong></td>
|
|
|
|
|
|
<td><span class='{(expenseSurplus >= 0 ? "income-value" : "expense-value")}'><strong>{expenseSurplus:N0}</strong></span></td>
|
|
|
|
|
|
<td><span class='{(incomeSurplus >= 0 ? "income-value" : "expense-value")}'><strong>{incomeSurplus:N0}</strong></span></td>
|
|
|
|
|
|
<td><span class='{totalClass}'><strong>{historicalSurplus:N0}</strong></span></td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</tbody></table>
|
|
|
|
|
|
""");
|
|
|
|
|
|
}
|
|
|
|
|
|
var finalGoal = totalIncome - totalExpense + historicalSurplus;
|
2026-01-15 20:31:10 +08:00
|
|
|
|
description.Append($"""
|
|
|
|
|
|
<p>
|
|
|
|
|
|
动态目标 = 计划盈余 <span class='{(totalIncome - totalExpense >= 0 ? "income-value" : "expense-value")}'>{totalIncome - totalExpense:N0}</span>
|
|
|
|
|
|
+ 年度历史盈亏 <span class='{(historicalSurplus >= 0 ? "income-value" : "expense-value")}'>{historicalSurplus:N0}</span>
|
|
|
|
|
|
= <span class='{(finalGoal >= 0 ? "income-value" : "expense-value")}'><strong>{finalGoal:N0}</strong></span></p>
|
|
|
|
|
|
""");
|
2026-01-15 20:00:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var finalLimit = periodType == BudgetPeriodType.Year ? (totalIncome - totalExpense + historicalSurplus) : (totalIncome - totalExpense);
|
2026-01-09 14:03:01 +08:00
|
|
|
|
|
2026-01-09 16:59:08 +08:00
|
|
|
|
var virtualBudget = await BuildVirtualSavingsBudgetRecordAsync(
|
|
|
|
|
|
periodType == BudgetPeriodType.Year ? -1 : -2,
|
|
|
|
|
|
date,
|
2026-01-15 20:00:41 +08:00
|
|
|
|
finalLimit);
|
2026-01-09 14:03:01 +08:00
|
|
|
|
|
|
|
|
|
|
// 计算实际发生的 收入 - 支出
|
2026-01-09 15:42:59 +08:00
|
|
|
|
var current = await CalculateCurrentAmountAsync(new BudgetRecord
|
|
|
|
|
|
{
|
|
|
|
|
|
Category = virtualBudget.Category,
|
|
|
|
|
|
Type = virtualBudget.Type,
|
|
|
|
|
|
SelectedCategories = virtualBudget.SelectedCategories,
|
|
|
|
|
|
StartDate = virtualBudget.StartDate,
|
|
|
|
|
|
}, date);
|
2026-01-09 14:03:01 +08:00
|
|
|
|
|
2026-01-09 15:42:59 +08:00
|
|
|
|
return BudgetResult.FromEntity(virtualBudget, current, date, description.ToString());
|
2026-01-09 14:03:01 +08:00
|
|
|
|
}
|
2026-01-09 16:59:08 +08:00
|
|
|
|
|
|
|
|
|
|
private async Task<BudgetRecord> BuildVirtualSavingsBudgetRecordAsync(
|
|
|
|
|
|
long id,
|
|
|
|
|
|
DateTime date,
|
|
|
|
|
|
decimal limit)
|
|
|
|
|
|
{
|
|
|
|
|
|
var savingsCategories = await configService.GetConfigByKeyAsync<string>("SavingsCategories") ?? string.Empty;
|
|
|
|
|
|
return new BudgetRecord
|
|
|
|
|
|
{
|
|
|
|
|
|
Id = id,
|
|
|
|
|
|
Name = id == -1 ? "年度存款" : "月度存款",
|
|
|
|
|
|
Category = BudgetCategory.Savings,
|
|
|
|
|
|
Type = id == -1 ? BudgetPeriodType.Year : BudgetPeriodType.Month,
|
|
|
|
|
|
Limit = limit,
|
|
|
|
|
|
StartDate = id == -1
|
|
|
|
|
|
? new DateTime(date.Year, 1, 1)
|
|
|
|
|
|
: new DateTime(date.Year, date.Month, 1),
|
|
|
|
|
|
SelectedCategories = savingsCategories
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-01-09 14:03:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public record BudgetResult
|
|
|
|
|
|
{
|
|
|
|
|
|
public long Id { get; set; }
|
|
|
|
|
|
public string Name { get; set; } = string.Empty;
|
|
|
|
|
|
public BudgetPeriodType Type { get; set; }
|
|
|
|
|
|
public decimal Limit { get; set; }
|
|
|
|
|
|
public decimal Current { get; set; }
|
|
|
|
|
|
public BudgetCategory Category { get; set; }
|
2026-01-18 22:04:56 +08:00
|
|
|
|
public string[] SelectedCategories { get; set; } = [];
|
2026-01-09 14:03:01 +08:00
|
|
|
|
public string StartDate { get; set; } = string.Empty;
|
|
|
|
|
|
public string Period { get; set; } = string.Empty;
|
|
|
|
|
|
public DateTime? PeriodStart { get; set; }
|
|
|
|
|
|
public DateTime? PeriodEnd { get; set; }
|
2026-01-18 22:04:56 +08:00
|
|
|
|
public bool NoLimit { get; set; }
|
|
|
|
|
|
public bool IsMandatoryExpense { get; set; }
|
2026-01-09 14:03:01 +08:00
|
|
|
|
public string Description { get; set; } = string.Empty;
|
|
|
|
|
|
|
|
|
|
|
|
public static BudgetResult FromEntity(
|
|
|
|
|
|
BudgetRecord entity,
|
|
|
|
|
|
decimal currentAmount = 0,
|
|
|
|
|
|
DateTime? referenceDate = null,
|
|
|
|
|
|
string description = "")
|
|
|
|
|
|
{
|
|
|
|
|
|
var date = referenceDate ?? DateTime.Now;
|
|
|
|
|
|
var (start, end) = BudgetService.GetPeriodRange(entity.StartDate, entity.Type, date);
|
|
|
|
|
|
|
|
|
|
|
|
return new BudgetResult
|
|
|
|
|
|
{
|
|
|
|
|
|
Id = entity.Id,
|
|
|
|
|
|
Name = entity.Name,
|
|
|
|
|
|
Type = entity.Type,
|
|
|
|
|
|
Limit = entity.Limit,
|
|
|
|
|
|
Current = currentAmount,
|
|
|
|
|
|
Category = entity.Category,
|
|
|
|
|
|
SelectedCategories = string.IsNullOrEmpty(entity.SelectedCategories)
|
2026-01-18 22:04:56 +08:00
|
|
|
|
? []
|
2026-01-09 14:03:01 +08:00
|
|
|
|
: entity.SelectedCategories.Split(','),
|
|
|
|
|
|
StartDate = entity.StartDate.ToString("yyyy-MM-dd"),
|
|
|
|
|
|
Period = entity.Type switch
|
|
|
|
|
|
{
|
|
|
|
|
|
BudgetPeriodType.Year => $"{start:yy}年",
|
|
|
|
|
|
BudgetPeriodType.Month => $"{start:yy}年第{start.Month}月",
|
|
|
|
|
|
_ => $"{start:yyyy-MM-dd} ~ {end:yyyy-MM-dd}"
|
|
|
|
|
|
},
|
|
|
|
|
|
PeriodStart = start,
|
|
|
|
|
|
PeriodEnd = end,
|
2026-01-15 10:53:05 +08:00
|
|
|
|
NoLimit = entity.NoLimit,
|
2026-01-16 23:18:04 +08:00
|
|
|
|
IsMandatoryExpense = entity.IsMandatoryExpense,
|
2026-01-09 14:03:01 +08:00
|
|
|
|
Description = description
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2026-01-06 21:15:02 +08:00
|
|
|
|
}
|
2026-01-09 15:42:59 +08:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 预算统计结果 DTO
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public class BudgetStatsDto
|
|
|
|
|
|
{
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 统计周期类型(Month/Year)
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public BudgetPeriodType PeriodType { get; set; }
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 使用率百分比(0-100)
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public decimal Rate { get; set; }
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 实际金额
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public decimal Current { get; set; }
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 目标/限额金额
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public decimal Limit { get; set; }
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 预算项数量
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public int Count { get; set; }
|
2026-01-17 14:38:40 +08:00
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 每日/每月累计金额趋势(对应当前周期内的实际发生额累计值)
|
|
|
|
|
|
/// </summary>
|
2026-01-18 22:04:56 +08:00
|
|
|
|
public List<decimal?> Trend { get; set; } = [];
|
2026-01-09 15:42:59 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 分类统计结果
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public class BudgetCategoryStats
|
|
|
|
|
|
{
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 月度统计
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public BudgetStatsDto Month { get; set; } = new();
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 年度统计
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public BudgetStatsDto Year { get; set; } = new();
|
2026-01-11 12:33:12 +08:00
|
|
|
|
}
|
2026-01-18 22:04:56 +08:00
|
|
|
|
|
2026-01-11 12:33:12 +08:00
|
|
|
|
public class UncoveredCategoryDetail
|
|
|
|
|
|
{
|
|
|
|
|
|
public string Category { get; set; } = string.Empty;
|
|
|
|
|
|
public int TransactionCount { get; set; }
|
|
|
|
|
|
public decimal TotalAmount { get; set; }
|
|
|
|
|
|
}
|