Files
EmailBill/Service/Budget/BudgetSavingsService.cs

1120 lines
40 KiB
C#
Raw Normal View History

using Service.Transaction;
2026-01-19 13:39:59 +08:00
namespace Service.Budget;
public interface IBudgetSavingsService
{
Task<BudgetResult> GetSavingsDtoAsync(
BudgetPeriodType periodType,
DateTime? referenceDate = null,
IEnumerable<BudgetRecord>? existingBudgets = null);
}
public class BudgetSavingsService(
IBudgetRepository budgetRepository,
2026-01-20 19:11:05 +08:00
IBudgetArchiveRepository budgetArchiveRepository,
2026-01-28 10:58:15 +08:00
ITransactionStatisticsService transactionStatisticsService,
2026-01-20 19:11:05 +08:00
IConfigService configService,
IDateTimeProvider dateTimeProvider
2026-01-19 13:39:59 +08:00
) : IBudgetSavingsService
{
public async Task<BudgetResult> GetSavingsDtoAsync(
BudgetPeriodType periodType,
DateTime? referenceDate = null,
IEnumerable<BudgetRecord>? 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);
2026-01-20 19:11:05 +08:00
var year = referenceDate?.Year ?? dateTimeProvider.Now.Year;
var month = referenceDate?.Month ?? dateTimeProvider.Now.Month;
if (periodType == BudgetPeriodType.Month)
2026-01-19 13:39:59 +08:00
{
2026-01-20 19:11:05 +08:00
return await GetForMonthAsync(budgets, year, month);
2026-01-19 13:39:59 +08:00
}
2026-01-20 19:11:05 +08:00
else if (periodType == BudgetPeriodType.Year)
2026-01-19 13:39:59 +08:00
{
return await GetForYearAsync(budgets, year);
}
throw new NotSupportedException($"Period type {periodType} is not supported.");
}
private async Task<BudgetResult> GetForMonthAsync(
IEnumerable<BudgetRecord> budgets,
2026-01-20 19:11:05 +08:00
int year,
2026-01-19 13:39:59 +08:00
int month)
{
2026-01-28 10:58:15 +08:00
var transactionClassify = await transactionStatisticsService.GetAmountGroupByClassifyAsync(
2026-01-20 19:11:05 +08:00
new DateTime(year, month, 1),
new DateTime(year, month, 1).AddMonths(1)
);
2026-01-19 13:39:59 +08:00
2026-01-20 19:11:05 +08:00
var monthlyIncomeItems = new List<(string name, decimal limit, decimal current, bool isMandatory)>();
var monthlyExpenseItems = new List<(string name, decimal limit, decimal current, bool isMandatory)>();
2026-01-19 13:39:59 +08:00
var monthlyBudgets = budgets
.Where(b => b.Type == BudgetPeriodType.Month);
2026-01-20 19:11:05 +08:00
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)>();
2026-01-19 13:39:59 +08:00
var yearlyBudgets = budgets
.Where(b => b.Type == BudgetPeriodType.Year);
2026-01-20 19:11:05 +08:00
// 只需要考虑实际发生在本月的年度预算 因为他会影响到月度的结余情况
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("<h3>月度预算收入明细</h3>");
description.AppendLine("""
<table>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
""");
foreach (var item in monthlyIncomeItems)
{
description.AppendLine($"""
<tr>
<td>{item.name}</td>
<td>{item.limit:N0}</td>
<td>{(item.isMandatory ? "是" : "否")}</td>
</tr>
""");
}
description.AppendLine("</tbody></table>");
description.AppendLine($"""
<p>
:
<span class='income-value'>
<strong>{monthlyIncomeItems.Sum(item => item.limit):N0}</strong>
</span>
</p>
""");
description.AppendLine("<h3>月度预算支出明细</h3>");
description.AppendLine("""
<table>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
""");
foreach (var item in monthlyExpenseItems)
{
description.AppendLine($"""
<tr>
<td>{item.name}</td>
<td>{item.limit:N0}</td>
<td>{(item.isMandatory ? "是" : "否")}</td>
</tr>
""");
}
description.AppendLine("</tbody></table>");
description.AppendLine($"""
<p>
:
<span class='expense-value'>
<strong>{monthlyExpenseItems.Sum(item => item.limit):N0}</strong>
</span>
</p>
""");
#endregion
2026-01-19 13:39:59 +08:00
2026-01-20 19:11:05 +08:00
#region
if (yearlyIncomeItems.Any())
{
description.AppendLine("<h3>年度收入预算(发生在本月)</h3>");
description.AppendLine("""
<table>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
""");
foreach (var item in yearlyIncomeItems)
{
description.AppendLine($"""
<tr>
<td>{item.name}</td>
<td>{(item.limit == 0 ? "不限额" : item.limit.ToString("N0"))}</td>
<td><span class='income-value'>{item.current:N0}</span></td>
</tr>
""");
}
description.AppendLine("</tbody></table>");
description.AppendLine($"""
<p>
:
<span class='income-value'>
<strong>{yearlyIncomeItems.Sum(item => item.current):N0}</strong>
</span>
</p>
""");
}
if (yearlyExpenseItems.Any())
{
description.AppendLine("<h3>年度支出预算(发生在本月)</h3>");
description.AppendLine("""
<table>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
""");
foreach (var item in yearlyExpenseItems)
{
description.AppendLine($"""
<tr>
<td>{item.name}</td>
<td>{(item.limit == 0 ? "不限额" : item.limit.ToString("N0"))}</td>
<td><span class='expense-value'>{item.current:N0}</span></td>
</tr>
""");
}
description.AppendLine("</tbody></table>");
description.AppendLine($"""
<p>
:
<span class='expense-value'>
<strong>{yearlyExpenseItems.Sum(item => item.current):N0}</strong>
</span>
</p>
""");
}
#endregion
#region
description.AppendLine("<h3>存款计划结论</h3>");
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($"""
<p>
:
<span class='income-value'>
<strong>{expectedSavings:N0}</strong>
</span>
=
</p>
<p>
&nbsp;&nbsp;&nbsp;&nbsp;
:
<span class='income-value'>
<strong>{monthlyIncomeItems.Sum(item => item.limit):N0}</strong>
</span>
</p>
""");
if (yearlyIncomeItems.Count > 0)
{
description.AppendLine($"""
<p>
&nbsp;&nbsp;&nbsp;&nbsp;
+ :
<span class='income-value'>
<strong>{yearlyIncomeItems.Sum(item => item.current):N0}</strong>
</span>
</p>
""");
}
description.AppendLine($"""
<p>
&nbsp;&nbsp;&nbsp;&nbsp;
- :
<span class='expense-value'>
<strong>{monthlyExpenseItems.Sum(item => item.limit):N0}</strong>
</span>
""");
if (yearlyExpenseItems.Count > 0)
{
description.AppendLine($"""
<p>
&nbsp;&nbsp;&nbsp;&nbsp;
- :
<span class='expense-value'>
<strong>{yearlyExpenseItems.Sum(item => item.current):N0}</strong>
</span>
</p>
""");
}
description.AppendLine($"""
</p>
""");
#endregion
2026-01-19 13:39:59 +08:00
2026-01-20 19:11:05 +08:00
var savingsCategories = await configService.GetConfigByKeyAsync<string>("SavingsCategories") ?? string.Empty;
2026-01-20 19:26:36 +08:00
var currentActual = 0m;
if (!string.IsNullOrEmpty(savingsCategories))
{
2026-01-30 10:41:19 +08:00
var cats = new HashSet<string>(savingsCategories.Split(',', StringSplitOptions.RemoveEmptyEntries));
foreach (var kvp in transactionClassify)
{
if (cats.Contains(kvp.Key.Item1))
{
currentActual += kvp.Value;
}
}
2026-01-20 19:26:36 +08:00
}
2026-01-20 19:11:05 +08:00
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
};
// 生成明细数据
var referenceDate = new DateTime(year, month, dateTimeProvider.Now.Day);
var details = GenerateMonthlyDetails(
monthlyIncomeItems,
monthlyExpenseItems,
yearlyIncomeItems,
yearlyExpenseItems,
referenceDate
);
var result = BudgetResult.FromEntity(
2026-01-20 19:11:05 +08:00
record,
2026-01-20 19:26:36 +08:00
currentActual,
2026-01-20 19:11:05 +08:00
new DateTime(year, month, 1),
description.ToString()
);
result.Details = details;
return result;
2026-01-19 13:39:59 +08:00
}
private async Task<BudgetResult> GetForYearAsync(
IEnumerable<BudgetRecord> budgets,
int year)
{
2026-01-20 19:11:05 +08:00
// 因为非当前月份的读取归档数据,这边依然是读取当前月份的数据
var currentMonth = dateTimeProvider.Now.Month;
2026-01-28 10:58:15 +08:00
var transactionClassify = await transactionStatisticsService.GetAmountGroupByClassifyAsync(
2026-01-20 19:11:05 +08:00
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)>();
2026-02-01 10:27:04 +08:00
var archiveSavingsItems = new List<(long id, string name, int[] months, decimal limit, decimal current)>();
2026-01-20 19:11:05 +08:00
// 获取归档数据
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,
2026-02-01 10:27:04 +08:00
BudgetCategory.Savings => archiveSavingsItems,
2026-01-20 19:11:05 +08:00
_ => 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("<h3>已归档收入明细</h3>");
description.AppendLine("""
<table>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
</tbody>
""");
// 已归档的收入
foreach (var (_, name, months, limit, current) in archiveIncomeItems)
{
description.AppendLine($"""
<tr>
<td>{name}</td>
<td>{(limit == 0 ? "不限额" : limit.ToString("N0"))}</td>
<td>{FormatMonths(months)}</td>
<td>{limit * months.Length:N0}</td>
<td><span class='income-value'>{current:N0}</span></td>
</tr>
""");
}
description.AppendLine("</tbody></table>");
archiveIncomeDiff = archiveIncomeItems.Sum(i => i.current) - archiveIncomeItems.Sum(i => i.limit * i.months.Length);
description.AppendLine($"""
<p>
2026-01-20 19:13:33 +08:00
<span class="highlight">: </span>
2026-01-20 19:11:05 +08:00
{(archiveIncomeDiff > 0 ? "超额收入" : "未达预期")}:
<span class='{(archiveIncomeDiff > 0 ? "income-value" : "expense-value")}'>
<strong>{archiveIncomeDiff:N0}</strong>
</span>
=
<span class='income-value'>
<strong>{archiveIncomeItems.Sum(i => i.limit * i.months.Length):N0}</strong>
</span>
-
:
<span class='income-value'>
<strong>{archiveIncomeItems.Sum(i => i.current):N0}</strong>
</span>
</p>
""");
}
#endregion
#region
description.AppendLine("<h3>预算收入明细</h3>");
description.AppendLine("""
<table>
<thead>
<tr>
<th></th>
<th></th>
<th>/</th>
<th></th>
</tr>
</thead>
<tbody>
""");
// 当前预算
foreach (var (_, name, limit, factor, _, _) in currentMonthlyIncomeItems)
{
description.AppendLine($"""
<tr>
<td>{name}</td>
<td>{(limit == 0 ? "不限额" : limit.ToString("N0"))}</td>
<td>{FormatMonthsByFactor(factor)}</td>
<td>{(limit == 0 ? "不限额" : (limit * factor).ToString("N0"))}</td>
</tr>
""");
}
// 年预算
foreach (var (_, name, limit, _, _, _) in currentYearlyIncomeItems)
{
description.AppendLine($"""
<tr>
<td>{name}</td>
<td>{(limit == 0 ? "不限额" : limit.ToString("N0"))}</td>
<td>{year}</td>
<td>{(limit == 0 ? "不限额" : limit.ToString("N0"))}</td>
</tr>
""");
}
description.AppendLine("</tbody></table>");
2026-01-30 10:41:19 +08:00
2026-01-20 19:11:05 +08:00
description.AppendLine($"""
<p>
:
<span class='expense-value'>
<strong>
2026-01-30 10:41:19 +08:00
{currentMonthlyIncomeItems.Sum(i => i.limit * i.factor)
+ currentYearlyIncomeItems.Sum(i => i.limit):N0}
2026-01-20 19:11:05 +08:00
</strong>
</span>
</p>
""");
#endregion
#region
var archiveExpenseDiff = 0m;
if (archiveExpenseItems.Any())
{
description.AppendLine("<h3>已归档支出明细</h3>");
description.AppendLine("""
<table>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
""");
// 已归档的支出
foreach (var (_, name, months, limit, current) in archiveExpenseItems)
{
description.AppendLine($"""
<tr>
<td>{name}</td>
<td>{(limit == 0 ? "不限额" : limit.ToString("N0"))}</td>
<td>{FormatMonths(months)}</td>
<td>{limit * months.Length:N0}</td>
<td><span class='expense-value'>{current:N0}</span></td>
</tr>
""");
}
description.AppendLine("</tbody></table>");
2026-01-30 10:41:19 +08:00
2026-01-20 19:11:05 +08:00
archiveExpenseDiff = archiveExpenseItems.Sum(i => i.limit * i.months.Length) - archiveExpenseItems.Sum(i => i.current);
description.AppendLine($"""
<p>
2026-01-20 19:13:33 +08:00
<span class="highlight">: </span>
2026-01-20 19:11:05 +08:00
{(archiveExpenseDiff > 0 ? "节省支出" : "超支")}:
<span class='{(archiveExpenseDiff > 0 ? "income-value" : "expense-value")}'>
<strong>{archiveExpenseDiff:N0}</strong>
</span>
=
<span class='expense-value'>
<strong>{archiveExpenseItems.Sum(i => i.limit * i.months.Length):N0}</strong>
</span>
- :
<span class='expense-value'>
<strong>{archiveExpenseItems.Sum(i => i.current):N0}</strong>
</span>
</p>
""");
}
#endregion
2026-02-01 10:27:04 +08:00
#region
var archiveSavingsDiff = 0m;
if (archiveSavingsItems.Any())
{
description.AppendLine("<h3>已归档存款明细</h3>");
description.AppendLine("""
<table>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
""");
// 已归档的存款
foreach (var (_, name, months, limit, current) in archiveSavingsItems)
{
description.AppendLine($"""
<tr>
<td>{name}</td>
<td>{(limit == 0 ? "不限额" : limit.ToString("N0"))}</td>
<td>{FormatMonths(months)}</td>
<td>{limit * months.Length:N0}</td>
<td><span class='income-value'>{current:N0}</span></td>
</tr>
""");
}
description.AppendLine("</tbody></table>");
2026-01-20 19:11:05 +08:00
2026-02-01 10:27:04 +08:00
archiveSavingsDiff = archiveSavingsItems.Sum(i => i.current) - archiveSavingsItems.Sum(i => i.limit * i.months.Length);
description.AppendLine($"""
<p>
<span class="highlight">: </span>
{(archiveSavingsDiff > 0 ? "超额存款" : "未达预期")}:
<span class='{(archiveSavingsDiff > 0 ? "income-value" : "expense-value")}'>
<strong>{archiveSavingsDiff:N0}</strong>
</span>
=
:
<span class='income-value'>
<strong>{archiveSavingsItems.Sum(i => i.current):N0}</strong>
</span>
-
:
<span class='income-value'>
<strong>{archiveSavingsItems.Sum(i => i.limit * i.months.Length):N0}</strong>
</span>
</p>
""");
}
#endregion
2026-01-20 19:11:05 +08:00
#region
description.AppendLine("<h3>预算支出明细</h3>");
description.AppendLine("""
<table>
<thead>
<tr>
<th></th>
<th></th>
<th>/</th>
<th></th>
</tr>
</thead>
<tbody>
""");
// 未来月预算
foreach (var (_, name, limit, factor, _, _) in currentMonthlyExpenseItems)
{
description.AppendLine($"""
<tr>
<td>{name}</td>
<td>{(limit == 0 ? "不限额" : limit.ToString("N0"))}</td>
<td>{FormatMonthsByFactor(factor)}</td>
<td>{(limit == 0 ? "不限额" : (limit * factor).ToString("N0"))}</td>
</tr>
""");
}
// 年预算
foreach (var (_, name, limit, _, _, _) in currentYearlyExpenseItems)
{
description.AppendLine($"""
<tr>
<td>{name}</td>
<td>{(limit == 0 ? "不限额" : limit.ToString("N0"))}</td>
<td>{year}</td>
<td>{(limit == 0 ? "不限额" : limit.ToString("N0"))}</td>
</tr>
""");
}
description.AppendLine("</tbody></table>");
// 合计
description.AppendLine($"""
<p>
:
<span class='expense-value'>
<strong>
2026-01-30 10:41:19 +08:00
{currentMonthlyExpenseItems.Sum(i => i.limit * i.factor)
+ currentYearlyExpenseItems.Sum(i => i.limit):N0}
2026-01-20 19:11:05 +08:00
</strong>
</span>
</p>
""");
#endregion
#region
var archiveIncomeBudget = archiveIncomeItems.Sum(i => i.limit * i.months.Length);
var archiveExpenseBudget = archiveExpenseItems.Sum(i => i.limit * i.months.Length);
2026-02-01 10:27:04 +08:00
// 如果有归档存款数据,直接使用;否则用收入-支出计算
var archiveSavings = archiveSavingsItems.Any()
? archiveSavingsItems.Sum(i => i.current)
: archiveIncomeBudget - archiveExpenseBudget + archiveIncomeDiff + archiveExpenseDiff;
2026-01-20 19:11:05 +08:00
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("<h3>存款计划结论</h3>");
description.AppendLine($"""
<p>
<strong></strong>
<span class='income-value'><strong>{archiveSavings:N0}</strong></span>
=
: <span class='income-value'>{archiveIncomeBudget:N0}</span>
-
: <span class='expense-value'>{archiveExpenseBudget:N0}</span>
{(archiveIncomeDiff >= 0 ? " + 超额收入" : " - 未达预期收入")}: <span class='{(archiveIncomeDiff >= 0 ? "income-value" : "expense-value")}'>{(archiveIncomeDiff >= 0 ? archiveIncomeDiff : -archiveIncomeDiff):N0}</span>
{(archiveExpenseDiff >= 0 ? " + 节省支出" : " - 超额支出")}: <span class='{(archiveExpenseDiff >= 0 ? "income-value" : "expense-value")}'>{(archiveExpenseDiff >= 0 ? archiveExpenseDiff : -archiveExpenseDiff):N0}</span>
</p>
<p>
<strong></strong>
<span class='income-value'><strong>{expectedSavings:N0}</strong></span>
=
: <span class='income-value'>{expectedIncome:N0}</span>
-
: <span class='expense-value'>{expectedExpense:N0}</span>
</p>
<p>
<strong></strong>
<span class='{(archiveSavings + expectedSavings > 0 ? "income-value" : "expense-value")}'>
<strong>{archiveSavings + expectedSavings:N0}</strong>
</span>
=
:
<span class='income-value'>{expectedSavings:N0}</span>
{(archiveSavings > 0 ? "+" : "-")}
:
<span class='{(archiveSavings > 0 ? "income-value" : "expense-value")}'>{Math.Abs(archiveSavings):N0}</span>
</p>
""");
#endregion
var savingsCategories = await configService.GetConfigByKeyAsync<string>("SavingsCategories") ?? string.Empty;
2026-01-30 10:41:19 +08:00
2026-01-20 19:11:05 +08:00
var currentActual = 0m;
2026-02-11 13:00:01 +08:00
// 1. 先累加已归档月份的存款金额
if (archiveSavingsItems.Any())
{
currentActual += archiveSavingsItems.Sum(i => i.current);
}
// 2. 再累加当前月的存款金额
2026-01-20 19:11:05 +08:00
if (!string.IsNullOrEmpty(savingsCategories))
{
2026-01-30 10:41:19 +08:00
var cats = new HashSet<string>(savingsCategories.Split(',', StringSplitOptions.RemoveEmptyEntries));
foreach (var kvp in transactionClassify)
{
if (cats.Contains(kvp.Key.Item1))
{
currentActual += kvp.Value;
}
}
2026-01-20 19:11:05 +08:00
}
2026-02-11 13:00:01 +08:00
2026-01-20 19:11:05 +08:00
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)
{
2026-01-28 17:00:58 +08:00
var isContinuous = true;
for (var i = 1; i < months.Length; i++)
2026-01-20 19:11:05 +08:00
{
if (months[i] != months[i - 1] + 1)
{
isContinuous = false;
break;
}
}
if (isContinuous)
{
return $"{months.First()}~{months.Last()}月";
}
}
return string.Join(", ", months) + "月";
}
2026-01-19 13:39:59 +08:00
}
/// <summary>
/// 计算月度计划存款
/// 公式:收入预算 + 发生在本月的年度收入 - 支出预算 - 发生在本月的年度支出
/// </summary>
public static decimal CalculateMonthlyPlannedSavings(
decimal monthlyIncomeBudget,
decimal yearlyIncomeInThisMonth,
decimal monthlyExpenseBudget,
decimal yearlyExpenseInThisMonth)
{
return monthlyIncomeBudget + yearlyIncomeInThisMonth
- monthlyExpenseBudget - yearlyExpenseInThisMonth;
}
/// <summary>
/// 计算年度计划存款
/// 公式:归档月已实收 + 未来月收入预算 - 归档月已实支 - 未来月支出预算
/// </summary>
public static decimal CalculateYearlyPlannedSavings(
decimal archivedIncome,
decimal futureIncomeBudget,
decimal archivedExpense,
decimal futureExpenseBudget)
{
return archivedIncome + futureIncomeBudget
- archivedExpense - futureExpenseBudget;
}
/// <summary>
/// 生成月度存款明细数据
/// </summary>
private SavingsDetail GenerateMonthlyDetails(
List<(string name, decimal limit, decimal current, bool isMandatory)> monthlyIncomeItems,
List<(string name, decimal limit, decimal current, bool isMandatory)> monthlyExpenseItems,
List<(string name, decimal limit, decimal current)> yearlyIncomeItems,
List<(string name, decimal limit, decimal current)> yearlyExpenseItems,
DateTime referenceDate)
{
var incomeDetails = new List<BudgetDetailItem>();
var expenseDetails = new List<BudgetDetailItem>();
// 处理月度收入
foreach (var item in monthlyIncomeItems)
{
var effectiveAmount = BudgetItemCalculator.CalculateEffectiveAmount(
BudgetCategory.Income,
item.limit,
item.current,
item.isMandatory,
isArchived: false,
referenceDate,
BudgetPeriodType.Month
);
var note = BudgetItemCalculator.GenerateCalculationNote(
BudgetCategory.Income,
item.limit,
item.current,
effectiveAmount,
item.isMandatory,
isArchived: false
);
incomeDetails.Add(new BudgetDetailItem
{
Id = 0, // 临时ID
Name = item.name,
Type = BudgetPeriodType.Month,
BudgetLimit = item.limit,
ActualAmount = item.current,
EffectiveAmount = effectiveAmount,
CalculationNote = note,
IsOverBudget = item.current > 0 && item.current < item.limit,
IsArchived = false
});
}
// 处理月度支出
foreach (var item in monthlyExpenseItems)
{
var effectiveAmount = BudgetItemCalculator.CalculateEffectiveAmount(
BudgetCategory.Expense,
item.limit,
item.current,
item.isMandatory,
isArchived: false,
referenceDate,
BudgetPeriodType.Month
);
var note = BudgetItemCalculator.GenerateCalculationNote(
BudgetCategory.Expense,
item.limit,
item.current,
effectiveAmount,
item.isMandatory,
isArchived: false
);
expenseDetails.Add(new BudgetDetailItem
{
Id = 0,
Name = item.name,
Type = BudgetPeriodType.Month,
BudgetLimit = item.limit,
ActualAmount = item.current,
EffectiveAmount = effectiveAmount,
CalculationNote = note,
IsOverBudget = item.current > item.limit,
IsArchived = false
});
}
// 处理年度收入(发生在本月的)
foreach (var item in yearlyIncomeItems)
{
incomeDetails.Add(new BudgetDetailItem
{
Id = 0,
Name = item.name,
Type = BudgetPeriodType.Year,
BudgetLimit = item.limit,
ActualAmount = item.current,
EffectiveAmount = item.current, // 年度预算发生在本月的直接用实际值
CalculationNote = "使用实际",
IsOverBudget = false,
IsArchived = false
});
}
// 处理年度支出(发生在本月的)
foreach (var item in yearlyExpenseItems)
{
expenseDetails.Add(new BudgetDetailItem
{
Id = 0,
Name = item.name,
Type = BudgetPeriodType.Year,
BudgetLimit = item.limit,
ActualAmount = item.current,
EffectiveAmount = item.current,
CalculationNote = "使用实际",
IsOverBudget = item.current > item.limit,
IsArchived = false
});
}
// 计算汇总
var totalIncome = incomeDetails.Sum(i => i.EffectiveAmount);
var totalExpense = expenseDetails.Sum(e => e.EffectiveAmount);
var plannedSavings = totalIncome - totalExpense;
var formula = $"收入 {totalIncome:N0} - 支出 {totalExpense:N0} = {plannedSavings:N0}";
return new SavingsDetail
{
IncomeItems = incomeDetails,
ExpenseItems = expenseDetails,
Summary = new SavingsCalculationSummary
{
TotalIncomeBudget = totalIncome,
TotalExpenseBudget = totalExpense,
PlannedSavings = plannedSavings,
CalculationFormula = formula
}
};
}
}