From a7414c792e8c2cb086a77138669ae08d32ab8df8 Mon Sep 17 00:00:00 2001 From: SunCheng Date: Fri, 20 Feb 2026 22:07:09 +0800 Subject: [PATCH] fix --- Application/BudgetApplication.cs | 46 ++- Application/Dto/BudgetDto.cs | 44 ++ Service/Budget/BudgetSavingsService.cs | 178 +++++++- Web/src/components/Budget/BudgetCard.vue | 5 + .../budgetV2/modules/SavingsBudgetContent.vue | 381 +++++++++--------- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/savings-plan-detail-view/spec.md | 0 .../tasks.md | 0 .../specs/savings-plan-detail-view/spec.md | 45 +++ 11 files changed, 498 insertions(+), 201 deletions(-) rename openspec/changes/{fix-deposit-detail-empty => archive/2026-02-20-fix-deposit-detail-empty}/.openspec.yaml (100%) rename openspec/changes/{fix-deposit-detail-empty => archive/2026-02-20-fix-deposit-detail-empty}/design.md (100%) rename openspec/changes/{fix-deposit-detail-empty => archive/2026-02-20-fix-deposit-detail-empty}/proposal.md (100%) rename openspec/changes/{fix-deposit-detail-empty => archive/2026-02-20-fix-deposit-detail-empty}/specs/savings-plan-detail-view/spec.md (100%) rename openspec/changes/{fix-deposit-detail-empty => archive/2026-02-20-fix-deposit-detail-empty}/tasks.md (100%) create mode 100644 openspec/specs/savings-plan-detail-view/spec.md diff --git a/Application/BudgetApplication.cs b/Application/BudgetApplication.cs index 63c9604..7462fc0 100644 --- a/Application/BudgetApplication.cs +++ b/Application/BudgetApplication.cs @@ -224,7 +224,51 @@ public class BudgetApplication( StartDate = startDate, NoLimit = result.NoLimit, IsMandatoryExpense = result.IsMandatoryExpense, - UsagePercentage = result.Limit > 0 ? result.Current / result.Limit * 100 : 0 + UsagePercentage = result.Limit > 0 ? result.Current / result.Limit * 100 : 0, + Details = result.Details != null ? MapToSavingsDetailDto(result.Details) : null + }; + } + + /// + /// 映射存款明细数据到DTO + /// + private static SavingsDetailDto MapToSavingsDetailDto(Service.Budget.SavingsDetail details) + { + return new SavingsDetailDto + { + IncomeItems = details.IncomeItems.Select(item => new BudgetDetailItemDto + { + Id = item.Id, + Name = item.Name, + Type = item.Type, + BudgetLimit = item.BudgetLimit, + ActualAmount = item.ActualAmount, + EffectiveAmount = item.EffectiveAmount, + CalculationNote = item.CalculationNote, + IsOverBudget = item.IsOverBudget, + IsArchived = item.IsArchived, + ArchivedMonths = item.ArchivedMonths + }).ToList(), + ExpenseItems = details.ExpenseItems.Select(item => new BudgetDetailItemDto + { + Id = item.Id, + Name = item.Name, + Type = item.Type, + BudgetLimit = item.BudgetLimit, + ActualAmount = item.ActualAmount, + EffectiveAmount = item.EffectiveAmount, + CalculationNote = item.CalculationNote, + IsOverBudget = item.IsOverBudget, + IsArchived = item.IsArchived, + ArchivedMonths = item.ArchivedMonths + }).ToList(), + Summary = new SavingsCalculationSummaryDto + { + TotalIncomeBudget = details.Summary.TotalIncomeBudget, + TotalExpenseBudget = details.Summary.TotalExpenseBudget, + PlannedSavings = details.Summary.PlannedSavings, + CalculationFormula = details.Summary.CalculationFormula + } }; } diff --git a/Application/Dto/BudgetDto.cs b/Application/Dto/BudgetDto.cs index 002ba83..a442296 100644 --- a/Application/Dto/BudgetDto.cs +++ b/Application/Dto/BudgetDto.cs @@ -16,8 +16,52 @@ public record BudgetResponse public bool NoLimit { get; init; } public bool IsMandatoryExpense { get; init; } public decimal UsagePercentage { get; init; } + + /// + /// 存款明细数据(仅存款预算返回) + /// + public SavingsDetailDto? Details { get; init; } } +/// +/// 存款明细数据 DTO +/// +public record SavingsDetailDto +{ + public List IncomeItems { get; init; } = new(); + public List ExpenseItems { get; init; } = new(); + public SavingsCalculationSummaryDto Summary { get; init; } = new(); +} + +/// +/// 预算明细项 DTO +/// +public record BudgetDetailItemDto +{ + public long Id { get; init; } + public string Name { get; init; } = string.Empty; + public BudgetPeriodType Type { get; init; } + public decimal BudgetLimit { get; init; } + public decimal ActualAmount { get; init; } + public decimal EffectiveAmount { get; init; } + public string CalculationNote { get; init; } = string.Empty; + public bool IsOverBudget { get; init; } + public bool IsArchived { get; init; } + public int[]? ArchivedMonths { get; init; } +} + +/// +/// 存款计算汇总 DTO +/// +public record SavingsCalculationSummaryDto +{ + public decimal TotalIncomeBudget { get; init; } + public decimal TotalExpenseBudget { get; init; } + public decimal PlannedSavings { get; init; } + public string CalculationFormula { get; init; } = string.Empty; +} + + /// /// 创建预算请求 /// diff --git a/Service/Budget/BudgetSavingsService.cs b/Service/Budget/BudgetSavingsService.cs index 56577ad..56dc2e9 100644 --- a/Service/Budget/BudgetSavingsService.cs +++ b/Service/Budget/BudgetSavingsService.cs @@ -876,12 +876,26 @@ public class BudgetSavingsService( UpdateTime = dateTimeProvider.Now }; - return BudgetResult.FromEntity( + // 生成明细数据 + var details = GenerateYearlyDetails( + currentMonthlyIncomeItems, + currentYearlyIncomeItems, + currentMonthlyExpenseItems, + currentYearlyExpenseItems, + archiveIncomeItems, + archiveExpenseItems, + new DateTime(year, 1, 1) + ); + + var result = BudgetResult.FromEntity( record, currentActual, new DateTime(year, 1, 1), description.ToString() ); + result.Details = details; + + return result; void AddOrIncCurrentItem( long id, @@ -1116,4 +1130,166 @@ public class BudgetSavingsService( } }; } + + /// + /// 生成年度存款明细数据 + /// + private SavingsDetail GenerateYearlyDetails( + List<(long id, string name, decimal limit, int factor, decimal current, bool isMandatory)> currentMonthlyIncomeItems, + List<(long id, string name, decimal limit, int factor, decimal current, bool isMandatory)> currentYearlyIncomeItems, + List<(long id, string name, decimal limit, int factor, decimal current, bool isMandatory)> currentMonthlyExpenseItems, + List<(long id, string name, decimal limit, int factor, decimal current, bool isMandatory)> currentYearlyExpenseItems, + List<(long id, string name, int[] months, decimal limit, decimal current)> archiveIncomeItems, + List<(long id, string name, int[] months, decimal limit, decimal current)> archiveExpenseItems, + DateTime referenceDate) + { + var incomeDetails = new List(); + var expenseDetails = new List(); + + // 处理已归档的收入预算 + foreach (var item in archiveIncomeItems) + { + incomeDetails.Add(new BudgetDetailItem + { + Id = item.id, + Name = item.name, + Type = BudgetPeriodType.Month, + BudgetLimit = item.limit, + ActualAmount = item.current, + EffectiveAmount = item.current, + CalculationNote = $"已归档 ({string.Join(", ", item.months)}月)", + IsOverBudget = false, + IsArchived = true, + ArchivedMonths = item.months + }); + } + + // 处理当前月度收入预算 + foreach (var item in currentMonthlyIncomeItems) + { + // 年度预算中,月度预算按 factor 倍率计算有效金额 + var effectiveAmount = item.limit == 0 ? item.current : item.limit * item.factor; + var note = item.limit == 0 + ? "不记额(使用实际)" + : $"预算 {item.limit:N0} × {item.factor}月 = {effectiveAmount:N0}"; + + incomeDetails.Add(new BudgetDetailItem + { + Id = item.id, + Name = item.name, + Type = BudgetPeriodType.Month, + BudgetLimit = item.limit, + ActualAmount = item.current, + EffectiveAmount = effectiveAmount, + CalculationNote = note, + IsOverBudget = item.current > 0 && item.current < item.limit, + IsArchived = false + }); + } + + // 处理当前年度收入预算 + foreach (var item in currentYearlyIncomeItems) + { + // 年度预算:硬性预算或不记额预算使用实际值,否则使用预算值 + var effectiveAmount = item.isMandatory || item.limit == 0 ? item.current : item.limit; + var note = item.isMandatory + ? "硬性(使用实际)" + : (item.limit == 0 ? "不记额(使用实际)" : "使用预算"); + + incomeDetails.Add(new BudgetDetailItem + { + Id = item.id, + Name = item.name, + Type = BudgetPeriodType.Year, + BudgetLimit = item.limit, + ActualAmount = item.current, + EffectiveAmount = effectiveAmount, + CalculationNote = note, + IsOverBudget = false, + IsArchived = false + }); + } + + // 处理已归档的支出预算 + foreach (var item in archiveExpenseItems) + { + expenseDetails.Add(new BudgetDetailItem + { + Id = item.id, + Name = item.name, + Type = BudgetPeriodType.Month, + BudgetLimit = item.limit, + ActualAmount = item.current, + EffectiveAmount = item.current, + CalculationNote = $"已归档 ({string.Join(", ", item.months)}月)", + IsOverBudget = false, + IsArchived = true, + ArchivedMonths = item.months + }); + } + + // 处理当前月度支出预算 + foreach (var item in currentMonthlyExpenseItems) + { + var effectiveAmount = item.limit == 0 ? item.current : item.limit * item.factor; + var note = item.limit == 0 + ? "不记额(使用实际)" + : $"预算 {item.limit:N0} × {item.factor}月 = {effectiveAmount:N0}"; + + expenseDetails.Add(new BudgetDetailItem + { + Id = item.id, + Name = item.name, + Type = BudgetPeriodType.Month, + BudgetLimit = item.limit, + ActualAmount = item.current, + EffectiveAmount = effectiveAmount, + CalculationNote = note, + IsOverBudget = item.current > item.limit, + IsArchived = false + }); + } + + // 处理当前年度支出预算 + foreach (var item in currentYearlyExpenseItems) + { + var effectiveAmount = item.isMandatory || item.limit == 0 ? item.current : item.limit; + var note = item.isMandatory + ? "硬性(使用实际)" + : (item.limit == 0 ? "不记额(使用实际)" : "使用预算"); + + expenseDetails.Add(new BudgetDetailItem + { + Id = item.id, + Name = item.name, + Type = BudgetPeriodType.Year, + BudgetLimit = item.limit, + ActualAmount = item.current, + EffectiveAmount = effectiveAmount, + CalculationNote = note, + IsOverBudget = item.current > item.limit, + IsArchived = false + }); + } + + // 计算汇总 + var totalIncome = incomeDetails.Sum(i => i.EffectiveAmount); + var totalExpense = expenseDetails.Sum(e => e.EffectiveAmount); + var plannedSavings = totalIncome - totalExpense; + + var formula = $"收入 {totalIncome:N0} - 支出 {totalExpense:N0} = {plannedSavings:N0}"; + + return new SavingsDetail + { + IncomeItems = incomeDetails, + ExpenseItems = expenseDetails, + Summary = new SavingsCalculationSummary + { + TotalIncomeBudget = totalIncome, + TotalExpenseBudget = totalExpense, + PlannedSavings = plannedSavings, + CalculationFormula = formula + } + }; + } } diff --git a/Web/src/components/Budget/BudgetCard.vue b/Web/src/components/Budget/BudgetCard.vue index 6c610de..81d625b 100644 --- a/Web/src/components/Budget/BudgetCard.vue +++ b/Web/src/components/Budget/BudgetCard.vue @@ -508,6 +508,11 @@ const handleQueryBills = async () => { } const percentage = computed(() => { + // 优先使用后端返回的 usagePercentage 字段 + if (props.budget.usagePercentage !== undefined && props.budget.usagePercentage !== null) { + return Math.round(props.budget.usagePercentage) + } + // 降级方案:如果后端没有返回该字段,前端计算 if (!props.budget.limit) { return 0 } diff --git a/Web/src/views/budgetV2/modules/SavingsBudgetContent.vue b/Web/src/views/budgetV2/modules/SavingsBudgetContent.vue index ae814e9..dceb9de 100644 --- a/Web/src/views/budgetV2/modules/SavingsBudgetContent.vue +++ b/Web/src/views/budgetV2/modules/SavingsBudgetContent.vue @@ -92,50 +92,68 @@ 收入明细 -
-
-
- {{ item.name }} - + + + + + + + + + + + - {{ item.type === 1 ? '月度' : '年度' }} - - -
-
- 预算 - ¥{{ formatMoney(item.budgetLimit) }} -
-
- 实际 - - ¥{{ formatMoney(item.actualAmount) }} - -
-
- 计算用 - ¥{{ formatMoney(item.effectiveAmount) }} -
-
-
- - {{ item.calculationNote }} - -
- + + + + + + +
名称预算额度实际金额计算用
+
+ {{ item.name }} + + {{ item.type === 1 ? '月' : '年' }} + + + 已归档 + +
+
{{ formatMoney(item.budgetLimit) }} + + {{ formatMoney(item.actualAmount) }} + + + {{ formatMoney(item.effectiveAmount) }} +
+

+ 收入预算合计: + + +

@@ -145,58 +163,70 @@ 支出明细
-
-
-
- {{ item.name }} - + + + + + + + + + + + - {{ item.type === 1 ? '月度' : '年度' }} - - -
-
- 预算 - ¥{{ formatMoney(item.budgetLimit) }} -
-
- 实际 - - ¥{{ formatMoney(item.actualAmount) }} - -
-
- 计算用 - ¥{{ formatMoney(item.effectiveAmount) }} -
-
-
- - {{ item.calculationNote }} - - - 超支 - -
- + + + + + + +
名称预算额度实际金额计算用
+
+ {{ item.name }} + + {{ item.type === 1 ? '月' : '年' }} + + + 已归档 + + + 超支 + +
+
{{ formatMoney(item.budgetLimit) }} + {{ formatMoney(item.actualAmount) }} + + {{ formatMoney(item.effectiveAmount) }} +
+

+ 支出预算合计: + + +

@@ -206,28 +236,27 @@ 计算汇总
-
-
- 收入合计 - - ¥{{ formatMoney(currentBudget.details.summary.totalIncomeBudget) }} +
+

计算公式

+

+ 收入预算合计: + + {{ formatMoney(currentBudget.details.summary.totalIncomeBudget) }} -

-
- 支出合计 - - ¥{{ formatMoney(currentBudget.details.summary.totalExpenseBudget) }} +

+

+ 支出预算合计: + + {{ formatMoney(currentBudget.details.summary.totalExpenseBudget) }} -

-
- 计划存款 - - ¥{{ formatMoney(currentBudget.details.summary.plannedSavings) }} +

+

+ 计划存款: + {{ currentBudget.details.summary.calculationFormula }} + = + {{ formatMoney(currentBudget.details.summary.plannedSavings) }} -

-
-
- {{ currentBudget.details.summary.calculationFormula }} +

@@ -400,6 +429,45 @@ const incomeCurrent = computed(() => matchedIncomeBudget.value?.current || 0) const expenseLimit = computed(() => matchedExpenseBudget.value?.limit || 0) const expenseCurrent = computed(() => matchedExpenseBudget.value?.current || 0) +// 归档和未来预算的汇总 (仅用于年度存款计划) +const hasArchivedIncome = computed(() => { + if (!currentBudget.value?.details) return false + return currentBudget.value.details.incomeItems.some(item => item.isArchived) +}) + +const archivedIncomeTotal = computed(() => { + if (!currentBudget.value?.details) return 0 + return currentBudget.value.details.incomeItems + .filter(item => item.isArchived) + .reduce((sum, item) => sum + item.effectiveAmount, 0) +}) + +const futureIncomeTotal = computed(() => { + if (!currentBudget.value?.details) return 0 + return currentBudget.value.details.incomeItems + .filter(item => !item.isArchived) + .reduce((sum, item) => sum + item.effectiveAmount, 0) +}) + +const hasArchivedExpense = computed(() => { + if (!currentBudget.value?.details) return false + return currentBudget.value.details.expenseItems.some(item => item.isArchived) +}) + +const archivedExpenseTotal = computed(() => { + if (!currentBudget.value?.details) return 0 + return currentBudget.value.details.expenseItems + .filter(item => item.isArchived) + .reduce((sum, item) => sum + item.effectiveAmount, 0) +}) + +const futureExpenseTotal = computed(() => { + if (!currentBudget.value?.details) return 0 + return currentBudget.value.details.expenseItems + .filter(item => !item.isArchived) + .reduce((sum, item) => sum + item.effectiveAmount, 0) +}) + // 辅助函数 const formatMoney = (val) => { return parseFloat(val || 0).toLocaleString(undefined, { @@ -647,98 +715,13 @@ const getProgressColor = (budget) => { padding: 0 8px; } -/* 明细表格样式 */ +/* 明细表格样式 - 使用 rich-html-content 统一样式 */ .detail-tables { display: flex; flex-direction: column; gap: 16px; } -.detail-table { - display: flex; - flex-direction: column; - gap: 12px; -} - -.detail-item { - background-color: var(--van-light-gray); - border-radius: 8px; - padding: 12px; - border-left: 3px solid var(--van-gray-4); -} - -.detail-item.overbudget { - border-left-color: var(--van-danger-color); - background-color: rgba(245, 34, 45, 0.05); -} - -.item-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 10px; -} - -.item-name { - font-size: 15px; - font-weight: 600; - color: var(--van-text-color); -} - -.item-amounts { - display: flex; - flex-direction: column; - gap: 6px; - margin-bottom: 8px; -} - -.amount-row { - display: flex; - justify-content: space-between; - align-items: center; - font-size: 13px; -} - -.amount-row.highlight { - padding-top: 6px; - margin-top: 4px; - border-top: 1px dashed var(--van-border-color); - font-weight: 600; -} - -.amount-label { - color: var(--van-text-color-2); -} - -.amount-value { - font-family: DIN Alternate, system-ui; - font-weight: 600; - color: var(--van-text-color); -} - -.amount-value.income { - color: var(--van-success-color); -} - -.amount-value.expense { - color: var(--van-danger-color); -} - -.amount-value.warning { - color: var(--van-warning-color); -} - -.amount-value.danger { - color: var(--van-danger-color); -} - -.item-note { - display: flex; - align-items: center; - gap: 6px; - flex-wrap: wrap; -} - .formula-row { display: flex; justify-content: space-between; diff --git a/openspec/changes/fix-deposit-detail-empty/.openspec.yaml b/openspec/changes/archive/2026-02-20-fix-deposit-detail-empty/.openspec.yaml similarity index 100% rename from openspec/changes/fix-deposit-detail-empty/.openspec.yaml rename to openspec/changes/archive/2026-02-20-fix-deposit-detail-empty/.openspec.yaml diff --git a/openspec/changes/fix-deposit-detail-empty/design.md b/openspec/changes/archive/2026-02-20-fix-deposit-detail-empty/design.md similarity index 100% rename from openspec/changes/fix-deposit-detail-empty/design.md rename to openspec/changes/archive/2026-02-20-fix-deposit-detail-empty/design.md diff --git a/openspec/changes/fix-deposit-detail-empty/proposal.md b/openspec/changes/archive/2026-02-20-fix-deposit-detail-empty/proposal.md similarity index 100% rename from openspec/changes/fix-deposit-detail-empty/proposal.md rename to openspec/changes/archive/2026-02-20-fix-deposit-detail-empty/proposal.md diff --git a/openspec/changes/fix-deposit-detail-empty/specs/savings-plan-detail-view/spec.md b/openspec/changes/archive/2026-02-20-fix-deposit-detail-empty/specs/savings-plan-detail-view/spec.md similarity index 100% rename from openspec/changes/fix-deposit-detail-empty/specs/savings-plan-detail-view/spec.md rename to openspec/changes/archive/2026-02-20-fix-deposit-detail-empty/specs/savings-plan-detail-view/spec.md diff --git a/openspec/changes/fix-deposit-detail-empty/tasks.md b/openspec/changes/archive/2026-02-20-fix-deposit-detail-empty/tasks.md similarity index 100% rename from openspec/changes/fix-deposit-detail-empty/tasks.md rename to openspec/changes/archive/2026-02-20-fix-deposit-detail-empty/tasks.md diff --git a/openspec/specs/savings-plan-detail-view/spec.md b/openspec/specs/savings-plan-detail-view/spec.md new file mode 100644 index 0000000..204c0cd --- /dev/null +++ b/openspec/specs/savings-plan-detail-view/spec.md @@ -0,0 +1,45 @@ +## MODIFIED Requirements + +### Requirement: Display income and expense budget in savings detail popup +The savings detail popup SHALL display the associated income budget and expense budget information for the selected savings plan, including both budget limits and current amounts. + +#### Scenario: User opens savings detail popup with matched budgets +- **WHEN** user clicks the detail button on a savings plan card +- **AND** there exist income and expense budgets for the same period and type +- **THEN** the popup SHALL display the income budget limit and current amount +- **AND** the popup SHALL display the expense budget limit and current amount +- **AND** the popup SHALL display the savings formula (Income Limit - Expense Limit = Planned Savings) +- **AND** the popup SHALL display the savings result (Planned Savings, Actual Savings, Remaining) + +#### Scenario: User opens savings detail popup without matched budgets +- **WHEN** user clicks the detail button on a savings plan card +- **AND** there are no income or expense budgets for the same period and type +- **THEN** the popup SHALL display 0 for income budget limit and current amount +- **AND** the popup SHALL display 0 for expense budget limit and current amount +- **AND** the popup SHALL still display the savings formula and result with these values + +### Requirement: Pass budget data to savings component +The parent component (Index.vue) SHALL pass income budgets and expense budgets to the SavingsBudgetContent component to enable detail popup display. + +#### Scenario: Budget data is loaded successfully +- **WHEN** the budget data is loaded from the API +- **THEN** the income budgets SHALL be passed to SavingsBudgetContent via props +- **AND** the expense budgets SHALL be passed to SavingsBudgetContent via props +- **AND** the savings budgets SHALL be passed to SavingsBudgetContent via props (existing behavior) + +### Requirement: Match income and expense budgets to savings plan +The SavingsBudgetContent component SHALL match income and expense budgets to the current savings plan based on periodStart and type fields. + +#### Scenario: Match budgets with same period and type +- **WHEN** displaying savings plan details +- **AND** the component searches for matching budgets +- **THEN** the component SHALL find income budgets where periodStart and type match the savings plan +- **AND** the component SHALL find expense budgets where periodStart and type match the savings plan +- **AND** if multiple matches exist, the component SHALL use the first match + +#### Scenario: No matching budgets found +- **WHEN** displaying savings plan details +- **AND** no income budget matches the savings plan's periodStart and type +- **OR** no expense budget matches the savings plan's periodStart and type +- **THEN** the component SHALL use 0 as the default value for unmatched budget fields +- **AND** the popup SHALL still render without errors