Files
EmailBill/Service/Budget/BudgetService.cs

720 lines
26 KiB
C#
Raw Normal View History

2026-01-22 11:06:52 +08:00
namespace Service.Budget;
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);
2026-01-15 20:00:41 +08:00
/// <summary>
/// 获取指定周期的存款预算信息
/// </summary>
Task<BudgetResult?> GetSavingsBudgetAsync(int year, int month, BudgetPeriodType type);
}
2026-01-18 22:04:56 +08:00
[UsedImplicitly]
public class BudgetService(
IBudgetRepository budgetRepository,
IBudgetArchiveRepository budgetArchiveRepository,
ITransactionRecordRepository transactionRecordRepository,
IOpenAiService openAiService,
IMessageService messageService,
2026-01-20 19:11:05 +08:00
ILogger<BudgetService> logger,
2026-01-21 18:52:31 +08:00
IBudgetSavingsService budgetSavingsService,
IDateTimeProvider dateTimeProvider
) : IBudgetService
{
public async Task<List<BudgetResult>> GetListAsync(DateTime referenceDate)
{
var year = referenceDate.Year;
var month = referenceDate.Month;
2026-01-21 18:52:31 +08:00
var isArchive = year < dateTimeProvider.Now.Year
|| (year == dateTimeProvider.Now.Year && month < dateTimeProvider.Now.Month);
if (isArchive)
{
var archive = await budgetArchiveRepository.GetArchiveAsync(year, month);
if (archive != null)
{
2026-01-21 18:52:31 +08:00
var (start, end) = GetPeriodRange(dateTimeProvider.Now, BudgetPeriodType.Month, referenceDate);
2026-01-17 15:55:46 +08:00
return [.. archive.Content.Select(c => new BudgetResult
{
2026-01-18 13:32:10 +08:00
Id = c.Id,
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,
Description = c.Description,
2026-01-17 15:55:46 +08:00
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));
}
// 创造虚拟的存款预算
2026-01-20 19:11:05 +08:00
dtos.Add(await budgetSavingsService.GetSavingsDtoAsync(
BudgetPeriodType.Month,
referenceDate,
budgets));
2026-01-20 19:11:05 +08:00
dtos.Add(await budgetSavingsService.GetSavingsDtoAsync(
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-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-20 19:11:05 +08:00
return await budgetSavingsService.GetSavingsDtoAsync(type, referenceDate);
2026-01-15 20:00:41 +08:00
}
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)
{
2026-01-21 18:52:31 +08:00
var date = referenceDate ?? dateTimeProvider.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 [];
// 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
};
2026-01-15 10:53:05 +08:00
// 获取当前分类下所有预算,排除不记额预算
var relevant = budgets
2026-01-15 10:53:05 +08:00
.Where(b => b.Category == category && !b.NoLimit)
.ToList();
2026-01-22 11:06:52 +08:00
// 月度统计中,只包含月度预算;年度统计中,包含所有预算
if (statType == BudgetPeriodType.Month)
{
relevant = relevant.Where(b => b.Type == BudgetPeriodType.Month).ToList();
}
if (relevant.Count == 0)
{
return result;
}
result.Count = relevant.Count;
decimal totalCurrent = 0;
decimal totalLimit = 0;
2026-01-21 19:51:41 +08:00
// 是否可以使用趋势统计来计算实际发生额(避免多预算重复计入同一笔账)
var transactionType = category switch
{
BudgetCategory.Expense => TransactionType.Expense,
BudgetCategory.Income => TransactionType.Income,
_ => TransactionType.None
};
foreach (var budget in relevant)
{
// 限额折算
var itemLimit = budget.Limit;
2026-01-22 11:06:52 +08:00
if (statType == BudgetPeriodType.Year && budget.Type == BudgetPeriodType.Month)
{
// 年度视图下,月度预算折算为年度
itemLimit = budget.Limit * 12;
}
totalLimit += itemLimit;
2026-01-21 19:51:41 +08:00
// 先逐预算累加当前值(作为后备值)
2026-01-18 22:04:56 +08:00
var selectedCategories = string.Join(',', budget.SelectedCategories);
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
}, referenceDate);
2026-01-21 19:51:41 +08:00
2026-01-22 11:06:52 +08:00
if (statType == BudgetPeriodType.Month)
{
totalCurrent += currentAmount;
}
2026-01-22 11:06:52 +08:00
else if (statType == BudgetPeriodType.Year)
{
2026-01-22 11:06:52 +08:00
// 年度视图下,累加所有预算的当前值
2026-01-21 19:51:41 +08:00
totalCurrent += currentAmount;
}
}
result.Limit = totalLimit;
// 计算每日/每月趋势
if (transactionType != TransactionType.None)
{
2026-01-18 22:04:56 +08:00
var hasGlobalBudget = relevant.Any(b => b.SelectedCategories.Length == 0);
var allClassifies = hasGlobalBudget
2026-01-17 15:03:19 +08:00
? []
: relevant
.SelectMany(b => b.SelectedCategories)
.Distinct()
.ToList();
DateTime startDate, endDate;
2026-01-18 22:04:56 +08:00
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;
2026-01-21 19:51:41 +08:00
decimal lastValidAccumulated = 0;
2026-01-21 18:52:31 +08:00
var now = dateTimeProvider.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;
2026-01-21 19:51:41 +08:00
lastValidAccumulated = accumulated;
}
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
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;
2026-01-21 19:51:41 +08:00
lastValidAccumulated = accumulated;
}
result.Trend.Add(accumulated);
}
}
2026-01-21 19:51:41 +08:00
// 如果有有效的趋势数据,使用去重后的实际发生额(趋势的累计值),避免同一账单被多预算重复计入
// 否则使用前面逐预算累加的值(作为后备)
if (lastValidAccumulated > 0)
{
totalCurrent = lastValidAccumulated;
}
}
2026-01-21 19:51:41 +08:00
result.Current = totalCurrent;
result.Rate = totalLimit > 0 ? totalCurrent / totalLimit * 100 : 0;
return result;
}
public async Task<string> ArchiveBudgetsAsync(int year, int month)
{
var referenceDate = new DateTime(year, month, 1);
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);
var content = budgets.Select(b => new BudgetArchiveContent
{
2026-01-18 13:32:10 +08:00
Id = b.Id,
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,
Description = b.Description
}).ToArray();
var archive = await budgetArchiveRepository.GetArchiveAsync(year, month);
if (archive != null)
{
archive.Content = content;
2026-01-21 18:52:31 +08:00
archive.ArchiveDate = dateTimeProvider.Now;
2026-01-15 20:00:41 +08:00
archive.ExpenseSurplus = expenseSurplus;
archive.IncomeSurplus = incomeSurplus;
if (!await budgetArchiveRepository.UpdateAsync(archive))
{
return "更新预算归档失败";
}
}
else
{
archive = new BudgetArchive
{
Year = year,
Month = month,
Content = content,
2026-01-21 18:52:31 +08:00
ArchiveDate = dateTimeProvider.Now,
2026-01-15 20:00:41 +08:00
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;
2026-01-18 22:04:56 +08:00
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)}
2026-01-13 17:00:44 +08:00
2. , JSON
{JsonSerializer.Serialize(monthTransactions)}
2026-01-13 17:00:44 +08:00
3. , JSON
{JsonSerializer.Serialize(yearTransactions)}
4. JSON
{JsonSerializer.Serialize(uncovered)}
1.
2. 使 HTML 使/
3. /
4.
5.
6.
2026-01-13 17:00:44 +08:00
7. 12
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
4. 使HTML标签h2h3ptableul/listrong
5. <span class='expense-value'></span>
6. <span class='income-value'></span>
7. <span class='highlight'></span>
8. htmlbodyhead
9. 使 style <style>
10. backgroundbackground-colorcolor
11. 使 div
2026-01-21 18:52:31 +08:00
{dateTimeProvider.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");
2026-01-15 21:19:03 +08:00
// 同时保存到归档总结
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)
{
2026-01-21 18:52:31 +08:00
var referenceDate = now ?? dateTimeProvider.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
2026-01-21 18:52:31 +08:00
&& (budget.Type == BudgetPeriodType.Year || referenceDate.Month == startDate.Month))
2026-01-16 23:18:04 +08:00
{
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;
}
internal static (DateTime start, DateTime end) GetPeriodRange(DateTime startDate, BudgetPeriodType type, DateTime referenceDate)
{
DateTime start;
DateTime end;
2026-01-08 14:41:50 +08:00
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);
}
}
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; } = [];
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; }
public string Description { get; set; } = string.Empty;
public static BudgetResult FromEntity(
BudgetRecord entity,
2026-01-21 18:52:31 +08:00
decimal currentAmount,
DateTime referenceDate,
string description = "")
{
2026-01-21 18:52:31 +08:00
var date = referenceDate;
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
? []
: 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,
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>
2026-01-18 22:04:56 +08:00
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();
}
2026-01-18 22:04:56 +08:00
public class UncoveredCategoryDetail
{
public string Category { get; set; } = string.Empty;
public int TransactionCount { get; set; }
public decimal TotalAmount { get; set; }
}