fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 31s
Docker Build & Deploy / Deploy to Production (push) Successful in 7s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 31s
Docker Build & Deploy / Deploy to Production (push) Successful in 7s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 1s
This commit is contained in:
@@ -426,6 +426,7 @@ public class BudgetSavingsService(
|
||||
// 归档的预算收入支出明细
|
||||
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 archiveSavingsItems = new List<(long id, string name, int[] months, decimal limit, decimal current)>();
|
||||
// 获取归档数据
|
||||
var archives = await budgetArchiveRepository.GetArchivesByYearAsync(year);
|
||||
var archiveBudgetGroups = archives
|
||||
@@ -440,6 +441,7 @@ public class BudgetSavingsService(
|
||||
{
|
||||
BudgetCategory.Income => archiveIncomeItems,
|
||||
BudgetCategory.Expense => archiveExpenseItems,
|
||||
BudgetCategory.Savings => archiveSavingsItems,
|
||||
_ => throw new NotSupportedException($"Category {archive.Category} is not supported.")
|
||||
};
|
||||
|
||||
@@ -663,7 +665,62 @@ public class BudgetSavingsService(
|
||||
""");
|
||||
}
|
||||
#endregion
|
||||
#region 构建归档存款明细表格
|
||||
var archiveSavingsDiff = 0m;
|
||||
if (archiveSavingsItems.Any())
|
||||
{
|
||||
description.AppendLine("<h3>已归档存款明细</h3>");
|
||||
description.AppendLine("""
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>预算</th>
|
||||
<th>月</th>
|
||||
<th>合计</th>
|
||||
<th>实际</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
""");
|
||||
|
||||
// 已归档的存款
|
||||
foreach (var (_, name, months, limit, current) in archiveSavingsItems)
|
||||
{
|
||||
description.AppendLine($"""
|
||||
<tr>
|
||||
<td>{name}</td>
|
||||
<td>{(limit == 0 ? "不限额" : limit.ToString("N0"))}</td>
|
||||
<td>{FormatMonths(months)}</td>
|
||||
<td>{limit * months.Length:N0}</td>
|
||||
<td><span class='income-value'>{current:N0}</span></td>
|
||||
</tr>
|
||||
""");
|
||||
}
|
||||
description.AppendLine("</tbody></table>");
|
||||
|
||||
archiveSavingsDiff = archiveSavingsItems.Sum(i => i.current) - archiveSavingsItems.Sum(i => i.limit * i.months.Length);
|
||||
description.AppendLine($"""
|
||||
<p>
|
||||
<span class="highlight">已归档存款总结: </span>
|
||||
{(archiveSavingsDiff > 0 ? "超额存款" : "未达预期")}:
|
||||
<span class='{(archiveSavingsDiff > 0 ? "income-value" : "expense-value")}'>
|
||||
<strong>{archiveSavingsDiff:N0}</strong>
|
||||
</span>
|
||||
=
|
||||
实际存款合计:
|
||||
<span class='income-value'>
|
||||
<strong>{archiveSavingsItems.Sum(i => i.current):N0}</strong>
|
||||
</span>
|
||||
-
|
||||
预算存款合计:
|
||||
<span class='income-value'>
|
||||
<strong>{archiveSavingsItems.Sum(i => i.limit * i.months.Length):N0}</strong>
|
||||
</span>
|
||||
</p>
|
||||
""");
|
||||
}
|
||||
#endregion
|
||||
#region 构建当前年度预算支出明细表格
|
||||
description.AppendLine("<h3>预算支出明细</h3>");
|
||||
description.AppendLine("""
|
||||
@@ -723,7 +780,10 @@ public class BudgetSavingsService(
|
||||
#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 archiveSavings = archiveSavingsItems.Any()
|
||||
? archiveSavingsItems.Sum(i => i.current)
|
||||
: 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);
|
||||
|
||||
@@ -525,7 +525,9 @@ public class BudgetStatsService(
|
||||
logger.LogDebug("获取到 {Count} 个当前有效预算", currentBudgetsDict.Count);
|
||||
|
||||
// 用于跟踪已处理的预算ID,避免重复
|
||||
// 对于年度预算,只添加一次;对于月度预算,跟踪 (Id, Month) 组合
|
||||
var processedBudgetIds = new HashSet<long>();
|
||||
var processedMonthlyBudgetKeys = new HashSet<(long Id, int Month)>();
|
||||
|
||||
// 1. 处理历史归档月份(1月到当前月-1)
|
||||
if (referenceDate.Year == now.Year && now.Month > 1)
|
||||
@@ -544,6 +546,9 @@ public class BudgetStatsService(
|
||||
// 对于月度预算,每个月都添加一个归档项
|
||||
if (item.Type == BudgetPeriodType.Month)
|
||||
{
|
||||
// 记录已处理的月度预算
|
||||
processedMonthlyBudgetKeys.Add((item.Id, m));
|
||||
|
||||
result.Add(new BudgetStatsItem
|
||||
{
|
||||
Id = item.Id,
|
||||
@@ -612,11 +617,15 @@ public class BudgetStatsService(
|
||||
logger.LogInformation("添加当前年度预算: {BudgetName} - 预算金额: {Limit}, 实际金额: {Current}",
|
||||
budget.Name, budget.Limit, currentAmount);
|
||||
}
|
||||
// 对于月度预算,仅添加当前月的预算项
|
||||
// 对于月度预算,仅添加当前月的预算项(如果还没有从归档中添加)
|
||||
else if (budget.Type == BudgetPeriodType.Month)
|
||||
{
|
||||
// 只计算当前月的实际值
|
||||
var currentAmount = await CalculateCurrentAmountAsync(budget, BudgetPeriodType.Month, referenceDate);
|
||||
// 检查当前月是否已经从归档中添加过
|
||||
if (!processedMonthlyBudgetKeys.Contains((budget.Id, now.Month)))
|
||||
{
|
||||
// 只计算当前月的实际值(使用真实的当前月日期,而不是referenceDate)
|
||||
var currentMonthDate = new DateTime(now.Year, now.Month, 1);
|
||||
var currentAmount = await CalculateCurrentAmountAsync(budget, BudgetPeriodType.Month, currentMonthDate);
|
||||
result.Add(new BudgetStatsItem
|
||||
{
|
||||
Id = budget.Id,
|
||||
@@ -634,8 +643,14 @@ public class BudgetStatsService(
|
||||
// 标记这是当前月的月度预算,用于年度限额计算
|
||||
IsCurrentMonth = true
|
||||
});
|
||||
logger.LogInformation("添加当前月的月度预算: {BudgetName} - 月度限额: {Limit}, 当前月实际值: {Current}",
|
||||
budget.Name, budget.Limit, currentAmount);
|
||||
logger.LogInformation("添加当前月的月度预算: {BudgetName} - 月度限额: {Limit}, 当前月实际值: {Current}, 计算日期: {CalculateDate:yyyy-MM}",
|
||||
budget.Name, budget.Limit, currentAmount, currentMonthDate);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogInformation("跳过已从归档添加的当前月月度预算: {BudgetName} - {Month}月",
|
||||
budget.Name, now.Month);
|
||||
}
|
||||
|
||||
// 如果还有剩余月份(未来月份),再添加一项作为未来的预算占位
|
||||
var remainingMonths = 12 - now.Month;
|
||||
@@ -1138,9 +1153,10 @@ public class BudgetStatsService(
|
||||
{
|
||||
var budgetLimit = CalculateBudgetLimit(budget, BudgetPeriodType.Year, referenceDate);
|
||||
var typeStr = budget.IsCurrentMonth ? "当前月" : "未来月";
|
||||
// 修正:当前月是1个月,未来月是剩余月份数量
|
||||
var calcStr = budget.IsCurrentMonth
|
||||
? $"1月×{budget.Limit:N0}"
|
||||
: $"{budget.RemainingMonths}月×{budget.Limit:N0}";
|
||||
? $"1个月×{budget.Limit:N0}"
|
||||
: $"{budget.RemainingMonths}个月×{budget.Limit:N0}";
|
||||
|
||||
description.AppendLine($"""
|
||||
<tr>
|
||||
@@ -1211,11 +1227,11 @@ public class BudgetStatsService(
|
||||
var budgetLimit = CalculateBudgetLimit(budget, BudgetPeriodType.Year, referenceDate);
|
||||
if (budget.IsCurrentMonth)
|
||||
{
|
||||
limitParts.Add($"{budget.Name}(当前月×{budget.Limit:N0}={budgetLimit:N0})");
|
||||
limitParts.Add($"{budget.Name}(当前月1个月×{budget.Limit:N0}={budgetLimit:N0})");
|
||||
}
|
||||
else
|
||||
{
|
||||
limitParts.Add($"{budget.Name}(剩余{budget.RemainingMonths}月×{budget.Limit:N0}={budgetLimit:N0})");
|
||||
limitParts.Add($"{budget.Name}(未来{budget.RemainingMonths}个月×{budget.Limit:N0}={budgetLimit:N0})");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,8 +32,22 @@ public class TransactionStatisticsService(
|
||||
{
|
||||
public async Task<Dictionary<string, (int count, decimal expense, decimal income, decimal saving)>> GetDailyStatisticsAsync(int year, int month, string? savingClassify = null)
|
||||
{
|
||||
var startDate = new DateTime(year, month, 1);
|
||||
var endDate = startDate.AddMonths(1);
|
||||
// 当 month=0 时,表示查询整年数据
|
||||
DateTime startDate;
|
||||
DateTime endDate;
|
||||
|
||||
if (month == 0)
|
||||
{
|
||||
// 查询整年:1月1日至12月31日
|
||||
startDate = new DateTime(year, 1, 1);
|
||||
endDate = new DateTime(year, 12, 31).AddDays(1); // 包含12月31日
|
||||
}
|
||||
else
|
||||
{
|
||||
// 查询指定月份
|
||||
startDate = new DateTime(year, month, 1);
|
||||
endDate = startDate.AddMonths(1);
|
||||
}
|
||||
|
||||
return await GetDailyStatisticsByRangeAsync(startDate, endDate, savingClassify);
|
||||
}
|
||||
|
||||
@@ -27,12 +27,27 @@
|
||||
<div
|
||||
class="remaining-label"
|
||||
>
|
||||
{{ activeTab === BudgetCategory.Expense ? '余额' : '差额' }}
|
||||
{{
|
||||
activeTab === BudgetCategory.Expense
|
||||
? (
|
||||
overallStats.month.current > overallStats.month.limit
|
||||
? '超支'
|
||||
: '余额'
|
||||
)
|
||||
: overallStats.month.current > overallStats.month.limit
|
||||
? '超额'
|
||||
: '差额'
|
||||
}}
|
||||
</div>
|
||||
<div
|
||||
class="remaining-value"
|
||||
:style="{ color:
|
||||
overallStats.month.current > overallStats.month.limit
|
||||
? activeTab === BudgetCategory.Expense ? 'var(--van-danger-color)' : 'var(--van-success-color)'
|
||||
: ''
|
||||
}"
|
||||
>
|
||||
¥{{ formatMoney(Math.max(0, overallStats.month.limit - overallStats.month.current)) }}
|
||||
¥{{ formatMoney(Math.abs(overallStats.month.limit - overallStats.month.current)) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -76,12 +91,13 @@
|
||||
<div
|
||||
class="remaining-label"
|
||||
>
|
||||
{{ activeTab === BudgetCategory.Expense ? '余额' : '差额' }}
|
||||
{{ activeTab === BudgetCategory.Expense ? (overallStats.year.current > overallStats.year.limit ? '超支' : '余额') : '差额' }}
|
||||
</div>
|
||||
<div
|
||||
class="remaining-value"
|
||||
:style="{ color: activeTab === BudgetCategory.Expense && overallStats.year.current > overallStats.year.limit ? 'var(--van-danger-color)' : '' }"
|
||||
>
|
||||
¥{{ formatMoney(Math.max(0, overallStats.year.limit - overallStats.year.current)) }}
|
||||
¥{{ formatMoney(Math.abs(overallStats.year.limit - overallStats.year.current)) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -247,20 +263,30 @@ const updateSingleGauge = (chart, data, isExpense) => {
|
||||
// 展示逻辑:支出显示剩余,收入显示已积累
|
||||
let displayRate
|
||||
if (isExpense) {
|
||||
// 支出:显示剩余容量 (100% - 已消耗%),随支出增大逐渐消耗
|
||||
// 支出:显示剩余容量 (100% - 已消耗%),随支出增大逐渐消耗;超支时显示超出部分
|
||||
displayRate = Math.max(0, 100 - rate)
|
||||
// 如果超支(rate > 100),显示超支部分(例如110% -> 显示10%超支)
|
||||
if (rate > 100) {
|
||||
displayRate = rate - 100
|
||||
}
|
||||
} else {
|
||||
// 收入:显示已积累 (%),随收入增多逐渐增多
|
||||
displayRate = Math.min(100, rate)
|
||||
// 收入:显示已积累 (%),随收入增多逐渐增多,可以超过100%
|
||||
displayRate = rate
|
||||
}
|
||||
|
||||
// 颜色逻辑:支出从绿色消耗到红色,收入从红色积累到绿色
|
||||
let color
|
||||
if (isExpense) {
|
||||
// 支出:满格绿色,随消耗逐渐变红 (根据剩余容量)
|
||||
if (displayRate <= 30) { color = getCssVar('--chart-danger') } // 红色
|
||||
else if (displayRate <= 65) { color = getCssVar('--chart-warning') } // 橙色
|
||||
else { color = getCssVar('--chart-success') } // 绿色
|
||||
if (rate > 100) {
|
||||
color = getCssVar('--chart-danger') // 超支显示红色
|
||||
} else if (displayRate <= 30) {
|
||||
color = getCssVar('--chart-danger') // 红色(剩余很少)
|
||||
} else if (displayRate <= 65) {
|
||||
color = getCssVar('--chart-warning') // 橙色
|
||||
} else {
|
||||
color = getCssVar('--chart-success') // 绿色(剩余充足)
|
||||
}
|
||||
} else {
|
||||
// 收入:空红色,随积累逐渐变绿 (根据已积累)
|
||||
if (displayRate <= 30) { color = getCssVar('--chart-danger') } // 红色
|
||||
@@ -275,7 +301,7 @@ const updateSingleGauge = (chart, data, isExpense) => {
|
||||
startAngle: 180,
|
||||
endAngle: 0,
|
||||
min: 0,
|
||||
max: 100,
|
||||
max: isExpense && rate > 100 ? 50 : 100, // 超支时显示0-50%范围(实际代表0-150%)
|
||||
splitNumber: 5,
|
||||
radius: '120%', // 放大一点以适应小卡片
|
||||
center: ['50%', '70%'],
|
||||
@@ -570,15 +596,15 @@ const updateBurndownChart = () => {
|
||||
if (isExpense) {
|
||||
// 支出:燃尽图(向下走)
|
||||
// 理想燃尽:每天均匀消耗
|
||||
const idealRemaining = Math.max(0, totalBudget * (1 - i / daysInMonth))
|
||||
const idealRemaining = totalBudget * (1 - i / daysInMonth)
|
||||
idealBurndown.push(Math.round(idealRemaining))
|
||||
|
||||
// 实际燃尽:根据当前日期显示
|
||||
// 实际燃尽:根据当前日期显示,允许负值以表示超支
|
||||
if (trend.length > 0) {
|
||||
// 后端返回了趋势数据
|
||||
const dayValue = trend[i - 1]
|
||||
if (dayValue !== undefined && dayValue !== null) {
|
||||
const actualRemaining = Math.max(0, totalBudget - dayValue)
|
||||
const actualRemaining = totalBudget - dayValue
|
||||
actualBurndown.push(Math.round(actualRemaining))
|
||||
} else {
|
||||
actualBurndown.push(null)
|
||||
@@ -586,7 +612,8 @@ const updateBurndownChart = () => {
|
||||
} else {
|
||||
// 后端没有趋势数据, fallback 到线性估算
|
||||
if (i <= currentDay && totalBudget > 0) {
|
||||
const actualRemaining = Math.max(0, totalBudget - (currentExpense * i / currentDay))
|
||||
// 允许显示负值以表示超支
|
||||
const actualRemaining = totalBudget - (currentExpense * i / currentDay)
|
||||
actualBurndown.push(Math.round(actualRemaining))
|
||||
} else {
|
||||
actualBurndown.push(null)
|
||||
@@ -762,14 +789,14 @@ const updateYearBurndownChart = () => {
|
||||
if (isExpense) {
|
||||
// 支出:燃尽图(向下走)
|
||||
// 理想燃尽:每月均匀消耗
|
||||
const idealRemaining = Math.max(0, totalBudget * (1 - (i + 1) / 12))
|
||||
const idealRemaining = totalBudget * (1 - (i + 1) / 12)
|
||||
idealBurndown.push(Math.round(idealRemaining))
|
||||
|
||||
// 实际燃尽:根据日期显示
|
||||
// 实际燃尽:根据日期显示,允许负值以表示超支
|
||||
if (trend.length > 0) {
|
||||
const monthValue = trend[i]
|
||||
if (monthValue !== undefined && monthValue !== null) {
|
||||
const actualRemaining = Math.max(0, totalBudget - monthValue)
|
||||
const actualRemaining = totalBudget - monthValue
|
||||
actualBurndown.push(Math.round(actualRemaining))
|
||||
} else {
|
||||
actualBurndown.push(null)
|
||||
@@ -778,7 +805,7 @@ const updateYearBurndownChart = () => {
|
||||
// Fallback: 如果是今年且月份未开始,或者去年,做线性统计
|
||||
const isFuture = year > currentYear || (year === currentYear && i > currentMonth)
|
||||
if (!isFuture && totalBudget > 0) {
|
||||
const actualRemaining = Math.max(0, totalBudget - (currentExpense * yearProgress))
|
||||
const actualRemaining = totalBudget - (currentExpense * yearProgress)
|
||||
actualBurndown.push(Math.round(actualRemaining))
|
||||
} else {
|
||||
actualBurndown.push(null)
|
||||
|
||||
@@ -66,10 +66,10 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 余额变化图表 -->
|
||||
<!-- 余额变化图表(融合收支趋势) -->
|
||||
<div
|
||||
class="balance-chart"
|
||||
style="height: 130px; padding: 0"
|
||||
style="height: 190px; padding: 0"
|
||||
>
|
||||
<div
|
||||
ref="balanceChartRef"
|
||||
@@ -77,31 +77,6 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 趋势统计 -->
|
||||
<div
|
||||
class="common-card"
|
||||
style="padding-bottom: 5px; margin-top: 12px;"
|
||||
>
|
||||
<div
|
||||
class="card-header"
|
||||
style="padding-bottom: 0;"
|
||||
>
|
||||
<h3 class="card-title">
|
||||
收支趋势
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="trend-chart"
|
||||
style="height: 240px; padding: 10px 0"
|
||||
>
|
||||
<div
|
||||
ref="chartRef"
|
||||
style="width: 100%; height: 100%"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 分类统计 -->
|
||||
<div
|
||||
class="common-card"
|
||||
@@ -450,10 +425,8 @@ const noneCategoriesView = computed(() => {
|
||||
const dailyData = ref([])
|
||||
// 余额数据(独立)
|
||||
const balanceData = ref([])
|
||||
const chartRef = ref(null)
|
||||
const pieChartRef = ref(null)
|
||||
const balanceChartRef = ref(null)
|
||||
let chartInstance = null
|
||||
let pieChartInstance = null
|
||||
let balanceChartInstance = null
|
||||
|
||||
@@ -576,7 +549,6 @@ const fetchStatistics = async (showLoading = true) => {
|
||||
firstLoading.value = false
|
||||
// DOM 更新后渲染图表
|
||||
nextTick(() => {
|
||||
renderChart(dailyData.value)
|
||||
renderPieChart()
|
||||
renderBalanceChart()
|
||||
})
|
||||
@@ -671,7 +643,7 @@ const fetchDailyData = async () => {
|
||||
// 如果不是首次加载(即DOM已存在),直接渲染
|
||||
if (!firstLoading.value) {
|
||||
nextTick(() => {
|
||||
renderChart(response.data)
|
||||
renderBalanceChart()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -691,6 +663,12 @@ const fetchBalanceData = async () => {
|
||||
|
||||
if (response.success && response.data) {
|
||||
balanceData.value = response.data
|
||||
// 如果不是首次加载,重新渲染余额图表
|
||||
if (!firstLoading.value) {
|
||||
nextTick(() => {
|
||||
renderBalanceChart()
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取余额统计数据失败:', error)
|
||||
@@ -698,193 +676,6 @@ const fetchBalanceData = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const renderChart = (data) => {
|
||||
if (!chartRef.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// 尝试获取DOM上的现有实例
|
||||
const existingInstance = echarts.getInstanceByDom(chartRef.value)
|
||||
|
||||
// 如果当前保存的实例与DOM不一致,或者DOM上已经有实例但我们没保存引用
|
||||
if (chartInstance && chartInstance !== existingInstance) {
|
||||
// 这种情况很少见,但为了保险,销毁旧的引用
|
||||
if (!chartInstance.isDisposed()) {
|
||||
chartInstance.dispose()
|
||||
}
|
||||
chartInstance = null
|
||||
}
|
||||
|
||||
// 如果DOM变了(transition导致的),旧的chartInstance绑定的DOM已经不在了
|
||||
// 这时 chartInstance.getDom() !== chartRef.value
|
||||
if (chartInstance && chartInstance.getDom() !== chartRef.value) {
|
||||
chartInstance.dispose()
|
||||
chartInstance = null
|
||||
}
|
||||
|
||||
// 如果DOM上已经有实例(可能由其他途径创建),复用它
|
||||
if (!chartInstance && existingInstance) {
|
||||
chartInstance = existingInstance
|
||||
}
|
||||
|
||||
if (!chartInstance) {
|
||||
chartInstance = echarts.init(chartRef.value)
|
||||
}
|
||||
|
||||
// 补全当月所有日期
|
||||
const now = new Date()
|
||||
let daysInMonth
|
||||
|
||||
if (currentYear.value === now.getFullYear() && currentMonth.value === now.getMonth() + 1) {
|
||||
// 如果是当前月,只显示到今天
|
||||
daysInMonth = now.getDate()
|
||||
} else {
|
||||
// 如果是过去月,显示整月
|
||||
daysInMonth = new Date(currentYear.value, currentMonth.value, 0).getDate()
|
||||
}
|
||||
|
||||
const fullData = []
|
||||
|
||||
// 创建日期映射
|
||||
const dataMap = new Map()
|
||||
data.forEach((item) => {
|
||||
const day = new Date(item.date).getDate()
|
||||
dataMap.set(day, item)
|
||||
})
|
||||
|
||||
for (let i = 1; i <= daysInMonth; i++) {
|
||||
const item = dataMap.get(i)
|
||||
if (item) {
|
||||
fullData.push(item)
|
||||
} else {
|
||||
fullData.push({
|
||||
date: `${currentYear.value}-${String(currentMonth.value).padStart(2, '0')}-${String(i).padStart(2, '0')}`,
|
||||
count: 0,
|
||||
expense: 0,
|
||||
income: 0,
|
||||
balance: 0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const dates = fullData.map((item) => {
|
||||
const date = new Date(item.date)
|
||||
return `${date.getDate()}日`
|
||||
})
|
||||
|
||||
// Calculate cumulative values
|
||||
let accumulatedExpense = 0
|
||||
let accumulatedIncome = 0
|
||||
let accumulatedBalance = 0
|
||||
|
||||
const expenses = fullData.map((item) => {
|
||||
accumulatedExpense += item.expense
|
||||
return accumulatedExpense
|
||||
})
|
||||
|
||||
const incomes = fullData.map((item) => {
|
||||
accumulatedIncome += item.income
|
||||
return accumulatedIncome
|
||||
})
|
||||
|
||||
const balances = fullData.map((item) => {
|
||||
accumulatedBalance += item.balance
|
||||
return accumulatedBalance
|
||||
})
|
||||
|
||||
const legendData = [
|
||||
{ name: '支出', value: '¥' + formatMoney(expenses[expenses.length - 1]) },
|
||||
{ name: '收入', value: '¥' + formatMoney(incomes[incomes.length - 1]) },
|
||||
{ name: '存款', value: '¥' + formatMoney(balances[balances.length - 1]) }
|
||||
]
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: function (params) {
|
||||
let result = params[0].name + '<br/>'
|
||||
params.forEach((param) => {
|
||||
result += param.marker + param.seriesName + ': ' + formatMoney(param.value) + '<br/>'
|
||||
})
|
||||
return result
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: legendData.map((item) => item.name),
|
||||
bottom: 0,
|
||||
textStyle: {
|
||||
color: getCssVar('--chart-text-muted') // 适配深色模式
|
||||
},
|
||||
formatter: function (name) {
|
||||
const item = legendData.find((d) => d.name === name)
|
||||
return item ? `${name} ${item.value}` : name
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '15%',
|
||||
top: '5%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: dates,
|
||||
axisLabel: {
|
||||
color: getCssVar('--chart-text-muted') // 适配深色模式
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
splitNumber: 5,
|
||||
axisLabel: {
|
||||
color: getCssVar('--chart-text-muted'), // 适配深色模式
|
||||
formatter: (value) => {
|
||||
return value / 1000 + 'k'
|
||||
}
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
type: 'dashed',
|
||||
color: getCssVar('--van-border-color') // 深色分割线
|
||||
}
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '支出',
|
||||
type: 'line',
|
||||
data: expenses,
|
||||
itemStyle: { color: getCssVar('--chart-color-1') },
|
||||
showSymbol: false,
|
||||
smooth: true,
|
||||
lineStyle: { width: 2 }
|
||||
},
|
||||
{
|
||||
name: '收入',
|
||||
type: 'line',
|
||||
data: incomes,
|
||||
itemStyle: { color: getCssVar('--chart-color-2') },
|
||||
showSymbol: false,
|
||||
smooth: true,
|
||||
lineStyle: { width: 2 }
|
||||
},
|
||||
{
|
||||
name: '存款',
|
||||
type: 'line',
|
||||
data: balances,
|
||||
itemStyle: { color: getCssVar('--chart-color-13') },
|
||||
showSymbol: false,
|
||||
smooth: true,
|
||||
lineStyle: { width: 2 }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
chartInstance.setOption(option)
|
||||
}
|
||||
|
||||
const renderPieChart = () => {
|
||||
if (!pieChartRef.value) {
|
||||
return
|
||||
@@ -1010,12 +801,12 @@ const renderPieChart = () => {
|
||||
pieChartInstance.setOption(option)
|
||||
}
|
||||
|
||||
// 渲染余额变化图表
|
||||
// 渲染余额变化图表(融合支出、收入、余额三条线)
|
||||
const renderBalanceChart = () => {
|
||||
if (!balanceChartRef.value) {
|
||||
return
|
||||
}
|
||||
if (balanceData.value.length === 0) {
|
||||
if (balanceData.value.length === 0 && dailyData.value.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1042,28 +833,168 @@ const renderBalanceChart = () => {
|
||||
balanceChartInstance = echarts.init(balanceChartRef.value)
|
||||
}
|
||||
|
||||
const dates = balanceData.value.map((item) => {
|
||||
// 判断是年度统计还是月度统计
|
||||
const isYearlyView = currentMonth.value === 0
|
||||
let dates, expenses, incomes, balances
|
||||
|
||||
if (isYearlyView) {
|
||||
// 按年统计:按月聚合数据
|
||||
const monthlyMap = new Map()
|
||||
const balanceMonthlyMap = new Map()
|
||||
|
||||
// 聚合 dailyData 按月
|
||||
dailyData.value.forEach((item) => {
|
||||
const date = new Date(item.date)
|
||||
return `${date.getMonth() + 1}/${date.getDate()}`
|
||||
const month = date.getMonth() + 1 // 1-12
|
||||
if (!monthlyMap.has(month)) {
|
||||
monthlyMap.set(month, { expense: 0, income: 0 })
|
||||
}
|
||||
const data = monthlyMap.get(month)
|
||||
data.expense += item.expense
|
||||
data.income += item.income
|
||||
})
|
||||
|
||||
const balances = balanceData.value.map((item) => item.cumulativeBalance)
|
||||
// 聚合 balanceData 按月(取每月最后一天的余额)
|
||||
balanceData.value.forEach((item) => {
|
||||
const date = new Date(item.date)
|
||||
const month = date.getMonth() + 1
|
||||
const day = date.getDate()
|
||||
|
||||
if (!balanceMonthlyMap.has(month) || day > balanceMonthlyMap.get(month).day) {
|
||||
balanceMonthlyMap.set(month, { balance: item.cumulativeBalance, day })
|
||||
}
|
||||
})
|
||||
|
||||
// 构建12个月的完整数据
|
||||
const now = new Date()
|
||||
const currentMonthNum = now.getFullYear() === currentYear.value ? now.getMonth() + 1 : 12
|
||||
|
||||
dates = []
|
||||
const monthlyExpenses = []
|
||||
const monthlyIncomes = []
|
||||
const monthlyBalances = []
|
||||
|
||||
let accumulatedExpense = 0
|
||||
let accumulatedIncome = 0
|
||||
|
||||
for (let m = 1; m <= currentMonthNum; m++) {
|
||||
dates.push(`${m}月`)
|
||||
|
||||
const data = monthlyMap.get(m) || { expense: 0, income: 0 }
|
||||
accumulatedExpense += data.expense
|
||||
accumulatedIncome += data.income
|
||||
|
||||
monthlyExpenses.push(accumulatedExpense)
|
||||
monthlyIncomes.push(accumulatedIncome)
|
||||
|
||||
const balanceData = balanceMonthlyMap.get(m)
|
||||
monthlyBalances.push(balanceData ? balanceData.balance : 0)
|
||||
}
|
||||
|
||||
expenses = monthlyExpenses
|
||||
incomes = monthlyIncomes
|
||||
balances = monthlyBalances
|
||||
|
||||
} else {
|
||||
// 按月统计:按日显示
|
||||
const now = new Date()
|
||||
let daysInMonth
|
||||
|
||||
if (currentYear.value === now.getFullYear() && currentMonth.value === now.getMonth() + 1) {
|
||||
daysInMonth = now.getDate()
|
||||
} else {
|
||||
daysInMonth = new Date(currentYear.value, currentMonth.value, 0).getDate()
|
||||
}
|
||||
|
||||
const fullData = []
|
||||
const dataMap = new Map()
|
||||
dailyData.value.forEach((item) => {
|
||||
const day = new Date(item.date).getDate()
|
||||
dataMap.set(day, item)
|
||||
})
|
||||
|
||||
// 创建余额映射
|
||||
const balanceMap = new Map()
|
||||
if (balanceData.value && balanceData.value.length > 0) {
|
||||
balanceData.value.forEach((item) => {
|
||||
const day = new Date(item.date).getDate()
|
||||
balanceMap.set(day, item.cumulativeBalance)
|
||||
})
|
||||
}
|
||||
|
||||
for (let i = 1; i <= daysInMonth; i++) {
|
||||
const item = dataMap.get(i)
|
||||
if (item) {
|
||||
fullData.push(item)
|
||||
} else {
|
||||
fullData.push({
|
||||
date: `${currentYear.value}-${String(currentMonth.value).padStart(2, '0')}-${String(i).padStart(2, '0')}`,
|
||||
count: 0,
|
||||
expense: 0,
|
||||
income: 0,
|
||||
balance: 0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
dates = fullData.map((item) => {
|
||||
const date = new Date(item.date)
|
||||
return `${date.getDate()}日`
|
||||
})
|
||||
|
||||
// 计算累计支出和收入
|
||||
let accumulatedExpense = 0
|
||||
let accumulatedIncome = 0
|
||||
|
||||
expenses = fullData.map((item) => {
|
||||
accumulatedExpense += item.expense
|
||||
return accumulatedExpense
|
||||
})
|
||||
|
||||
incomes = fullData.map((item) => {
|
||||
accumulatedIncome += item.income
|
||||
return accumulatedIncome
|
||||
})
|
||||
|
||||
// 使用余额接口数据
|
||||
balances = fullData.map((item, index) => {
|
||||
const day = index + 1
|
||||
return balanceMap.get(day) || 0
|
||||
})
|
||||
}
|
||||
|
||||
const legendData = [
|
||||
{ name: '支出', value: '¥' + formatMoney(expenses[expenses.length - 1]) },
|
||||
{ name: '收入', value: '¥' + formatMoney(incomes[incomes.length - 1]) },
|
||||
{ name: '余额', value: '¥' + formatMoney(balances[balances.length - 1]) }
|
||||
]
|
||||
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: function (params) {
|
||||
if (params.length === 0) {
|
||||
return ''
|
||||
let result = params[0].name + '<br/>'
|
||||
params.forEach((param) => {
|
||||
result += param.marker + param.seriesName + ': ¥' + formatMoney(param.value) + '<br/>'
|
||||
})
|
||||
return result
|
||||
}
|
||||
const param = params[0]
|
||||
return `${param.name}<br/>余额: ¥${formatMoney(param.value)}`
|
||||
},
|
||||
legend: {
|
||||
data: legendData.map((item) => item.name),
|
||||
bottom: 0,
|
||||
textStyle: {
|
||||
color: getCssVar('--chart-text-muted')
|
||||
},
|
||||
formatter: function (name) {
|
||||
const item = legendData.find((d) => d.name === name)
|
||||
return item ? `${name} ${item.value}` : name
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '5%',
|
||||
bottom: '15%',
|
||||
top: '5%',
|
||||
containLabel: true
|
||||
},
|
||||
@@ -1094,35 +1025,37 @@ const renderBalanceChart = () => {
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '支出',
|
||||
type: 'line',
|
||||
data: expenses,
|
||||
itemStyle: { color: '#ff6b6b' },
|
||||
showSymbol: false,
|
||||
smooth: true,
|
||||
lineStyle: { width: 2 }
|
||||
},
|
||||
{
|
||||
name: '收入',
|
||||
type: 'line',
|
||||
data: incomes,
|
||||
itemStyle: { color: '#51cf66' },
|
||||
showSymbol: false,
|
||||
smooth: true,
|
||||
lineStyle: { width: 2 }
|
||||
},
|
||||
{
|
||||
name: '余额',
|
||||
type: 'line',
|
||||
data: balances,
|
||||
itemStyle: { color: getCssVar('--chart-color-13') },
|
||||
itemStyle: { color: '#4c9cf1' },
|
||||
showSymbol: false,
|
||||
smooth: true,
|
||||
lineStyle: { width: 2 },
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
color: getCssVar('--chart-color-13')
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: getCssVar('--chart-color-13')
|
||||
}
|
||||
])
|
||||
}
|
||||
lineStyle: { width: 2 }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
balanceChartInstance.setOption(option)
|
||||
// 设置图表透明度
|
||||
if (balanceChartRef.value) {
|
||||
balanceChartRef.value.style.opacity = '0.85'
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到智能分析页面
|
||||
@@ -1308,24 +1241,11 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
const handleResize = () => {
|
||||
chartInstance && chartInstance.resize()
|
||||
pieChartInstance && pieChartInstance.resize()
|
||||
balanceChartInstance && balanceChartInstance.resize()
|
||||
}
|
||||
|
||||
// 监听DOM引用变化,确保在月份切换DOM重建后重新渲染图表
|
||||
watch(chartRef, (newVal) => {
|
||||
// 无论有没有数据,只要DOM变了,就尝试渲染
|
||||
// 如果没有数据,renderChart 内部也应该处理(或者我们可以传空数据)
|
||||
if (newVal) {
|
||||
setTimeout(() => {
|
||||
// 传入当前 dailyData,即使是空的,renderChart 应该能处理
|
||||
renderChart(dailyData.value || [])
|
||||
chartInstance && chartInstance.resize()
|
||||
}, 50)
|
||||
}
|
||||
})
|
||||
|
||||
watch(pieChartRef, (newVal) => {
|
||||
if (newVal) {
|
||||
setTimeout(() => {
|
||||
@@ -1362,7 +1282,6 @@ onBeforeUnmount(() => {
|
||||
window.removeEventListener &&
|
||||
window.removeEventListener('transaction-deleted', onGlobalTransactionDeleted)
|
||||
window.removeEventListener('resize', handleResize)
|
||||
chartInstance && chartInstance.dispose()
|
||||
pieChartInstance && pieChartInstance.dispose()
|
||||
balanceChartInstance && balanceChartInstance.dispose()
|
||||
})
|
||||
|
||||
393
WebApi.Test/Budget/BudgetStatsArchiveTest.cs
Normal file
393
WebApi.Test/Budget/BudgetStatsArchiveTest.cs
Normal file
@@ -0,0 +1,393 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute.ReturnsExtensions;
|
||||
using Service.Transaction;
|
||||
|
||||
namespace WebApi.Test.Budget;
|
||||
|
||||
/// <summary>
|
||||
/// 预算统计 - 归档数据重复计算测试
|
||||
/// </summary>
|
||||
public class BudgetStatsArchiveTest : BaseTest
|
||||
{
|
||||
private readonly IBudgetRepository _budgetRepo = Substitute.For<IBudgetRepository>();
|
||||
private readonly IBudgetArchiveRepository _archiveRepo = Substitute.For<IBudgetArchiveRepository>();
|
||||
private readonly ITransactionStatisticsService _transactionStatsService = Substitute.For<ITransactionStatisticsService>();
|
||||
private readonly IDateTimeProvider _dateTimeProvider = Substitute.For<IDateTimeProvider>();
|
||||
|
||||
private IBudgetStatsService CreateService()
|
||||
{
|
||||
return new BudgetStatsService(
|
||||
_budgetRepo,
|
||||
_archiveRepo,
|
||||
_transactionStatsService,
|
||||
_dateTimeProvider,
|
||||
Substitute.For<ILogger<BudgetStatsService>>()
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 测试场景:当前为2月,用户切换到1月(已归档)查看预算
|
||||
/// 预期:年度统计不应重复计算1月的数据
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetCategoryStats_切换到已归档月份_年度统计不重复计算_Test()
|
||||
{
|
||||
// Arrange - 模拟当前时间为2026年2月1日
|
||||
var now = new DateTime(2026, 2, 1);
|
||||
_dateTimeProvider.Now.Returns(now);
|
||||
|
||||
// 用户在前端选择查看1月的预算(referenceDate = 2026-01-01)
|
||||
var referenceDate = new DateTime(2026, 1, 1);
|
||||
|
||||
// 创建一个月度预算:房贷
|
||||
var monthlyBudget = new BudgetRecord
|
||||
{
|
||||
Id = 100,
|
||||
Name = "房贷",
|
||||
Category = BudgetCategory.Expense,
|
||||
Type = BudgetPeriodType.Month,
|
||||
Limit = 9000, // 每月9000元
|
||||
StartDate = new DateTime(2026, 1, 1),
|
||||
SelectedCategories = "房贷",
|
||||
IsMandatoryExpense = false,
|
||||
NoLimit = false
|
||||
};
|
||||
|
||||
// 当前预算列表
|
||||
_budgetRepo.GetAllAsync().Returns([monthlyBudget]);
|
||||
|
||||
// 1月的归档数据(实际支出9158.7)
|
||||
var januaryArchive = new BudgetArchive
|
||||
{
|
||||
Year = 2026,
|
||||
Month = 1,
|
||||
Content = new[]
|
||||
{
|
||||
new BudgetArchiveContent
|
||||
{
|
||||
Id = 100,
|
||||
Name = "房贷",
|
||||
Category = BudgetCategory.Expense,
|
||||
Type = BudgetPeriodType.Month,
|
||||
Limit = 9000,
|
||||
Actual = 9158.7m, // 1月实际支出
|
||||
SelectedCategories = ["房贷"],
|
||||
IsMandatoryExpense = false,
|
||||
NoLimit = false
|
||||
}
|
||||
}
|
||||
};
|
||||
_archiveRepo.GetArchiveAsync(2026, 1).Returns(januaryArchive);
|
||||
_archiveRepo.GetArchiveAsync(2026, 2).ReturnsNull();
|
||||
|
||||
// 模拟2月的实际交易数据(假设2月到现在实际支出了3000)
|
||||
var feb1 = new DateTime(2026, 2, 1);
|
||||
var feb28 = new DateTime(2026, 2, 28);
|
||||
_budgetRepo.GetCurrentAmountAsync(
|
||||
Arg.Is<BudgetRecord>(b => b.Id == 100),
|
||||
Arg.Is<DateTime>(d => d >= feb1 && d <= feb28),
|
||||
Arg.Any<DateTime>()
|
||||
).Returns(3000m);
|
||||
|
||||
// 模拟交易统计数据(用于趋势图)
|
||||
_transactionStatsService.GetFilteredTrendStatisticsAsync(
|
||||
Arg.Any<DateTime>(),
|
||||
Arg.Any<DateTime>(),
|
||||
TransactionType.Expense,
|
||||
Arg.Any<List<string>>(),
|
||||
true
|
||||
).Returns(new Dictionary<DateTime, decimal>
|
||||
{
|
||||
{ new DateTime(2026, 1, 1), 9158.7m }, // 1月
|
||||
{ new DateTime(2026, 2, 1), 3000m } // 2月
|
||||
});
|
||||
|
||||
// 模拟月度统计的交易数据
|
||||
_transactionStatsService.GetFilteredTrendStatisticsAsync(
|
||||
Arg.Any<DateTime>(),
|
||||
Arg.Any<DateTime>(),
|
||||
TransactionType.Expense,
|
||||
Arg.Any<List<string>>()
|
||||
).Returns(new Dictionary<DateTime, decimal>
|
||||
{
|
||||
{ new DateTime(2026, 1, 1), 9158.7m }
|
||||
});
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act - 调用获取分类统计(用户选择查看1月)
|
||||
var result = await service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate);
|
||||
|
||||
// Assert - 验证年度统计
|
||||
result.Should().NotBeNull();
|
||||
result.Year.Should().NotBeNull();
|
||||
|
||||
// 年度预算限额 = 1月归档(9000) + 2月当前(9000) + 未来10个月(9000 * 10) = 108000
|
||||
result.Year.Limit.Should().Be(108000);
|
||||
|
||||
// 年度实际支出 = 1月归档(9158.7) + 2月当前(3000) = 12158.7
|
||||
// 关键:不应该包含两次1月的数据!
|
||||
result.Year.Current.Should().Be(12158.7m);
|
||||
|
||||
// 使用率 = 12158.7 / 108000 * 100 = 11.26%
|
||||
result.Year.Rate.Should().BeApproximately(11.26m, 0.01m);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 测试场景:当前为2月,用户切换到1月(已归档)查看预算,包含年度预算
|
||||
/// 预期:年度预算只计算一次
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetCategoryStats_年度预算_切换到已归档月份_不重复计算_Test()
|
||||
{
|
||||
// Arrange - 模拟当前时间为2026年2月1日
|
||||
var now = new DateTime(2026, 2, 1);
|
||||
_dateTimeProvider.Now.Returns(now);
|
||||
|
||||
var referenceDate = new DateTime(2026, 1, 1);
|
||||
|
||||
// 创建年度预算和月度预算
|
||||
var yearlyBudget = new BudgetRecord
|
||||
{
|
||||
Id = 200,
|
||||
Name = "教育费",
|
||||
Category = BudgetCategory.Expense,
|
||||
Type = BudgetPeriodType.Year,
|
||||
Limit = 8000, // 全年8000元
|
||||
StartDate = new DateTime(2026, 1, 1),
|
||||
SelectedCategories = "教育",
|
||||
IsMandatoryExpense = false,
|
||||
NoLimit = false
|
||||
};
|
||||
|
||||
var monthlyBudget = new BudgetRecord
|
||||
{
|
||||
Id = 100,
|
||||
Name = "生活费",
|
||||
Category = BudgetCategory.Expense,
|
||||
Type = BudgetPeriodType.Month,
|
||||
Limit = 2000,
|
||||
StartDate = new DateTime(2026, 1, 1),
|
||||
SelectedCategories = "餐饮",
|
||||
IsMandatoryExpense = false,
|
||||
NoLimit = false
|
||||
};
|
||||
|
||||
_budgetRepo.GetAllAsync().Returns([yearlyBudget, monthlyBudget]);
|
||||
|
||||
// 1月归档数据
|
||||
var januaryArchive = new BudgetArchive
|
||||
{
|
||||
Year = 2026,
|
||||
Month = 1,
|
||||
Content = new[]
|
||||
{
|
||||
new BudgetArchiveContent
|
||||
{
|
||||
Id = 200,
|
||||
Name = "教育费",
|
||||
Category = BudgetCategory.Expense,
|
||||
Type = BudgetPeriodType.Year,
|
||||
Limit = 8000,
|
||||
Actual = 7257m, // 全年实际(从1月累计)
|
||||
SelectedCategories = ["教育"],
|
||||
IsMandatoryExpense = false,
|
||||
NoLimit = false
|
||||
},
|
||||
new BudgetArchiveContent
|
||||
{
|
||||
Id = 100,
|
||||
Name = "生活费",
|
||||
Category = BudgetCategory.Expense,
|
||||
Type = BudgetPeriodType.Month,
|
||||
Limit = 2000,
|
||||
Actual = 2000m, // 1月实际
|
||||
SelectedCategories = ["餐饮"],
|
||||
IsMandatoryExpense = false,
|
||||
NoLimit = false
|
||||
}
|
||||
}
|
||||
};
|
||||
_archiveRepo.GetArchiveAsync(2026, 1).Returns(januaryArchive);
|
||||
_archiveRepo.GetArchiveAsync(2026, 2).ReturnsNull();
|
||||
|
||||
// 2月的实际数据
|
||||
var feb1 = new DateTime(2026, 2, 1);
|
||||
var feb28 = new DateTime(2026, 2, 28);
|
||||
_budgetRepo.GetCurrentAmountAsync(
|
||||
Arg.Is<BudgetRecord>(b => b.Id == 100),
|
||||
Arg.Is<DateTime>(d => d >= feb1 && d <= feb28),
|
||||
Arg.Any<DateTime>()
|
||||
).Returns(1800m);
|
||||
|
||||
// 年度预算的当前实际值(整年累计,包括1月归档的7257)
|
||||
var year1 = new DateTime(2026, 1, 1);
|
||||
var year12 = new DateTime(2026, 12, 31);
|
||||
_budgetRepo.GetCurrentAmountAsync(
|
||||
Arg.Is<BudgetRecord>(b => b.Id == 200),
|
||||
Arg.Is<DateTime>(d => d >= year1),
|
||||
Arg.Any<DateTime>()
|
||||
).Returns(7257m); // 全年累计
|
||||
|
||||
_transactionStatsService.GetFilteredTrendStatisticsAsync(
|
||||
Arg.Any<DateTime>(),
|
||||
Arg.Any<DateTime>(),
|
||||
TransactionType.Expense,
|
||||
Arg.Any<List<string>>(),
|
||||
true
|
||||
).Returns(new Dictionary<DateTime, decimal>
|
||||
{
|
||||
{ new DateTime(2026, 1, 1), 9257m }, // 1月: 教育7257 + 生活2000
|
||||
{ new DateTime(2026, 2, 1), 1800m } // 2月: 生活1800
|
||||
});
|
||||
|
||||
// 模拟月度统计的交易数据
|
||||
_transactionStatsService.GetFilteredTrendStatisticsAsync(
|
||||
Arg.Any<DateTime>(),
|
||||
Arg.Any<DateTime>(),
|
||||
TransactionType.Expense,
|
||||
Arg.Any<List<string>>()
|
||||
).Returns(new Dictionary<DateTime, decimal>
|
||||
{
|
||||
{ new DateTime(2026, 1, 1), 9257m }
|
||||
});
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result = await service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate);
|
||||
|
||||
// Assert
|
||||
result.Year.Should().NotBeNull();
|
||||
|
||||
// 年度限额 = 教育费(8000) + 生活费1月归档(2000) + 生活费2月(2000) + 生活费未来10月(2000*10) = 32000
|
||||
result.Year.Limit.Should().Be(32000);
|
||||
|
||||
// 年度实际支出 = 教育费(7257) + 生活费1月(2000) + 生活费2月(1800) = 11057
|
||||
// 关键:教育费(年度预算)只应该计算一次!
|
||||
result.Year.Current.Should().Be(11057m);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 测试场景:当前为3月,用户切换到1月查看
|
||||
/// 预期:年度统计应包含1月归档 + 2月归档 + 3月当前
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetCategoryStats_多个归档月份_不重复计算_Test()
|
||||
{
|
||||
// Arrange - 模拟当前时间为2026年3月15日
|
||||
var now = new DateTime(2026, 3, 15);
|
||||
_dateTimeProvider.Now.Returns(now);
|
||||
|
||||
var referenceDate = new DateTime(2026, 1, 1);
|
||||
|
||||
var monthlyBudget = new BudgetRecord
|
||||
{
|
||||
Id = 100,
|
||||
Name = "房贷",
|
||||
Category = BudgetCategory.Expense,
|
||||
Type = BudgetPeriodType.Month,
|
||||
Limit = 9000,
|
||||
StartDate = new DateTime(2026, 1, 1),
|
||||
SelectedCategories = "房贷",
|
||||
IsMandatoryExpense = false,
|
||||
NoLimit = false
|
||||
};
|
||||
|
||||
_budgetRepo.GetAllAsync().Returns([monthlyBudget]);
|
||||
|
||||
// 1月归档
|
||||
_archiveRepo.GetArchiveAsync(2026, 1).Returns(new BudgetArchive
|
||||
{
|
||||
Year = 2026,
|
||||
Month = 1,
|
||||
Content = new[]
|
||||
{
|
||||
new BudgetArchiveContent
|
||||
{
|
||||
Id = 100,
|
||||
Name = "房贷",
|
||||
Category = BudgetCategory.Expense,
|
||||
Type = BudgetPeriodType.Month,
|
||||
Limit = 9000,
|
||||
Actual = 9158.7m,
|
||||
SelectedCategories = ["房贷"],
|
||||
IsMandatoryExpense = false,
|
||||
NoLimit = false
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 2月归档
|
||||
_archiveRepo.GetArchiveAsync(2026, 2).Returns(new BudgetArchive
|
||||
{
|
||||
Year = 2026,
|
||||
Month = 2,
|
||||
Content = new[]
|
||||
{
|
||||
new BudgetArchiveContent
|
||||
{
|
||||
Id = 100,
|
||||
Name = "房贷",
|
||||
Category = BudgetCategory.Expense,
|
||||
Type = BudgetPeriodType.Month,
|
||||
Limit = 9000,
|
||||
Actual = 9126.1m,
|
||||
SelectedCategories = ["房贷"],
|
||||
IsMandatoryExpense = false,
|
||||
NoLimit = false
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
_archiveRepo.GetArchiveAsync(2026, 3).ReturnsNull();
|
||||
|
||||
// 3月当前实际数据(到3月15日)
|
||||
_budgetRepo.GetCurrentAmountAsync(
|
||||
Arg.Is<BudgetRecord>(b => b.Id == 100),
|
||||
Arg.Any<DateTime>(),
|
||||
Arg.Any<DateTime>()
|
||||
).Returns(4500m); // 3月已支出4500
|
||||
|
||||
_transactionStatsService.GetFilteredTrendStatisticsAsync(
|
||||
Arg.Any<DateTime>(),
|
||||
Arg.Any<DateTime>(),
|
||||
TransactionType.Expense,
|
||||
Arg.Any<List<string>>(),
|
||||
true
|
||||
).Returns(new Dictionary<DateTime, decimal>
|
||||
{
|
||||
{ new DateTime(2026, 1, 1), 9158.7m },
|
||||
{ new DateTime(2026, 2, 1), 9126.1m },
|
||||
{ new DateTime(2026, 3, 1), 4500m }
|
||||
});
|
||||
|
||||
// 模拟月度统计的交易数据
|
||||
_transactionStatsService.GetFilteredTrendStatisticsAsync(
|
||||
Arg.Any<DateTime>(),
|
||||
Arg.Any<DateTime>(),
|
||||
TransactionType.Expense,
|
||||
Arg.Any<List<string>>()
|
||||
).Returns(new Dictionary<DateTime, decimal>
|
||||
{
|
||||
{ new DateTime(2026, 1, 1), 9158.7m }
|
||||
});
|
||||
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result = await service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate);
|
||||
|
||||
// Assert
|
||||
result.Year.Should().NotBeNull();
|
||||
|
||||
// 年度限额 = 1月归档(9000) + 2月归档(9000) + 3月当前(9000) + 未来9月(9000*9) = 108000
|
||||
result.Year.Limit.Should().Be(108000);
|
||||
|
||||
// 年度实际 = 1月归档(9158.7) + 2月归档(9126.1) + 3月当前(4500) = 22784.8
|
||||
result.Year.Current.Should().Be(22784.8m);
|
||||
|
||||
// 验证每个月只计算了一次
|
||||
result.Year.Rate.Should().BeApproximately(21.10m, 0.01m);
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,77 @@ public class TransactionStatisticsServiceTest : BaseTest
|
||||
result["2024-01-01"].expense.Should().Be(150m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDailyStatisticsAsync_月份为0查询全年()
|
||||
{
|
||||
// Arrange
|
||||
var year = 2024;
|
||||
var month = 0; // 0 表示查询全年
|
||||
var testData = new List<TransactionRecord>
|
||||
{
|
||||
// 1月
|
||||
new() { Id=1, OccurredAt=new DateTime(2024,1,15), Amount=-100m, Type=TransactionType.Expense },
|
||||
new() { Id=2, OccurredAt=new DateTime(2024,1,20), Amount=5000m, Type=TransactionType.Income },
|
||||
// 6月
|
||||
new() { Id=3, OccurredAt=new DateTime(2024,6,10), Amount=-200m, Type=TransactionType.Expense },
|
||||
new() { Id=4, OccurredAt=new DateTime(2024,6,15), Amount=3000m, Type=TransactionType.Income },
|
||||
// 12月
|
||||
new() { Id=5, OccurredAt=new DateTime(2024,12,25), Amount=-300m, Type=TransactionType.Expense },
|
||||
new() { Id=6, OccurredAt=new DateTime(2024,12,31), Amount=2000m, Type=TransactionType.Income }
|
||||
};
|
||||
|
||||
ConfigureQueryAsync(testData);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetDailyStatisticsAsync(year, month);
|
||||
|
||||
// Assert - 应包含全年各个月份的数据
|
||||
result.Should().ContainKey("2024-01-15");
|
||||
result.Should().ContainKey("2024-06-10");
|
||||
result.Should().ContainKey("2024-12-31");
|
||||
result["2024-01-15"].expense.Should().Be(100m);
|
||||
result["2024-06-10"].expense.Should().Be(200m);
|
||||
result["2024-12-31"].income.Should().Be(2000m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDailyStatisticsAsync_月份为0不应抛出异常()
|
||||
{
|
||||
// Arrange
|
||||
var year = 2026;
|
||||
var month = 0;
|
||||
ConfigureQueryAsync(new List<TransactionRecord>());
|
||||
|
||||
// Act & Assert - 不应抛出 ArgumentOutOfRangeException
|
||||
var act = async () => await _service.GetDailyStatisticsAsync(year, month);
|
||||
await act.Should().NotThrowAsync<ArgumentOutOfRangeException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetDailyStatisticsAsync_包含存款分类统计()
|
||||
{
|
||||
// Arrange
|
||||
var year = 2024;
|
||||
var month = 1;
|
||||
var savingClassify = "股票,基金"; // 存款分类
|
||||
var testData = new List<TransactionRecord>
|
||||
{
|
||||
new() { Id=1, OccurredAt=new DateTime(2024,1,1), Amount=-100m, Type=TransactionType.Expense, Classify="餐饮" },
|
||||
new() { Id=2, OccurredAt=new DateTime(2024,1,1), Amount=-500m, Type=TransactionType.Expense, Classify="股票" },
|
||||
new() { Id=3, OccurredAt=new DateTime(2024,1,1), Amount=-300m, Type=TransactionType.Expense, Classify="基金" }
|
||||
};
|
||||
|
||||
ConfigureQueryAsync(testData);
|
||||
|
||||
// Act
|
||||
var result = await _service.GetDailyStatisticsAsync(year, month, savingClassify);
|
||||
|
||||
// Assert
|
||||
result.Should().ContainKey("2024-01-01");
|
||||
result["2024-01-01"].saving.Should().Be(800m); // 股票500 + 基金300
|
||||
result["2024-01-01"].expense.Should().Be(900m); // 总支出
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTrendStatisticsAsync_多个月份()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user