重构存款预算
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>
|
/// </summary>
|
||||||
public static IServiceCollection AddServices(this IServiceCollection services)
|
public static IServiceCollection AddServices(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
|
services.AddSingleton<IDateTimeProvider, DateTimeProvider>();
|
||||||
// 扫描程序集
|
// 扫描程序集
|
||||||
var serviceAssembly = Assembly.Load("Service");
|
var serviceAssembly = Assembly.Load("Service");
|
||||||
var repositoryAssembly = Assembly.Load("Repository");
|
var repositoryAssembly = Assembly.Load("Repository");
|
||||||
|
|||||||
@@ -40,5 +40,7 @@
|
|||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
|
||||||
<PackageVersion Include="xunit" Version="2.9.3"/>
|
<PackageVersion Include="xunit" Version="2.9.3"/>
|
||||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4"/>
|
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4"/>
|
||||||
|
<PackageVersion Include="NSubstitute" Version="5.3.0" />
|
||||||
|
<PackageVersion Include="FluentAssertions" Version="8.0.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -28,10 +28,6 @@ public class BudgetRepository(IFreeSql freeSql) : BaseRepository<BudgetRecord>(f
|
|||||||
{
|
{
|
||||||
query = query.Where(t => t.Type == TransactionType.Income);
|
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);
|
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>()
|
var records = await FreeSql.Select<BudgetRecord>()
|
||||||
.Where(b => b.SelectedCategories.Contains(oldName) &&
|
.Where(b => b.SelectedCategories.Contains(oldName) &&
|
||||||
((type == TransactionType.Expense && b.Category == BudgetCategory.Expense) ||
|
((type == TransactionType.Expense && b.Category == BudgetCategory.Expense) ||
|
||||||
(type == TransactionType.Income && b.Category == BudgetCategory.Income) ||
|
(type == TransactionType.Income && b.Category == BudgetCategory.Income)))
|
||||||
(type == TransactionType.None && b.Category == BudgetCategory.Savings)))
|
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
foreach (var record in records)
|
foreach (var record in records)
|
||||||
|
|||||||
@@ -217,6 +217,8 @@ public interface ITransactionRecordRepository : IBaseRepository<TransactionRecor
|
|||||||
/// <param name="type">交易类型</param>
|
/// <param name="type">交易类型</param>
|
||||||
/// <returns>影响行数</returns>
|
/// <returns>影响行数</returns>
|
||||||
Task<int> UpdateCategoryNameAsync(string oldName, string newName, TransactionType type);
|
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
|
public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<TransactionRecord>(freeSql), ITransactionRecordRepository
|
||||||
@@ -276,7 +278,7 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
|||||||
var dateEnd = dateStart.AddMonths(1);
|
var dateEnd = dateStart.AddMonths(1);
|
||||||
query = query.Where(t => t.OccurredAt >= dateStart && t.OccurredAt < dateEnd);
|
query = query.Where(t => t.OccurredAt >= dateStart && t.OccurredAt < dateEnd);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按日期范围筛选
|
// 按日期范围筛选
|
||||||
query = query.WhereIf(startDate.HasValue, t => t.OccurredAt >= startDate!.Value)
|
query = query.WhereIf(startDate.HasValue, t => t.OccurredAt >= startDate!.Value)
|
||||||
.WhereIf(endDate.HasValue, t => t.OccurredAt <= endDate!.Value);
|
.WhereIf(endDate.HasValue, t => t.OccurredAt <= endDate!.Value);
|
||||||
@@ -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 expense = g.Where(t => t.Type == TransactionType.Expense).Sum(t => Math.Abs(t.Amount));
|
||||||
|
|
||||||
var saving = 0m;
|
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));
|
saving = g.Where(t => savingClassify.Split(',').Contains(t.Classify)).Sum(t => Math.Abs(t.Amount));
|
||||||
}
|
}
|
||||||
@@ -649,19 +651,19 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
|||||||
|
|
||||||
// 计算每个候选账单的相关度分数
|
// 计算每个候选账单的相关度分数
|
||||||
var scoredResults = candidates
|
var scoredResults = candidates
|
||||||
.Select(record =>
|
.Select(record =>
|
||||||
{
|
{
|
||||||
var matchedCount = keywords.Count(keyword => record.Reason.Contains(keyword, StringComparison.OrdinalIgnoreCase));
|
var matchedCount = keywords.Count(keyword => record.Reason.Contains(keyword, StringComparison.OrdinalIgnoreCase));
|
||||||
var matchRate = (double)matchedCount / keywords.Count;
|
var matchRate = (double)matchedCount / keywords.Count;
|
||||||
|
|
||||||
// 额外加分:完全匹配整个摘要(相似度更高)
|
// 额外加分:完全匹配整个摘要(相似度更高)
|
||||||
var exactMatchBonus = keywords.Any(k => record.Reason.Equals(k, StringComparison.OrdinalIgnoreCase)) ? 0.2 : 0.0;
|
var exactMatchBonus = keywords.Any(k => record.Reason.Equals(k, StringComparison.OrdinalIgnoreCase)) ? 0.2 : 0.0;
|
||||||
|
|
||||||
// 长度相似度加分:长度越接近,相关度越高
|
// 长度相似度加分:长度越接近,相关度越高
|
||||||
var avgKeywordLength = keywords.Average(k => k.Length);
|
var avgKeywordLength = keywords.Average(k => k.Length);
|
||||||
var lengthSimilarity = 1.0 - Math.Min(1.0, Math.Abs(record.Reason.Length - avgKeywordLength) / Math.Max(record.Reason.Length, avgKeywordLength));
|
var lengthSimilarity = 1.0 - Math.Min(1.0, Math.Abs(record.Reason.Length - avgKeywordLength) / Math.Max(record.Reason.Length, avgKeywordLength));
|
||||||
var lengthBonus = lengthSimilarity * 0.1;
|
var lengthBonus = lengthSimilarity * 0.1;
|
||||||
|
|
||||||
var score = matchRate + exactMatchBonus + lengthBonus;
|
var score = matchRate + exactMatchBonus + lengthBonus;
|
||||||
return (record, score);
|
return (record, score);
|
||||||
})
|
})
|
||||||
@@ -695,9 +697,9 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
|||||||
.Where(t => Math.Abs(t.Amount) >= minAmount && Math.Abs(t.Amount) <= maxAmount)
|
.Where(t => Math.Abs(t.Amount) >= minAmount && Math.Abs(t.Amount) <= maxAmount)
|
||||||
.Take(50)
|
.Take(50)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
return list.OrderBy(t => Math.Abs(Math.Abs(t.Amount) - absAmount))
|
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();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -757,6 +759,21 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
|||||||
.GroupBy(t => t.OccurredAt.Date)
|
.GroupBy(t => t.OccurredAt.Date)
|
||||||
.ToDictionary(g => g.Key, g => g.Sum(x => Math.Abs(x.Amount)));
|
.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>
|
/// <summary>
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ public interface IBudgetSavingsService
|
|||||||
|
|
||||||
public class BudgetSavingsService(
|
public class BudgetSavingsService(
|
||||||
IBudgetRepository budgetRepository,
|
IBudgetRepository budgetRepository,
|
||||||
IBudgetArchiveRepository budgetArchiveRepository
|
IBudgetArchiveRepository budgetArchiveRepository,
|
||||||
|
ITransactionRecordRepository transactionsRepository,
|
||||||
|
IConfigService configService,
|
||||||
|
IDateTimeProvider dateTimeProvider
|
||||||
) : IBudgetSavingsService
|
) : IBudgetSavingsService
|
||||||
{
|
{
|
||||||
public async Task<BudgetResult> GetSavingsDtoAsync(
|
public async Task<BudgetResult> GetSavingsDtoAsync(
|
||||||
@@ -36,14 +39,14 @@ public class BudgetSavingsService(
|
|||||||
.ThenBy(b => b.Type)
|
.ThenBy(b => b.Type)
|
||||||
.ThenByDescending(b => b.Limit);
|
.ThenByDescending(b => b.Limit);
|
||||||
|
|
||||||
var year = referenceDate?.Year ?? DateTime.Now.Year;
|
var year = referenceDate?.Year ?? dateTimeProvider.Now.Year;
|
||||||
var month = referenceDate?.Month ?? DateTime.Now.Month;
|
var month = referenceDate?.Month ?? dateTimeProvider.Now.Month;
|
||||||
|
|
||||||
if(periodType == BudgetPeriodType.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);
|
return await GetForYearAsync(budgets, year);
|
||||||
}
|
}
|
||||||
@@ -53,28 +56,812 @@ public class BudgetSavingsService(
|
|||||||
|
|
||||||
private async Task<BudgetResult> GetForMonthAsync(
|
private async Task<BudgetResult> GetForMonthAsync(
|
||||||
IEnumerable<BudgetRecord> budgets,
|
IEnumerable<BudgetRecord> budgets,
|
||||||
int year,
|
int year,
|
||||||
int month)
|
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
|
var monthlyBudgets = budgets
|
||||||
.Where(b => b.Type == BudgetPeriodType.Month);
|
.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
|
var yearlyBudgets = budgets
|
||||||
.Where(b => b.Type == BudgetPeriodType.Year);
|
.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(
|
private async Task<BudgetResult> GetForYearAsync(
|
||||||
IEnumerable<BudgetRecord> budgets,
|
IEnumerable<BudgetRecord> budgets,
|
||||||
int year)
|
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,
|
IBudgetArchiveRepository budgetArchiveRepository,
|
||||||
ITransactionRecordRepository transactionRecordRepository,
|
ITransactionRecordRepository transactionRecordRepository,
|
||||||
IOpenAiService openAiService,
|
IOpenAiService openAiService,
|
||||||
IConfigService configService,
|
|
||||||
IMessageService messageService,
|
IMessageService messageService,
|
||||||
ILogger<BudgetService> logger
|
ILogger<BudgetService> logger,
|
||||||
|
IBudgetSavingsService budgetSavingsService
|
||||||
) : IBudgetService
|
) : IBudgetService
|
||||||
{
|
{
|
||||||
public async Task<List<BudgetResult>> GetListAsync(DateTime referenceDate)
|
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,
|
BudgetPeriodType.Month,
|
||||||
referenceDate,
|
referenceDate,
|
||||||
budgets));
|
budgets));
|
||||||
dtos.Add(await GetSavingsDtoAsync(
|
dtos.Add(await budgetSavingsService.GetSavingsDtoAsync(
|
||||||
BudgetPeriodType.Year,
|
BudgetPeriodType.Year,
|
||||||
referenceDate,
|
referenceDate,
|
||||||
budgets));
|
budgets));
|
||||||
@@ -103,7 +103,7 @@ public class BudgetService(
|
|||||||
public async Task<BudgetResult?> GetSavingsBudgetAsync(int year, int month, BudgetPeriodType type)
|
public async Task<BudgetResult?> GetSavingsBudgetAsync(int year, int month, BudgetPeriodType type)
|
||||||
{
|
{
|
||||||
var referenceDate = new DateTime(year, month, 1);
|
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)
|
public async Task<BudgetCategoryStats> GetCategoryStatsAsync(BudgetCategory category, DateTime referenceDate)
|
||||||
@@ -595,484 +595,6 @@ public class BudgetService(
|
|||||||
|
|
||||||
return (start, end);
|
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
|
public record BudgetResult
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
<!-- eslint-disable vue/no-v-html -->
|
<!-- eslint-disable vue/no-v-html -->
|
||||||
<template>
|
<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 class="budget-content-wrapper">
|
||||||
<!-- 折叠状态 -->
|
<!-- 折叠状态 -->
|
||||||
<div v-if="!isExpanded" class="budget-collapsed">
|
<div
|
||||||
|
v-if="!isExpanded"
|
||||||
|
class="budget-collapsed"
|
||||||
|
>
|
||||||
<div class="collapsed-header">
|
<div class="collapsed-header">
|
||||||
<div class="budget-info">
|
<div class="budget-info">
|
||||||
<slot name="tag">
|
<slot name="tag">
|
||||||
@@ -14,17 +22,26 @@
|
|||||||
class="status-tag"
|
class="status-tag"
|
||||||
>
|
>
|
||||||
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
|
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
|
||||||
<span v-if="budget.isMandatoryExpense" class="mandatory-mark">📌</span>
|
<span
|
||||||
|
v-if="budget.isMandatoryExpense"
|
||||||
|
class="mandatory-mark"
|
||||||
|
>📌</span>
|
||||||
</van-tag>
|
</van-tag>
|
||||||
</slot>
|
</slot>
|
||||||
<h3 class="card-title">
|
<h3 class="card-title">
|
||||||
{{ budget.name }}
|
{{ budget.name }}
|
||||||
</h3>
|
</h3>
|
||||||
<span v-if="budget.selectedCategories?.length" class="card-subtitle">
|
<span
|
||||||
|
v-if="budget.selectedCategories?.length"
|
||||||
|
class="card-subtitle"
|
||||||
|
>
|
||||||
({{ budget.selectedCategories.join('、') }})
|
({{ budget.selectedCategories.join('、') }})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<van-icon name="arrow-down" class="expand-icon" />
|
<van-icon
|
||||||
|
name="arrow-down"
|
||||||
|
class="expand-icon"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="collapsed-footer">
|
<div class="collapsed-footer">
|
||||||
@@ -43,14 +60,23 @@
|
|||||||
|
|
||||||
<div class="collapsed-item">
|
<div class="collapsed-item">
|
||||||
<span class="compact-label">达成率</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 展开状态 -->
|
<!-- 展开状态 -->
|
||||||
<div v-else class="budget-inner-card">
|
<div
|
||||||
<div class="card-header" style="margin-bottom: 0">
|
v-else
|
||||||
|
class="budget-inner-card"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="card-header"
|
||||||
|
style="margin-bottom: 0"
|
||||||
|
>
|
||||||
<div class="budget-info">
|
<div class="budget-info">
|
||||||
<slot name="tag">
|
<slot name="tag">
|
||||||
<van-tag
|
<van-tag
|
||||||
@@ -59,10 +85,16 @@
|
|||||||
class="status-tag"
|
class="status-tag"
|
||||||
>
|
>
|
||||||
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
|
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
|
||||||
<span v-if="budget.isMandatoryExpense" class="mandatory-mark">📌</span>
|
<span
|
||||||
|
v-if="budget.isMandatoryExpense"
|
||||||
|
class="mandatory-mark"
|
||||||
|
>📌</span>
|
||||||
</van-tag>
|
</van-tag>
|
||||||
</slot>
|
</slot>
|
||||||
<h3 class="card-title" style="max-width: 120px">
|
<h3
|
||||||
|
class="card-title"
|
||||||
|
style="max-width: 120px"
|
||||||
|
>
|
||||||
{{ budget.name }}
|
{{ budget.name }}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
@@ -84,14 +116,22 @@
|
|||||||
@click.stop="handleQueryBills"
|
@click.stop="handleQueryBills"
|
||||||
/>
|
/>
|
||||||
<template v-if="budget.category !== 2">
|
<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>
|
</template>
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="budget-body">
|
<div class="budget-body">
|
||||||
<div v-if="budget.selectedCategories?.length" class="category-tags">
|
<div
|
||||||
|
v-if="budget.selectedCategories?.length"
|
||||||
|
class="category-tags"
|
||||||
|
>
|
||||||
<van-tag
|
<van-tag
|
||||||
v-for="cat in budget.selectedCategories"
|
v-for="cat in budget.selectedCategories"
|
||||||
:key="cat"
|
:key="cat"
|
||||||
@@ -109,16 +149,17 @@
|
|||||||
|
|
||||||
<div class="progress-section">
|
<div class="progress-section">
|
||||||
<slot name="progress-info">
|
<slot name="progress-info">
|
||||||
<span class="period-type"
|
<span class="period-type">{{ periodLabel }}{{ budget.category === 0 ? '使用' : '达成' }}</span>
|
||||||
>{{ periodLabel }}{{ budget.category === 0 ? '使用' : '达成' }}</span
|
|
||||||
>
|
|
||||||
<van-progress
|
<van-progress
|
||||||
:percentage="Math.min(percentage, 100)"
|
:percentage="Math.min(percentage, 100)"
|
||||||
stroke-width="8"
|
stroke-width="8"
|
||||||
:color="progressColor"
|
:color="progressColor"
|
||||||
:show-pivot="false"
|
:show-pivot="false"
|
||||||
/>
|
/>
|
||||||
<span class="percent" :class="percentClass">{{ percentage }}%</span>
|
<span
|
||||||
|
class="percent"
|
||||||
|
:class="percentClass"
|
||||||
|
>{{ percentage }}%</span>
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
<div class="progress-section time-progress">
|
<div class="progress-section time-progress">
|
||||||
@@ -132,11 +173,25 @@
|
|||||||
<span class="percent">{{ timePercentage }}%</span>
|
<span class="percent">{{ timePercentage }}%</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<van-collapse-transition>
|
<transition
|
||||||
<div v-if="budget.description && showDescription" class="budget-description">
|
name="collapse"
|
||||||
<div class="description-content rich-html-content" v-html="budget.description" />
|
@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>
|
</div>
|
||||||
</van-collapse-transition>
|
</transition>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
@@ -146,7 +201,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 关联账单列表弹窗 -->
|
<!-- 关联账单列表弹窗 -->
|
||||||
<PopupContainer v-model="showBillListModal" title="关联账单列表" height="75%">
|
<PopupContainer
|
||||||
|
v-model="showBillListModal"
|
||||||
|
title="关联账单列表"
|
||||||
|
height="75%"
|
||||||
|
>
|
||||||
<TransactionList
|
<TransactionList
|
||||||
:transactions="billList"
|
:transactions="billList"
|
||||||
:loading="billLoading"
|
:loading="billLoading"
|
||||||
@@ -160,25 +219,43 @@
|
|||||||
</div>
|
</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 class="budget-content-wrapper">
|
||||||
<!-- 折叠状态 -->
|
<!-- 折叠状态 -->
|
||||||
<div v-if="!isExpanded" class="budget-collapsed">
|
<div
|
||||||
|
v-if="!isExpanded"
|
||||||
|
class="budget-collapsed"
|
||||||
|
>
|
||||||
<div class="collapsed-header">
|
<div class="collapsed-header">
|
||||||
<div class="budget-info">
|
<div class="budget-info">
|
||||||
<slot name="tag">
|
<slot name="tag">
|
||||||
<van-tag type="success" plain class="status-tag">
|
<van-tag
|
||||||
|
type="success"
|
||||||
|
plain
|
||||||
|
class="status-tag"
|
||||||
|
>
|
||||||
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
|
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
|
||||||
</van-tag>
|
</van-tag>
|
||||||
</slot>
|
</slot>
|
||||||
<h3 class="card-title">
|
<h3 class="card-title">
|
||||||
{{ budget.name }}
|
{{ budget.name }}
|
||||||
</h3>
|
</h3>
|
||||||
<span v-if="budget.selectedCategories?.length" class="card-subtitle">
|
<span
|
||||||
|
v-if="budget.selectedCategories?.length"
|
||||||
|
class="card-subtitle"
|
||||||
|
>
|
||||||
({{ budget.selectedCategories.join('、') }})
|
({{ budget.selectedCategories.join('、') }})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<van-icon name="arrow-down" class="expand-icon" />
|
<van-icon
|
||||||
|
name="arrow-down"
|
||||||
|
class="expand-icon"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="collapsed-footer no-limit-footer">
|
<div class="collapsed-footer no-limit-footer">
|
||||||
@@ -194,15 +271,28 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 展开状态 -->
|
<!-- 展开状态 -->
|
||||||
<div v-else class="budget-inner-card">
|
<div
|
||||||
<div class="card-header" style="margin-bottom: 0">
|
v-else
|
||||||
|
class="budget-inner-card"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="card-header"
|
||||||
|
style="margin-bottom: 0"
|
||||||
|
>
|
||||||
<div class="budget-info">
|
<div class="budget-info">
|
||||||
<slot name="tag">
|
<slot name="tag">
|
||||||
<van-tag type="success" plain class="status-tag">
|
<van-tag
|
||||||
|
type="success"
|
||||||
|
plain
|
||||||
|
class="status-tag"
|
||||||
|
>
|
||||||
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
|
{{ budget.type === BudgetPeriodType.Year ? '年度' : '月度' }}
|
||||||
</van-tag>
|
</van-tag>
|
||||||
</slot>
|
</slot>
|
||||||
<h3 class="card-title" style="max-width: 120px">
|
<h3
|
||||||
|
class="card-title"
|
||||||
|
style="max-width: 120px"
|
||||||
|
>
|
||||||
{{ budget.name }}
|
{{ budget.name }}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
@@ -224,14 +314,22 @@
|
|||||||
@click.stop="handleQueryBills"
|
@click.stop="handleQueryBills"
|
||||||
/>
|
/>
|
||||||
<template v-if="budget.category !== 2">
|
<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>
|
</template>
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="budget-body">
|
<div class="budget-body">
|
||||||
<div v-if="budget.selectedCategories?.length" class="category-tags">
|
<div
|
||||||
|
v-if="budget.selectedCategories?.length"
|
||||||
|
class="category-tags"
|
||||||
|
>
|
||||||
<van-tag
|
<van-tag
|
||||||
v-for="cat in budget.selectedCategories"
|
v-for="cat in budget.selectedCategories"
|
||||||
:key="cat"
|
:key="cat"
|
||||||
@@ -248,31 +346,53 @@
|
|||||||
<div class="amount-item">
|
<div class="amount-item">
|
||||||
<span>
|
<span>
|
||||||
<span class="label">实际</span>
|
<span class="label">实际</span>
|
||||||
<span class="value" style="margin-left: 12px"
|
<span
|
||||||
>¥{{ budget.current?.toFixed(0) || 0 }}</span
|
class="value"
|
||||||
>
|
style="margin-left: 12px"
|
||||||
|
>¥{{ budget.current?.toFixed(0) || 0 }}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="no-limit-notice">
|
<div class="no-limit-notice">
|
||||||
<span>
|
<span>
|
||||||
<van-icon name="info-o" style="margin-right: 4px" />
|
<van-icon
|
||||||
|
name="info-o"
|
||||||
|
style="margin-right: 4px"
|
||||||
|
/>
|
||||||
不记额预算 - 直接计入存款明细
|
不记额预算 - 直接计入存款明细
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<van-collapse-transition>
|
<transition
|
||||||
<div v-if="budget.description && showDescription" class="budget-description">
|
name="collapse"
|
||||||
<div class="description-content rich-html-content" v-html="budget.description" />
|
@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>
|
</div>
|
||||||
</van-collapse-transition>
|
</transition>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 关联账单列表弹窗 -->
|
<!-- 关联账单列表弹窗 -->
|
||||||
<PopupContainer v-model="showBillListModal" title="关联账单列表" height="75%">
|
<PopupContainer
|
||||||
|
v-model="showBillListModal"
|
||||||
|
title="关联账单列表"
|
||||||
|
height="75%"
|
||||||
|
>
|
||||||
<TransactionList
|
<TransactionList
|
||||||
:transactions="billList"
|
:transactions="billList"
|
||||||
:loading="billLoading"
|
:loading="billLoading"
|
||||||
@@ -321,6 +441,10 @@ const billList = ref([])
|
|||||||
const billLoading = ref(false)
|
const billLoading = ref(false)
|
||||||
|
|
||||||
const toggleExpand = () => {
|
const toggleExpand = () => {
|
||||||
|
// 存款类型(category === 2)强制保持展开状态,不可折叠
|
||||||
|
if (props.budget.category === 2) {
|
||||||
|
return
|
||||||
|
}
|
||||||
isExpanded.value = !isExpanded.value
|
isExpanded.value = !isExpanded.value
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,6 +511,36 @@ const timePercentage = computed(() => {
|
|||||||
|
|
||||||
return Math.round(((now - start) / (end - start)) * 100)
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -400,6 +554,10 @@ const timePercentage = computed(() => {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.budget-card.cursor-default {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.no-limit-card {
|
.no-limit-card {
|
||||||
border-left: 3px solid var(--van-success-color);
|
border-left: 3px solid var(--van-success-color);
|
||||||
}
|
}
|
||||||
@@ -661,11 +819,13 @@ const timePercentage = computed(() => {
|
|||||||
color: var(--van-success-color);
|
color: var(--van-success-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.budget-collapse-wrapper {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.budget-description {
|
.budget-description {
|
||||||
|
border-top: 1px solid var(--van-border-color);
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
background-color: var(--van-background);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.description-content {
|
.description-content {
|
||||||
@@ -680,15 +840,4 @@ const timePercentage = computed(() => {
|
|||||||
display: inline-block;
|
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>
|
</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="Microsoft.NET.Test.Sdk"/>
|
||||||
<PackageReference Include="xunit"/>
|
<PackageReference Include="xunit"/>
|
||||||
<PackageReference Include="xunit.runner.visualstudio"/>
|
<PackageReference Include="xunit.runner.visualstudio"/>
|
||||||
|
<PackageReference Include="NSubstitute" />
|
||||||
|
<PackageReference Include="FluentAssertions" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Using Include="Xunit"/>
|
<ProjectReference Include="..\Service\Service.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\Service\Service.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
Reference in New Issue
Block a user