All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 22s
Docker Build & Deploy / Deploy to Production (push) Successful in 7s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
- 创建 LogCleanupJob 替代 LogCleanupService (BackgroundService) - 在 Expand.cs 中注册 LogCleanupJob (每天凌晨2点执行, 保留30天日志) - 从 Program.cs 移除 LogCleanupService 的 HostedService 注册 - 删除 Service/LogCleanupService.cs - 删除 Service/PeriodicBillBackgroundService.cs (已无用的重复服务) 所有后台任务现在统一通过 Quartz.NET 管理, 支持运行时控制
873 lines
32 KiB
C#
873 lines
32 KiB
C#
using Service.Transaction;
|
||
|
||
namespace Service.Budget;
|
||
|
||
public interface IBudgetSavingsService
|
||
{
|
||
Task<BudgetResult> GetSavingsDtoAsync(
|
||
BudgetPeriodType periodType,
|
||
DateTime? referenceDate = null,
|
||
IEnumerable<BudgetRecord>? existingBudgets = null);
|
||
}
|
||
|
||
public class BudgetSavingsService(
|
||
IBudgetRepository budgetRepository,
|
||
IBudgetArchiveRepository budgetArchiveRepository,
|
||
ITransactionStatisticsService transactionStatisticsService,
|
||
IConfigService configService,
|
||
IDateTimeProvider dateTimeProvider
|
||
) : 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);
|
||
|
||
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<BudgetResult> GetForMonthAsync(
|
||
IEnumerable<BudgetRecord> 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("<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
|
||
|
||
#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>
|
||
|
||
计划收入:
|
||
<span class='income-value'>
|
||
<strong>{monthlyIncomeItems.Sum(item => item.limit):N0}</strong>
|
||
</span>
|
||
</p>
|
||
""");
|
||
if (yearlyIncomeItems.Count > 0)
|
||
{
|
||
description.AppendLine($"""
|
||
<p>
|
||
|
||
+ 本月发生的年度预算收入:
|
||
<span class='income-value'>
|
||
<strong>{yearlyIncomeItems.Sum(item => item.current):N0}</strong>
|
||
</span>
|
||
</p>
|
||
""");
|
||
}
|
||
description.AppendLine($"""
|
||
<p>
|
||
|
||
- 计划支出:
|
||
<span class='expense-value'>
|
||
<strong>{monthlyExpenseItems.Sum(item => item.limit):N0}</strong>
|
||
</span>
|
||
""");
|
||
if (yearlyExpenseItems.Count > 0)
|
||
{
|
||
description.AppendLine($"""
|
||
<p>
|
||
|
||
- 本月发生的年度预算支出:
|
||
<span class='expense-value'>
|
||
<strong>{yearlyExpenseItems.Sum(item => item.current):N0}</strong>
|
||
</span>
|
||
</p>
|
||
""");
|
||
}
|
||
description.AppendLine($"""
|
||
</p>
|
||
""");
|
||
#endregion
|
||
|
||
var savingsCategories = await configService.GetConfigByKeyAsync<string>("SavingsCategories") ?? string.Empty;
|
||
var currentActual = 0m;
|
||
if (!string.IsNullOrEmpty(savingsCategories))
|
||
{
|
||
var cats = new HashSet<string>(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<BudgetResult> GetForYearAsync(
|
||
IEnumerable<BudgetRecord> 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("<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>
|
||
<span class="highlight">已归档收入总结: </span>
|
||
{(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>");
|
||
|
||
description.AppendLine($"""
|
||
<p>
|
||
预算收入合计:
|
||
<span class='expense-value'>
|
||
<strong>
|
||
{
|
||
currentMonthlyIncomeItems.Sum(i => i.limit * i.factor)
|
||
+ currentYearlyIncomeItems.Sum(i => i.limit)
|
||
:N0}
|
||
</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>");
|
||
|
||
archiveExpenseDiff = archiveExpenseItems.Sum(i => i.limit * i.months.Length) - archiveExpenseItems.Sum(i => i.current);
|
||
description.AppendLine($"""
|
||
<p>
|
||
<span class="highlight">已归档支出总结: </span>
|
||
{(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
|
||
|
||
#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>
|
||
{
|
||
currentMonthlyExpenseItems.Sum(i => i.limit * i.factor)
|
||
+ currentYearlyExpenseItems.Sum(i => i.limit)
|
||
:N0}
|
||
</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);
|
||
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("<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;
|
||
|
||
var currentActual = 0m;
|
||
if (!string.IsNullOrEmpty(savingsCategories))
|
||
{
|
||
var cats = new HashSet<string>(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) + "月";
|
||
}
|
||
}
|
||
} |