From 61916dc6dac55ccf716df860c48c7c47525d5262 Mon Sep 17 00:00:00 2001 From: SunCheng Date: Sun, 1 Feb 2026 10:27:04 +0800 Subject: [PATCH] fix --- Service/Budget/BudgetSavingsService.cs | 62 ++- Service/Budget/BudgetStatsService.cs | 66 ++- .../TransactionStatisticsService.cs | 18 +- .../components/Budget/BudgetChartAnalysis.vue | 65 ++- Web/src/views/StatisticsView.vue | 445 +++++++----------- WebApi.Test/Budget/BudgetStatsArchiveTest.cs | 393 ++++++++++++++++ .../TransactionStatisticsServiceTest.cs | 71 +++ 7 files changed, 810 insertions(+), 310 deletions(-) create mode 100644 WebApi.Test/Budget/BudgetStatsArchiveTest.cs diff --git a/Service/Budget/BudgetSavingsService.cs b/Service/Budget/BudgetSavingsService.cs index be8412b..0772142 100644 --- a/Service/Budget/BudgetSavingsService.cs +++ b/Service/Budget/BudgetSavingsService.cs @@ -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("

已归档存款明细

"); + description.AppendLine(""" + + + + + + + + + + + + """); + // 已归档的存款 + foreach (var (_, name, months, limit, current) in archiveSavingsItems) + { + description.AppendLine($""" + + + + + + + + """); + } + description.AppendLine("
名称预算合计实际
{name}{(limit == 0 ? "不限额" : limit.ToString("N0"))}{FormatMonths(months)}{limit * months.Length:N0}{current:N0}
"); + + archiveSavingsDiff = archiveSavingsItems.Sum(i => i.current) - archiveSavingsItems.Sum(i => i.limit * i.months.Length); + description.AppendLine($""" +

+ 已归档存款总结: + {(archiveSavingsDiff > 0 ? "超额存款" : "未达预期")}: + + {archiveSavingsDiff:N0} + + = + 实际存款合计: + + {archiveSavingsItems.Sum(i => i.current):N0} + + - + 预算存款合计: + + {archiveSavingsItems.Sum(i => i.limit * i.months.Length):N0} + +

+ """); + } + #endregion #region 构建当前年度预算支出明细表格 description.AppendLine("

预算支出明细

"); 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); diff --git a/Service/Budget/BudgetStatsService.cs b/Service/Budget/BudgetStatsService.cs index e468536..fff5c68 100644 --- a/Service/Budget/BudgetStatsService.cs +++ b/Service/Budget/BudgetStatsService.cs @@ -525,7 +525,9 @@ public class BudgetStatsService( logger.LogDebug("获取到 {Count} 个当前有效预算", currentBudgetsDict.Count); // 用于跟踪已处理的预算ID,避免重复 + // 对于年度预算,只添加一次;对于月度预算,跟踪 (Id, Month) 组合 var processedBudgetIds = new HashSet(); + 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($""" @@ -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})"); } } diff --git a/Service/Transaction/TransactionStatisticsService.cs b/Service/Transaction/TransactionStatisticsService.cs index 48a1b3d..952c706 100644 --- a/Service/Transaction/TransactionStatisticsService.cs +++ b/Service/Transaction/TransactionStatisticsService.cs @@ -32,8 +32,22 @@ public class TransactionStatisticsService( { public async Task> 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); } diff --git a/Web/src/components/Budget/BudgetChartAnalysis.vue b/Web/src/components/Budget/BudgetChartAnalysis.vue index b4c0a25..58a665b 100644 --- a/Web/src/components/Budget/BudgetChartAnalysis.vue +++ b/Web/src/components/Budget/BudgetChartAnalysis.vue @@ -27,12 +27,27 @@
- {{ activeTab === BudgetCategory.Expense ? '余额' : '差额' }} + {{ + activeTab === BudgetCategory.Expense + ? ( + overallStats.month.current > overallStats.month.limit + ? '超支' + : '余额' + ) + : overallStats.month.current > overallStats.month.limit + ? '超额' + : '差额' + }}
- ¥{{ formatMoney(Math.max(0, overallStats.month.limit - overallStats.month.current)) }} + ¥{{ formatMoney(Math.abs(overallStats.month.limit - overallStats.month.current)) }}
@@ -76,12 +91,13 @@
- {{ activeTab === BudgetCategory.Expense ? '余额' : '差额' }} + {{ activeTab === BudgetCategory.Expense ? (overallStats.year.current > overallStats.year.limit ? '超支' : '余额') : '差额' }}
- ¥{{ formatMoney(Math.max(0, overallStats.year.limit - overallStats.year.current)) }} + ¥{{ formatMoney(Math.abs(overallStats.year.limit - overallStats.year.current)) }}
@@ -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) diff --git a/Web/src/views/StatisticsView.vue b/Web/src/views/StatisticsView.vue index f45e4f8..bdd2c5e 100644 --- a/Web/src/views/StatisticsView.vue +++ b/Web/src/views/StatisticsView.vue @@ -66,10 +66,10 @@ - +
- - -
-
-

- 收支趋势 -

-
- -
-
-
-
{ 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 + '
' - params.forEach((param) => { - result += param.marker + param.seriesName + ': ' + formatMoney(param.value) + '
' - }) - 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}
余额: ¥${formatMoney(param.value)}` + let result = params[0].name + '
' + params.forEach((param) => { + result += param.marker + param.seriesName + ': ¥' + formatMoney(param.value) + '
' + }) + 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() }) diff --git a/WebApi.Test/Budget/BudgetStatsArchiveTest.cs b/WebApi.Test/Budget/BudgetStatsArchiveTest.cs new file mode 100644 index 0000000..e5502d8 --- /dev/null +++ b/WebApi.Test/Budget/BudgetStatsArchiveTest.cs @@ -0,0 +1,393 @@ +using Microsoft.Extensions.Logging; +using NSubstitute.ReturnsExtensions; +using Service.Transaction; + +namespace WebApi.Test.Budget; + +/// +/// 预算统计 - 归档数据重复计算测试 +/// +public class BudgetStatsArchiveTest : BaseTest +{ + private readonly IBudgetRepository _budgetRepo = Substitute.For(); + private readonly IBudgetArchiveRepository _archiveRepo = Substitute.For(); + private readonly ITransactionStatisticsService _transactionStatsService = Substitute.For(); + private readonly IDateTimeProvider _dateTimeProvider = Substitute.For(); + + private IBudgetStatsService CreateService() + { + return new BudgetStatsService( + _budgetRepo, + _archiveRepo, + _transactionStatsService, + _dateTimeProvider, + Substitute.For>() + ); + } + + /// + /// 测试场景:当前为2月,用户切换到1月(已归档)查看预算 + /// 预期:年度统计不应重复计算1月的数据 + /// + [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(b => b.Id == 100), + Arg.Is(d => d >= feb1 && d <= feb28), + Arg.Any() + ).Returns(3000m); + + // 模拟交易统计数据(用于趋势图) + _transactionStatsService.GetFilteredTrendStatisticsAsync( + Arg.Any(), + Arg.Any(), + TransactionType.Expense, + Arg.Any>(), + true + ).Returns(new Dictionary + { + { new DateTime(2026, 1, 1), 9158.7m }, // 1月 + { new DateTime(2026, 2, 1), 3000m } // 2月 + }); + + // 模拟月度统计的交易数据 + _transactionStatsService.GetFilteredTrendStatisticsAsync( + Arg.Any(), + Arg.Any(), + TransactionType.Expense, + Arg.Any>() + ).Returns(new Dictionary + { + { 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); + } + + /// + /// 测试场景:当前为2月,用户切换到1月(已归档)查看预算,包含年度预算 + /// 预期:年度预算只计算一次 + /// + [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(b => b.Id == 100), + Arg.Is(d => d >= feb1 && d <= feb28), + Arg.Any() + ).Returns(1800m); + + // 年度预算的当前实际值(整年累计,包括1月归档的7257) + var year1 = new DateTime(2026, 1, 1); + var year12 = new DateTime(2026, 12, 31); + _budgetRepo.GetCurrentAmountAsync( + Arg.Is(b => b.Id == 200), + Arg.Is(d => d >= year1), + Arg.Any() + ).Returns(7257m); // 全年累计 + + _transactionStatsService.GetFilteredTrendStatisticsAsync( + Arg.Any(), + Arg.Any(), + TransactionType.Expense, + Arg.Any>(), + true + ).Returns(new Dictionary + { + { new DateTime(2026, 1, 1), 9257m }, // 1月: 教育7257 + 生活2000 + { new DateTime(2026, 2, 1), 1800m } // 2月: 生活1800 + }); + + // 模拟月度统计的交易数据 + _transactionStatsService.GetFilteredTrendStatisticsAsync( + Arg.Any(), + Arg.Any(), + TransactionType.Expense, + Arg.Any>() + ).Returns(new Dictionary + { + { 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); + } + + /// + /// 测试场景:当前为3月,用户切换到1月查看 + /// 预期:年度统计应包含1月归档 + 2月归档 + 3月当前 + /// + [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(b => b.Id == 100), + Arg.Any(), + Arg.Any() + ).Returns(4500m); // 3月已支出4500 + + _transactionStatsService.GetFilteredTrendStatisticsAsync( + Arg.Any(), + Arg.Any(), + TransactionType.Expense, + Arg.Any>(), + true + ).Returns(new Dictionary + { + { new DateTime(2026, 1, 1), 9158.7m }, + { new DateTime(2026, 2, 1), 9126.1m }, + { new DateTime(2026, 3, 1), 4500m } + }); + + // 模拟月度统计的交易数据 + _transactionStatsService.GetFilteredTrendStatisticsAsync( + Arg.Any(), + Arg.Any(), + TransactionType.Expense, + Arg.Any>() + ).Returns(new Dictionary + { + { 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); + } +} diff --git a/WebApi.Test/Transaction/TransactionStatisticsServiceTest.cs b/WebApi.Test/Transaction/TransactionStatisticsServiceTest.cs index 47f9e71..353ee8f 100644 --- a/WebApi.Test/Transaction/TransactionStatisticsServiceTest.cs +++ b/WebApi.Test/Transaction/TransactionStatisticsServiceTest.cs @@ -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 + { + // 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()); + + // Act & Assert - 不应抛出 ArgumentOutOfRangeException + var act = async () => await _service.GetDailyStatisticsAsync(year, month); + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task GetDailyStatisticsAsync_包含存款分类统计() + { + // Arrange + var year = 2024; + var month = 1; + var savingClassify = "股票,基金"; // 存款分类 + var testData = new List + { + 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_多个月份() {