Files
EmailBill/Service/Budget/BudgetService.cs
SunCheng d052ae5197 fix
2026-02-10 17:49:19 +08:00

550 lines
20 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 Service.AI;
using Service.Message;
using Service.Transaction;
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);
/// <summary>
/// 获取指定周期的存款预算信息
/// </summary>
Task<BudgetResult?> GetSavingsBudgetAsync(int year, int month, BudgetPeriodType type);
}
[UsedImplicitly]
public class BudgetService(
IBudgetRepository budgetRepository,
IBudgetArchiveRepository budgetArchiveRepository,
ITransactionRecordRepository transactionRecordRepository,
ITransactionStatisticsService transactionStatisticsService,
ISmartHandleService smartHandleService,
IMessageService messageService,
ILogger<BudgetService> logger,
IBudgetSavingsService budgetSavingsService,
IDateTimeProvider dateTimeProvider,
IBudgetStatsService budgetStatsService
) : IBudgetService
{
public async Task<List<BudgetResult>> GetListAsync(DateTime referenceDate)
{
var year = referenceDate.Year;
var month = referenceDate.Month;
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)
{
var (start, end) = GetPeriodRange(dateTimeProvider.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 budgetSavingsService.GetSavingsDtoAsync(
BudgetPeriodType.Month,
referenceDate,
budgets));
dtos.Add(await budgetSavingsService.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 budgetSavingsService.GetSavingsDtoAsync(type, referenceDate);
}
public async Task<BudgetCategoryStats> GetCategoryStatsAsync(BudgetCategory category, DateTime referenceDate)
{
return await budgetStatsService.GetCategoryStatsAsync(category, referenceDate);
}
public async Task<List<UncoveredCategoryDetail>> GetUncoveredCategoriesAsync(BudgetCategory category, DateTime? referenceDate = null)
{
var date = referenceDate ?? dateTimeProvider.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 transactionStatisticsService.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;
}
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 = dateTimeProvider.Now;
archive.ExpenseSurplus = expenseSurplus;
archive.IncomeSurplus = incomeSurplus;
if (!await budgetArchiveRepository.UpdateAsync(archive))
{
return "更新预算归档失败";
}
}
else
{
archive = new BudgetArchive
{
Year = year,
Month = month,
Content = content,
ArchiveDate = dateTimeProvider.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 包裹大段内容
【系统信息】
当前时间:{dateTimeProvider.Now:yyyy-MM-dd HH:mm:ss}
预算归档周期:{year}年{month}月
直接输出纯净 HTML 内容,不要带有 Markdown 代码块包裹。
""";
// 使用 SmartHandleService 统一封装的报告生成方法
var htmlReport = await smartHandleService.GenerateBudgetReportAsync(dataPrompt, year, month);
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 ?? dateTimeProvider.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
&& (budget.Type == BudgetPeriodType.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);
}
}
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,
DateTime referenceDate,
string description = "")
{
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)
? []
: 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>
/// HTML 格式的详细描述(罗列每个预算的额度和实际值及计算公式)
/// </summary>
public string Description { get; set; } = string.Empty;
}
/// <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; }
}