using Service.Transaction;
namespace Service.Budget;
public interface IBudgetSavingsService
{
Task GetSavingsDtoAsync(
BudgetPeriodType periodType,
DateTime? referenceDate = null,
IEnumerable? existingBudgets = null);
}
public class BudgetSavingsService(
IBudgetRepository budgetRepository,
IBudgetArchiveRepository budgetArchiveRepository,
ITransactionStatisticsService transactionStatisticsService,
IConfigService configService,
IDateTimeProvider dateTimeProvider
) : IBudgetSavingsService
{
public async Task GetSavingsDtoAsync(
BudgetPeriodType periodType,
DateTime? referenceDate = null,
IEnumerable? existingBudgets = null)
{
var budgets = existingBudgets;
if (existingBudgets == null)
{
budgets = await budgetRepository.GetAllAsync();
}
if (budgets == null)
{
throw new InvalidOperationException("No budgets found.");
}
budgets = budgets
// 排序顺序 1.硬性预算 2.月度->年度 3.实际金额倒叙
.OrderBy(b => b.IsMandatoryExpense)
.ThenBy(b => b.Type)
.ThenByDescending(b => b.Limit);
var year = referenceDate?.Year ?? dateTimeProvider.Now.Year;
var month = referenceDate?.Month ?? dateTimeProvider.Now.Month;
if (periodType == BudgetPeriodType.Month)
{
return await GetForMonthAsync(budgets, year, month);
}
else if (periodType == BudgetPeriodType.Year)
{
return await GetForYearAsync(budgets, year);
}
throw new NotSupportedException($"Period type {periodType} is not supported.");
}
private async Task GetForMonthAsync(
IEnumerable budgets,
int year,
int month)
{
var transactionClassify = await transactionStatisticsService.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);
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 (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 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 = -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,
currentActual,
new DateTime(year, month, 1),
description.ToString()
);
}
private async Task GetForYearAsync(
IEnumerable budgets,
int year)
{
// 因为非当前月份的读取归档数据,这边依然是读取当前月份的数据
var currentMonth = dateTimeProvider.Now.Month;
var transactionClassify = await transactionStatisticsService.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)
{
var isContinuous = true;
for (var 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) + "月";
}
}
}