重构存款预算
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
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:
11
Common/IDateTimeProvider.cs
Normal file
11
Common/IDateTimeProvider.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace Common;
|
||||
|
||||
public interface IDateTimeProvider
|
||||
{
|
||||
DateTime Now { get; }
|
||||
}
|
||||
|
||||
public class DateTimeProvider : IDateTimeProvider
|
||||
{
|
||||
public DateTime Now => DateTime.Now;
|
||||
}
|
||||
@@ -22,6 +22,7 @@ public static class ServiceExtension
|
||||
/// </summary>
|
||||
public static IServiceCollection AddServices(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IDateTimeProvider, DateTimeProvider>();
|
||||
// 扫描程序集
|
||||
var serviceAssembly = Assembly.Load("Service");
|
||||
var repositoryAssembly = Assembly.Load("Repository");
|
||||
|
||||
@@ -40,5 +40,7 @@
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
|
||||
<PackageVersion Include="xunit" Version="2.9.3"/>
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4"/>
|
||||
<PackageVersion Include="NSubstitute" Version="5.3.0" />
|
||||
<PackageVersion Include="FluentAssertions" Version="8.0.1" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -28,10 +28,6 @@ public class BudgetRepository(IFreeSql freeSql) : BaseRepository<BudgetRecord>(f
|
||||
{
|
||||
query = query.Where(t => t.Type == TransactionType.Income);
|
||||
}
|
||||
else if (budget.Category == BudgetCategory.Savings)
|
||||
{
|
||||
query = query.Where(t => t.Type == TransactionType.None);
|
||||
}
|
||||
|
||||
return await query.SumAsync(t => t.Amount);
|
||||
}
|
||||
@@ -41,8 +37,7 @@ public class BudgetRepository(IFreeSql freeSql) : BaseRepository<BudgetRecord>(f
|
||||
var records = await FreeSql.Select<BudgetRecord>()
|
||||
.Where(b => b.SelectedCategories.Contains(oldName) &&
|
||||
((type == TransactionType.Expense && b.Category == BudgetCategory.Expense) ||
|
||||
(type == TransactionType.Income && b.Category == BudgetCategory.Income) ||
|
||||
(type == TransactionType.None && b.Category == BudgetCategory.Savings)))
|
||||
(type == TransactionType.Income && b.Category == BudgetCategory.Income)))
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var record in records)
|
||||
|
||||
@@ -217,6 +217,8 @@ public interface ITransactionRecordRepository : IBaseRepository<TransactionRecor
|
||||
/// <param name="type">交易类型</param>
|
||||
/// <returns>影响行数</returns>
|
||||
Task<int> UpdateCategoryNameAsync(string oldName, string newName, TransactionType type);
|
||||
|
||||
Task<Dictionary<(string, TransactionType), decimal>> GetAmountGroupByClassifyAsync(DateTime startTime, DateTime endTime);
|
||||
}
|
||||
|
||||
public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<TransactionRecord>(freeSql), ITransactionRecordRepository
|
||||
@@ -379,7 +381,7 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
||||
var expense = g.Where(t => t.Type == TransactionType.Expense).Sum(t => Math.Abs(t.Amount));
|
||||
|
||||
var saving = 0m;
|
||||
if(!string.IsNullOrEmpty(savingClassify))
|
||||
if (!string.IsNullOrEmpty(savingClassify))
|
||||
{
|
||||
saving = g.Where(t => savingClassify.Split(',').Contains(t.Classify)).Sum(t => Math.Abs(t.Amount));
|
||||
}
|
||||
@@ -697,7 +699,7 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
||||
.ToListAsync();
|
||||
|
||||
return list.OrderBy(t => Math.Abs(Math.Abs(t.Amount) - absAmount))
|
||||
.ThenBy(x=> Math.Abs((x.OccurredAt - currentRecord.OccurredAt).TotalSeconds))
|
||||
.ThenBy(x => Math.Abs((x.OccurredAt - currentRecord.OccurredAt).TotalSeconds))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
@@ -757,6 +759,21 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
||||
.GroupBy(t => t.OccurredAt.Date)
|
||||
.ToDictionary(g => g.Key, g => g.Sum(x => Math.Abs(x.Amount)));
|
||||
}
|
||||
|
||||
public async Task<Dictionary<(string, TransactionType), decimal>> GetAmountGroupByClassifyAsync(DateTime startTime, DateTime endTime)
|
||||
{
|
||||
var result = await FreeSql.Select<TransactionRecord>()
|
||||
.Where(t => t.OccurredAt >= startTime && t.OccurredAt < endTime)
|
||||
.GroupBy(t => new { t.Classify, t.Type })
|
||||
.ToListAsync(g => new
|
||||
{
|
||||
g.Key.Classify,
|
||||
g.Key.Type,
|
||||
TotalAmount = g.Sum(g.Value.Amount - g.Value.RefundAmount)
|
||||
});
|
||||
|
||||
return result.ToDictionary(x => (x.Classify, x.Type), x => x.TotalAmount);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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;
|
||||
var year = referenceDate?.Year ?? dateTimeProvider.Now.Year;
|
||||
var month = referenceDate?.Month ?? dateTimeProvider.Now.Month;
|
||||
|
||||
if(periodType == BudgetPeriodType.Month)
|
||||
if (periodType == BudgetPeriodType.Month)
|
||||
{
|
||||
return await GetForMonthAsync(budgets, year, month);
|
||||
}
|
||||
else if(periodType == BudgetPeriodType.Year)
|
||||
else if (periodType == BudgetPeriodType.Year)
|
||||
{
|
||||
return await GetForYearAsync(budgets, year);
|
||||
}
|
||||
@@ -56,25 +59,809 @@ public class BudgetSavingsService(
|
||||
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>
|
||||
|
||||
计划收入:
|
||||
<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 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) + "月";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<!-- 普通预算卡片 -->
|
||||
<div v-if="!budget.noLimit" class="common-card budget-card" @click="toggleExpand">
|
||||
<div
|
||||
v-if="!budget.noLimit"
|
||||
class="common-card budget-card"
|
||||
:class="{ 'cursor-default': budget.category === 2 }"
|
||||
@click="toggleExpand"
|
||||
>
|
||||
<div class="budget-content-wrapper">
|
||||
<!-- 折叠状态 -->
|
||||
<div v-if="!isExpanded" class="budget-collapsed">
|
||||
<div
|
||||
v-if="!isExpanded"
|
||||
class="budget-collapsed"
|
||||
>
|
||||
<div class="collapsed-header">
|
||||
<div class="budget-info">
|
||||
<slot name="tag">
|
||||
@@ -14,17 +22,26 @@
|
||||
class="status-tag"
|
||||
>
|
||||
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
|
||||
<span v-if="budget.isMandatoryExpense" class="mandatory-mark">📌</span>
|
||||
<span
|
||||
v-if="budget.isMandatoryExpense"
|
||||
class="mandatory-mark"
|
||||
>📌</span>
|
||||
</van-tag>
|
||||
</slot>
|
||||
<h3 class="card-title">
|
||||
{{ budget.name }}
|
||||
</h3>
|
||||
<span v-if="budget.selectedCategories?.length" class="card-subtitle">
|
||||
<span
|
||||
v-if="budget.selectedCategories?.length"
|
||||
class="card-subtitle"
|
||||
>
|
||||
({{ budget.selectedCategories.join('、') }})
|
||||
</span>
|
||||
</div>
|
||||
<van-icon name="arrow-down" class="expand-icon" />
|
||||
<van-icon
|
||||
name="arrow-down"
|
||||
class="expand-icon"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="collapsed-footer">
|
||||
@@ -43,14 +60,23 @@
|
||||
|
||||
<div class="collapsed-item">
|
||||
<span class="compact-label">达成率</span>
|
||||
<span class="compact-value" :class="percentClass">{{ percentage }}%</span>
|
||||
<span
|
||||
class="compact-value"
|
||||
:class="percentClass"
|
||||
>{{ percentage }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 展开状态 -->
|
||||
<div v-else class="budget-inner-card">
|
||||
<div class="card-header" style="margin-bottom: 0">
|
||||
<div
|
||||
v-else
|
||||
class="budget-inner-card"
|
||||
>
|
||||
<div
|
||||
class="card-header"
|
||||
style="margin-bottom: 0"
|
||||
>
|
||||
<div class="budget-info">
|
||||
<slot name="tag">
|
||||
<van-tag
|
||||
@@ -59,10 +85,16 @@
|
||||
class="status-tag"
|
||||
>
|
||||
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
|
||||
<span v-if="budget.isMandatoryExpense" class="mandatory-mark">📌</span>
|
||||
<span
|
||||
v-if="budget.isMandatoryExpense"
|
||||
class="mandatory-mark"
|
||||
>📌</span>
|
||||
</van-tag>
|
||||
</slot>
|
||||
<h3 class="card-title" style="max-width: 120px">
|
||||
<h3
|
||||
class="card-title"
|
||||
style="max-width: 120px"
|
||||
>
|
||||
{{ budget.name }}
|
||||
</h3>
|
||||
</div>
|
||||
@@ -84,14 +116,22 @@
|
||||
@click.stop="handleQueryBills"
|
||||
/>
|
||||
<template v-if="budget.category !== 2">
|
||||
<van-button icon="edit" size="small" plain @click.stop="$emit('click', budget)" />
|
||||
<van-button
|
||||
icon="edit"
|
||||
size="small"
|
||||
plain
|
||||
@click.stop="$emit('click', budget)"
|
||||
/>
|
||||
</template>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="budget-body">
|
||||
<div v-if="budget.selectedCategories?.length" class="category-tags">
|
||||
<div
|
||||
v-if="budget.selectedCategories?.length"
|
||||
class="category-tags"
|
||||
>
|
||||
<van-tag
|
||||
v-for="cat in budget.selectedCategories"
|
||||
:key="cat"
|
||||
@@ -109,16 +149,17 @@
|
||||
|
||||
<div class="progress-section">
|
||||
<slot name="progress-info">
|
||||
<span class="period-type"
|
||||
>{{ periodLabel }}{{ budget.category === 0 ? '使用' : '达成' }}</span
|
||||
>
|
||||
<span class="period-type">{{ periodLabel }}{{ budget.category === 0 ? '使用' : '达成' }}</span>
|
||||
<van-progress
|
||||
:percentage="Math.min(percentage, 100)"
|
||||
stroke-width="8"
|
||||
:color="progressColor"
|
||||
:show-pivot="false"
|
||||
/>
|
||||
<span class="percent" :class="percentClass">{{ percentage }}%</span>
|
||||
<span
|
||||
class="percent"
|
||||
:class="percentClass"
|
||||
>{{ percentage }}%</span>
|
||||
</slot>
|
||||
</div>
|
||||
<div class="progress-section time-progress">
|
||||
@@ -132,11 +173,25 @@
|
||||
<span class="percent">{{ timePercentage }}%</span>
|
||||
</div>
|
||||
|
||||
<van-collapse-transition>
|
||||
<div v-if="budget.description && showDescription" class="budget-description">
|
||||
<div class="description-content rich-html-content" v-html="budget.description" />
|
||||
<transition
|
||||
name="collapse"
|
||||
@enter="onEnter"
|
||||
@after-enter="onAfterEnter"
|
||||
@leave="onLeave"
|
||||
@after-leave="onAfterLeave"
|
||||
>
|
||||
<div
|
||||
v-if="budget.description && showDescription"
|
||||
class="budget-collapse-wrapper"
|
||||
>
|
||||
<div class="budget-description">
|
||||
<div
|
||||
class="description-content rich-html-content"
|
||||
v-html="budget.description"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</van-collapse-transition>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<div class="card-footer">
|
||||
@@ -146,7 +201,11 @@
|
||||
</div>
|
||||
|
||||
<!-- 关联账单列表弹窗 -->
|
||||
<PopupContainer v-model="showBillListModal" title="关联账单列表" height="75%">
|
||||
<PopupContainer
|
||||
v-model="showBillListModal"
|
||||
title="关联账单列表"
|
||||
height="75%"
|
||||
>
|
||||
<TransactionList
|
||||
:transactions="billList"
|
||||
:loading="billLoading"
|
||||
@@ -160,25 +219,43 @@
|
||||
</div>
|
||||
|
||||
<!-- 不记额预算卡片 -->
|
||||
<div v-else class="common-card budget-card no-limit-card" @click="toggleExpand">
|
||||
<div
|
||||
v-else
|
||||
class="common-card budget-card no-limit-card"
|
||||
:class="{ 'cursor-default': budget.category === 2 }"
|
||||
@click="toggleExpand"
|
||||
>
|
||||
<div class="budget-content-wrapper">
|
||||
<!-- 折叠状态 -->
|
||||
<div v-if="!isExpanded" class="budget-collapsed">
|
||||
<div
|
||||
v-if="!isExpanded"
|
||||
class="budget-collapsed"
|
||||
>
|
||||
<div class="collapsed-header">
|
||||
<div class="budget-info">
|
||||
<slot name="tag">
|
||||
<van-tag type="success" plain class="status-tag">
|
||||
<van-tag
|
||||
type="success"
|
||||
plain
|
||||
class="status-tag"
|
||||
>
|
||||
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
|
||||
</van-tag>
|
||||
</slot>
|
||||
<h3 class="card-title">
|
||||
{{ budget.name }}
|
||||
</h3>
|
||||
<span v-if="budget.selectedCategories?.length" class="card-subtitle">
|
||||
<span
|
||||
v-if="budget.selectedCategories?.length"
|
||||
class="card-subtitle"
|
||||
>
|
||||
({{ budget.selectedCategories.join('、') }})
|
||||
</span>
|
||||
</div>
|
||||
<van-icon name="arrow-down" class="expand-icon" />
|
||||
<van-icon
|
||||
name="arrow-down"
|
||||
class="expand-icon"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="collapsed-footer no-limit-footer">
|
||||
@@ -194,15 +271,28 @@
|
||||
</div>
|
||||
|
||||
<!-- 展开状态 -->
|
||||
<div v-else class="budget-inner-card">
|
||||
<div class="card-header" style="margin-bottom: 0">
|
||||
<div
|
||||
v-else
|
||||
class="budget-inner-card"
|
||||
>
|
||||
<div
|
||||
class="card-header"
|
||||
style="margin-bottom: 0"
|
||||
>
|
||||
<div class="budget-info">
|
||||
<slot name="tag">
|
||||
<van-tag type="success" plain class="status-tag">
|
||||
<van-tag
|
||||
type="success"
|
||||
plain
|
||||
class="status-tag"
|
||||
>
|
||||
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
|
||||
</van-tag>
|
||||
</slot>
|
||||
<h3 class="card-title" style="max-width: 120px">
|
||||
<h3
|
||||
class="card-title"
|
||||
style="max-width: 120px"
|
||||
>
|
||||
{{ budget.name }}
|
||||
</h3>
|
||||
</div>
|
||||
@@ -224,14 +314,22 @@
|
||||
@click.stop="handleQueryBills"
|
||||
/>
|
||||
<template v-if="budget.category !== 2">
|
||||
<van-button icon="edit" size="small" plain @click.stop="$emit('click', budget)" />
|
||||
<van-button
|
||||
icon="edit"
|
||||
size="small"
|
||||
plain
|
||||
@click.stop="$emit('click', budget)"
|
||||
/>
|
||||
</template>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="budget-body">
|
||||
<div v-if="budget.selectedCategories?.length" class="category-tags">
|
||||
<div
|
||||
v-if="budget.selectedCategories?.length"
|
||||
class="category-tags"
|
||||
>
|
||||
<van-tag
|
||||
v-for="cat in budget.selectedCategories"
|
||||
:key="cat"
|
||||
@@ -248,31 +346,53 @@
|
||||
<div class="amount-item">
|
||||
<span>
|
||||
<span class="label">实际</span>
|
||||
<span class="value" style="margin-left: 12px"
|
||||
>¥{{ budget.current?.toFixed(0) || 0 }}</span
|
||||
>
|
||||
<span
|
||||
class="value"
|
||||
style="margin-left: 12px"
|
||||
>¥{{ budget.current?.toFixed(0) || 0 }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="no-limit-notice">
|
||||
<span>
|
||||
<van-icon name="info-o" style="margin-right: 4px" />
|
||||
<van-icon
|
||||
name="info-o"
|
||||
style="margin-right: 4px"
|
||||
/>
|
||||
不记额预算 - 直接计入存款明细
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<van-collapse-transition>
|
||||
<div v-if="budget.description && showDescription" class="budget-description">
|
||||
<div class="description-content rich-html-content" v-html="budget.description" />
|
||||
<transition
|
||||
name="collapse"
|
||||
@enter="onEnter"
|
||||
@after-enter="onAfterEnter"
|
||||
@leave="onLeave"
|
||||
@after-leave="onAfterLeave"
|
||||
>
|
||||
<div
|
||||
v-if="budget.description && showDescription"
|
||||
class="budget-collapse-wrapper"
|
||||
>
|
||||
<div class="budget-description">
|
||||
<div
|
||||
class="description-content rich-html-content"
|
||||
v-html="budget.description"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</van-collapse-transition>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 关联账单列表弹窗 -->
|
||||
<PopupContainer v-model="showBillListModal" title="关联账单列表" height="75%">
|
||||
<PopupContainer
|
||||
v-model="showBillListModal"
|
||||
title="关联账单列表"
|
||||
height="75%"
|
||||
>
|
||||
<TransactionList
|
||||
:transactions="billList"
|
||||
:loading="billLoading"
|
||||
@@ -321,6 +441,10 @@ const billList = ref([])
|
||||
const billLoading = ref(false)
|
||||
|
||||
const toggleExpand = () => {
|
||||
// 存款类型(category === 2)强制保持展开状态,不可折叠
|
||||
if (props.budget.category === 2) {
|
||||
return
|
||||
}
|
||||
isExpanded.value = !isExpanded.value
|
||||
}
|
||||
|
||||
@@ -387,6 +511,36 @@ const timePercentage = computed(() => {
|
||||
|
||||
return Math.round(((now - start) / (end - start)) * 100)
|
||||
})
|
||||
|
||||
const onEnter = (el) => {
|
||||
el.style.height = '0'
|
||||
el.style.overflow = 'hidden'
|
||||
// Force reflow
|
||||
el.offsetHeight
|
||||
el.style.transition = 'height 0.3s ease-in-out'
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
}
|
||||
|
||||
const onAfterEnter = (el) => {
|
||||
el.style.height = ''
|
||||
el.style.overflow = ''
|
||||
el.style.transition = ''
|
||||
}
|
||||
|
||||
const onLeave = (el) => {
|
||||
el.style.height = `${el.scrollHeight}px`
|
||||
el.style.overflow = 'hidden'
|
||||
// Force reflow
|
||||
el.offsetHeight
|
||||
el.style.transition = 'height 0.3s ease-in-out'
|
||||
el.style.height = '0'
|
||||
}
|
||||
|
||||
const onAfterLeave = (el) => {
|
||||
el.style.height = ''
|
||||
el.style.overflow = ''
|
||||
el.style.transition = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -400,6 +554,10 @@ const timePercentage = computed(() => {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.budget-card.cursor-default {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.no-limit-card {
|
||||
border-left: 3px solid var(--van-success-color);
|
||||
}
|
||||
@@ -661,11 +819,13 @@ const timePercentage = computed(() => {
|
||||
color: var(--van-success-color);
|
||||
}
|
||||
|
||||
.budget-collapse-wrapper {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.budget-description {
|
||||
border-top: 1px solid var(--van-border-color);
|
||||
margin-top: 8px;
|
||||
background-color: var(--van-background);
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.description-content {
|
||||
@@ -680,15 +840,4 @@ const timePercentage = computed(() => {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* @media (prefers-color-scheme: dark) {
|
||||
.budget-description {
|
||||
background-color: var(--van-background-2);
|
||||
}
|
||||
.description-content {
|
||||
color: var(--van-text-color-2);
|
||||
}
|
||||
.collapsed-row .value {
|
||||
color: var(--van-text-color);
|
||||
}
|
||||
} */
|
||||
</style>
|
||||
|
||||
18
WebApi.Test/Basic/BaseTest.cs
Normal file
18
WebApi.Test/Basic/BaseTest.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace WebApi.Test.Basic;
|
||||
|
||||
public class BaseTest
|
||||
{
|
||||
public BaseTest()
|
||||
{
|
||||
// 初始化雪花算法ID生成器
|
||||
var options = new IdGeneratorOptions(1);
|
||||
try
|
||||
{
|
||||
YitIdHelper.SetIdGenerator(options);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// 忽略重复设置可能引发的异常
|
||||
}
|
||||
}
|
||||
}
|
||||
510
WebApi.Test/BudgetSavingsTest.cs
Normal file
510
WebApi.Test/BudgetSavingsTest.cs
Normal file
@@ -0,0 +1,510 @@
|
||||
using Common;
|
||||
|
||||
namespace WebApi.Test;
|
||||
|
||||
public class BudgetSavingsTest : BaseTest
|
||||
{
|
||||
private readonly IBudgetRepository _budgetRepository = Substitute.For<IBudgetRepository>();
|
||||
private readonly IBudgetArchiveRepository _budgetArchiveRepository = Substitute.For<IBudgetArchiveRepository>();
|
||||
private readonly ITransactionRecordRepository _transactionsRepository = Substitute.For<ITransactionRecordRepository>();
|
||||
private readonly IConfigService _configService = Substitute.For<IConfigService>();
|
||||
private readonly IDateTimeProvider _dateTimeProvider = Substitute.For<IDateTimeProvider>();
|
||||
private readonly BudgetSavingsService _service;
|
||||
|
||||
public BudgetSavingsTest()
|
||||
{
|
||||
_dateTimeProvider.Now.Returns(DateTime.Now);
|
||||
_service = new BudgetSavingsService(
|
||||
_budgetRepository,
|
||||
_budgetArchiveRepository,
|
||||
_transactionsRepository,
|
||||
_configService,
|
||||
_dateTimeProvider
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSavings_月度_Test()
|
||||
{
|
||||
// Arrange
|
||||
var referenceDate = new DateTime(2024, 1, 1);
|
||||
var budgets = new List<BudgetRecord>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 1, Name = "工资", Type = BudgetPeriodType.Month, Limit = 10000, Category = BudgetCategory.Income,
|
||||
SelectedCategories = "工资"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 2, Name = "餐饮", Type = BudgetPeriodType.Month, Limit = 2000, Category = BudgetCategory.Expense,
|
||||
SelectedCategories = "餐饮"
|
||||
}
|
||||
};
|
||||
|
||||
var transactions = new Dictionary<(string, TransactionType), decimal>
|
||||
{
|
||||
{ ("工资", TransactionType.Income), 10000m },
|
||||
{ ("餐饮", TransactionType.Expense), 1500m }
|
||||
};
|
||||
|
||||
_budgetRepository.GetAllAsync().Returns(budgets);
|
||||
_transactionsRepository.GetAmountGroupByClassifyAsync(Arg.Any<DateTime>(), Arg.Any<DateTime>())
|
||||
.Returns(transactions);
|
||||
_configService.GetConfigByKeyAsync<string>("SavingsCategories").Returns(Task.FromResult<string?>("存款"));
|
||||
|
||||
// Act
|
||||
var result = await _service.GetSavingsDtoAsync(BudgetPeriodType.Month, referenceDate);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Limit.Should().Be(10000 - 2000); // 计划收入 - 计划支出 = 10000 - 2000 = 8000
|
||||
result.Current.Should().Be(1500 - 10000); // 实际支出 - 实际收入 = 1500 - 10000 = -8500
|
||||
result.Name.Should().Be("月度存款计划");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSavings_月度_年度收支_Test()
|
||||
{
|
||||
// Arrange
|
||||
var referenceDate = new DateTime(2024, 1, 1);
|
||||
var budgets = new List<BudgetRecord>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = 1, Name = "工资", Type = BudgetPeriodType.Month, Limit = 10000, Category = BudgetCategory.Income,
|
||||
SelectedCategories = "工资"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 2, Name = "餐饮", Type = BudgetPeriodType.Month, Limit = 2000, Category = BudgetCategory.Expense,
|
||||
SelectedCategories = "餐饮"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 3, Name = "年终奖", Type = BudgetPeriodType.Year, Limit = 50000, Category = BudgetCategory.Income,
|
||||
SelectedCategories = "奖金"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = 4, Name = "保险", Type = BudgetPeriodType.Year, Limit = 6000, Category = BudgetCategory.Expense,
|
||||
SelectedCategories = "保险"
|
||||
}
|
||||
};
|
||||
|
||||
var transactions = new Dictionary<(string, TransactionType), decimal>
|
||||
{
|
||||
{ ("工资", TransactionType.Income), 10000m },
|
||||
{ ("餐饮", TransactionType.Expense), 1500m },
|
||||
{ ("奖金", TransactionType.Income), 50000m },
|
||||
{ ("保险", TransactionType.Expense), 6000m }
|
||||
};
|
||||
|
||||
_budgetRepository.GetAllAsync().Returns(budgets);
|
||||
_transactionsRepository.GetAmountGroupByClassifyAsync(Arg.Any<DateTime>(), Arg.Any<DateTime>())
|
||||
.Returns(transactions);
|
||||
_configService.GetConfigByKeyAsync<string>("SavingsCategories").Returns(Task.FromResult<string?>("存款"));
|
||||
|
||||
// Act
|
||||
var result = await _service.GetSavingsDtoAsync(BudgetPeriodType.Month, referenceDate);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
// 计划收入 = 月度计划收入(10000) + 本月发生的年度实际收入(50000) = 60000
|
||||
// 计划支出 = 月度计划支出(2000) + 本月发生的年度实际支出(6000) = 8000
|
||||
// 计划存款 = 60000 - 8000 = 52000
|
||||
result.Limit.Should().Be(60000 - 8000);
|
||||
|
||||
// 实际 = 实际支出(1500 + 6000) - 实际收入(10000 + 50000) = 7500 - 60000 = -52500
|
||||
result.Current.Should().Be((1500 + 6000) - (10000 + 50000));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSavings_月度_年度收支_硬性收支_Test()
|
||||
{
|
||||
// Arrange
|
||||
// 模拟当前日期为 2026-01-20
|
||||
var now = new DateTime(2026, 1, 20);
|
||||
_dateTimeProvider.Now.Returns(now);
|
||||
|
||||
var referenceDate = new DateTime(2026, 1, 1);
|
||||
var budgets = new List<BudgetRecord>
|
||||
{
|
||||
// 房租 3100,硬性支出。假设目前还没付(实际为0),系统应按 20/31 天估算为 2000
|
||||
new()
|
||||
{
|
||||
Id = 1, Name = "房租", Type = BudgetPeriodType.Month, Limit = 3100, Category = BudgetCategory.Expense,
|
||||
SelectedCategories = "房租", IsMandatoryExpense = true
|
||||
},
|
||||
// 理财收益 6200,硬性收入。假设目前还没到账(实际为0),系统应按 20/31 天估算为 4000
|
||||
new()
|
||||
{
|
||||
Id = 2, Name = "理财收益", Type = BudgetPeriodType.Month, Limit = 6200, Category = BudgetCategory.Income,
|
||||
SelectedCategories = "理财", IsMandatoryExpense = true
|
||||
}
|
||||
};
|
||||
|
||||
// 模拟实际交易为 0
|
||||
var transactions = new Dictionary<(string, TransactionType), decimal>();
|
||||
|
||||
_budgetRepository.GetAllAsync().Returns(budgets);
|
||||
_transactionsRepository.GetAmountGroupByClassifyAsync(Arg.Any<DateTime>(), Arg.Any<DateTime>())
|
||||
.Returns(transactions);
|
||||
_configService.GetConfigByKeyAsync<string>("SavingsCategories").Returns(Task.FromResult<string?>("存款"));
|
||||
|
||||
// Act
|
||||
var result = await _service.GetSavingsDtoAsync(BudgetPeriodType.Month, referenceDate);
|
||||
|
||||
// Assert
|
||||
// 2026年1月有31天,当前是20号
|
||||
// 预期的估算值:
|
||||
// 支出 = 3100 / 31 * 20 = 2000
|
||||
// 收入 = 6200 / 31 * 20 = 4000
|
||||
|
||||
result.Should().NotBeNull();
|
||||
// 计划存款 = 计划收入(6200) - 计划支出(3100) = 3100
|
||||
result.Limit.Should().Be(6200 - 3100);
|
||||
|
||||
// 实际 = 估算支出(2000) - 估算收入(4000) = -2000
|
||||
result.Current.Should().Be(2000 - 4000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSavings_年度_预算_实际_Test()
|
||||
{
|
||||
// Arrange
|
||||
var year = 2024;
|
||||
var referenceDate = new DateTime(year, 1, 1);
|
||||
_dateTimeProvider.Now.Returns(new DateTime(year, 1, 20));
|
||||
|
||||
var budgets = new List<BudgetRecord>
|
||||
{
|
||||
new() { Id = 1, Name = "工资", Type = BudgetPeriodType.Month, Limit = 10000, Category = BudgetCategory.Income, SelectedCategories = "工资" },
|
||||
new() { Id = 2, Name = "房租", Type = BudgetPeriodType.Month, Limit = 3000, Category = BudgetCategory.Expense, SelectedCategories = "房租" },
|
||||
new() { Id = 3, Name = "年终奖", Type = BudgetPeriodType.Year, Limit = 50000, Category = BudgetCategory.Income, SelectedCategories = "奖金" },
|
||||
new() { Id = 4, Name = "旅游", Type = BudgetPeriodType.Year, Limit = 20000, Category = BudgetCategory.Expense, SelectedCategories = "旅游" }
|
||||
};
|
||||
|
||||
var transactions = new Dictionary<(string, TransactionType), decimal>
|
||||
{
|
||||
{ ("工资", TransactionType.Income), 10000m },
|
||||
{ ("房租", TransactionType.Expense), 3000m },
|
||||
{ ("存款", TransactionType.None), 2000m }
|
||||
};
|
||||
|
||||
_budgetRepository.GetAllAsync().Returns(budgets);
|
||||
_transactionsRepository.GetAmountGroupByClassifyAsync(Arg.Any<DateTime>(), Arg.Any<DateTime>())
|
||||
.Returns(transactions);
|
||||
_budgetArchiveRepository.GetArchivesByYearAsync(year).Returns(new List<BudgetArchive>());
|
||||
_configService.GetConfigByKeyAsync<string>("SavingsCategories").Returns(Task.FromResult<string?>("存款"));
|
||||
|
||||
// Act
|
||||
var result = await _service.GetSavingsDtoAsync(BudgetPeriodType.Year, referenceDate);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
// MonthlyIncome: 10000 * 12 = 170000
|
||||
// MonthlyExpense: 3000 * 12 = 56000
|
||||
// YearlyIncome: 50000 * 1 = 50000
|
||||
// YearlyExpense: 20000 * 1 = 20000
|
||||
// Savings: (170000 + 50000) - (56000 + 20000) = 114000
|
||||
result.Limit.Should().Be(114000);
|
||||
result.Current.Should().Be(2000);
|
||||
result.Name.Should().Be("年度存款计划");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSavings_年度_归档盈亏_Test()
|
||||
{
|
||||
// Arrange
|
||||
var year = 2024;
|
||||
// 当前是3月15号
|
||||
_dateTimeProvider.Now.Returns(new DateTime(year, 3, 15));
|
||||
|
||||
var budgets = new List<BudgetRecord>
|
||||
{
|
||||
// Monthly Budget changed from 10000 (Jan) to 11000 (Current/Feb)
|
||||
new() { Id = 1, Name = "工资", Type = BudgetPeriodType.Month, Limit = 11000, Category = BudgetCategory.Income, SelectedCategories = "工资" },
|
||||
new() { Id = 2, Name = "房租", Type = BudgetPeriodType.Month, Limit = 3000, Category = BudgetCategory.Expense, SelectedCategories = "房租" },
|
||||
};
|
||||
|
||||
var currentTransactions = new Dictionary<(string, TransactionType), decimal>
|
||||
{
|
||||
{ ("工资", TransactionType.Income), 11000m }
|
||||
};
|
||||
|
||||
var archives = new List<BudgetArchive>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Year = year, Month = 1,
|
||||
Content =
|
||||
[
|
||||
new BudgetArchiveContent
|
||||
{
|
||||
Id = 1,
|
||||
Name = "工资",
|
||||
Type = BudgetPeriodType.Month,
|
||||
Limit = 10000,
|
||||
Actual = 12000,
|
||||
Category = BudgetCategory.Income
|
||||
|
||||
},
|
||||
new BudgetArchiveContent
|
||||
{
|
||||
Id = 2,
|
||||
Name = "房租",
|
||||
Type = BudgetPeriodType.Month,
|
||||
Limit = 3000,
|
||||
Actual = 3600,
|
||||
Category = BudgetCategory.Expense
|
||||
}
|
||||
]
|
||||
},
|
||||
new()
|
||||
{
|
||||
Year = year, Month = 2,
|
||||
Content =
|
||||
[
|
||||
new BudgetArchiveContent
|
||||
{
|
||||
Id = 1,
|
||||
Name = "工资",
|
||||
Type = BudgetPeriodType.Month,
|
||||
Limit = 11000,
|
||||
Actual = 3000,
|
||||
Category = BudgetCategory.Income
|
||||
|
||||
},
|
||||
new BudgetArchiveContent
|
||||
{
|
||||
Id = 2,
|
||||
Name = "房租",
|
||||
Type = BudgetPeriodType.Month,
|
||||
Limit = 3000,
|
||||
Actual = 5000,
|
||||
Category = BudgetCategory.Expense
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
_budgetRepository.GetAllAsync().Returns(budgets);
|
||||
_transactionsRepository.GetAmountGroupByClassifyAsync(Arg.Any<DateTime>(), Arg.Any<DateTime>())
|
||||
.Returns(currentTransactions);
|
||||
_budgetArchiveRepository.GetArchivesByYearAsync(year).Returns(archives);
|
||||
_configService.GetConfigByKeyAsync<string>("SavingsCategories").Returns(Task.FromResult<string?>("存款"));
|
||||
|
||||
// Act
|
||||
var result = await _service.GetSavingsDtoAsync(BudgetPeriodType.Year, new DateTime(year, 1, 1));
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
// 归档实际收入1月 = 12000 - 3600 = 8400
|
||||
// 归档实际收入2月 = 3000 - 5000 = -2000
|
||||
// 预计收入 = 8400 + -2000 + 11000 * 10 = 116400
|
||||
// 预计支出 = 3000 * 10 = 30000
|
||||
// 预计存款 = 116400 - 30000 = 86400
|
||||
result.Limit.Should().Be(86400);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSavings_年度_硬性收支_Test()
|
||||
{
|
||||
// Arrange
|
||||
var year = 2024;
|
||||
// 当前是3月15号
|
||||
_dateTimeProvider.Now.Returns(new DateTime(year, 3, 15));
|
||||
|
||||
var budgets = new List<BudgetRecord>
|
||||
{
|
||||
// Monthly Budget changed from 10000 (Jan) to 11000 (Current/Feb)
|
||||
new() { Id = 1, Name = "工资", Type = BudgetPeriodType.Month, Limit = 11000, Category = BudgetCategory.Income, SelectedCategories = "工资" },
|
||||
new() { Id = 2, Name = "房租", Type = BudgetPeriodType.Month, Limit = 3000, Category = BudgetCategory.Expense, SelectedCategories = "房租" },
|
||||
new() { Id = 3, Name = "硬性支出", Type = BudgetPeriodType.Year, Limit = 10000, Category = BudgetCategory.Expense, SelectedCategories = "房租", IsMandatoryExpense = true },
|
||||
};
|
||||
|
||||
var currentTransactions = new Dictionary<(string, TransactionType), decimal>
|
||||
{
|
||||
{ ("工资", TransactionType.Income), 11000m }
|
||||
};
|
||||
|
||||
var archives = new List<BudgetArchive>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Year = year, Month = 1,
|
||||
Content =
|
||||
[
|
||||
new BudgetArchiveContent
|
||||
{
|
||||
Id = 1,
|
||||
Name = "工资",
|
||||
Type = BudgetPeriodType.Month,
|
||||
Limit = 10000,
|
||||
Actual = 12000,
|
||||
Category = BudgetCategory.Income
|
||||
|
||||
},
|
||||
new BudgetArchiveContent
|
||||
{
|
||||
Id = 2,
|
||||
Name = "房租",
|
||||
Type = BudgetPeriodType.Month,
|
||||
Limit = 3000,
|
||||
Actual = 3600,
|
||||
Category = BudgetCategory.Expense
|
||||
}
|
||||
]
|
||||
},
|
||||
new()
|
||||
{
|
||||
Year = year, Month = 2,
|
||||
Content =
|
||||
[
|
||||
new BudgetArchiveContent
|
||||
{
|
||||
Id = 1,
|
||||
Name = "工资",
|
||||
Type = BudgetPeriodType.Month,
|
||||
Limit = 11000,
|
||||
Actual = 3000,
|
||||
Category = BudgetCategory.Income
|
||||
|
||||
},
|
||||
new BudgetArchiveContent
|
||||
{
|
||||
Id = 2,
|
||||
Name = "房租",
|
||||
Type = BudgetPeriodType.Month,
|
||||
Limit = 3000,
|
||||
Actual = 5000,
|
||||
Category = BudgetCategory.Expense
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
_budgetRepository.GetAllAsync().Returns(budgets);
|
||||
_transactionsRepository.GetAmountGroupByClassifyAsync(Arg.Any<DateTime>(), Arg.Any<DateTime>())
|
||||
.Returns(currentTransactions);
|
||||
_budgetArchiveRepository.GetArchivesByYearAsync(year).Returns(archives);
|
||||
_configService.GetConfigByKeyAsync<string>("SavingsCategories").Returns(Task.FromResult<string?>("存款"));
|
||||
|
||||
// Act
|
||||
var result = await _service.GetSavingsDtoAsync(BudgetPeriodType.Year, new DateTime(year, 1, 1));
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
// 归档实际收入1月 = 12000 - 3600 = 8400
|
||||
// 归档实际收入2月 = 3000 - 5000 = -2000
|
||||
// 预计收入 = 8400 + -2000 + 11000 * 10 = 116400
|
||||
// 硬性支出平均到每天 = 10000 / 366 * 75 = 2049.18
|
||||
// 预计支出 = 3000 * 10 = 30000
|
||||
// 预计存款 = 116400 - 30000 - 2049.18 = 84350.82
|
||||
result.Limit.Should().BeApproximately(84350.82m, 0.01m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSavings_年度_不限额_Test()
|
||||
{
|
||||
// Arrange
|
||||
var year = 2024;
|
||||
// 当前是3月15号
|
||||
_dateTimeProvider.Now.Returns(new DateTime(year, 3, 15));
|
||||
|
||||
var budgets = new List<BudgetRecord>
|
||||
{
|
||||
// Monthly Budget changed from 10000 (Jan) to 11000 (Current/Feb)
|
||||
new() { Id = 1, Name = "工资", Type = BudgetPeriodType.Month, Limit = 11000, Category = BudgetCategory.Income, SelectedCategories = "工资" },
|
||||
new() { Id = 2, Name = "房租", Type = BudgetPeriodType.Month, Limit = 3000, Category = BudgetCategory.Expense, SelectedCategories = "房租" },
|
||||
new() { Id = 3, Name = "硬性支出", Type = BudgetPeriodType.Year, Limit = 10000, Category = BudgetCategory.Expense, SelectedCategories = "房租", IsMandatoryExpense = true },
|
||||
new() { Id = 4, Name = "意外支出", Type = BudgetPeriodType.Year, Limit = 0, Category = BudgetCategory.Expense, SelectedCategories = "意外支出", NoLimit = true },
|
||||
new() { Id = 5, Name = "意外收入", Type = BudgetPeriodType.Month, Limit = 0, Category = BudgetCategory.Income, SelectedCategories = "意外收入", NoLimit = true }
|
||||
};
|
||||
|
||||
var currentTransactions = new Dictionary<(string, TransactionType), decimal>
|
||||
{
|
||||
{ ("工资", TransactionType.Income), 11000m },
|
||||
{ ("意外支出", TransactionType.Expense), 300m },
|
||||
{ ("意外收入", TransactionType.Income), 2000m },
|
||||
};
|
||||
|
||||
var archives = new List<BudgetArchive>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Year = year, Month = 1,
|
||||
Content =
|
||||
[
|
||||
new BudgetArchiveContent
|
||||
{
|
||||
Id = 1,
|
||||
Name = "工资",
|
||||
Type = BudgetPeriodType.Month,
|
||||
Limit = 10000,
|
||||
Actual = 12000,
|
||||
Category = BudgetCategory.Income
|
||||
|
||||
},
|
||||
new BudgetArchiveContent
|
||||
{
|
||||
Id = 2,
|
||||
Name = "房租",
|
||||
Type = BudgetPeriodType.Month,
|
||||
Limit = 3000,
|
||||
Actual = 3600,
|
||||
Category = BudgetCategory.Expense
|
||||
}
|
||||
]
|
||||
},
|
||||
new()
|
||||
{
|
||||
Year = year, Month = 2,
|
||||
Content =
|
||||
[
|
||||
new BudgetArchiveContent
|
||||
{
|
||||
Id = 1,
|
||||
Name = "工资",
|
||||
Type = BudgetPeriodType.Month,
|
||||
Limit = 11000,
|
||||
Actual = 3000,
|
||||
Category = BudgetCategory.Income
|
||||
|
||||
},
|
||||
new BudgetArchiveContent
|
||||
{
|
||||
Id = 2,
|
||||
Name = "房租",
|
||||
Type = BudgetPeriodType.Month,
|
||||
Limit = 3000,
|
||||
Actual = 5000,
|
||||
Category = BudgetCategory.Expense
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
_budgetRepository.GetAllAsync().Returns(budgets);
|
||||
_transactionsRepository.GetAmountGroupByClassifyAsync(Arg.Any<DateTime>(), Arg.Any<DateTime>())
|
||||
.Returns(currentTransactions);
|
||||
_budgetArchiveRepository.GetArchivesByYearAsync(year).Returns(archives);
|
||||
_configService.GetConfigByKeyAsync<string>("SavingsCategories").Returns(Task.FromResult<string?>("存款"));
|
||||
|
||||
// Act
|
||||
var result = await _service.GetSavingsDtoAsync(BudgetPeriodType.Year, new DateTime(year, 1, 1));
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
// 归档实际收入1月 = 12000 - 3600 = 8400
|
||||
// 归档实际收入2月 = 3000 - 5000 = -2000
|
||||
// 预计收入 = 8400 + -2000 + 11000 * 10 = 116400
|
||||
// 硬性支出平均到每天 = 10000 / 366 * 75 = 2049.18
|
||||
// 预计支出 = 3000 * 10 = 30000
|
||||
// 预计意外支出 = 300
|
||||
// 预计意外收入 = 2000
|
||||
// 预计存款 = 116400 - 30000 - 2049.18 - 300 + 2000 = 86050.82
|
||||
result.Limit.Should().BeApproximately(86050.82m, 0.1m);
|
||||
}
|
||||
}
|
||||
9
WebApi.Test/GlobalUsings.cs
Normal file
9
WebApi.Test/GlobalUsings.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
global using Service;
|
||||
global using Repository;
|
||||
global using Entity;
|
||||
global using FluentAssertions;
|
||||
global using NSubstitute;
|
||||
global using Service.Budget;
|
||||
global using Xunit;
|
||||
global using Yitter.IdGenerator;
|
||||
global using WebApi.Test.Basic;
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace WebApi.Test;
|
||||
|
||||
public class UnitTest1
|
||||
{
|
||||
[Fact]
|
||||
public void Test1()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
@@ -12,14 +12,11 @@
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
|
||||
<PackageReference Include="xunit"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio"/>
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit"/>
|
||||
<ProjectReference Include="..\Service\Service.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Service\Service.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user