diff --git a/Entity/BudgetArchive.cs b/Entity/BudgetArchive.cs new file mode 100644 index 0000000..a64a73c --- /dev/null +++ b/Entity/BudgetArchive.cs @@ -0,0 +1,39 @@ +namespace Entity; + +public class BudgetArchive : BaseEntity +{ + /// + /// 预算Id + /// + public long BudgetId { get; set; } + + /// + /// 预算周期类型 + /// + public BudgetPeriodType BudgetType { get; set; } + + /// + /// 预算金额 + /// + public decimal BudgetedAmount { get; set; } + + /// + /// 周期内实际发生金额 + /// + public decimal RealizedAmount { get; set; } + + /// + /// 归档目标年份 + /// + public int Year { get; set; } + + /// + /// 归档目标月份 + /// + public int Month { get; set; } + + /// + /// 归档日期 + /// + public DateTime ArchiveDate { get; set; } = DateTime.Now; +} diff --git a/Repository/BaseRepository.cs b/Repository/BaseRepository.cs index b41d2bc..9e90d9d 100644 --- a/Repository/BaseRepository.cs +++ b/Repository/BaseRepository.cs @@ -25,7 +25,7 @@ public interface IBaseRepository where T : BaseEntity /// 添加数据 /// Task AddAsync(T entity); - + /// /// 添加数据 /// @@ -45,6 +45,13 @@ public interface IBaseRepository where T : BaseEntity /// 删除数据 /// Task DeleteAsync(long id); + + /// + /// 执行动态SQL查询,返回动态对象 + /// + /// 完整的SELECT SQL语句 + /// 动态查询结果列表 + Task> ExecuteDynamicSqlAsync(string completeSql); } @@ -157,4 +164,22 @@ public abstract class BaseRepository(IFreeSql freeSql) : IBaseRepository w return false; } } + + public async Task> ExecuteDynamicSqlAsync(string completeSql) + { + var dt = await FreeSql.Ado.ExecuteDataTableAsync(completeSql); + var result = new List(); + + foreach (System.Data.DataRow row in dt.Rows) + { + var expando = new System.Dynamic.ExpandoObject() as IDictionary; + foreach (System.Data.DataColumn column in dt.Columns) + { + expando[column.ColumnName] = row[column]; + } + result.Add(expando); + } + + return result; + } } diff --git a/Repository/BudgetArchiveRepository.cs b/Repository/BudgetArchiveRepository.cs new file mode 100644 index 0000000..1824206 --- /dev/null +++ b/Repository/BudgetArchiveRepository.cs @@ -0,0 +1,34 @@ +namespace Repository; + +public interface IBudgetArchiveRepository : IBaseRepository +{ + Task GetArchiveAsync(long budgetId, int year, int month); + Task> GetListAsync(int year, int month); +} + +public class BudgetArchiveRepository( + IFreeSql freeSql +) : BaseRepository(freeSql), IBudgetArchiveRepository +{ + public async Task GetArchiveAsync(long budgetId, int year, int month) + { + return await FreeSql.Select() + .Where(a => a.BudgetId == budgetId && + a.Year == year && + a.Month == month) + .ToOneAsync(); + } + + public async Task> GetListAsync(int year, int month) + { + return await FreeSql.Select() + .Where( + a => a.BudgetType == BudgetPeriodType.Month && + a.Year == year && + a.Month == month || + a.BudgetType == BudgetPeriodType.Year && + a.Year == year + ) + .ToListAsync(); + } +} \ No newline at end of file diff --git a/Repository/TransactionRecordRepository.cs b/Repository/TransactionRecordRepository.cs index d7c1237..44edfde 100644 --- a/Repository/TransactionRecordRepository.cs +++ b/Repository/TransactionRecordRepository.cs @@ -146,13 +146,6 @@ public interface ITransactionRecordRepository : IBaseRepository查询结果列表 Task> ExecuteRawSqlAsync(string completeSql); - /// - /// 执行动态SQL查询,返回动态对象 - /// - /// 完整的SELECT SQL语句 - /// 动态查询结果列表 - Task> ExecuteDynamicSqlAsync(string completeSql); - /// /// 根据关键词查询已分类的账单(用于智能分类参考) /// @@ -459,23 +452,6 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository(completeSql); } - public async Task> ExecuteDynamicSqlAsync(string completeSql) - { - var dt = await FreeSql.Ado.ExecuteDataTableAsync(completeSql); - var result = new List(); - - foreach (System.Data.DataRow row in dt.Rows) - { - var expando = new System.Dynamic.ExpandoObject() as IDictionary; - foreach (System.Data.DataColumn column in dt.Columns) - { - expando[column.ColumnName] = row[column]; - } - result.Add(expando); - } - - return result; - } public async Task GetMonthlyStatisticsAsync(int year, int month) { var startDate = new DateTime(year, month, 1); diff --git a/Service/BudgetService.cs b/Service/BudgetService.cs index 40f1833..39daee7 100644 --- a/Service/BudgetService.cs +++ b/Service/BudgetService.cs @@ -2,54 +2,255 @@ public interface IBudgetService { - Task> GetAllAsync(); - Task GetByIdAsync(long id); - Task AddAsync(BudgetRecord budget); - Task DeleteAsync(long id); - Task UpdateAsync(BudgetRecord budget); - Task CalculateCurrentAmountAsync(BudgetRecord budget, DateTime? now = null); - Task ToggleStopAsync(long id); + Task> GetListAsync(DateTime? referenceDate = null); + + Task GetStatisticsAsync(long id, DateTime? referenceDate = null); + + Task ArchiveBudgetsAsync(int year, int month); } public class BudgetService( - IBudgetRepository budgetRepository + IBudgetRepository budgetRepository, + IBudgetArchiveRepository budgetArchiveRepository, + ITransactionRecordRepository transactionRecordRepository, + IOpenAiService openAiService, + IConfigService configService, + IMessageRecordService messageService, + ILogger logger ) : IBudgetService { - public async Task> GetAllAsync() + public async Task> GetListAsync(DateTime? referenceDate = null) { - var list = await budgetRepository.GetAllAsync(); - return list.ToList(); + var budgets = await budgetRepository.GetAllAsync(); + var dtos = new List(); + + foreach (var budget in budgets) + { + var currentAmount = await CalculateCurrentAmountAsync(budget, referenceDate); + dtos.Add(BudgetResult.FromEntity(budget, currentAmount, referenceDate)); + } + + // 创造虚拟的存款预算 + dtos.Add(await GetVirtualSavingsDtoAsync( + BudgetPeriodType.Month, + referenceDate, + budgets)); + dtos.Add(await GetVirtualSavingsDtoAsync( + BudgetPeriodType.Year, + referenceDate, + budgets)); + + return dtos.Where(dto => dto != null).Cast().ToList(); } - public async Task GetByIdAsync(long id) + public async Task GetStatisticsAsync(long id, DateTime? referenceDate = null) { - return await budgetRepository.GetByIdAsync(id); - } + if (id == -1) + { + return await GetVirtualSavingsDtoAsync(BudgetPeriodType.Year, referenceDate); + } + if (id == -2) + { + return await GetVirtualSavingsDtoAsync(BudgetPeriodType.Month, referenceDate); + } - public async Task AddAsync(BudgetRecord budget) - { - return await budgetRepository.AddAsync(budget); - } - - public async Task DeleteAsync(long id) - { - return await budgetRepository.DeleteAsync(id); - } - - public async Task UpdateAsync(BudgetRecord budget) - { - return await budgetRepository.UpdateAsync(budget); - } - - public async Task ToggleStopAsync(long id) - { var budget = await budgetRepository.GetByIdAsync(id); - if (budget == null) return false; - budget.IsStopped = !budget.IsStopped; - return await budgetRepository.UpdateAsync(budget); + if (budget == null) + { + return null; + } + + var currentAmount = await CalculateCurrentAmountAsync(budget, referenceDate); + return BudgetResult.FromEntity(budget, currentAmount, referenceDate); } - public async Task CalculateCurrentAmountAsync(BudgetRecord budget, DateTime? now = null) + public async Task ArchiveBudgetsAsync(int year, int month) + { + var referenceDate = new DateTime(year, month, 1); + var budgets = await GetListAsync(referenceDate); + + var addArchives = new List(); + var updateArchives = new List(); + foreach (var budget in budgets) + { + var archive = await budgetArchiveRepository.GetArchiveAsync(budget.Id, year, month); + + if (archive != null) + { + archive.RealizedAmount = budget.Current; + archive.ArchiveDate = DateTime.Now; + updateArchives.Add(archive); + } + else + { + archive = new BudgetArchive + { + BudgetId = budget.Id, + BudgetType = budget.Type, + Year = year, + Month = month, + BudgetedAmount = budget.Limit, + RealizedAmount = budget.Current, + ArchiveDate = DateTime.Now + }; + + addArchives.Add(archive); + } + } + + if (addArchives.Count > 0) + { + if (!await budgetArchiveRepository.AddRangeAsync(addArchives)) + { + return "保存预算归档失败"; + } + } + if (updateArchives.Count > 0) + { + if (!await budgetArchiveRepository.UpdateRangeAsync(updateArchives)) + { + return "更新预算归档失败"; + } + } + + _ = NotifyAsync(year, month); + return string.Empty; + } + + private async Task NotifyAsync(int year, int month) + { + try + { + var archives = await budgetArchiveRepository.GetListAsync(year, month); + var budgets = await budgetRepository.GetAllAsync(); + var budgetMap = budgets.ToDictionary(b => b.Id, b => b); + + var archiveData = archives.Select(a => + { + budgetMap.TryGetValue(a.BudgetId, out var br); + var name = br?.Name ?? (a.BudgetId == -1 ? "年度存款" : a.BudgetId == -2 ? "月度存款" : "未知"); + return new + { + Name = name, + Type = a.BudgetType.ToString(), + Limit = a.BudgetedAmount, + Actual = a.RealizedAmount, + Category = br?.Category.ToString() ?? (a.BudgetId < 0 ? "Savings" : "Unknown") + }; + }).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 = budgets + .Where(b => !string.IsNullOrEmpty(b.SelectedCategories)) + .SelectMany(b => b.SelectedCategories.Split(',')) + .Distinct() + .ToHashSet(); + + var uncovered = monthTransactions + .Where(t => + { + var dict = (IDictionary)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}月 + 账单数据说明:支出金额已取绝对值(TotalAmount 为正数表示支出/收入的总量)。 + + 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. 语言风格:专业、清晰、简洁,适合财务报告阅读。 + + 【格式要求】 + 1. 使用HTML格式(移动端H5页面风格) + 2. 生成清晰的报告标题(基于用户问题) + 3. 使用表格展示统计数据(table > thead/tbody > tr > th/td) + 4. 使用合适的HTML标签:h2(标题)、h3(小节)、p(段落)、table(表格)、ul/li(列表)、strong(强调) + 5. 支出金额用 金额 包裹 + 6. 收入金额用 金额 包裹 + 7. 重要结论用 内容 高亮 + + 【样式限制(重要)】 + 8. 不要包含 html、body、head 标签 + 9. 不要使用任何 style 属性或 diff --git a/Web/src/views/SettingView.vue b/Web/src/views/SettingView.vue index 015e60f..b5db43a 100644 --- a/Web/src/views/SettingView.vue +++ b/Web/src/views/SettingView.vue @@ -42,6 +42,7 @@ +
@@ -291,6 +292,10 @@ const handleReloadFromNetwork = async () => { } } +const handleScheduledTasks = () => { + router.push({ name: 'scheduled-tasks' }) +} +