diff --git a/Common/IDateTimeProvider.cs b/Common/IDateTimeProvider.cs
new file mode 100644
index 0000000..fd8cc5f
--- /dev/null
+++ b/Common/IDateTimeProvider.cs
@@ -0,0 +1,11 @@
+namespace Common;
+
+public interface IDateTimeProvider
+{
+ DateTime Now { get; }
+}
+
+public class DateTimeProvider : IDateTimeProvider
+{
+ public DateTime Now => DateTime.Now;
+}
diff --git a/Common/ServiceExtension.cs b/Common/ServiceExtension.cs
index 7a8bb18..6bfc6d8 100644
--- a/Common/ServiceExtension.cs
+++ b/Common/ServiceExtension.cs
@@ -22,6 +22,7 @@ public static class ServiceExtension
///
public static IServiceCollection AddServices(this IServiceCollection services)
{
+ services.AddSingleton();
// 扫描程序集
var serviceAssembly = Assembly.Load("Service");
var repositoryAssembly = Assembly.Load("Repository");
diff --git a/Directory.Packages.props b/Directory.Packages.props
index e27c728..25c0912 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -40,5 +40,7 @@
+
+
\ No newline at end of file
diff --git a/Repository/BudgetRepository.cs b/Repository/BudgetRepository.cs
index deeaae9..e9f4324 100644
--- a/Repository/BudgetRepository.cs
+++ b/Repository/BudgetRepository.cs
@@ -28,10 +28,6 @@ public class BudgetRepository(IFreeSql freeSql) : BaseRepository(f
{
query = query.Where(t => t.Type == TransactionType.Income);
}
- else if (budget.Category == BudgetCategory.Savings)
- {
- query = query.Where(t => t.Type == TransactionType.None);
- }
return await query.SumAsync(t => t.Amount);
}
@@ -41,8 +37,7 @@ public class BudgetRepository(IFreeSql freeSql) : BaseRepository(f
var records = await FreeSql.Select()
.Where(b => b.SelectedCategories.Contains(oldName) &&
((type == TransactionType.Expense && b.Category == BudgetCategory.Expense) ||
- (type == TransactionType.Income && b.Category == BudgetCategory.Income) ||
- (type == TransactionType.None && b.Category == BudgetCategory.Savings)))
+ (type == TransactionType.Income && b.Category == BudgetCategory.Income)))
.ToListAsync();
foreach (var record in records)
diff --git a/Repository/TransactionRecordRepository.cs b/Repository/TransactionRecordRepository.cs
index bc575ff..398cffa 100644
--- a/Repository/TransactionRecordRepository.cs
+++ b/Repository/TransactionRecordRepository.cs
@@ -217,6 +217,8 @@ public interface ITransactionRecordRepository : IBaseRepository交易类型
/// 影响行数
Task UpdateCategoryNameAsync(string oldName, string newName, TransactionType type);
+
+ Task> GetAmountGroupByClassifyAsync(DateTime startTime, DateTime endTime);
}
public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository(freeSql), ITransactionRecordRepository
@@ -276,7 +278,7 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository t.OccurredAt >= dateStart && t.OccurredAt < dateEnd);
}
-
+
// 按日期范围筛选
query = query.WhereIf(startDate.HasValue, t => t.OccurredAt >= startDate!.Value)
.WhereIf(endDate.HasValue, t => t.OccurredAt <= endDate!.Value);
@@ -379,7 +381,7 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository t.Type == TransactionType.Expense).Sum(t => Math.Abs(t.Amount));
var saving = 0m;
- if(!string.IsNullOrEmpty(savingClassify))
+ if (!string.IsNullOrEmpty(savingClassify))
{
saving = g.Where(t => savingClassify.Split(',').Contains(t.Classify)).Sum(t => Math.Abs(t.Amount));
}
@@ -649,19 +651,19 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository
+ .Select(record =>
{
var matchedCount = keywords.Count(keyword => record.Reason.Contains(keyword, StringComparison.OrdinalIgnoreCase));
var matchRate = (double)matchedCount / keywords.Count;
-
+
// 额外加分:完全匹配整个摘要(相似度更高)
var exactMatchBonus = keywords.Any(k => record.Reason.Equals(k, StringComparison.OrdinalIgnoreCase)) ? 0.2 : 0.0;
-
+
// 长度相似度加分:长度越接近,相关度越高
var avgKeywordLength = keywords.Average(k => k.Length);
var lengthSimilarity = 1.0 - Math.Min(1.0, Math.Abs(record.Reason.Length - avgKeywordLength) / Math.Max(record.Reason.Length, avgKeywordLength));
var lengthBonus = lengthSimilarity * 0.1;
-
+
var score = matchRate + exactMatchBonus + lengthBonus;
return (record, score);
})
@@ -695,9 +697,9 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository Math.Abs(t.Amount) >= minAmount && Math.Abs(t.Amount) <= maxAmount)
.Take(50)
.ToListAsync();
-
+
return list.OrderBy(t => Math.Abs(Math.Abs(t.Amount) - absAmount))
- .ThenBy(x=> Math.Abs((x.OccurredAt - currentRecord.OccurredAt).TotalSeconds))
+ .ThenBy(x => Math.Abs((x.OccurredAt - currentRecord.OccurredAt).TotalSeconds))
.ToList();
}
@@ -757,6 +759,21 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository t.OccurredAt.Date)
.ToDictionary(g => g.Key, g => g.Sum(x => Math.Abs(x.Amount)));
}
+
+ public async Task> GetAmountGroupByClassifyAsync(DateTime startTime, DateTime endTime)
+ {
+ var result = await FreeSql.Select()
+ .Where(t => t.OccurredAt >= startTime && t.OccurredAt < endTime)
+ .GroupBy(t => new { t.Classify, t.Type })
+ .ToListAsync(g => new
+ {
+ g.Key.Classify,
+ g.Key.Type,
+ TotalAmount = g.Sum(g.Value.Amount - g.Value.RefundAmount)
+ });
+
+ return result.ToDictionary(x => (x.Classify, x.Type), x => x.TotalAmount);
+ }
}
///
diff --git a/Service/Budget/BudgetSavingsService.cs b/Service/Budget/BudgetSavingsService.cs
index 8cf029b..5701498 100644
--- a/Service/Budget/BudgetSavingsService.cs
+++ b/Service/Budget/BudgetSavingsService.cs
@@ -10,7 +10,10 @@ public interface IBudgetSavingsService
public class BudgetSavingsService(
IBudgetRepository budgetRepository,
- IBudgetArchiveRepository budgetArchiveRepository
+ IBudgetArchiveRepository budgetArchiveRepository,
+ ITransactionRecordRepository transactionsRepository,
+ IConfigService configService,
+ IDateTimeProvider dateTimeProvider
) : IBudgetSavingsService
{
public async Task GetSavingsDtoAsync(
@@ -36,14 +39,14 @@ public class BudgetSavingsService(
.ThenBy(b => b.Type)
.ThenByDescending(b => b.Limit);
- var year = referenceDate?.Year ?? DateTime.Now.Year;
- var month = referenceDate?.Month ?? DateTime.Now.Month;
-
- if(periodType == BudgetPeriodType.Month)
+ var year = referenceDate?.Year ?? dateTimeProvider.Now.Year;
+ var month = referenceDate?.Month ?? dateTimeProvider.Now.Month;
+
+ if (periodType == BudgetPeriodType.Month)
{
- return await GetForMonthAsync(budgets, year, month);
+ return await GetForMonthAsync(budgets, year, month);
}
- else if(periodType == BudgetPeriodType.Year)
+ else if (periodType == BudgetPeriodType.Year)
{
return await GetForYearAsync(budgets, year);
}
@@ -53,28 +56,812 @@ public class BudgetSavingsService(
private async Task GetForMonthAsync(
IEnumerable budgets,
- int year,
+ int year,
int month)
{
- var result = new BudgetResult
- {
-
- };
+ var transactionClassify = await transactionsRepository.GetAmountGroupByClassifyAsync(
+ new DateTime(year, month, 1),
+ new DateTime(year, month, 1).AddMonths(1)
+ );
+ var monthlyIncomeItems = new List<(string name, decimal limit, decimal current, bool isMandatory)>();
+ var monthlyExpenseItems = new List<(string name, decimal limit, decimal current, bool isMandatory)>();
var monthlyBudgets = budgets
.Where(b => b.Type == BudgetPeriodType.Month);
+ foreach (var budget in monthlyBudgets)
+ {
+ var classifyList = budget.SelectedCategories.Split(',', StringSplitOptions.RemoveEmptyEntries);
+
+ decimal currentAmount = 0;
+ var transactionType = budget.Category switch
+ {
+ BudgetCategory.Income => TransactionType.Income,
+ BudgetCategory.Expense => TransactionType.Expense,
+ _ => throw new NotSupportedException($"Budget category {budget.Category} is not supported.")
+ };
+
+ foreach (var classify in classifyList)
+ {
+ // 获取分类+收入支出类型一致的金额
+ if (transactionClassify.TryGetValue((classify, transactionType), out var amount))
+ {
+ currentAmount += amount;
+ }
+ }
+
+ // 硬性预算 如果实际发生金额小于 应(总天数/实际天数)发生金额
+ // 直接取应发生金额(为了预算的准确性)
+ if (budget.IsMandatoryExpense && currentAmount == 0)
+ {
+ currentAmount = budget.Limit / DateTime.DaysInMonth(year, month) * dateTimeProvider.Now.Day;
+ }
+
+ if (budget.Category == BudgetCategory.Income)
+ {
+ monthlyIncomeItems.Add((
+ name: budget.Name,
+ limit: budget.Limit,
+ current: currentAmount,
+ isMandatory: budget.IsMandatoryExpense
+ ));
+ }
+ else if (budget.Category == BudgetCategory.Expense)
+ {
+ monthlyExpenseItems.Add((
+ name: budget.Name,
+ limit: budget.Limit,
+ current: currentAmount,
+ isMandatory: budget.IsMandatoryExpense
+ ));
+ }
+ }
+
+ var yearlyIncomeItems = new List<(string name, decimal limit, decimal current)>();
+ var yearlyExpenseItems = new List<(string name, decimal limit, decimal current)>();
var yearlyBudgets = budgets
.Where(b => b.Type == BudgetPeriodType.Year);
+ // 只需要考虑实际发生在本月的年度预算 因为他会影响到月度的结余情况
+ foreach (var budget in yearlyBudgets)
+ {
+ var classifyList = budget.SelectedCategories.Split(',', StringSplitOptions.RemoveEmptyEntries);
- // var actualAmount = await budgetRepository.GetCurrentAmountAsync(budget, startDate, endDate);
+ decimal currentAmount = 0;
+ var transactionType = budget.Category switch
+ {
+ BudgetCategory.Income => TransactionType.Income,
+ BudgetCategory.Expense => TransactionType.Expense,
+ _ => throw new NotSupportedException($"Budget category {budget.Category} is not supported.")
+ };
- throw new NotImplementedException();
+ foreach (var classify in classifyList)
+ {
+ // 获取分类+收入支出类型一致的金额
+ if (transactionClassify.TryGetValue((classify, transactionType), out var amount))
+ {
+ currentAmount += amount;
+ }
+ }
+
+ if (currentAmount == 0)
+ {
+ continue;
+ }
+
+ if (budget.Category == BudgetCategory.Income)
+ {
+ yearlyIncomeItems.Add((
+ name: budget.Name,
+ limit: budget.Limit,
+ current: currentAmount
+ ));
+ }
+ else if (budget.Category == BudgetCategory.Expense)
+ {
+ yearlyExpenseItems.Add((
+ name: budget.Name,
+ limit: budget.Limit,
+ current: currentAmount
+ ));
+ }
+ }
+
+ var description = new StringBuilder();
+
+ #region 构建月度收入支出明细表格
+ description.AppendLine("月度预算收入明细
");
+ description.AppendLine("""
+
+
+
+ | 名称 |
+ 预算 |
+ 硬性收入 |
+
+
+
+ """);
+
+ foreach (var item in monthlyIncomeItems)
+ {
+ description.AppendLine($"""
+
+ | {item.name} |
+ {item.limit:N0} |
+ {(item.isMandatory ? "是" : "否")} |
+
+ """);
+ }
+
+ description.AppendLine("
");
+ description.AppendLine($"""
+
+ 收入合计:
+
+ {monthlyIncomeItems.Sum(item => item.limit):N0}
+
+
+ """);
+
+ description.AppendLine("月度预算支出明细
");
+ description.AppendLine("""
+
+
+
+ | 名称 |
+ 预算 |
+ 硬性支出 |
+
+
+
+ """);
+ foreach (var item in monthlyExpenseItems)
+ {
+ description.AppendLine($"""
+
+ | {item.name} |
+ {item.limit:N0} |
+ {(item.isMandatory ? "是" : "否")} |
+
+ """);
+ }
+ description.AppendLine("
");
+ description.AppendLine($"""
+
+ 支出合计:
+
+ {monthlyExpenseItems.Sum(item => item.limit):N0}
+
+
+ """);
+ #endregion
+
+ #region 构建发生在本月的年度预算收入支出明细表格
+ if (yearlyIncomeItems.Any())
+ {
+ description.AppendLine("年度收入预算(发生在本月)
");
+ description.AppendLine("""
+
+
+
+ | 名称 |
+ 预算 |
+ 本月收入 |
+
+
+
+ """);
+
+ foreach (var item in yearlyIncomeItems)
+ {
+ description.AppendLine($"""
+
+ | {item.name} |
+ {(item.limit == 0 ? "不限额" : item.limit.ToString("N0"))} |
+ {item.current:N0} |
+
+ """);
+ }
+
+ description.AppendLine("
");
+ description.AppendLine($"""
+
+ 收入合计:
+
+ {yearlyIncomeItems.Sum(item => item.current):N0}
+
+
+ """);
+ }
+
+ if (yearlyExpenseItems.Any())
+ {
+ description.AppendLine("年度支出预算(发生在本月)
");
+ description.AppendLine("""
+
+
+
+ | 名称 |
+ 预算 |
+ 本月支出 |
+
+
+
+ """);
+ foreach (var item in yearlyExpenseItems)
+ {
+ description.AppendLine($"""
+
+ | {item.name} |
+ {(item.limit == 0 ? "不限额" : item.limit.ToString("N0"))} |
+ {item.current:N0} |
+
+ """);
+ }
+ description.AppendLine("
");
+ description.AppendLine($"""
+
+ 支出合计:
+
+ {yearlyExpenseItems.Sum(item => item.current):N0}
+
+
+ """);
+ }
+
+ #endregion
+
+ #region 总结
+
+ description.AppendLine("存款计划结论
");
+ var plannedIncome = monthlyIncomeItems.Sum(item => item.limit == 0 ? item.current : item.limit) + yearlyIncomeItems.Sum(item => item.current);
+ var plannedExpense = monthlyExpenseItems.Sum(item => item.limit == 0 ? item.current : item.limit) + yearlyExpenseItems.Sum(item => item.current);
+ var expectedSavings = plannedIncome - plannedExpense;
+ description.AppendLine($"""
+
+ 计划存款:
+
+ {expectedSavings:N0}
+
+ =
+
+
+
+ 计划收入:
+
+ {monthlyIncomeItems.Sum(item => item.limit):N0}
+
+
+ """);
+ if (yearlyIncomeItems.Count > 0)
+ {
+ description.AppendLine($"""
+
+
+ + 本月发生的年度预算收入:
+
+ {yearlyIncomeItems.Sum(item => item.current):N0}
+
+
+ """);
+ }
+ description.AppendLine($"""
+
+
+ - 计划支出:
+
+ {monthlyExpenseItems.Sum(item => item.limit):N0}
+
+ """);
+ if (yearlyExpenseItems.Count > 0)
+ {
+ description.AppendLine($"""
+
+
+ - 本月发生的年度预算支出:
+
+ {yearlyExpenseItems.Sum(item => item.current):N0}
+
+
+ """);
+ }
+ description.AppendLine($"""
+
+ """);
+ #endregion
+
+ var savingsCategories = await configService.GetConfigByKeyAsync("SavingsCategories") ?? string.Empty;
+ var current = monthlyExpenseItems.Sum(item => item.current)
+ + yearlyExpenseItems.Sum(item => item.current)
+ - monthlyIncomeItems.Sum(item => item.current)
+ - yearlyIncomeItems.Sum(item => item.current);
+ var record = new BudgetRecord
+ {
+ Id = -2,
+ Name = "月度存款计划",
+ Type = BudgetPeriodType.Month,
+ Limit = expectedSavings,
+ Category = BudgetCategory.Savings,
+ SelectedCategories = savingsCategories,
+ StartDate = new DateTime(year, month, 1),
+ NoLimit = false,
+ IsMandatoryExpense = false,
+ CreateTime = dateTimeProvider.Now,
+ UpdateTime = dateTimeProvider.Now
+ };
+
+ return BudgetResult.FromEntity(
+ record,
+ current,
+ new DateTime(year, month, 1),
+ description.ToString()
+ );
}
private async Task GetForYearAsync(
IEnumerable budgets,
int year)
{
- throw new NotImplementedException();
+ // 因为非当前月份的读取归档数据,这边依然是读取当前月份的数据
+ var currentMonth = dateTimeProvider.Now.Month;
+ var transactionClassify = await transactionsRepository.GetAmountGroupByClassifyAsync(
+ new DateTime(year, currentMonth, 1),
+ new DateTime(year, currentMonth, 1).AddMonths(1)
+ );
+
+ var currentMonthlyIncomeItems = new List<(long id, string name, decimal limit, int factor, decimal current, bool isMandatory)>();
+ var currentYearlyIncomeItems = new List<(long id, string name, decimal limit, int factor, decimal current, bool isMandatory)>();
+ var currentMonthlyExpenseItems = new List<(long id, string name, decimal limit, int factor, decimal current, bool isMandatory)>();
+ var currentYearlyExpenseItems = new List<(long id, string name, decimal limit, int factor, decimal current, bool isMandatory)>();
+ // 归档的预算收入支出明细
+ var archiveIncomeItems = new List<(long id, string name, int[] months, decimal limit, decimal current)>();
+ var archiveExpenseItems = new List<(long id, string name, int[] months, decimal limit, decimal current)>();
+ // 获取归档数据
+ var archives = await budgetArchiveRepository.GetArchivesByYearAsync(year);
+ var archiveBudgetGroups = archives
+ .SelectMany(a => a.Content.Select(x => (a.Month, Archive: x)))
+ .Where(b => b.Archive.Type == BudgetPeriodType.Month) // 因为本来就是当前年度预算的生成 ,归档无需关心年度, 以最新地为准即可
+ .GroupBy(b => (b.Archive.Id, b.Archive.Limit));
+
+ foreach (var archiveBudgetGroup in archiveBudgetGroups)
+ {
+ var (_, archive) = archiveBudgetGroup.First();
+ var archiveItems = archive.Category switch
+ {
+ BudgetCategory.Income => archiveIncomeItems,
+ BudgetCategory.Expense => archiveExpenseItems,
+ _ => throw new NotSupportedException($"Category {archive.Category} is not supported.")
+ };
+
+ archiveItems.Add((
+ id: archiveBudgetGroup.Key.Id,
+ name: archive.Name,
+ months: archiveBudgetGroup.Select(x => x.Month).OrderBy(m => m).ToArray(),
+ limit: archiveBudgetGroup.Key.Limit,
+ current: archiveBudgetGroup.Sum(x => x.Archive.Actual)
+ ));
+ }
+
+ // 处理当月最新地没有归档的预算
+ foreach (var budget in budgets)
+ {
+ var currentAmount = 0m;
+
+ var classifyList = budget.SelectedCategories.Split(',', StringSplitOptions.RemoveEmptyEntries);
+
+ var transactionType = budget.Category switch
+ {
+ BudgetCategory.Income => TransactionType.Income,
+ BudgetCategory.Expense => TransactionType.Expense,
+ _ => throw new NotSupportedException($"Budget category {budget.Category} is not supported.")
+ };
+
+ foreach (var classify in classifyList)
+ {
+ // 获取分类+收入支出类型一致的金额
+ if (transactionClassify.TryGetValue((classify, transactionType), out var amount))
+ {
+ currentAmount += amount;
+ }
+ }
+
+ // 硬性预算 如果实际发生金额小于 应(总天数/实际天数)发生金额
+ // 直接取应发生金额(为了预算的准确性)
+ if (budget.IsMandatoryExpense && currentAmount == 0)
+ {
+ currentAmount = budget.IsMandatoryExpense && currentAmount == 0
+ ? budget.Limit / (DateTime.IsLeapYear(year) ? 366 : 365) * dateTimeProvider.Now.DayOfYear
+ : budget.Limit / DateTime.DaysInMonth(year, currentMonth) * dateTimeProvider.Now.Day;
+ }
+
+ AddOrIncCurrentItem(
+ budget.Id,
+ budget.Type,
+ budget.Category,
+ budget.Name,
+ budget.Limit,
+ budget.Type == BudgetPeriodType.Year
+ ? 1
+ : 12 - currentMonth + 1,
+ currentAmount,
+ budget.IsMandatoryExpense
+ );
+ }
+
+ var description = new StringBuilder();
+
+ #region 构建归档收入明细表格
+ var archiveIncomeDiff = 0m;
+ if (archiveIncomeItems.Any())
+ {
+ description.AppendLine("已归档收入明细
");
+ description.AppendLine("""
+
+
+
+ | 名称 |
+ 预算 |
+ 月 |
+ 合计 |
+ 实际 |
+
+
+
+ """);
+ // 已归档的收入
+ foreach (var (_, name, months, limit, current) in archiveIncomeItems)
+ {
+ description.AppendLine($"""
+
+ | {name} |
+ {(limit == 0 ? "不限额" : limit.ToString("N0"))} |
+ {FormatMonths(months)} |
+ {limit * months.Length:N0} |
+ {current:N0} |
+
+ """);
+ }
+ description.AppendLine("
");
+ archiveIncomeDiff = archiveIncomeItems.Sum(i => i.current) - archiveIncomeItems.Sum(i => i.limit * i.months.Length);
+ description.AppendLine($"""
+
+ 已归档收入总结
+
+
+ {(archiveIncomeDiff > 0 ? "超额收入" : "未达预期")}:
+
+ {archiveIncomeDiff:N0}
+
+ =
+
+ {archiveIncomeItems.Sum(i => i.limit * i.months.Length):N0}
+
+ -
+ 实际收入合计:
+
+ {archiveIncomeItems.Sum(i => i.current):N0}
+
+
+ """);
+ }
+ #endregion
+
+ #region 构建年度预算收入明细表格
+ description.AppendLine("预算收入明细
");
+ description.AppendLine("""
+
+
+
+ | 名称 |
+ 预算 |
+ 月/年 |
+ 合计 |
+
+
+
+ """);
+
+ // 当前预算
+ foreach (var (_, name, limit, factor, _, _) in currentMonthlyIncomeItems)
+ {
+ description.AppendLine($"""
+
+ | {name} |
+ {(limit == 0 ? "不限额" : limit.ToString("N0"))} |
+ {FormatMonthsByFactor(factor)} |
+ {(limit == 0 ? "不限额" : (limit * factor).ToString("N0"))} |
+
+ """);
+ }
+
+ // 年预算
+ foreach (var (_, name, limit, _, _, _) in currentYearlyIncomeItems)
+ {
+ description.AppendLine($"""
+
+ | {name} |
+ {(limit == 0 ? "不限额" : limit.ToString("N0"))} |
+ {year}年 |
+ {(limit == 0 ? "不限额" : limit.ToString("N0"))} |
+
+ """);
+ }
+ description.AppendLine("
");
+
+ description.AppendLine($"""
+
+ 预算收入合计:
+
+
+ {
+ currentMonthlyIncomeItems.Sum(i => i.limit * i.factor)
+ + currentYearlyIncomeItems.Sum(i => i.limit)
+ :N0}
+
+
+
+ """);
+ #endregion
+
+ #region 构建年度归档支出明细表格
+ var archiveExpenseDiff = 0m;
+ if (archiveExpenseItems.Any())
+ {
+ description.AppendLine("已归档支出明细
");
+ description.AppendLine("""
+
+
+
+ | 名称 |
+ 预算 |
+ 月 |
+ 合计 |
+ 实际 |
+
+
+
+ """);
+
+ // 已归档的支出
+ foreach (var (_, name, months, limit, current) in archiveExpenseItems)
+ {
+ description.AppendLine($"""
+
+ | {name} |
+ {(limit == 0 ? "不限额" : limit.ToString("N0"))} |
+ {FormatMonths(months)} |
+ {limit * months.Length:N0} |
+ {current:N0} |
+
+ """);
+ }
+ description.AppendLine("
");
+
+ archiveExpenseDiff = archiveExpenseItems.Sum(i => i.limit * i.months.Length) - archiveExpenseItems.Sum(i => i.current);
+ description.AppendLine($"""
+
+ 已归档支出总结
+
+
+
+ {(archiveExpenseDiff > 0 ? "节省支出" : "超支")}:
+
+ {archiveExpenseDiff:N0}
+
+ =
+
+ {archiveExpenseItems.Sum(i => i.limit * i.months.Length):N0}
+
+ - 实际支出合计:
+
+ {archiveExpenseItems.Sum(i => i.current):N0}
+
+
+ """);
+ }
+ #endregion
+
+ #region 构建当前年度预算支出明细表格
+ description.AppendLine("预算支出明细
");
+ description.AppendLine("""
+
+
+
+ | 名称 |
+ 预算 |
+ 月/年 |
+ 合计 |
+
+
+
+ """);
+
+ // 未来月预算
+ foreach (var (_, name, limit, factor, _, _) in currentMonthlyExpenseItems)
+ {
+ description.AppendLine($"""
+
+ | {name} |
+ {(limit == 0 ? "不限额" : limit.ToString("N0"))} |
+ {FormatMonthsByFactor(factor)} |
+ {(limit == 0 ? "不限额" : (limit * factor).ToString("N0"))} |
+
+ """);
+ }
+
+ // 年预算
+ foreach (var (_, name, limit, _, _, _) in currentYearlyExpenseItems)
+ {
+ description.AppendLine($"""
+
+ | {name} |
+ {(limit == 0 ? "不限额" : limit.ToString("N0"))} |
+ {year}年 |
+ {(limit == 0 ? "不限额" : limit.ToString("N0"))} |
+
+ """);
+ }
+ description.AppendLine("
");
+
+ // 合计
+ description.AppendLine($"""
+
+ 支出预算合计:
+
+
+ {
+ currentMonthlyExpenseItems.Sum(i => i.limit * i.factor)
+ + currentYearlyExpenseItems.Sum(i => i.limit)
+ :N0}
+
+
+
+ """);
+ #endregion
+
+ #region 总结
+ var archiveIncomeBudget = archiveIncomeItems.Sum(i => i.limit * i.months.Length);
+ var archiveExpenseBudget = archiveExpenseItems.Sum(i => i.limit * i.months.Length);
+ var archiveSavings = archiveIncomeBudget - archiveExpenseBudget + archiveIncomeDiff + archiveExpenseDiff;
+
+ var expectedIncome = currentMonthlyIncomeItems.Sum(i => i.limit == 0 ? i.current : i.limit * i.factor) + currentYearlyIncomeItems.Sum(i => i.isMandatory || i.limit == 0 ? i.current : i.limit);
+ var expectedExpense = currentMonthlyExpenseItems.Sum(i => i.limit == 0 ? i.current : i.limit * i.factor) + currentYearlyExpenseItems.Sum(i => i.isMandatory || i.limit == 0 ? i.current : i.limit);
+ var expectedSavings = expectedIncome - expectedExpense;
+
+ description.AppendLine("存款计划结论
");
+ description.AppendLine($"""
+
+ 归档存款:
+ {archiveSavings:N0}
+ =
+ 归档收入: {archiveIncomeBudget:N0}
+ -
+ 归档支出: {archiveExpenseBudget:N0}
+ {(archiveIncomeDiff >= 0 ? " + 超额收入" : " - 未达预期收入")}: {(archiveIncomeDiff >= 0 ? archiveIncomeDiff : -archiveIncomeDiff):N0}
+ {(archiveExpenseDiff >= 0 ? " + 节省支出" : " - 超额支出")}: {(archiveExpenseDiff >= 0 ? archiveExpenseDiff : -archiveExpenseDiff):N0}
+
+
+ 预计存款:
+ {expectedSavings:N0}
+ =
+ 预计收入: {expectedIncome:N0}
+ -
+ 预计支出: {expectedExpense:N0}
+
+
+ 存档总结:
+
+ {archiveSavings + expectedSavings:N0}
+
+ =
+ 预计存款:
+ {expectedSavings:N0}
+ {(archiveSavings > 0 ? "+" : "-")}
+ 归档存款:
+ {Math.Abs(archiveSavings):N0}
+
+ """);
+ #endregion
+
+ var savingsCategories = await configService.GetConfigByKeyAsync("SavingsCategories") ?? string.Empty;
+
+ var currentActual = 0m;
+ if (!string.IsNullOrEmpty(savingsCategories))
+ {
+ var cats = new HashSet(savingsCategories.Split(',', StringSplitOptions.RemoveEmptyEntries));
+ foreach(var kvp in transactionClassify)
+ {
+ if (cats.Contains(kvp.Key.Item1))
+ {
+ currentActual += kvp.Value;
+ }
+ }
+ }
+
+ var record = new BudgetRecord
+ {
+ Id = -1,
+ Name = "年度存款计划",
+ Type = BudgetPeriodType.Year,
+ Limit = archiveSavings + expectedSavings,
+ Category = BudgetCategory.Savings,
+ SelectedCategories = savingsCategories,
+ StartDate = new DateTime(year, 1, 1),
+ NoLimit = false,
+ IsMandatoryExpense = false,
+ CreateTime = dateTimeProvider.Now,
+ UpdateTime = dateTimeProvider.Now
+ };
+
+ return BudgetResult.FromEntity(
+ record,
+ currentActual,
+ new DateTime(year, 1, 1),
+ description.ToString()
+ );
+
+ void AddOrIncCurrentItem(
+ long id,
+ BudgetPeriodType periodType,
+ BudgetCategory category,
+ string name,
+ decimal limit,
+ int factor,
+ decimal incAmount,
+ bool isMandatory)
+ {
+ var current = (periodType, category) switch
+ {
+ (BudgetPeriodType.Month, BudgetCategory.Income) => currentMonthlyIncomeItems,
+ (BudgetPeriodType.Month, BudgetCategory.Expense) => currentMonthlyExpenseItems,
+ (BudgetPeriodType.Year, BudgetCategory.Income) => currentYearlyIncomeItems,
+ (BudgetPeriodType.Year, BudgetCategory.Expense) => currentYearlyExpenseItems,
+ _ => throw new NotSupportedException($"Category {category} is not supported.")
+ };
+
+ if (current.Any(i => i.id == id))
+ {
+ var existing = current.First(i => i.id == id);
+ current.Remove(existing);
+ current.Add((id, existing.name, existing.limit, existing.factor + factor, existing.current + incAmount, isMandatory));
+ }
+ else
+ {
+ current.Add((id, name, limit, factor, incAmount, isMandatory));
+ }
+ }
+
+ string FormatMonthsByFactor(int factor)
+ {
+ var months = factor == 12
+ ? Enumerable.Range(1, 12).ToArray()
+ : Enumerable.Range(dateTimeProvider.Now.Month, factor).ToArray();
+
+ return FormatMonths(months.ToArray());
+ }
+
+ string FormatMonths(int[] months)
+ {
+ // 如果是连续的月份 则简化显示 1~3
+ Array.Sort(months);
+ if (months.Length >= 2)
+ {
+ bool isContinuous = true;
+ for (int i = 1; i < months.Length; i++)
+ {
+ if (months[i] != months[i - 1] + 1)
+ {
+ isContinuous = false;
+ break;
+ }
+ }
+
+ if (isContinuous)
+ {
+ return $"{months.First()}~{months.Last()}月";
+ }
+ }
+
+ return string.Join(", ", months) + "月";
+ }
}
}
\ No newline at end of file
diff --git a/Service/Budget/BudgetService.cs b/Service/Budget/BudgetService.cs
index fb50622..2d727d0 100644
--- a/Service/Budget/BudgetService.cs
+++ b/Service/Budget/BudgetService.cs
@@ -30,9 +30,9 @@ public class BudgetService(
IBudgetArchiveRepository budgetArchiveRepository,
ITransactionRecordRepository transactionRecordRepository,
IOpenAiService openAiService,
- IConfigService configService,
IMessageService messageService,
- ILogger logger
+ ILogger logger,
+ IBudgetSavingsService budgetSavingsService
) : IBudgetService
{
public async Task> GetListAsync(DateTime referenceDate)
@@ -80,11 +80,11 @@ public class BudgetService(
}
// 创造虚拟的存款预算
- dtos.Add(await GetSavingsDtoAsync(
+ dtos.Add(await budgetSavingsService.GetSavingsDtoAsync(
BudgetPeriodType.Month,
referenceDate,
budgets));
- dtos.Add(await GetSavingsDtoAsync(
+ dtos.Add(await budgetSavingsService.GetSavingsDtoAsync(
BudgetPeriodType.Year,
referenceDate,
budgets));
@@ -103,7 +103,7 @@ public class BudgetService(
public async Task GetSavingsBudgetAsync(int year, int month, BudgetPeriodType type)
{
var referenceDate = new DateTime(year, month, 1);
- return await GetSavingsDtoAsync(type, referenceDate);
+ return await budgetSavingsService.GetSavingsDtoAsync(type, referenceDate);
}
public async Task GetCategoryStatsAsync(BudgetCategory category, DateTime referenceDate)
@@ -595,484 +595,6 @@ public class BudgetService(
return (start, end);
}
-
- private async Task GetSavingsDtoAsync(
- BudgetPeriodType periodType,
- DateTime? referenceDate = null,
- IEnumerable? 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();
- foreach (var b in allBudgets)
- {
- if (b.Category == BudgetCategory.Savings) continue;
-
- processedIds.Add(b.Id);
- decimal factor;
- decimal historicalAmount = 0m;
- var historicalMonths = new List();
-
- 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("预算收入明细
");
- if (incomeItems.Count == 0) description.Append("无收入预算
");
- else
- {
- // 根据是否有历史数据决定表格列
- var hasHistoricalData = incomeItems.Any(i => i.Historical > 0);
-
- if (hasHistoricalData)
- {
- description.Append("""
-
-
-
- | 名称 |
- 当前预算 |
- 剩余月数 |
- 历史归档 |
- 合计 |
-
-
-
- """);
- foreach (var item in incomeItems)
- {
- description.Append($"""
-
- | {item.Name} |
- {item.Limit:N0} |
- {item.Factor:0.##} |
- {item.Historical:N0} |
- {item.Total:N0} |
-
- """);
- }
- }
- else
- {
- description.Append("""
-
-
-
- | 名称 |
- 金额 |
- 折算 |
- 合计 |
-
-
-
- """);
- foreach (var item in incomeItems)
- {
- description.Append($"""
-
- | {item.Name} |
- {item.Limit:N0} |
- {item.Factor:0.##} |
- {item.Total:N0} |
-
- """);
- }
- }
- description.Append("
");
- }
- description.Append($"收入合计: {incomeLimitAtPeriod:N0}
");
-
- if (periodType == BudgetPeriodType.Year && noLimitIncomeItems.Count > 0)
- {
- description.Append("不记额收入明细
");
- description.Append("""
-
-
-
- | 预算名称 |
- 实际发生 |
-
-
-
- """);
- foreach (var (name, amount) in noLimitIncomeItems)
- {
- description.Append($"""
-
- | {name} |
- {amount:N0} |
-
- """);
- }
- description.Append("
");
- description.Append($"不记额收入合计: {noLimitIncomeAtPeriod:N0}
");
- }
-
- description.Append("预算支出明细
");
- if (expenseItems.Count == 0) description.Append("无支出预算
");
- else
- {
- // 根据是否有历史数据决定表格列
- var hasHistoricalData = expenseItems.Any(i => i.Historical > 0);
-
- if (hasHistoricalData)
- {
- description.Append("""
-
-
-
- | 名称 |
- 当前预算 |
- 剩余月数 |
- 历史归档 |
- 合计 |
-
-
-
- """);
- foreach (var item in expenseItems)
- {
- description.Append($"""
-
- | {item.Name} |
- {item.Limit:N0} |
- {item.Factor:0.##} |
- {item.Historical:N0} |
- {item.Total:N0} |
-
- """);
- }
- }
- else
- {
- description.Append("""
-
-
-
- | 名称 |
- 金额 |
- 折算 |
- 合计 |
-
-
-
- """);
- foreach (var item in expenseItems)
- {
- description.Append($"""
-
- | {item.Name} |
- {item.Limit:N0} |
- {item.Factor:0.##} |
- {item.Total:N0} |
-
- """);
- }
- }
- description.Append("
");
- }
- description.Append($"支出合计: {expenseLimitAtPeriod:N0}
");
-
- if (periodType == BudgetPeriodType.Year && noLimitExpenseItems.Count > 0)
- {
- description.Append("不记额支出明细
");
- description.Append("""
-
-
-
- | 预算名称 |
- 实际发生 |
-
-
-
- """);
- foreach (var (name, amount) in noLimitExpenseItems)
- {
- description.Append($"""
-
- | {name} |
- {amount:N0} |
-
- """);
- }
- description.Append("
");
- description.Append($"不记额支出合计: {noLimitExpenseAtPeriod:N0}
");
- }
-
- description.Append("存款计划结论
");
- // 修改计算公式:包含不记额收入和支出
- var totalIncome = incomeLimitAtPeriod + noLimitIncomeAtPeriod;
- var totalExpense = expenseLimitAtPeriod + noLimitExpenseAtPeriod;
- description.Append($"计划收入 = 预算 {incomeLimitAtPeriod:N0} + 不记额 {noLimitIncomeAtPeriod:N0} = {totalIncome:N0}
");
- description.Append($"计划支出 = 预算 {expenseLimitAtPeriod:N0} + 不记额 {noLimitExpenseAtPeriod:N0} = {totalExpense:N0}
");
- description.Append($"计划盈余 = 计划收入 {totalIncome:N0} - 计划支出 {totalExpense:N0} = {totalIncome - totalExpense:N0}
");
-
- 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("历史月份盈亏
");
- description.Append("""
-
-
-
- | 月份 |
- 支出结余 |
- 收入结余 |
- 合计 |
-
-
-
- """);
-
- foreach (var archive in archives)
- {
- var monthlyTotal = archive.ExpenseSurplus + archive.IncomeSurplus;
- var monthlyClass = monthlyTotal >= 0 ? "income-value" : "expense-value";
- description.Append($"""
-
- | {archive.Month}月 |
- {archive.ExpenseSurplus:N0} |
- {archive.IncomeSurplus:N0} |
- {monthlyTotal:N0} |
-
- """);
- }
-
- var totalClass = historicalSurplus >= 0 ? "income-value" : "expense-value";
- description.Append($"""
-
- | 汇总 |
- {expenseSurplus:N0} |
- {incomeSurplus:N0} |
- {historicalSurplus:N0} |
-
-
- """);
- }
- var finalGoal = totalIncome - totalExpense + historicalSurplus;
- description.Append($"""
-
- 动态目标 = 计划盈余 {totalIncome - totalExpense:N0}
- + 年度历史盈亏 {historicalSurplus:N0}
- = {finalGoal:N0}
- """);
- }
-
- 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 BuildVirtualSavingsBudgetRecordAsync(
- long id,
- DateTime date,
- decimal limit)
- {
- var savingsCategories = await configService.GetConfigByKeyAsync("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
diff --git a/Web/src/components/Budget/BudgetCard.vue b/Web/src/components/Budget/BudgetCard.vue
index 0d2bcec..0f5951f 100644
--- a/Web/src/components/Budget/BudgetCard.vue
+++ b/Web/src/components/Budget/BudgetCard.vue
@@ -1,10 +1,18 @@
-