Files
EmailBill/Service/BudgetService.cs
2026-01-18 22:04:56 +08:00

1190 lines
45 KiB
C#
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.
using JetBrains.Annotations;
namespace Service;
public interface IBudgetService
{
Task<List<BudgetResult>> GetListAsync(DateTime referenceDate);
Task<string> ArchiveBudgetsAsync(int year, int month);
/// <summary>
/// 获取指定分类的统计信息(月度和年度)
/// </summary>
Task<BudgetCategoryStats> GetCategoryStatsAsync(BudgetCategory category, DateTime referenceDate);
/// <summary>
/// 获取未被预算覆盖的分类统计信息
/// </summary>
Task<List<UncoveredCategoryDetail>> GetUncoveredCategoriesAsync(BudgetCategory category, DateTime? referenceDate = null);
Task<string?> GetArchiveSummaryAsync(int year, int month);
/// <summary>
/// 获取指定周期的存款预算信息
/// </summary>
Task<BudgetResult?> GetSavingsBudgetAsync(int year, int month, BudgetPeriodType type);
}
[UsedImplicitly]
public class BudgetService(
IBudgetRepository budgetRepository,
IBudgetArchiveRepository budgetArchiveRepository,
ITransactionRecordRepository transactionRecordRepository,
IOpenAiService openAiService,
IConfigService configService,
IMessageService messageService,
ILogger<BudgetService> logger
) : IBudgetService
{
public async Task<List<BudgetResult>> GetListAsync(DateTime referenceDate)
{
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)
{
var (start, end) = GetPeriodRange(DateTime.Now, BudgetPeriodType.Month, referenceDate);
return [.. archive.Content.Select(c => new BudgetResult
{
Id = c.Id,
Name = c.Name,
Type = c.Type,
Limit = c.Limit,
Current = c.Actual,
Category = c.Category,
SelectedCategories = c.SelectedCategories,
NoLimit = c.NoLimit,
IsMandatoryExpense = c.IsMandatoryExpense,
Description = c.Description,
PeriodStart = start,
PeriodEnd = end,
})];
}
logger.LogWarning("获取预算列表时发现归档数据缺失Year: {Year}, Month: {Month}", year, month);
}
var budgets = await budgetRepository.GetAllAsync();
var dtos = new List<BudgetResult?>();
foreach (var budget in budgets)
{
var currentAmount = await CalculateCurrentAmountAsync(budget, referenceDate);
dtos.Add(BudgetResult.FromEntity(budget, currentAmount, referenceDate));
}
// 创造虚拟的存款预算
dtos.Add(await GetSavingsDtoAsync(
BudgetPeriodType.Month,
referenceDate,
budgets));
dtos.Add(await GetSavingsDtoAsync(
BudgetPeriodType.Year,
referenceDate,
budgets));
dtos = dtos
.Where(x => x != null)
.Cast<BudgetResult>()
.OrderByDescending(x => x.IsMandatoryExpense)
.ThenBy(x => x.Type)
.ThenByDescending(x => x.Current)
.ToList()!;
return [.. dtos.Where(dto => dto != null).Cast<BudgetResult>()];
}
public async Task<BudgetResult?> GetSavingsBudgetAsync(int year, int month, BudgetPeriodType type)
{
var referenceDate = new DateTime(year, month, 1);
return await GetSavingsDtoAsync(type, referenceDate);
}
public async Task<BudgetCategoryStats> GetCategoryStatsAsync(BudgetCategory category, DateTime referenceDate)
{
var budgets = await GetListAsync(referenceDate);
var result = new BudgetCategoryStats();
// 获取月度统计
result.Month = await CalculateCategoryStatsAsync(budgets, category, BudgetPeriodType.Month, referenceDate);
// 获取年度统计
result.Year = await CalculateCategoryStatsAsync(budgets, category, BudgetPeriodType.Year, referenceDate);
return result;
}
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
};
if (transactionType == TransactionType.None) return [];
// 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();
}
public async Task<string?> GetArchiveSummaryAsync(int year, int month)
{
var archive = await budgetArchiveRepository.GetArchiveAsync(year, month);
return archive?.Summary;
}
private async Task<BudgetStatsDto> CalculateCategoryStatsAsync(
List<BudgetResult> budgets,
BudgetCategory category,
BudgetPeriodType statType,
DateTime referenceDate)
{
var result = new BudgetStatsDto
{
PeriodType = statType,
Rate = 0,
Current = 0,
Limit = 0,
Count = 0
};
// 获取当前分类下所有预算,排除不记额预算
var relevant = budgets
.Where(b => b.Category == category && !b.NoLimit)
.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;
// 当前值累加
var selectedCategories = string.Join(',', budget.SelectedCategories);
var currentAmount = await CalculateCurrentAmountAsync(new()
{
Name = budget.Name,
Type = budget.Type,
Limit = budget.Limit,
Category = budget.Category,
SelectedCategories = selectedCategories,
StartDate = new DateTime(referenceDate.Year, referenceDate.Month, 1),
IsMandatoryExpense = budget.IsMandatoryExpense
}, referenceDate);
if (budget.Type == statType)
{
totalCurrent += currentAmount;
}
else
{
// 如果周期不匹配
if (statType == BudgetPeriodType.Year && budget.Type == BudgetPeriodType.Month)
{
// 在年度视图下,月度预算计入其当前值(作为对年度目前的贡献)
totalCurrent += currentAmount;
}
// 月度视图下,年度预算的 current 不计入
}
}
result.Limit = totalLimit;
result.Current = totalCurrent;
result.Rate = totalLimit > 0 ? totalCurrent / totalLimit * 100 : 0;
// 计算每日/每月趋势
var transactionType = category switch
{
BudgetCategory.Expense => TransactionType.Expense,
BudgetCategory.Income => TransactionType.Income,
_ => TransactionType.None
};
if (transactionType != TransactionType.None)
{
var hasGlobalBudget = relevant.Any(b => b.SelectedCategories.Length == 0);
var allClassifies = hasGlobalBudget
? []
: relevant
.SelectMany(b => b.SelectedCategories)
.Distinct()
.ToList();
DateTime startDate, endDate;
bool groupByMonth;
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);
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);
}
}
}
return result;
}
public async Task<string> ArchiveBudgetsAsync(int year, int month)
{
var referenceDate = new DateTime(year, month, 1);
var budgets = await GetListAsync(referenceDate);
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);
var content = budgets.Select(b => new BudgetArchiveContent
{
Id = b.Id,
Name = b.Name,
Type = b.Type,
Limit = b.Limit,
Actual = b.Current,
Category = b.Category,
SelectedCategories = b.SelectedCategories,
NoLimit = b.NoLimit,
IsMandatoryExpense = b.IsMandatoryExpense,
Description = b.Description
}).ToArray();
var archive = await budgetArchiveRepository.GetArchiveAsync(year, month);
if (archive != null)
{
archive.Content = content;
archive.ArchiveDate = DateTime.Now;
archive.ExpenseSurplus = expenseSurplus;
archive.IncomeSurplus = incomeSurplus;
if (!await budgetArchiveRepository.UpdateAsync(archive))
{
return "更新预算归档失败";
}
}
else
{
archive = new BudgetArchive
{
Year = year,
Month = month,
Content = content,
ArchiveDate = DateTime.Now,
ExpenseSurplus = expenseSurplus,
IncomeSurplus = incomeSurplus
};
if (!await budgetArchiveRepository.AddAsync(archive))
{
return "保存预算归档失败";
}
}
_ = NotifyAsync(year, month);
return string.Empty;
}
private async Task NotifyAsync(int year, int month)
{
try
{
var archives = await budgetArchiveRepository.GetListAsync(year, month);
var archiveData = archives.SelectMany(a => a.Content.Select(c => new
{
c.Name,
Type = c.Type.ToString(),
c.Limit,
c.Actual,
Category = c.Category.ToString(),
c.SelectedCategories
})).ToList();
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)
var budgetedCategories = archiveData
.SelectMany(b => b.SelectedCategories)
.Where(c => !string.IsNullOrEmpty(c))
.Distinct()
.ToHashSet();
var uncovered = monthTransactions
.Where(t =>
{
var dict = (IDictionary<string, object>)t;
var classify = dict["Classify"].ToString() ?? "";
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)}
2. 本月账单类目明细(按分类, JSON
{JsonSerializer.Serialize(monthTransactions)}
3. 全年累计账单类目明细(按分类, JSON
{JsonSerializer.Serialize(yearTransactions)}
4. 未被任何预算覆盖的支出分类JSON
{JsonSerializer.Serialize(uncovered)}
请生成一份专业且美观的预算执行分析报告,严格遵守以下要求:
【内容要求】
1. 概览:总结本月预算达成情况。
2. 预算详情:使用 HTML 表格展示预算执行明细(预算项、预算额、实际额、使用/达成率、状态)。
3. 超支/异常预警:重点分析超支项或支出异常的分类。
4. 消费透视:针对“未被预算覆盖的支出”提供分析建议。分析这些账单产生的合理性,并评估是否需要为其中的大额或频发分类建立新预算。
5. 改进建议:根据当前时间进度和预算完成进度,基于本月整体收入支出情况,给出下月预算调整或消费改进的专业化建议。
6. 语言风格:专业、清晰、简洁,适合财务报告阅读。
7. 如果报告月份是12月需要报告年度预算的执行情况。
【格式要求】
1. 使用HTML格式移动端H5页面风格
2. 生成清晰的报告标题(基于用户问题)
3. 使用表格展示统计数据table > thead/tbody > tr > th/td
3.1 table要求不能超过屏幕宽度尽可能简洁明了避免冗余信息
3.2 预算金额精确到整数即可实际金额精确到小数点后1位
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 包裹大段内容
【系统信息】
当前时间:{DateTime.Now:yyyy-MM-dd HH:mm:ss}
预算归档周期:{year}年{month}月
直接输出纯净 HTML 内容,不要带有 Markdown 代码块包裹。
""";
var htmlReport = await openAiService.ChatAsync(dataPrompt);
if (!string.IsNullOrEmpty(htmlReport))
{
await messageService.AddAsync(
title: $"{year}年{month}月 - 预算归档报告",
content: htmlReport,
type: MessageType.Html,
url: "/balance?tab=message");
// 同时保存到归档总结
var first = archives.First();
first.Summary = htmlReport;
await budgetArchiveRepository.UpdateAsync(first);
}
}
catch (Exception ex)
{
logger.LogError(ex, "生成预算执行通知报告失败");
}
}
private async Task<decimal> CalculateCurrentAmountAsync(BudgetRecord budget, DateTime? now = null)
{
var referenceDate = now ?? DateTime.Now;
var (startDate, endDate) = GetPeriodRange(budget.StartDate, budget.Type, referenceDate);
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;
}
if (budget.Type == BudgetPeriodType.Year)
{
// 计算本年的天数(考虑闰年)
var daysInYear = DateTime.IsLeapYear(referenceDate.Year) ? 366 : 365;
// 计算当前已经过的天数(包括今天)
var daysElapsed = referenceDate.DayOfYear;
// 根据预算金额和经过天数计算应累加的金额
var mandatoryAccumulation = budget.Limit * daysElapsed / daysInYear;
// 返回实际消费和硬性消费累加中的较大值
return mandatoryAccumulation;
}
}
return actualAmount;
}
internal static (DateTime start, DateTime end) GetPeriodRange(DateTime startDate, BudgetPeriodType type, DateTime referenceDate)
{
DateTime start;
DateTime end;
if (type == BudgetPeriodType.Month)
{
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);
}
private async Task<BudgetResult?> GetSavingsDtoAsync(
BudgetPeriodType periodType,
DateTime? referenceDate = null,
IEnumerable<BudgetRecord>? existingBudgets = null)
{
var allBudgets = existingBudgets;
if (existingBudgets == null)
{
allBudgets = await budgetRepository.GetAllAsync();
}
if (allBudgets == null)
{
return null;
}
allBudgets = allBudgets
// 排序顺序 1.硬性预算 2.月度->年度 3.实际金额倒叙
.OrderBy(b => b.IsMandatoryExpense)
.ThenBy(b => b.Type)
.ThenByDescending(b => b.Limit);
var date = referenceDate ?? DateTime.Now;
decimal incomeLimitAtPeriod = 0;
decimal expenseLimitAtPeriod = 0;
decimal noLimitIncomeAtPeriod = 0; // 新增:不记额收入汇总
decimal noLimitExpenseAtPeriod = 0; // 新增:不记额支出汇总
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)>();
var noLimitIncomeItems = new List<(string Name, decimal Amount)>(); // 新增
var noLimitExpenseItems = new List<(string Name, decimal Amount)>(); // 新增
// 如果是年度计算,先从归档中获取所有历史数据
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>();
foreach (var b in allBudgets)
{
if (b.Category == BudgetCategory.Savings) continue;
processedIds.Add(b.Id);
decimal factor;
decimal historicalAmount = 0m;
var historicalMonths = new List<int>();
if (periodType == BudgetPeriodType.Year)
{
if (b.Type == BudgetPeriodType.Month)
{
// 月度预算在年度计算时:历史归档 + 剩余月份预算
// 收集该预算的所有历史月份数据
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;
}
}
else if (periodType == BudgetPeriodType.Month)
{
factor = b.Type switch
{
BudgetPeriodType.Month => 1,
BudgetPeriodType.Year => 0,
_ => 0
};
}
else
{
factor = 0; // 其他周期暂不计算虚拟存款
}
if (factor <= 0 && historicalAmount <= 0) continue;
// 处理不记额预算
if (b.NoLimit)
{
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));
}
}
else
{
// 普通预算:历史金额 + 当前预算折算
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})";
}
if (b.Category == BudgetCategory.Income)
{
incomeLimitAtPeriod += subtotal;
incomeItems.Add((displayName, b.Limit, factor, historicalAmount, subtotal));
}
else if (b.Category == BudgetCategory.Expense)
{
expenseLimitAtPeriod += subtotal;
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));
}
}
}
var description = new StringBuilder();
description.Append("<h3>预算收入明细</h3>");
if (incomeItems.Count == 0) description.Append("<p>无收入预算</p>");
else
{
// 根据是否有历史数据决定表格列
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($"""
<tr>
<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>
</tr>
""");
}
}
else
{
description.Append("""
<table>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
""");
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>
""");
}
}
description.Append("</tbody></table>");
}
description.Append($"<p>收入合计: <span class='income-value'><strong>{incomeLimitAtPeriod:N0}</strong></span></p>");
if (periodType == BudgetPeriodType.Year && noLimitIncomeItems.Count > 0)
{
description.Append("<h3>不记额收入明细</h3>");
description.Append("""
<table>
<thead>
<tr>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
""");
foreach (var (name, amount) in noLimitIncomeItems)
{
description.Append($"""
<tr>
<td>{name}</td>
<td><span class='income-value'>{amount:N0}</span></td>
</tr>
""");
}
description.Append("</tbody></table>");
description.Append($"<p>不记额收入合计: <span class='income-value'><strong>{noLimitIncomeAtPeriod:N0}</strong></span></p>");
}
description.Append("<h3>预算支出明细</h3>");
if (expenseItems.Count == 0) description.Append("<p>无支出预算</p>");
else
{
// 根据是否有历史数据决定表格列
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($"""
<tr>
<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>
</tr>
""");
}
}
else
{
description.Append("""
<table>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
""");
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>
""");
}
}
description.Append("</tbody></table>");
}
description.Append($"<p>支出合计: <span class='expense-value'><strong>{expenseLimitAtPeriod:N0}</strong></span></p>");
if (periodType == BudgetPeriodType.Year && noLimitExpenseItems.Count > 0)
{
description.Append("<h3>不记额支出明细</h3>");
description.Append("""
<table>
<thead>
<tr>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
""");
foreach (var (name, amount) in noLimitExpenseItems)
{
description.Append($"""
<tr>
<td>{name}</td>
<td><span class='expense-value'>{amount:N0}</span></td>
</tr>
""");
}
description.Append("</tbody></table>");
description.Append($"<p>不记额支出合计: <span class='expense-value'><strong>{noLimitExpenseAtPeriod:N0}</strong></span></p>");
}
description.Append("<h3>存款计划结论</h3>");
// 修改计算公式:包含不记额收入和支出
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>");
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>");
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;
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>
""");
}
var finalLimit = periodType == BudgetPeriodType.Year ? (totalIncome - totalExpense + historicalSurplus) : (totalIncome - totalExpense);
var virtualBudget = await BuildVirtualSavingsBudgetRecordAsync(
periodType == BudgetPeriodType.Year ? -1 : -2,
date,
finalLimit);
// 计算实际发生的 收入 - 支出
var current = await CalculateCurrentAmountAsync(new BudgetRecord
{
Category = virtualBudget.Category,
Type = virtualBudget.Type,
SelectedCategories = virtualBudget.SelectedCategories,
StartDate = virtualBudget.StartDate,
}, date);
return BudgetResult.FromEntity(virtualBudget, current, date, description.ToString());
}
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
};
}
}
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; }
public string[] SelectedCategories { get; set; } = [];
public string StartDate { get; set; } = string.Empty;
public string Period { get; set; } = string.Empty;
public DateTime? PeriodStart { get; set; }
public DateTime? PeriodEnd { get; set; }
public bool NoLimit { get; set; }
public bool IsMandatoryExpense { get; set; }
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)
? []
: 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,
NoLimit = entity.NoLimit,
IsMandatoryExpense = entity.IsMandatoryExpense,
Description = description
};
}
}
/// <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; }
/// <summary>
/// 每日/每月累计金额趋势(对应当前周期内的实际发生额累计值)
/// </summary>
public List<decimal?> Trend { get; set; } = [];
}
/// <summary>
/// 分类统计结果
/// </summary>
public class BudgetCategoryStats
{
/// <summary>
/// 月度统计
/// </summary>
public BudgetStatsDto Month { get; set; } = new();
/// <summary>
/// 年度统计
/// </summary>
public BudgetStatsDto Year { get; set; } = new();
}
public class UncoveredCategoryDetail
{
public string Category { get; set; } = string.Empty;
public int TransactionCount { get; set; }
public decimal TotalAmount { get; set; }
}