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

This commit is contained in:
2026-02-01 10:27:04 +08:00
parent 704f58b1a1
commit 61916dc6da
7 changed files with 810 additions and 310 deletions

View File

@@ -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);

View File

@@ -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,30 +617,40 @@ 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);
result.Add(new BudgetStatsItem
// 检查当前月是否已经从归档中添加过
if (!processedMonthlyBudgetKeys.Contains((budget.Id, now.Month)))
{
Id = budget.Id,
Name = budget.Name,
Type = budget.Type,
Limit = budget.Limit, // 月度预算的原始限额
Current = currentAmount, // 当前月的实际值
Category = budget.Category,
SelectedCategories = string.IsNullOrEmpty(budget.SelectedCategories)
? []
: budget.SelectedCategories.Split(',', StringSplitOptions.RemoveEmptyEntries),
NoLimit = budget.NoLimit,
IsMandatoryExpense = budget.IsMandatoryExpense,
IsArchive = false,
// 标记这是当前月的月度预算,用于年度限额计算
IsCurrentMonth = true
});
logger.LogInformation("添加当前月的月度预算: {BudgetName} - 月度限额: {Limit}, 当前月实际值: {Current}",
budget.Name, budget.Limit, currentAmount);
// 只计算当前月的实际值使用真实的当前月日期而不是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,
Name = budget.Name,
Type = budget.Type,
Limit = budget.Limit, // 月度预算的原始限额
Current = currentAmount, // 当前月的实际值
Category = budget.Category,
SelectedCategories = string.IsNullOrEmpty(budget.SelectedCategories)
? []
: budget.SelectedCategories.Split(',', StringSplitOptions.RemoveEmptyEntries),
NoLimit = budget.NoLimit,
IsMandatoryExpense = budget.IsMandatoryExpense,
IsArchive = false,
// 标记这是当前月的月度预算,用于年度限额计算
IsCurrentMonth = true
});
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})");
}
}

View File

@@ -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);
}

View File

@@ -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)

View File

@@ -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 date = new Date(item.date)
return `${date.getMonth() + 1}/${date.getDate()}`
})
// 判断是年度统计还是月度统计
const isYearlyView = currentMonth.value === 0
let dates, expenses, incomes, balances
const balances = balanceData.value.map((item) => item.cumulativeBalance)
if (isYearlyView) {
// 按年统计:按月聚合数据
const monthlyMap = new Map()
const balanceMonthlyMap = new Map()
// 聚合 dailyData 按月
dailyData.value.forEach((item) => {
const date = new Date(item.date)
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
})
// 聚合 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 ''
}
const param = params[0]
return `${param.name}<br/>余额: ¥${formatMoney(param.value)}`
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: '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()
})

View 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);
}
}

View File

@@ -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_多个月份()
{