重构存款预算
Some checks failed
Docker Build & Deploy / Build Docker Image (push) Failing after 44s
Docker Build & Deploy / Deploy to Production (push) Has been skipped
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 3s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s

This commit is contained in:
SunCheng
2026-01-20 19:11:05 +08:00
parent 44d9fbb0f6
commit 0ffeb41605
13 changed files with 1591 additions and 583 deletions

View File

@@ -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<BudgetResult> 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<BudgetResult> GetForMonthAsync(
IEnumerable<BudgetRecord> 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("<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>
&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
var savingsCategories = await configService.GetConfigByKeyAsync<string>("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<BudgetResult> GetForYearAsync(
IEnumerable<BudgetRecord> 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("<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>
</p>
<p>
{(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>
</p>
<p>
{(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) + "月";
}
}
}

View File

@@ -30,9 +30,9 @@ public class BudgetService(
IBudgetArchiveRepository budgetArchiveRepository,
ITransactionRecordRepository transactionRecordRepository,
IOpenAiService openAiService,
IConfigService configService,
IMessageService messageService,
ILogger<BudgetService> logger
ILogger<BudgetService> logger,
IBudgetSavingsService budgetSavingsService
) : IBudgetService
{
public async Task<List<BudgetResult>> 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<BudgetResult?> 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<BudgetCategoryStats> GetCategoryStatsAsync(BudgetCategory category, DateTime referenceDate)
@@ -595,484 +595,6 @@ public class BudgetService(
return (start, end);
}
private async Task<BudgetResult?> GetSavingsDtoAsync(
BudgetPeriodType periodType,
DateTime? referenceDate = null,
IEnumerable<BudgetRecord>? 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<long>();
foreach (var b in allBudgets)
{
if (b.Category == BudgetCategory.Savings) continue;
processedIds.Add(b.Id);
decimal factor;
decimal historicalAmount = 0m;
var historicalMonths = new List<int>();
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("<h3>预算收入明细</h3>");
if (incomeItems.Count == 0) description.Append("<p>无收入预算</p>");
else
{
// 根据是否有历史数据决定表格列
var hasHistoricalData = incomeItems.Any(i => i.Historical > 0);
if (hasHistoricalData)
{
description.Append("""
<table>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
""");
foreach (var item in incomeItems)
{
description.Append($"""
<tr>
<td>{item.Name}</td>
<td>{item.Limit:N0}</td>
<td>{item.Factor:0.##}</td>
<td>{item.Historical:N0}</td>
<td><span class='income-value'>{item.Total:N0}</span></td>
</tr>
""");
}
}
else
{
description.Append("""
<table>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
""");
foreach (var item in incomeItems)
{
description.Append($"""
<tr>
<td>{item.Name}</td>
<td>{item.Limit:N0}</td>
<td>{item.Factor:0.##}</td>
<td><span class='income-value'>{item.Total:N0}</span></td>
</tr>
""");
}
}
description.Append("</tbody></table>");
}
description.Append($"<p>收入合计: <span class='income-value'><strong>{incomeLimitAtPeriod:N0}</strong></span></p>");
if (periodType == BudgetPeriodType.Year && noLimitIncomeItems.Count > 0)
{
description.Append("<h3>不记额收入明细</h3>");
description.Append("""
<table>
<thead>
<tr>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
""");
foreach (var (name, amount) in noLimitIncomeItems)
{
description.Append($"""
<tr>
<td>{name}</td>
<td><span class='income-value'>{amount:N0}</span></td>
</tr>
""");
}
description.Append("</tbody></table>");
description.Append($"<p>不记额收入合计: <span class='income-value'><strong>{noLimitIncomeAtPeriod:N0}</strong></span></p>");
}
description.Append("<h3>预算支出明细</h3>");
if (expenseItems.Count == 0) description.Append("<p>无支出预算</p>");
else
{
// 根据是否有历史数据决定表格列
var hasHistoricalData = expenseItems.Any(i => i.Historical > 0);
if (hasHistoricalData)
{
description.Append("""
<table>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
""");
foreach (var item in expenseItems)
{
description.Append($"""
<tr>
<td>{item.Name}</td>
<td>{item.Limit:N0}</td>
<td>{item.Factor:0.##}</td>
<td>{item.Historical:N0}</td>
<td><span class='expense-value'>{item.Total:N0}</span></td>
</tr>
""");
}
}
else
{
description.Append("""
<table>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
""");
foreach (var item in expenseItems)
{
description.Append($"""
<tr>
<td>{item.Name}</td>
<td>{item.Limit:N0}</td>
<td>{item.Factor:0.##}</td>
<td><span class='expense-value'>{item.Total:N0}</span></td>
</tr>
""");
}
}
description.Append("</tbody></table>");
}
description.Append($"<p>支出合计: <span class='expense-value'><strong>{expenseLimitAtPeriod:N0}</strong></span></p>");
if (periodType == BudgetPeriodType.Year && noLimitExpenseItems.Count > 0)
{
description.Append("<h3>不记额支出明细</h3>");
description.Append("""
<table>
<thead>
<tr>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
""");
foreach (var (name, amount) in noLimitExpenseItems)
{
description.Append($"""
<tr>
<td>{name}</td>
<td><span class='expense-value'>{amount:N0}</span></td>
</tr>
""");
}
description.Append("</tbody></table>");
description.Append($"<p>不记额支出合计: <span class='expense-value'><strong>{noLimitExpenseAtPeriod:N0}</strong></span></p>");
}
description.Append("<h3>存款计划结论</h3>");
// 修改计算公式:包含不记额收入和支出
var totalIncome = incomeLimitAtPeriod + noLimitIncomeAtPeriod;
var totalExpense = expenseLimitAtPeriod + noLimitExpenseAtPeriod;
description.Append($"<p>计划收入 = 预算 <span class='income-value'>{incomeLimitAtPeriod:N0}</span> + 不记额 <span class='income-value'>{noLimitIncomeAtPeriod:N0}</span> = <span class='income-value'><strong>{totalIncome:N0}</strong></span></p>");
description.Append($"<p>计划支出 = 预算 <span class='expense-value'>{expenseLimitAtPeriod:N0}</span> + 不记额 <span class='expense-value'>{noLimitExpenseAtPeriod:N0}</span> = <span class='expense-value'><strong>{totalExpense:N0}</strong></span></p>");
description.Append($"<p>计划盈余 = 计划收入 <span class='income-value'>{totalIncome:N0}</span> - 计划支出 <span class='expense-value'>{totalExpense:N0}</span> = <span class='income-value'><strong>{totalIncome - totalExpense:N0}</strong></span></p>");
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("<h3>历史月份盈亏</h3>");
description.Append("""
<table>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
""");
foreach (var archive in archives)
{
var monthlyTotal = archive.ExpenseSurplus + archive.IncomeSurplus;
var monthlyClass = monthlyTotal >= 0 ? "income-value" : "expense-value";
description.Append($"""
<tr>
<td>{archive.Month}月</td>
<td><span class='{(archive.ExpenseSurplus >= 0 ? "income-value" : "expense-value")}'>{archive.ExpenseSurplus:N0}</span></td>
<td><span class='{(archive.IncomeSurplus >= 0 ? "income-value" : "expense-value")}'>{archive.IncomeSurplus:N0}</span></td>
<td><span class='{monthlyClass}'>{monthlyTotal:N0}</span></td>
</tr>
""");
}
var totalClass = historicalSurplus >= 0 ? "income-value" : "expense-value";
description.Append($"""
<tr>
<td><strong>汇总</strong></td>
<td><span class='{(expenseSurplus >= 0 ? "income-value" : "expense-value")}'><strong>{expenseSurplus:N0}</strong></span></td>
<td><span class='{(incomeSurplus >= 0 ? "income-value" : "expense-value")}'><strong>{incomeSurplus:N0}</strong></span></td>
<td><span class='{totalClass}'><strong>{historicalSurplus:N0}</strong></span></td>
</tr>
</tbody></table>
""");
}
var finalGoal = totalIncome - totalExpense + historicalSurplus;
description.Append($"""
<p>
动态目标 = 计划盈余 <span class='{(totalIncome - totalExpense >= 0 ? "income-value" : "expense-value")}'>{totalIncome - totalExpense:N0}</span>
+ <span class='{(historicalSurplus >= 0 ? "income-value" : "expense-value")}'>{historicalSurplus:N0}</span>
= <span class='{(finalGoal >= 0 ? "income-value" : "expense-value")}'><strong>{finalGoal:N0}</strong></span></p>
""");
}
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<BudgetRecord> BuildVirtualSavingsBudgetRecordAsync(
long id,
DateTime date,
decimal limit)
{
var savingsCategories = await configService.GetConfigByKeyAsync<string>("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