From 0ffeb416056f1098fba667a62b63524a559a7e31 Mon Sep 17 00:00:00 2001 From: SunCheng Date: Tue, 20 Jan 2026 19:11:05 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E5=AD=98=E6=AC=BE=E9=A2=84?= =?UTF-8?q?=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Common/IDateTimeProvider.cs | 11 + Common/ServiceExtension.cs | 1 + Directory.Packages.props | 2 + Repository/BudgetRepository.cs | 7 +- Repository/TransactionRecordRepository.cs | 33 +- Service/Budget/BudgetSavingsService.cs | 817 +++++++++++++++++++++- Service/Budget/BudgetService.cs | 488 +------------ Web/src/components/Budget/BudgetCard.vue | 259 +++++-- WebApi.Test/Basic/BaseTest.cs | 18 + WebApi.Test/BudgetSavingsTest.cs | 510 ++++++++++++++ WebApi.Test/GlobalUsings.cs | 9 + WebApi.Test/UnitTest1.cs | 10 - WebApi.Test/WebApi.Test.csproj | 9 +- 13 files changed, 1591 insertions(+), 583 deletions(-) create mode 100644 Common/IDateTimeProvider.cs create mode 100644 WebApi.Test/Basic/BaseTest.cs create mode 100644 WebApi.Test/BudgetSavingsTest.cs create mode 100644 WebApi.Test/GlobalUsings.cs delete mode 100644 WebApi.Test/UnitTest1.cs 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($""" + + + + + + """); + } + + description.AppendLine("
名称预算硬性收入
{item.name}{item.limit:N0}{(item.isMandatory ? "是" : "否")}
"); + description.AppendLine($""" +

+ 收入合计: + + {monthlyIncomeItems.Sum(item => item.limit):N0} + +

+ """); + + description.AppendLine("

月度预算支出明细

"); + description.AppendLine(""" + + + + + + + + + + """); + foreach (var item in monthlyExpenseItems) + { + description.AppendLine($""" + + + + + + """); + } + description.AppendLine("
名称预算硬性支出
{item.name}{item.limit:N0}{(item.isMandatory ? "是" : "否")}
"); + description.AppendLine($""" +

+ 支出合计: + + {monthlyExpenseItems.Sum(item => item.limit):N0} + +

+ """); + #endregion + + #region 构建发生在本月的年度预算收入支出明细表格 + if (yearlyIncomeItems.Any()) + { + description.AppendLine("

年度收入预算(发生在本月)

"); + description.AppendLine(""" + + + + + + + + + + """); + + foreach (var item in yearlyIncomeItems) + { + description.AppendLine($""" + + + + + + """); + } + + description.AppendLine("
名称预算本月收入
{item.name}{(item.limit == 0 ? "不限额" : item.limit.ToString("N0"))}{item.current:N0}
"); + description.AppendLine($""" +

+ 收入合计: + + {yearlyIncomeItems.Sum(item => item.current):N0} + +

+ """); + } + + if (yearlyExpenseItems.Any()) + { + description.AppendLine("

年度支出预算(发生在本月)

"); + description.AppendLine(""" + + + + + + + + + + """); + foreach (var item in yearlyExpenseItems) + { + description.AppendLine($""" + + + + + + """); + } + description.AppendLine("
名称预算本月支出
{item.name}{(item.limit == 0 ? "不限额" : item.limit.ToString("N0"))}{item.current:N0}
"); + 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($""" + + + + + + + + """); + } + description.AppendLine("
名称预算合计实际
{name}{(limit == 0 ? "不限额" : limit.ToString("N0"))}{FormatMonths(months)}{limit * months.Length:N0}{current:N0}
"); + 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($""" + + + + + + + """); + } + + // 年预算 + foreach (var (_, name, limit, _, _, _) in currentYearlyIncomeItems) + { + description.AppendLine($""" + + + + + + + """); + } + description.AppendLine("
名称预算月/年合计
{name}{(limit == 0 ? "不限额" : limit.ToString("N0"))}{FormatMonthsByFactor(factor)}{(limit == 0 ? "不限额" : (limit * factor).ToString("N0"))}
{name}{(limit == 0 ? "不限额" : limit.ToString("N0"))}{year}年{(limit == 0 ? "不限额" : limit.ToString("N0"))}
"); + + 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($""" + + + + + + + + """); + } + description.AppendLine("
名称预算合计实际
{name}{(limit == 0 ? "不限额" : limit.ToString("N0"))}{FormatMonths(months)}{limit * months.Length:N0}{current:N0}
"); + + 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($""" + + + + + + + """); + } + + // 年预算 + foreach (var (_, name, limit, _, _, _) in currentYearlyExpenseItems) + { + description.AppendLine($""" + + + + + + + """); + } + description.AppendLine("
名称预算月/年合计
{name}{(limit == 0 ? "不限额" : limit.ToString("N0"))}{FormatMonthsByFactor(factor)}{(limit == 0 ? "不限额" : (limit * factor).ToString("N0"))}
{name}{(limit == 0 ? "不限额" : limit.ToString("N0"))}{year}年{(limit == 0 ? "不限额" : limit.ToString("N0"))}
"); + + // 合计 + 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($""" - - - - - - - - """); - } - } - else - { - description.Append(""" -
名称当前预算剩余月数历史归档合计
{item.Name}{item.Limit:N0}{item.Factor:0.##}{item.Historical:N0}{item.Total:N0}
- - - - - - - - - - """); - foreach (var item in incomeItems) - { - description.Append($""" - - - - - - - """); - } - } - description.Append("
名称金额折算合计
{item.Name}{item.Limit:N0}{item.Factor:0.##}{item.Total:N0}
"); - } - description.Append($"

收入合计: {incomeLimitAtPeriod:N0}

"); - - if (periodType == BudgetPeriodType.Year && noLimitIncomeItems.Count > 0) - { - description.Append("

不记额收入明细

"); - description.Append(""" - - - - - - - - - """); - foreach (var (name, amount) in noLimitIncomeItems) - { - description.Append($""" - - - - - """); - } - description.Append("
预算名称实际发生
{name}{amount:N0}
"); - 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($""" - - - - - - - - """); - } - } - else - { - description.Append(""" -
名称当前预算剩余月数历史归档合计
{item.Name}{item.Limit:N0}{item.Factor:0.##}{item.Historical:N0}{item.Total:N0}
- - - - - - - - - - """); - foreach (var item in expenseItems) - { - description.Append($""" - - - - - - - """); - } - } - description.Append("
名称金额折算合计
{item.Name}{item.Limit:N0}{item.Factor:0.##}{item.Total:N0}
"); - } - description.Append($"

支出合计: {expenseLimitAtPeriod:N0}

"); - - if (periodType == BudgetPeriodType.Year && noLimitExpenseItems.Count > 0) - { - description.Append("

不记额支出明细

"); - description.Append(""" - - - - - - - - - """); - foreach (var (name, amount) in noLimitExpenseItems) - { - description.Append($""" - - - - - """); - } - description.Append("
预算名称实际发生
{name}{amount:N0}
"); - 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($""" - - - - - - - """); - } - - var totalClass = historicalSurplus >= 0 ? "income-value" : "expense-value"; - description.Append($""" - - - - - - -
月份支出结余收入结余合计
{archive.Month}月{archive.ExpenseSurplus:N0}{archive.IncomeSurplus:N0}{monthlyTotal:N0}
汇总{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 @@