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 @@
+