fix
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 25s
Docker Build & Deploy / Deploy to Production (push) Successful in 6s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 25s
Docker Build & Deploy / Deploy to Production (push) Successful in 6s
Docker Build & Deploy / Cleanup Dangling Images (push) Successful in 1s
Docker Build & Deploy / WeChat Notification (push) Successful in 2s
This commit is contained in:
@@ -512,6 +512,11 @@ public class BudgetStatsDto
|
||||
/// 每日/每月累计金额趋势(对应当前周期内的实际发生额累计值)
|
||||
/// </summary>
|
||||
public List<decimal?> Trend { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// HTML 格式的详细描述(罗列每个预算的额度和实际值及计算公式)
|
||||
/// </summary>
|
||||
public string Description { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -77,7 +77,7 @@ public class BudgetStatsService(
|
||||
|
||||
result.Count = budgets.Count;
|
||||
|
||||
// 2. 计算限额总值(考虑不限额预算的特殊处理)
|
||||
// 2. 计算限额总值
|
||||
logger.LogDebug("开始计算限额总值,共 {BudgetCount} 个预算", budgets.Count);
|
||||
decimal totalLimit = 0;
|
||||
int budgetIndex = 0;
|
||||
@@ -85,42 +85,39 @@ public class BudgetStatsService(
|
||||
{
|
||||
budgetIndex++;
|
||||
var itemLimit = CalculateBudgetLimit(budget, BudgetPeriodType.Month, referenceDate);
|
||||
logger.LogInformation("预算 {BudgetIndex}/{BudgetCount}: {BudgetName} (ID={BudgetId}) - 预算金额: {BudgetLimit}, 实际金额: {CurrentAmount}, 计算算法: {Algorithm}",
|
||||
budgetIndex, budgets.Count, budget.Name, budget.Id, budget.Limit, budget.Current,
|
||||
budget.NoLimit ? "不限额预算" : budget.IsMandatoryExpense ? "硬性预算" : "普通预算");
|
||||
logger.LogTrace("预算 {BudgetName} (ID={BudgetId}) 计算后限额: {ItemLimit}",
|
||||
budget.Name, budget.Id, itemLimit);
|
||||
totalLimit += itemLimit;
|
||||
}
|
||||
result.Limit = totalLimit;
|
||||
logger.LogDebug("限额总值计算完成: {TotalLimit}", totalLimit);
|
||||
|
||||
// 3. 计算当前实际值(避免重复计算同一笔交易)
|
||||
// 3. 计算当前实际值
|
||||
// 使用 GetAllBudgetsWithArchiveAsync 中已经计算好的 Current 值
|
||||
decimal totalCurrent = budgets.Sum(b => b.Current);
|
||||
logger.LogInformation("【实际值来源】累加各预算的实际值: {TotalCurrent}元(共{Count}个预算)", totalCurrent, budgets.Count);
|
||||
|
||||
var transactionType = category switch
|
||||
{
|
||||
BudgetCategory.Expense => TransactionType.Expense,
|
||||
BudgetCategory.Income => TransactionType.Income,
|
||||
_ => TransactionType.None
|
||||
};
|
||||
logger.LogDebug("交易类型: {TransactionType}", transactionType);
|
||||
|
||||
// 计算当前实际值,考虑硬性预算的特殊逻辑
|
||||
decimal totalCurrent = 0;
|
||||
// 计算趋势数据(用于图表展示)
|
||||
var now = dateTimeProvider.Now;
|
||||
var (startDate, endDate) = GetStatPeriodRange(BudgetPeriodType.Month, referenceDate);
|
||||
logger.LogDebug("统计时间段: {StartDate:yyyy-MM-dd} 到 {EndDate:yyyy-MM-dd}", startDate, endDate);
|
||||
|
||||
if (transactionType != TransactionType.None)
|
||||
{
|
||||
// 获取所有相关分类
|
||||
// 获取所有相关分类(用于趋势图表)
|
||||
var allClassifies = budgets
|
||||
.SelectMany(b => b.SelectedCategories)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
logger.LogDebug("相关分类数量: {ClassifyCount}", allClassifies.Count);
|
||||
|
||||
// 获取趋势统计数据(去重计算)
|
||||
logger.LogDebug("开始获取交易趋势统计数据");
|
||||
// 获取趋势统计数据(仅用于图表展示)
|
||||
logger.LogDebug("开始获取交易趋势统计数据(用于图表)");
|
||||
var dailyStats = await transactionRecordRepository.GetFilteredTrendStatisticsAsync(
|
||||
startDate,
|
||||
endDate,
|
||||
@@ -129,7 +126,7 @@ public class BudgetStatsService(
|
||||
false);
|
||||
logger.LogDebug("获取到 {DayCount} 天的交易数据", dailyStats.Count);
|
||||
|
||||
// 计算累计值
|
||||
// 计算累计值(用于趋势图)
|
||||
decimal accumulated = 0;
|
||||
var daysInMonth = DateTime.DaysInMonth(startDate.Year, startDate.Month);
|
||||
logger.LogDebug("本月天数: {DaysInMonth}", daysInMonth);
|
||||
@@ -155,29 +152,154 @@ public class BudgetStatsService(
|
||||
logger.LogTrace("日期 {CurrentDate:yyyy-MM-dd}: 无交易数据,累计={Accumulated}",
|
||||
currentDate, accumulated);
|
||||
}
|
||||
result.Trend.Add(accumulated);
|
||||
|
||||
// 对每一天的累计值应用硬性预算调整
|
||||
var adjustedAccumulated = accumulated;
|
||||
if (transactionType == TransactionType.Expense)
|
||||
{
|
||||
var mandatoryBudgets = budgets.Where(b => b.IsMandatoryExpense).ToList();
|
||||
|
||||
// 检查是否为当前月份
|
||||
bool isCurrentMonth = referenceDate.Year == now.Year && referenceDate.Month == now.Month;
|
||||
if (isCurrentMonth && currentDate.Date <= now.Date)
|
||||
{
|
||||
// 对于每个硬性预算,计算其虚拟消耗并累加
|
||||
foreach (var budget in mandatoryBudgets)
|
||||
{
|
||||
decimal mandatoryDailyAmount = 0;
|
||||
|
||||
if (budget.Type == BudgetPeriodType.Month)
|
||||
{
|
||||
// 月度硬性预算按当天的天数比例
|
||||
mandatoryDailyAmount = budget.Limit * i / daysInMonth;
|
||||
}
|
||||
else if (budget.Type == BudgetPeriodType.Year)
|
||||
{
|
||||
// 年度硬性预算按当天的天数比例
|
||||
var daysInYear = DateTime.IsLeapYear(referenceDate.Year) ? 366 : 365;
|
||||
var dayOfYear = currentDate.DayOfYear;
|
||||
mandatoryDailyAmount = budget.Limit * dayOfYear / daysInYear;
|
||||
}
|
||||
|
||||
// 检查该硬性预算当天是否有实际交易记录
|
||||
// 如果budget.Current为0或很小,说明没有实际交易,需要加上虚拟消耗
|
||||
// 简化处理:直接检查如果这个硬性预算的实际值小于应有值,就补上差额
|
||||
var expectedTotal = mandatoryDailyAmount;
|
||||
|
||||
// 获取这个硬性预算对应的实际交易累计(从accumulated中无法单独提取)
|
||||
// 简化方案:直接添加硬性预算的虚拟值,让其累加到实际支出上
|
||||
// 但这样会重复计算有交易记录的硬性预算
|
||||
|
||||
// 更好的方案:只在硬性预算没有实际交易时才添加虚拟值
|
||||
// 由于budget.Current已经包含了虚拟消耗(在CalculateCurrentAmountAsync中处理)
|
||||
// 我们需要知道是否有实际交易
|
||||
|
||||
// 最简单的方案:如果budget.Current等于虚拟值,说明没有实际交易,累加虚拟值
|
||||
// 但这在趋势计算中无法判断每一天的情况
|
||||
|
||||
// 实际上,正确的做法是:
|
||||
// 1. dailyStats 只包含实际交易
|
||||
// 2. 对于硬性预算,如果它没有实际交易,需要补充虚拟消耗
|
||||
// 3. 判断方法:比较当天该预算应有的虚拟值和实际累计值
|
||||
|
||||
// 由于我们无法在这里区分某个特定预算的交易,
|
||||
// 使用简化方案:总的实际交易 + 总的硬性预算虚拟消耗的差额
|
||||
}
|
||||
|
||||
// 简化实现:计算所有硬性预算的总虚拟消耗
|
||||
decimal totalMandatoryVirtual = 0;
|
||||
foreach (var budget in mandatoryBudgets)
|
||||
{
|
||||
if (budget.Type == BudgetPeriodType.Month)
|
||||
{
|
||||
totalMandatoryVirtual += budget.Limit * i / daysInMonth;
|
||||
}
|
||||
else if (budget.Type == BudgetPeriodType.Year)
|
||||
{
|
||||
var daysInYear = DateTime.IsLeapYear(referenceDate.Year) ? 366 : 365;
|
||||
var dayOfYear = currentDate.DayOfYear;
|
||||
totalMandatoryVirtual += budget.Limit * dayOfYear / daysInYear;
|
||||
}
|
||||
}
|
||||
|
||||
// 关键:accumulated是所有预算的实际交易累计(不包含虚拟消耗)
|
||||
// totalMandatoryVirtual是所有硬性预算的虚拟消耗
|
||||
// 但如果硬性预算有实际交易,accumulated中已经包含了,会重复
|
||||
// 所以需要:accumulated + (totalMandatoryVirtual - 硬性预算的实际交易部分)
|
||||
|
||||
// 更简单的理解:
|
||||
// - 如果某个硬性预算本月完全没有交易记录,它的虚拟值应该加到accumulated上
|
||||
// - 如果某个硬性预算有部分交易记录,应该补齐到虚拟值
|
||||
// - 实现:取 max(accumulated, totalMandatoryVirtual) 是不对的
|
||||
// - 正确:accumulated + 硬性预算中没有实际交易的那部分的虚拟值
|
||||
|
||||
// 由于无法精确区分,采用近似方案:
|
||||
// 计算所有硬性预算的Current总和,这个值已经包含了虚拟消耗(在CalculateCurrentAmountAsync中处理)
|
||||
decimal totalMandatoryCurrent = budgets
|
||||
.Where(b => b.IsMandatoryExpense)
|
||||
.Sum(b => b.Current);
|
||||
|
||||
// 计算非硬性预算的交易累计(这部分在accumulated中)
|
||||
// 但accumulated是所有交易的累计,包括硬性预算的实际交易
|
||||
|
||||
// 最终简化方案:
|
||||
// dailyStats包含所有实际交易(包括硬性预算的实际交易)
|
||||
// 对于没有实际交易的硬性预算,它们的虚拟消耗没有在dailyStats中
|
||||
// 所以:adjustedAccumulated = accumulated + 没有实际交易的硬性预算的虚拟消耗
|
||||
|
||||
// 实用方法:每个硬性预算,取 max(它在dailyStats中的累计, 虚拟值)
|
||||
// 但我们无法从dailyStats中提取单个预算的数据
|
||||
|
||||
// 终极简化:如果硬性预算的Current值等于虚拟值,说明没有实际交易
|
||||
// 这种情况下,accumulated中不包含这部分,需要加上虚拟值
|
||||
// 如果Current值大于虚拟值,说明有实际交易,accumulated中已包含,不需要调整
|
||||
|
||||
decimal mandatoryAdjustment = 0;
|
||||
foreach (var budget in mandatoryBudgets)
|
||||
{
|
||||
decimal dailyVirtual = 0;
|
||||
if (budget.Type == BudgetPeriodType.Month)
|
||||
{
|
||||
dailyVirtual = budget.Limit * i / daysInMonth;
|
||||
}
|
||||
else if (budget.Type == BudgetPeriodType.Year)
|
||||
{
|
||||
var daysInYear = DateTime.IsLeapYear(referenceDate.Year) ? 366 : 365;
|
||||
var dayOfYear = currentDate.DayOfYear;
|
||||
dailyVirtual = budget.Limit * dayOfYear / daysInYear;
|
||||
}
|
||||
|
||||
// 如果budget.Current约等于整月的虚拟值,说明没有实际交易
|
||||
// 但Current是整个月的,dailyVirtual是到当前天的
|
||||
// 需要判断该预算是否有实际交易记录
|
||||
// 简化:假设如果硬性预算的Current等于虚拟值(误差<1元),就没有实际交易
|
||||
|
||||
decimal monthlyVirtual = budget.Type == BudgetPeriodType.Month
|
||||
? budget.Limit * now.Day / daysInMonth
|
||||
: budget.Limit * now.DayOfYear / (DateTime.IsLeapYear(now.Year) ? 366 : 365);
|
||||
|
||||
if (Math.Abs(budget.Current - monthlyVirtual) < 1)
|
||||
{
|
||||
// 没有实际交易,需要添加虚拟消耗
|
||||
mandatoryAdjustment += dailyVirtual;
|
||||
logger.LogTrace("日期 {CurrentDate:yyyy-MM-dd}: 硬性预算 {BudgetName} 无实际交易,添加虚拟消耗 {Virtual}",
|
||||
currentDate, budget.Name, dailyVirtual);
|
||||
}
|
||||
}
|
||||
|
||||
adjustedAccumulated += mandatoryAdjustment;
|
||||
}
|
||||
}
|
||||
|
||||
result.Trend.Add(adjustedAccumulated);
|
||||
}
|
||||
|
||||
totalCurrent = accumulated;
|
||||
logger.LogDebug("交易累计值计算完成: {TotalCurrent}", totalCurrent);
|
||||
|
||||
logger.LogDebug("趋势图数据计算完成(已应用硬性预算调整)");
|
||||
}
|
||||
else
|
||||
{
|
||||
// 对于非收入/支出分类,使用逐预算累加
|
||||
logger.LogDebug("非收入/支出分类,使用逐预算累加,共 {BudgetCount} 个预算", budgets.Count);
|
||||
budgetIndex = 0;
|
||||
foreach (var budget in budgets)
|
||||
{
|
||||
budgetIndex++;
|
||||
var currentAmount = await CalculateCurrentAmountAsync(budget, BudgetPeriodType.Month, referenceDate);
|
||||
logger.LogInformation("预算 {BudgetIndex}/{BudgetCount}: {BudgetName} (ID={BudgetId}) - 实际金额计算: 预算金额={BudgetLimit}, 当前值={CurrentAmount}, 算法={Algorithm}",
|
||||
budgetIndex, budgets.Count, budget.Name, budget.Id, budget.Limit, currentAmount,
|
||||
budget.IsArchive ? "归档数据" : "实时计算");
|
||||
logger.LogTrace("预算 {BudgetName} (ID={BudgetId}) 当前值: {CurrentAmount}",
|
||||
budget.Name, budget.Id, currentAmount);
|
||||
totalCurrent += currentAmount;
|
||||
}
|
||||
logger.LogDebug("预算累加完成: {TotalCurrent}", totalCurrent);
|
||||
// 对于非收入/支出分类,趋势图为空
|
||||
logger.LogDebug("非收入/支出分类,趋势图为空");
|
||||
}
|
||||
|
||||
// 对于硬性预算,如果当前月份且实际值为0,需要按时间比例计算
|
||||
@@ -199,33 +321,26 @@ public class BudgetStatsService(
|
||||
}
|
||||
|
||||
result.Current = totalCurrent;
|
||||
logger.LogInformation("【最终确定值】月度实际支出: {TotalCurrent}元(已应用硬性预算调整)", totalCurrent);
|
||||
|
||||
// 4. 计算使用率
|
||||
result.Rate = totalLimit > 0 ? result.Current / totalLimit * 100 : 0;
|
||||
logger.LogDebug("使用率计算完成: {Rate:F2}%", result.Rate);
|
||||
logger.LogInformation("【使用率】{Current}元 ÷ {Limit}元 × 100% = {Rate:F2}%", result.Current, totalLimit, result.Rate);
|
||||
|
||||
// 5. 生成计算明细汇总日志
|
||||
var limitParts = new List<string>();
|
||||
var currentParts = new List<string>();
|
||||
budgetIndex = 0;
|
||||
foreach (var budget in budgets)
|
||||
// 5. 生成预算明细汇总日志
|
||||
var budgetDetails = budgets.Select(b =>
|
||||
{
|
||||
budgetIndex++;
|
||||
var itemLimit = CalculateBudgetLimit(budget, BudgetPeriodType.Month, referenceDate);
|
||||
var limitPart = budget.IsArchive
|
||||
? $"{budget.Name}({budget.ArchiveMonth}月归档){itemLimit}元"
|
||||
: $"{budget.Name}{itemLimit}元";
|
||||
limitParts.Add(limitPart);
|
||||
|
||||
var currentPart = budget.IsArchive
|
||||
? $"{budget.Name}({budget.ArchiveMonth}月归档){budget.Current}元"
|
||||
: $"{budget.Name}{budget.Current}元";
|
||||
currentParts.Add(currentPart);
|
||||
}
|
||||
var limitSummary = string.Join(" + ", limitParts);
|
||||
var currentSummary = string.Join(" + ", currentParts);
|
||||
logger.LogInformation("月度统计计算明细: 预算={LimitSummary}={TotalLimit}元, 已支出={CurrentSummary}={TotalCurrent}元, 使用率={Rate:F2}%",
|
||||
limitSummary, totalLimit, currentSummary, totalCurrent, result.Rate);
|
||||
var limit = CalculateBudgetLimit(b, BudgetPeriodType.Month, referenceDate);
|
||||
var prefix = b.IsArchive ? $"({b.ArchiveMonth}月归档)" : "";
|
||||
var suffix = b.IsMandatoryExpense ? "[硬性]" : "";
|
||||
return $"{b.Name}{prefix}{suffix}:{limit}元";
|
||||
});
|
||||
var budgetSummary = string.Join(" + ", budgetDetails);
|
||||
logger.LogInformation("【月度预算明细】{BudgetDetails} = {TotalLimit}元",
|
||||
budgetSummary, totalLimit);
|
||||
|
||||
// 6. 生成 HTML 描述
|
||||
result.Description = GenerateMonthlyDescription(budgets, totalLimit, totalCurrent, referenceDate, category);
|
||||
|
||||
logger.LogDebug("月度分类统计计算完成");
|
||||
return result;
|
||||
@@ -266,18 +381,16 @@ public class BudgetStatsService(
|
||||
{
|
||||
budgetIndex++;
|
||||
var itemLimit = CalculateBudgetLimit(budget, BudgetPeriodType.Year, referenceDate);
|
||||
logger.LogInformation("年度预算 {BudgetIndex}/{BudgetCount}: {BudgetName} (ID={BudgetId}) - 原始预算金额: {BudgetLimit}, 当前实际金额: {CurrentAmount}, 预算类型: {BudgetType}, 算法: {Algorithm}",
|
||||
budgetIndex, budgets.Count, budget.Name, budget.Id, budget.Limit, budget.Current,
|
||||
logger.LogInformation("年度预算 {BudgetIndex}/{BudgetCount}: {BudgetName} - 原始预算金额: {BudgetLimit}, 当前实际金额: {CurrentAmount}, 预算类型: {BudgetType}, 算法: {Algorithm}",
|
||||
budgetIndex, budgets.Count, budget.Name, budget.Limit, budget.Current,
|
||||
budget.Type == BudgetPeriodType.Month ? "月度预算" : "年度预算",
|
||||
budget.NoLimit ? "不限额预算" : budget.IsMandatoryExpense ? "硬性预算" : "普通预算");
|
||||
logger.LogTrace("预算 {BudgetName} (ID={BudgetId}) 年度计算后限额: {ItemLimit}",
|
||||
budget.Name, budget.Id, itemLimit);
|
||||
totalLimit += itemLimit;
|
||||
}
|
||||
result.Limit = totalLimit;
|
||||
logger.LogDebug("年度限额总值计算完成: {TotalLimit}", totalLimit);
|
||||
|
||||
// 3. 计算当前实际值(避免重复计算同一笔交易)
|
||||
// 3. 计算当前实际值
|
||||
var transactionType = category switch
|
||||
{
|
||||
BudgetCategory.Expense => TransactionType.Expense,
|
||||
@@ -287,22 +400,25 @@ public class BudgetStatsService(
|
||||
logger.LogDebug("交易类型: {TransactionType}", transactionType);
|
||||
|
||||
// 计算当前实际值,考虑硬性预算的特殊逻辑
|
||||
decimal totalCurrent = 0;
|
||||
decimal totalCurrent = budgets.Sum(b => b.Current);
|
||||
logger.LogInformation("【实际值来源】累加各预算的实际值: {TotalCurrent}元(共{Count}个预算)",
|
||||
totalCurrent, budgets.Count);
|
||||
|
||||
var now = dateTimeProvider.Now;
|
||||
var (startDate, endDate) = GetStatPeriodRange(BudgetPeriodType.Year, referenceDate);
|
||||
logger.LogDebug("统计时间段: {StartDate:yyyy-MM-dd} 到 {EndDate:yyyy-MM-dd}", startDate, endDate);
|
||||
|
||||
if (transactionType != TransactionType.None)
|
||||
{
|
||||
// 获取所有相关分类
|
||||
// 获取所有相关分类(用于趋势图表)
|
||||
var allClassifies = budgets
|
||||
.SelectMany(b => b.SelectedCategories)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
logger.LogDebug("相关分类数量: {ClassifyCount}", allClassifies.Count);
|
||||
|
||||
// 获取趋势统计数据(去重计算)
|
||||
logger.LogDebug("开始获取交易趋势统计数据");
|
||||
// 获取趋势统计数据(仅用于图表展示)
|
||||
logger.LogDebug("开始获取交易趋势统计数据(用于图表)");
|
||||
var dailyStats = await transactionRecordRepository.GetFilteredTrendStatisticsAsync(
|
||||
startDate,
|
||||
endDate,
|
||||
@@ -311,7 +427,7 @@ public class BudgetStatsService(
|
||||
true);
|
||||
logger.LogDebug("获取到 {MonthCount} 个月的交易数据", dailyStats.Count);
|
||||
|
||||
// 计算累计值
|
||||
// 计算累计值(用于趋势图)
|
||||
decimal accumulated = 0;
|
||||
for (int i = 1; i <= 12; i++)
|
||||
{
|
||||
@@ -335,29 +451,70 @@ public class BudgetStatsService(
|
||||
logger.LogTrace("月份 {Month:yyyy-MM}: 无交易数据,累计={Accumulated}",
|
||||
currentMonthDate, accumulated);
|
||||
}
|
||||
result.Trend.Add(accumulated);
|
||||
|
||||
// 对每个月的累计值应用硬性预算调整
|
||||
var adjustedAccumulated = accumulated;
|
||||
if (transactionType == TransactionType.Expense)
|
||||
{
|
||||
var mandatoryBudgets = budgets.Where(b => b.IsMandatoryExpense).ToList();
|
||||
|
||||
// 检查是否为当前年份
|
||||
bool isCurrentYear = referenceDate.Year == now.Year;
|
||||
if (isCurrentYear && currentMonthDate <= now)
|
||||
{
|
||||
decimal mandatoryAdjustment = 0;
|
||||
foreach (var budget in mandatoryBudgets)
|
||||
{
|
||||
decimal monthlyVirtual = 0;
|
||||
|
||||
if (budget.Type == BudgetPeriodType.Month)
|
||||
{
|
||||
// 月度硬性预算:如果该月已完成,累加整月;如果是当前月,按天数比例
|
||||
if (i < now.Month)
|
||||
{
|
||||
monthlyVirtual = budget.Limit * i;
|
||||
}
|
||||
else if (i == now.Month)
|
||||
{
|
||||
var daysInMonth = DateTime.DaysInMonth(now.Year, now.Month);
|
||||
monthlyVirtual = budget.Limit * (i - 1) + (budget.Limit * now.Day / daysInMonth);
|
||||
}
|
||||
}
|
||||
else if (budget.Type == BudgetPeriodType.Year)
|
||||
{
|
||||
var daysInYear = DateTime.IsLeapYear(referenceDate.Year) ? 366 : 365;
|
||||
var lastDayOfMonth = new DateTime(currentMonthDate.Year, i, DateTime.DaysInMonth(currentMonthDate.Year, i));
|
||||
var dayOfYear = i < now.Month ? lastDayOfMonth.DayOfYear : now.DayOfYear;
|
||||
monthlyVirtual = budget.Limit * dayOfYear / daysInYear;
|
||||
}
|
||||
|
||||
// 判断该硬性预算是否有实际交易
|
||||
decimal yearlyVirtual = budget.Type == BudgetPeriodType.Month
|
||||
? budget.Limit * now.Month + (budget.Limit * now.Day / DateTime.DaysInMonth(now.Year, now.Month)) - budget.Limit
|
||||
: budget.Limit * now.DayOfYear / (DateTime.IsLeapYear(now.Year) ? 366 : 365);
|
||||
|
||||
if (Math.Abs(budget.Current - yearlyVirtual) < 1)
|
||||
{
|
||||
// 没有实际交易,需要添加虚拟消耗
|
||||
mandatoryAdjustment += monthlyVirtual;
|
||||
logger.LogTrace("月份 {Month:yyyy-MM}: 硬性预算 {BudgetName} 无实际交易,添加虚拟消耗 {Virtual}",
|
||||
currentMonthDate, budget.Name, monthlyVirtual);
|
||||
}
|
||||
}
|
||||
|
||||
adjustedAccumulated += mandatoryAdjustment;
|
||||
}
|
||||
}
|
||||
|
||||
result.Trend.Add(adjustedAccumulated);
|
||||
}
|
||||
|
||||
totalCurrent = accumulated;
|
||||
logger.LogDebug("交易累计值计算完成: {TotalCurrent}", totalCurrent);
|
||||
logger.LogDebug("趋势图数据计算完成(已应用硬性预算调整)");
|
||||
}
|
||||
else
|
||||
{
|
||||
// 对于非收入/支出分类,使用逐预算累加
|
||||
logger.LogDebug("非收入/支出分类,使用逐预算累加,共 {BudgetCount} 个预算", budgets.Count);
|
||||
budgetIndex = 0;
|
||||
foreach (var budget in budgets)
|
||||
{
|
||||
budgetIndex++;
|
||||
var currentAmount = await CalculateCurrentAmountAsync(budget, BudgetPeriodType.Year, referenceDate);
|
||||
logger.LogInformation("年度预算 {BudgetIndex}/{BudgetCount}: {BudgetName} (ID={BudgetId}) - 实际金额计算: 原始预算={BudgetLimit}, 年度实际值={CurrentAmount}, 数据来源: {DataSource}",
|
||||
budgetIndex, budgets.Count, budget.Name, budget.Id, budget.Limit, currentAmount,
|
||||
budget.IsArchive ? "归档数据" : "实时计算");
|
||||
logger.LogTrace("预算 {BudgetName} (ID={BudgetId}) 年度当前值: {CurrentAmount}",
|
||||
budget.Name, budget.Id, currentAmount);
|
||||
totalCurrent += currentAmount;
|
||||
}
|
||||
logger.LogDebug("年度预算累加完成: {TotalCurrent}", totalCurrent);
|
||||
// 对于非收入/支出分类,趋势图为空
|
||||
logger.LogDebug("非收入/支出分类,趋势图为空");
|
||||
}
|
||||
|
||||
// 对于硬性预算,如果当前年份且实际值为0,需要按时间比例计算
|
||||
@@ -379,37 +536,26 @@ public class BudgetStatsService(
|
||||
}
|
||||
|
||||
result.Current = totalCurrent;
|
||||
logger.LogInformation("【最终确定值】年度实际支出: {TotalCurrent}元(已应用硬性预算调整)", totalCurrent);
|
||||
|
||||
// 4. 计算使用率
|
||||
result.Rate = totalLimit > 0 ? result.Current / totalLimit * 100 : 0;
|
||||
logger.LogDebug("使用率计算完成: {Rate:F2}%", result.Rate);
|
||||
logger.LogInformation("【使用率】{Current}元 ÷ {Limit}元 × 100% = {Rate:F2}%", result.Current, totalLimit, result.Rate);
|
||||
|
||||
// 5. 生成计算明细汇总日志
|
||||
var limitParts = new List<string>();
|
||||
var currentParts = new List<string>();
|
||||
budgetIndex = 0;
|
||||
foreach (var budget in budgets)
|
||||
// 5. 生成预算明细汇总日志
|
||||
var budgetDetails = budgets.Select(b =>
|
||||
{
|
||||
budgetIndex++;
|
||||
var itemLimit = CalculateBudgetLimit(budget, BudgetPeriodType.Year, referenceDate);
|
||||
var limitPart = budget.IsArchive
|
||||
? $"{budget.Name}(归档){itemLimit}元"
|
||||
: budget.RemainingMonths > 0
|
||||
? $"{budget.Name}(剩余{budget.RemainingMonths}月){itemLimit}元"
|
||||
: $"{budget.Name}{itemLimit}元";
|
||||
limitParts.Add(limitPart);
|
||||
|
||||
var currentPart = budget.IsArchive
|
||||
? $"{budget.Name}(归档){budget.Current}元"
|
||||
: budget.RemainingMonths > 0
|
||||
? $"{budget.Name}(剩余{budget.RemainingMonths}月){budget.Current}元"
|
||||
: $"{budget.Name}{budget.Current}元";
|
||||
currentParts.Add(currentPart);
|
||||
}
|
||||
var limitSummary = string.Join(" + ", limitParts);
|
||||
var currentSummary = string.Join(" + ", currentParts);
|
||||
logger.LogInformation("年度统计计算明细: 预算={LimitSummary}={TotalLimit}元, 已支出={CurrentSummary}={TotalCurrent}元, 使用率={Rate:F2}%",
|
||||
limitSummary, totalLimit, currentSummary, totalCurrent, result.Rate);
|
||||
var limit = CalculateBudgetLimit(b, BudgetPeriodType.Year, referenceDate);
|
||||
var prefix = b.IsArchive ? "(归档)" : b.RemainingMonths > 0 ? $"(剩余{b.RemainingMonths}月)" : "";
|
||||
var suffix = b.IsMandatoryExpense ? "[硬性]" : "";
|
||||
return $"{b.Name}{prefix}{suffix}:{limit}元";
|
||||
});
|
||||
var budgetSummary = string.Join(" + ", budgetDetails);
|
||||
logger.LogInformation("【年度预算明细】{BudgetDetails} = {TotalLimit}元",
|
||||
budgetSummary, totalLimit);
|
||||
|
||||
// 6. 生成 HTML 描述
|
||||
result.Description = GenerateYearlyDescription(budgets, totalLimit, totalCurrent, referenceDate, category);
|
||||
|
||||
logger.LogDebug("年度分类统计计算完成");
|
||||
return result;
|
||||
@@ -475,8 +621,8 @@ public class BudgetStatsService(
|
||||
IsArchive = true,
|
||||
ArchiveMonth = m
|
||||
});
|
||||
logger.LogInformation("添加归档月度预算: {BudgetName} (ID={BudgetId}) - {Month}月归档, 预算金额: {Limit}, 实际金额: {Actual}",
|
||||
item.Name, item.Id, m, item.Limit, item.Actual);
|
||||
logger.LogInformation("添加归档月度预算: {BudgetName} - {Month}月归档, 预算金额: {Limit}, 实际金额: {Actual}",
|
||||
item.Name, m, item.Limit, item.Actual);
|
||||
}
|
||||
// 对于年度预算,只添加一次
|
||||
else if (item.Type == BudgetPeriodType.Year && !processedBudgetIds.Contains(item.Id))
|
||||
@@ -495,8 +641,8 @@ public class BudgetStatsService(
|
||||
IsMandatoryExpense = item.IsMandatoryExpense,
|
||||
IsArchive = true
|
||||
});
|
||||
logger.LogInformation("添加归档年度预算: {BudgetName} (ID={BudgetId}) - 预算金额: {Limit}, 实际金额: {Actual}",
|
||||
item.Name, item.Id, item.Limit, item.Actual);
|
||||
logger.LogInformation("添加归档年度预算: {BudgetName} - 预算金额: {Limit}, 实际金额: {Actual}",
|
||||
item.Name, item.Limit, item.Actual);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -527,8 +673,8 @@ public class BudgetStatsService(
|
||||
IsMandatoryExpense = budget.IsMandatoryExpense,
|
||||
IsArchive = false
|
||||
});
|
||||
logger.LogInformation("添加当前年度预算: {BudgetName} (ID={BudgetId}) - 预算金额: {Limit}, 实际金额: {Current}",
|
||||
budget.Name, budget.Id, budget.Limit, currentAmount);
|
||||
logger.LogInformation("添加当前年度预算: {BudgetName} - 预算金额: {Limit}, 实际金额: {Current}",
|
||||
budget.Name, budget.Limit, currentAmount);
|
||||
}
|
||||
// 对于月度预算,添加当前及未来月份的预算(标记剩余月份数)
|
||||
else if (budget.Type == BudgetPeriodType.Month)
|
||||
@@ -550,8 +696,8 @@ public class BudgetStatsService(
|
||||
IsArchive = false,
|
||||
RemainingMonths = remainingMonths
|
||||
});
|
||||
logger.LogInformation("添加当前月度预算(剩余月份): {BudgetName} (ID={BudgetId}) - 预算金额: {Limit}, 剩余月份: {RemainingMonths}",
|
||||
budget.Name, budget.Id, budget.Limit, remainingMonths);
|
||||
logger.LogInformation("添加当前月度预算(剩余月份): {BudgetName} - 预算金额: {Limit}, 剩余月份: {RemainingMonths}",
|
||||
budget.Name, budget.Limit, remainingMonths);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -588,8 +734,8 @@ public class BudgetStatsService(
|
||||
IsMandatoryExpense = item.IsMandatoryExpense,
|
||||
IsArchive = true
|
||||
});
|
||||
logger.LogInformation("添加归档预算: {BudgetName} (ID={BudgetId}) - 归档月份: {Year}-{Month:00}, 预算金额: {BudgetLimit}, 实际金额: {ActualAmount}",
|
||||
item.Name, item.Id, year, month, item.Limit, item.Actual);
|
||||
logger.LogInformation("添加归档预算: {BudgetName} - 归档月份: {Year}-{Month:00}, 预算金额: {BudgetLimit}, 实际金额: {ActualAmount}",
|
||||
item.Name, year, month, item.Limit, item.Actual);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -626,8 +772,8 @@ public class BudgetStatsService(
|
||||
IsMandatoryExpense = budget.IsMandatoryExpense,
|
||||
IsArchive = false
|
||||
});
|
||||
logger.LogInformation("添加当前预算: {BudgetName} (ID={BudgetId}) - 预算金额: {BudgetLimit}, 实时计算实际金额: {CurrentAmount}, 预算类型: {BudgetType}",
|
||||
budget.Name, budget.Id, budget.Limit, currentAmount,
|
||||
logger.LogInformation("添加当前预算: {BudgetName} - 预算金额: {BudgetLimit}, 实时计算实际金额: {CurrentAmount}, 预算类型: {BudgetType}",
|
||||
budget.Name, budget.Limit, currentAmount,
|
||||
budget.Type == BudgetPeriodType.Month ? "月度预算" : "年度预算");
|
||||
}
|
||||
}
|
||||
@@ -679,7 +825,7 @@ public class BudgetStatsService(
|
||||
// 不记额预算的限额为0
|
||||
if (budget.NoLimit)
|
||||
{
|
||||
logger.LogTrace("预算 {BudgetName} (ID={BudgetId}) 为不记额预算,限额返回0", budget.Name, budget.Id);
|
||||
logger.LogTrace("预算 {BudgetName} 为不记额预算,限额返回0", budget.Name);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -704,7 +850,7 @@ public class BudgetStatsService(
|
||||
// 兼容旧逻辑(如果没有设置RemainingMonths)
|
||||
else
|
||||
{
|
||||
logger.LogWarning("预算 {BudgetName} (ID={BudgetId}) 年度统计时未设置RemainingMonths,使用默认折算逻辑", budget.Name, budget.Id);
|
||||
logger.LogWarning("预算 {BudgetName} 年度统计时未设置RemainingMonths,使用默认折算逻辑", budget.Name);
|
||||
if (budget.IsMandatoryExpense)
|
||||
{
|
||||
var now = dateTimeProvider.Now;
|
||||
@@ -728,8 +874,8 @@ public class BudgetStatsService(
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation("预算 {BudgetName} (ID={BudgetId}) 限额计算: 原始预算={OriginalLimit}, 计算后限额={CalculatedLimit}, 算法: {Algorithm}",
|
||||
budget.Name, budget.Id, budget.Limit, itemLimit, algorithmDescription);
|
||||
logger.LogInformation("预算 {BudgetName} 限额计算: 原始预算={OriginalLimit}, 计算后限额={CalculatedLimit}, 算法: {Algorithm}",
|
||||
budget.Name, budget.Limit, itemLimit, algorithmDescription);
|
||||
|
||||
return itemLimit;
|
||||
}
|
||||
@@ -791,15 +937,15 @@ public class BudgetStatsService(
|
||||
if (statType == BudgetPeriodType.Month)
|
||||
{
|
||||
isCurrentPeriod = referenceDate.Year == now.Year && referenceDate.Month == now.Month;
|
||||
logger.LogInformation("硬性预算 {MandatoryIndex}/{MandatoryCount}: {BudgetName} (ID={BudgetId}) - 检查月度周期: 参考月份={RefMonth}, 当前月份={CurrentMonth}, 是否为当前周期={IsCurrent}",
|
||||
mandatoryIndex, mandatoryBudgets.Count, budget.Name, budget.Id,
|
||||
logger.LogInformation("硬性预算 {MandatoryIndex}/{MandatoryCount}: {BudgetName} - 检查月度周期: 参考月份={RefMonth}, 当前月份={CurrentMonth}, 是否为当前周期={IsCurrent}",
|
||||
mandatoryIndex, mandatoryBudgets.Count, budget.Name,
|
||||
referenceDate.ToString("yyyy-MM"), now.ToString("yyyy-MM"), isCurrentPeriod);
|
||||
}
|
||||
else // Year
|
||||
{
|
||||
isCurrentPeriod = referenceDate.Year == now.Year;
|
||||
logger.LogInformation("硬性预算 {MandatoryIndex}/{MandatoryCount}: {BudgetName} (ID={BudgetId}) - 检查年度周期: 参考年份={RefYear}, 当前年份={CurrentYear}, 是否为当前周期={IsCurrent}",
|
||||
mandatoryIndex, mandatoryBudgets.Count, budget.Name, budget.Id,
|
||||
logger.LogInformation("硬性预算 {MandatoryIndex}/{MandatoryCount}: {BudgetName} - 检查年度周期: 参考年份={RefYear}, 当前年份={CurrentYear}, 是否为当前周期={IsCurrent}",
|
||||
mandatoryIndex, mandatoryBudgets.Count, budget.Name,
|
||||
referenceDate.Year, now.Year, isCurrentPeriod);
|
||||
}
|
||||
|
||||
@@ -830,26 +976,26 @@ public class BudgetStatsService(
|
||||
budget.Name, budget.Limit, daysInYear, daysElapsed, mandatoryAccumulation);
|
||||
}
|
||||
|
||||
logger.LogInformation("硬性预算 {BudgetName} (ID={BudgetId}) 应累加值计算: 算法={Algorithm}",
|
||||
budget.Name, budget.Id, accumulationAlgorithm);
|
||||
logger.LogInformation("硬性预算 {BudgetName} 应累加值计算: 算法={Algorithm}",
|
||||
budget.Name, accumulationAlgorithm);
|
||||
|
||||
// 如果趋势数据中的累计值小于硬性预算的应累加值,使用硬性预算的值
|
||||
if (adjustedTotal < mandatoryAccumulation)
|
||||
{
|
||||
var adjustmentAmount = mandatoryAccumulation - adjustedTotal;
|
||||
logger.LogInformation("硬性预算 {BudgetName} (ID={BudgetId}) 触发调整: 调整前总计={BeforeTotal}, 应累加值={MandatoryAccumulation}, 调整金额={AdjustmentAmount}, 调整后总计={AfterTotal}",
|
||||
budget.Name, budget.Id, adjustedTotal, mandatoryAccumulation, adjustmentAmount, mandatoryAccumulation);
|
||||
logger.LogInformation("硬性预算 {BudgetName} 触发调整: 调整前总计={BeforeTotal}, 应累加值={MandatoryAccumulation}, 调整金额={AdjustmentAmount}, 调整后总计={AfterTotal}",
|
||||
budget.Name, adjustedTotal, mandatoryAccumulation, adjustmentAmount, mandatoryAccumulation);
|
||||
adjustedTotal = mandatoryAccumulation;
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogDebug("硬性预算 {BudgetName} (ID={BudgetId}) 未触发调整: 当前总计={CurrentTotal} >= 应累加值={MandatoryAccumulation}",
|
||||
budget.Name, budget.Id, adjustedTotal, mandatoryAccumulation);
|
||||
logger.LogDebug("硬性预算 {BudgetName} 未触发调整: 当前总计={CurrentTotal} >= 应累加值={MandatoryAccumulation}",
|
||||
budget.Name, adjustedTotal, mandatoryAccumulation);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogInformation("硬性预算 {BudgetName} (ID={BudgetId}) 不在当前统计周期,跳过调整", budget.Name, budget.Id);
|
||||
logger.LogInformation("硬性预算 {BudgetName} 不在当前统计周期,跳过调整", budget.Name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -937,4 +1083,263 @@ public class BudgetStatsService(
|
||||
public int ArchiveMonth { get; set; } // 归档月份(1-12),用于标识归档数据来自哪个月
|
||||
public int RemainingMonths { get; set; } // 剩余月份数,用于年度统计时的月度预算折算
|
||||
}
|
||||
|
||||
private string GenerateMonthlyDescription(List<BudgetStatsItem> budgets, decimal totalLimit, decimal totalCurrent, DateTime referenceDate, BudgetCategory category)
|
||||
{
|
||||
var description = new StringBuilder();
|
||||
var categoryName = category == BudgetCategory.Expense ? "支出" : "收入";
|
||||
|
||||
description.AppendLine($"<h3>月度{categoryName}预算明细</h3>");
|
||||
description.AppendLine("""
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>预算额度</th>
|
||||
<th>实际金额</th>
|
||||
<th>类型</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
""");
|
||||
|
||||
foreach (var budget in budgets)
|
||||
{
|
||||
var budgetLimit = CalculateBudgetLimit(budget, BudgetPeriodType.Month, referenceDate);
|
||||
var typeLabel = budget.IsMandatoryExpense ? "硬性" : "普通";
|
||||
var archiveLabel = budget.IsArchive ? $" ({budget.ArchiveMonth}月归档)" : "";
|
||||
|
||||
description.AppendLine($"""
|
||||
<tr>
|
||||
<td>{budget.Name}{archiveLabel}</td>
|
||||
<td>{budgetLimit:N0}</td>
|
||||
<td><span class='{(category == BudgetCategory.Expense ? "expense-value" : "income-value")}'>{budget.Current:N1}</span></td>
|
||||
<td>{typeLabel}</td>
|
||||
</tr>
|
||||
""");
|
||||
}
|
||||
|
||||
description.AppendLine("</tbody></table>");
|
||||
|
||||
// 计算公式
|
||||
description.AppendLine($"<h3>计算公式</h3>");
|
||||
description.AppendLine($"<p><strong>预算额度合计:</strong>");
|
||||
var limitParts = budgets.Select(b =>
|
||||
{
|
||||
var limit = CalculateBudgetLimit(b, BudgetPeriodType.Month, referenceDate);
|
||||
var archiveLabel = b.IsArchive ? $"({b.ArchiveMonth}月归档)" : "";
|
||||
return $"{b.Name}{archiveLabel}({limit:N0})";
|
||||
});
|
||||
description.AppendLine($"{string.Join(" + ", limitParts)} = <span class='{(category == BudgetCategory.Expense ? "expense-value" : "income-value")}'><strong>{totalLimit:N0}</strong></span></p>");
|
||||
|
||||
description.AppendLine($"<p><strong>实际{categoryName}合计:</strong>");
|
||||
var currentParts = budgets.Select(b =>
|
||||
{
|
||||
var archiveLabel = b.IsArchive ? $"({b.ArchiveMonth}月归档)" : "";
|
||||
return $"{b.Name}{archiveLabel}({b.Current:N1})";
|
||||
});
|
||||
description.AppendLine($"{string.Join(" + ", currentParts)} = <span class='{(category == BudgetCategory.Expense ? "expense-value" : "income-value")}'><strong>{totalCurrent:N1}</strong></span></p>");
|
||||
|
||||
var rate = totalLimit > 0 ? totalCurrent / totalLimit * 100 : 0;
|
||||
description.AppendLine($"<p><strong>使用率:</strong>{totalCurrent:N1} ÷ {totalLimit:N0} × 100% = <span class='highlight'><strong>{rate:F2}%</strong></span></p>");
|
||||
|
||||
return description.ToString();
|
||||
}
|
||||
|
||||
private string GenerateYearlyDescription(List<BudgetStatsItem> budgets, decimal totalLimit, decimal totalCurrent, DateTime referenceDate, BudgetCategory category)
|
||||
{
|
||||
var description = new StringBuilder();
|
||||
var categoryName = category == BudgetCategory.Expense ? "支出" : "收入";
|
||||
|
||||
// 分组:归档的月度预算、归档的年度预算、当前月度预算(剩余月份)、当前年度预算
|
||||
var archivedMonthlyBudgets = budgets.Where(b => b.IsArchive && b.Type == BudgetPeriodType.Month).ToList();
|
||||
var archivedYearlyBudgets = budgets.Where(b => b.IsArchive && b.Type == BudgetPeriodType.Year).ToList();
|
||||
var currentMonthlyBudgets = budgets.Where(b => !b.IsArchive && b.Type == BudgetPeriodType.Month).ToList();
|
||||
var currentYearlyBudgets = budgets.Where(b => !b.IsArchive && b.Type == BudgetPeriodType.Year).ToList();
|
||||
|
||||
// 归档月度预算明细
|
||||
if (archivedMonthlyBudgets.Any())
|
||||
{
|
||||
description.AppendLine($"<h3>已归档月度{categoryName}预算</h3>");
|
||||
description.AppendLine("""
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>预算额度</th>
|
||||
<th>实际金额</th>
|
||||
<th>归档月份</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
""");
|
||||
|
||||
foreach (var budget in archivedMonthlyBudgets.GroupBy(b => b.Id))
|
||||
{
|
||||
var first = budget.First();
|
||||
var months = budget.Select(b => b.ArchiveMonth).OrderBy(m => m).ToArray();
|
||||
var monthsText = FormatMonths(months);
|
||||
var groupLimit = first.Limit * budget.Count();
|
||||
var groupCurrent = budget.Sum(b => b.Current);
|
||||
|
||||
description.AppendLine($"""
|
||||
<tr>
|
||||
<td>{first.Name}</td>
|
||||
<td>{groupLimit:N0}</td>
|
||||
<td><span class='{(category == BudgetCategory.Expense ? "expense-value" : "income-value")}'>{groupCurrent:N1}</span></td>
|
||||
<td>{monthsText}</td>
|
||||
</tr>
|
||||
""");
|
||||
}
|
||||
|
||||
description.AppendLine("</tbody></table>");
|
||||
}
|
||||
|
||||
// 当前月度预算(剩余月份)
|
||||
if (currentMonthlyBudgets.Any())
|
||||
{
|
||||
description.AppendLine($"<h3>当前月度{categoryName}预算(剩余月份)</h3>");
|
||||
description.AppendLine("""
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>单月预算</th>
|
||||
<th>剩余月份</th>
|
||||
<th>合计额度</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
""");
|
||||
|
||||
foreach (var budget in currentMonthlyBudgets)
|
||||
{
|
||||
var budgetLimit = CalculateBudgetLimit(budget, BudgetPeriodType.Year, referenceDate);
|
||||
|
||||
description.AppendLine($"""
|
||||
<tr>
|
||||
<td>{budget.Name}</td>
|
||||
<td>{budget.Limit:N0}</td>
|
||||
<td>{budget.RemainingMonths}</td>
|
||||
<td>{budgetLimit:N0}</td>
|
||||
</tr>
|
||||
""");
|
||||
}
|
||||
|
||||
description.AppendLine("</tbody></table>");
|
||||
}
|
||||
|
||||
// 年度预算明细
|
||||
if (archivedYearlyBudgets.Any() || currentYearlyBudgets.Any())
|
||||
{
|
||||
description.AppendLine($"<h3>年度{categoryName}预算</h3>");
|
||||
description.AppendLine("""
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>预算额度</th>
|
||||
<th>实际金额</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
""");
|
||||
|
||||
foreach (var budget in archivedYearlyBudgets.Concat(currentYearlyBudgets))
|
||||
{
|
||||
var statusLabel = budget.IsArchive ? "归档" : "当前";
|
||||
var typeLabel = budget.IsMandatoryExpense ? "硬性" : "普通";
|
||||
|
||||
description.AppendLine($"""
|
||||
<tr>
|
||||
<td>{budget.Name}</td>
|
||||
<td>{budget.Limit:N0}</td>
|
||||
<td><span class='{(category == BudgetCategory.Expense ? "expense-value" : "income-value")}'>{budget.Current:N1}</span></td>
|
||||
<td>{statusLabel}/{typeLabel}</td>
|
||||
</tr>
|
||||
""");
|
||||
}
|
||||
|
||||
description.AppendLine("</tbody></table>");
|
||||
}
|
||||
|
||||
// 计算公式
|
||||
description.AppendLine($"<h3>计算公式</h3>");
|
||||
description.AppendLine($"<p><strong>年度预算额度合计:</strong>");
|
||||
var limitParts = new List<string>();
|
||||
|
||||
// 归档月度预算部分
|
||||
foreach (var group in archivedMonthlyBudgets.GroupBy(b => b.Id))
|
||||
{
|
||||
var first = group.First();
|
||||
var count = group.Count();
|
||||
var groupTotalLimit = first.Limit * count;
|
||||
limitParts.Add($"{first.Name}(归档{count}月×{first.Limit:N0}={groupTotalLimit:N0})");
|
||||
}
|
||||
|
||||
// 当前月度预算部分
|
||||
foreach (var budget in currentMonthlyBudgets)
|
||||
{
|
||||
var budgetLimit = CalculateBudgetLimit(budget, BudgetPeriodType.Year, referenceDate);
|
||||
limitParts.Add($"{budget.Name}(剩余{budget.RemainingMonths}月×{budget.Limit:N0}={budgetLimit:N0})");
|
||||
}
|
||||
|
||||
// 年度预算部分
|
||||
foreach (var budget in archivedYearlyBudgets.Concat(currentYearlyBudgets))
|
||||
{
|
||||
limitParts.Add($"{budget.Name}({budget.Limit:N0})");
|
||||
}
|
||||
|
||||
description.AppendLine($"{string.Join(" + ", limitParts)} = <span class='{(category == BudgetCategory.Expense ? "expense-value" : "income-value")}'><strong>{totalLimit:N0}</strong></span></p>");
|
||||
|
||||
description.AppendLine($"<p><strong>实际{categoryName}合计:</strong>");
|
||||
var currentParts = new List<string>();
|
||||
|
||||
// 归档月度预算的实际值
|
||||
foreach (var group in archivedMonthlyBudgets.GroupBy(b => b.Id))
|
||||
{
|
||||
var first = group.First();
|
||||
var groupTotalCurrent = group.Sum(b => b.Current);
|
||||
currentParts.Add($"{first.Name}(归档{groupTotalCurrent:N1})");
|
||||
}
|
||||
|
||||
// 年度预算的实际值
|
||||
foreach (var budget in archivedYearlyBudgets.Concat(currentYearlyBudgets))
|
||||
{
|
||||
currentParts.Add($"{budget.Name}({budget.Current:N1})");
|
||||
}
|
||||
|
||||
description.AppendLine($"{string.Join(" + ", currentParts)} = <span class='{(category == BudgetCategory.Expense ? "expense-value" : "income-value")}'><strong>{totalCurrent:N1}</strong></span></p>");
|
||||
|
||||
var rate = totalLimit > 0 ? totalCurrent / totalLimit * 100 : 0;
|
||||
description.AppendLine($"<p><strong>使用率:</strong>{totalCurrent:N1} ÷ {totalLimit:N0} × 100% = <span class='highlight'><strong>{rate:F2}%</strong></span></p>");
|
||||
|
||||
return description.ToString();
|
||||
}
|
||||
|
||||
private string FormatMonths(int[] months)
|
||||
{
|
||||
if (months.Length == 0) return "";
|
||||
if (months.Length == 1) return $"{months[0]}月";
|
||||
|
||||
// 如果是连续的月份,简化显示为 1~3月
|
||||
Array.Sort(months);
|
||||
bool isContinuous = true;
|
||||
for (int i = 1; i < months.Length; i++)
|
||||
{
|
||||
if (months[i] != months[i - 1] + 1)
|
||||
{
|
||||
isContinuous = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (isContinuous)
|
||||
{
|
||||
return $"{months.First()}~{months.Last()}月";
|
||||
}
|
||||
|
||||
return string.Join(", ", months) + "月";
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,14 @@
|
||||
<div class="chart-header">
|
||||
<div class="chart-title">
|
||||
<!-- 月度健康度 -->
|
||||
{{ activeTab === BudgetCategory.Expense ? '使用情况' : '完成情况' }}
|
||||
(月度)
|
||||
{{ activeTab === BudgetCategory.Expense ? '使用情况(月度)' : '完成情况(月度)' }}
|
||||
<van-icon
|
||||
name="info-o"
|
||||
size="16"
|
||||
color="var(--van-primary-color)"
|
||||
style="margin-left: auto; cursor: pointer"
|
||||
@click="showDescriptionPopup = true; activeDescTab = 'month'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gauge-wrapper">
|
||||
@@ -50,8 +56,14 @@
|
||||
<div class="chart-card gauge-card">
|
||||
<div class="chart-header">
|
||||
<div class="chart-title">
|
||||
{{ activeTab === BudgetCategory.Expense ? '使用情况' : '完成情况' }}
|
||||
(年度)
|
||||
{{ activeTab === BudgetCategory.Expense ? '使用情况(年度)' : '完成情况(年度)' }}
|
||||
<van-icon
|
||||
name="info-o"
|
||||
size="16"
|
||||
color="var(--van-primary-color)"
|
||||
style="margin-left: auto; cursor: pointer"
|
||||
@click="showDescriptionPopup = true; activeDescTab = 'year'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gauge-wrapper">
|
||||
@@ -161,6 +173,19 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详细描述弹窗 -->
|
||||
<PopupContainer
|
||||
v-model="showDescriptionPopup"
|
||||
:title="activeDescTab === 'month' ? '预算额度/实际详情(月度)' : '预算额度/实际详情(年度)'"
|
||||
height="70%"
|
||||
>
|
||||
<div
|
||||
class="rich-html-content"
|
||||
style="padding: 16px"
|
||||
v-html="activeDescTab === 'month' ? (overallStats.month?.description || '<p style=\'text-align:center;color:var(--van-text-color-3)\'>暂无数据</p>') : (overallStats.year?.description || '<p style=\'text-align:center;color:var(--van-text-color-3)\'>暂无数据</p>')"
|
||||
/>
|
||||
</PopupContainer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -168,6 +193,7 @@ import { ref, onMounted, watch, nextTick, onUnmounted, computed } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import { BudgetCategory } from '@/constants/enums'
|
||||
import { getCssVar } from '@/utils/theme'
|
||||
import PopupContainer from '@/components/PopupContainer.vue'
|
||||
|
||||
const props = defineProps({
|
||||
overallStats: {
|
||||
@@ -194,15 +220,16 @@ const varianceChartRef = ref(null)
|
||||
const burndownChartRef = ref(null)
|
||||
const yearBurndownChartRef = ref(null)
|
||||
|
||||
// 弹窗状态
|
||||
const showDescriptionPopup = ref(false)
|
||||
const activeDescTab = ref('month')
|
||||
|
||||
let monthGaugeChart = null
|
||||
let yearGaugeChart = null
|
||||
let varianceChart = null
|
||||
let burndownChart = null
|
||||
let yearBurndownChart = null
|
||||
|
||||
const monthBudgets = computed(() => (props.budgets || []).filter(b => b.type === 1))
|
||||
const yearBudgets = computed(() => (props.budgets || []).filter(b => b.type === 2))
|
||||
|
||||
const formatMoney = (val) => {
|
||||
if (Math.abs(val) >= 10000) {
|
||||
return (val / 10000).toFixed(1) + 'w'
|
||||
@@ -213,14 +240,6 @@ const formatMoney = (val) => {
|
||||
})
|
||||
}
|
||||
|
||||
const initGaugeChart = (chartInstance, dom, data, isExpense) => {
|
||||
if (!dom) { return null }
|
||||
|
||||
const chart = echarts.init(dom)
|
||||
updateSingleGauge(chart, data, isExpense)
|
||||
return chart
|
||||
}
|
||||
|
||||
const updateSingleGauge = (chart, data, isExpense) => {
|
||||
if (!chart) { return }
|
||||
|
||||
@@ -258,8 +277,8 @@ const updateSingleGauge = (chart, data, isExpense) => {
|
||||
min: 0,
|
||||
max: 100,
|
||||
splitNumber: 5,
|
||||
radius: '110%', // 放大一点以适应小卡片
|
||||
center: ['50%', '75%'],
|
||||
radius: '120%', // 放大一点以适应小卡片
|
||||
center: ['50%', '70%'],
|
||||
itemStyle: {
|
||||
color: color,
|
||||
shadowColor: getCssVar('--chart-shadow'),
|
||||
|
||||
@@ -639,14 +639,16 @@ const fetchCategoryStats = async () => {
|
||||
current: data.month?.current || 0,
|
||||
limit: data.month?.limit || 0,
|
||||
count: data.month?.count || 0,
|
||||
trend: data.month?.trend || []
|
||||
trend: data.month?.trend || [],
|
||||
description: data.month?.description || ''
|
||||
},
|
||||
year: {
|
||||
rate: data.year?.rate?.toFixed(1) || '0.0',
|
||||
current: data.year?.current || 0,
|
||||
limit: data.year?.limit || 0,
|
||||
count: data.year?.count || 0,
|
||||
trend: data.year?.trend || []
|
||||
trend: data.year?.trend || [],
|
||||
description: data.year?.description || ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -648,9 +648,8 @@ onMounted(() => {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* @media (prefers-color-scheme: dark) {
|
||||
:deep(.van-dropdown-menu) {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
} */
|
||||
/* 设置页面容器背景色 */
|
||||
:deep(.van-nav-bar) {
|
||||
background: transparent !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -94,10 +94,11 @@ public class BudgetStatsTest : BaseTest
|
||||
var result = await _service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate);
|
||||
|
||||
// Assert
|
||||
// 1月有31天,15号经过了15天
|
||||
// 3100 * 15 / 31 = 1500
|
||||
// 硬性预算的限额保持不变,不根据时间计算
|
||||
result.Month.Limit.Should().Be(3100);
|
||||
result.Month.Current.Should().Be(1500);
|
||||
// 实际使用值根据时间计算:1月有31天,15号经过了15天
|
||||
// 3100 * 15 / 31 ≈ 1500
|
||||
result.Month.Current.Should().BeApproximately(1500, 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -188,10 +189,11 @@ public class BudgetStatsTest : BaseTest
|
||||
var result = await _service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate);
|
||||
|
||||
// Assert
|
||||
// 2024是闰年,366天。1月1号是第1天。
|
||||
// 3660 * 1 / 366 = 10
|
||||
// 硬性预算的限额保持不变
|
||||
result.Year.Limit.Should().Be(3660);
|
||||
result.Year.Current.Should().Be(10);
|
||||
// 实际使用值根据时间计算:2024是闰年,366天。1月1号是第1天。
|
||||
// 3660 * 1 / 366 ≈ 10
|
||||
result.Year.Current.Should().BeApproximately(10, 0.1m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -214,10 +216,11 @@ public class BudgetStatsTest : BaseTest
|
||||
var result = await _service.GetCategoryStatsAsync(BudgetCategory.Expense, referenceDate);
|
||||
|
||||
// Assert
|
||||
// 2024是闰年。1月(31) + 2月(29) + 3月(31) = 91天
|
||||
// 3660 * 91 / 366 = 910
|
||||
// 硬性预算的限额保持不变
|
||||
result.Year.Limit.Should().Be(3660);
|
||||
result.Year.Current.Should().Be(910);
|
||||
// 实际使用值根据时间计算:2024是闰年。1月(31) + 2月(29) + 3月(31) = 91天
|
||||
// 3660 * 91 / 366 ≈ 910
|
||||
result.Year.Current.Should().BeApproximately(910, 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user