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