From 94c7c90b9194a11d3fec4f6d48546748ae40f479 Mon Sep 17 00:00:00 2001 From: olsch01 Date: Wed, 11 Mar 2026 15:46:56 -0400 Subject: [PATCH] fix: use project estimated_cost for reserve funded ratio calculation The health score funded ratio was only reading from the reserve_components table (replacement_cost), but users enter their reserve data on the Projects page using estimated_cost. When reserve_components is empty, the funded ratio now falls back to reserve-funded projects for: - Total replacement cost (estimated_cost) - Component funding status (current_fund_balance) - Urgent components due within 5 years (remaining_life_years) - AI prompt component detail lines Co-Authored-By: Claude Opus 4.6 --- .../health-scores/health-scores.service.ts | 51 ++++++++++++------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/backend/src/modules/health-scores/health-scores.service.ts b/backend/src/modules/health-scores/health-scores.service.ts index ffd73a2..cece9be 100644 --- a/backend/src/modules/health-scores/health-scores.service.ts +++ b/backend/src/modules/health-scores/health-scores.service.ts @@ -566,10 +566,12 @@ export class HealthScoresService { FROM reserve_components ORDER BY remaining_life_years ASC NULLS LAST `), - // Capital projects + // Capital projects (include component-level fields for funded ratio when reserve_components is empty) qr.query(` - SELECT name, estimated_cost, target_year, target_month, fund_source, - status, priority, current_fund_balance, funded_percentage + SELECT name, estimated_cost, actual_cost, target_year, target_month, fund_source, + status, priority, current_fund_balance, funded_percentage, + category, useful_life_years, remaining_life_years, condition_rating, + annual_contribution FROM projects WHERE is_active = true AND status IN ('planned', 'approved', 'in_progress') ORDER BY target_year, target_month NULLS LAST @@ -604,11 +606,19 @@ export class HealthScoresService { const totalReserveFund = reserveCash + totalInvestments; - const totalReplacementCost = reserveComponents - .reduce((s: number, c: any) => s + parseFloat(c.replacement_cost || '0'), 0); + // Use reserve_components for funded ratio when available; fall back to + // reserve-funded projects (which carry the same estimated_cost / lifecycle + // fields that users actually populate on the Projects page). + const reserveProjects = projects.filter((p: any) => p.fund_source === 'reserve'); + const useComponentsTable = reserveComponents.length > 0; - const totalComponentFunded = reserveComponents - .reduce((s: number, c: any) => s + parseFloat(c.current_fund_balance || '0'), 0); + const totalReplacementCost = useComponentsTable + ? reserveComponents.reduce((s: number, c: any) => s + parseFloat(c.replacement_cost || '0'), 0) + : reserveProjects.reduce((s: number, p: any) => s + parseFloat(p.estimated_cost || '0'), 0); + + const totalComponentFunded = useComponentsTable + ? reserveComponents.reduce((s: number, c: any) => s + parseFloat(c.current_fund_balance || '0'), 0) + : reserveProjects.reduce((s: number, p: any) => s + parseFloat(p.current_fund_balance || '0'), 0); const percentFunded = totalReplacementCost > 0 ? (totalReserveFund / totalReplacementCost) * 100 : 0; @@ -623,10 +633,14 @@ export class HealthScoresService { .filter((b: any) => b.account_type === 'expense') .reduce((s: number, b: any) => s + parseFloat(b.annual_total || '0'), 0); - // Components needing replacement within 5 years - const urgentComponents = reserveComponents.filter( - (c: any) => c.remaining_life_years !== null && parseFloat(c.remaining_life_years) <= 5, - ); + // Components needing replacement within 5 years — use whichever source has data + const urgentComponents = useComponentsTable + ? reserveComponents.filter( + (c: any) => c.remaining_life_years !== null && parseFloat(c.remaining_life_years) <= 5, + ) + : reserveProjects.filter( + (p: any) => p.remaining_life_years !== null && parseFloat(p.remaining_life_years) <= 5, + ); // ── Build 12-month forward reserve cash flow projection ── @@ -757,6 +771,7 @@ export class HealthScoresService { accounts, investments, reserveComponents, + reserveProjects, projects, budgets, assessments, @@ -967,13 +982,15 @@ Provide 3-5 factors and 1-3 actionable recommendations. Be specific with dollar `- ${i.name} | ${i.investment_type} @ ${i.institution} | $${parseFloat(i.current_value || i.principal || '0').toFixed(2)} | Rate: ${parseFloat(i.interest_rate || '0').toFixed(2)}% | Maturity: ${i.maturity_date ? new Date(i.maturity_date).toLocaleDateString() : 'N/A'}`, ).join('\n'); - const componentLines = data.reserveComponents.length === 0 - ? 'No reserve components tracked.' - : data.reserveComponents.map((c: any) => { - const cost = parseFloat(c.replacement_cost || '0'); + // Build component lines from reserve_components if available, otherwise from reserve-funded projects + const componentSource = data.reserveComponents.length > 0 ? data.reserveComponents : data.reserveProjects; + const componentLines = componentSource.length === 0 + ? 'No reserve components or reserve projects tracked.' + : componentSource.map((c: any) => { + const cost = parseFloat(c.replacement_cost || c.estimated_cost || '0'); const funded = parseFloat(c.current_fund_balance || '0'); const pct = cost > 0 ? ((funded / cost) * 100).toFixed(0) : '0'; - return `- ${c.name} [${c.category}] | Life: ${c.useful_life_years}yr, Remaining: ${c.remaining_life_years}yr | Cost: $${cost.toFixed(0)} | Funded: $${funded.toFixed(0)} (${pct}%) | Condition: ${c.condition_rating}/10 | Annual Contribution: $${parseFloat(c.annual_contribution || '0').toFixed(0)}`; + return `- ${c.name} [${c.category || 'N/A'}] | Life: ${c.useful_life_years || '?'}yr, Remaining: ${c.remaining_life_years || '?'}yr | Cost: $${cost.toFixed(0)} | Funded: $${funded.toFixed(0)} (${pct}%) | Condition: ${c.condition_rating || '?'}/10 | Annual Contribution: $${parseFloat(c.annual_contribution || '0').toFixed(0)}`; }).join('\n'); const projectLines = data.projects.length === 0 @@ -989,7 +1006,7 @@ Provide 3-5 factors and 1-3 actionable recommendations. Be specific with dollar const urgentLines = data.urgentComponents.length === 0 ? 'None — no components due within 5 years.' : data.urgentComponents.map((c: any) => { - const cost = parseFloat(c.replacement_cost || '0'); + const cost = parseFloat(c.replacement_cost || c.estimated_cost || '0'); const funded = parseFloat(c.current_fund_balance || '0'); const gap = cost - funded; return `- ${c.name}: ${c.remaining_life_years} years remaining, $${gap.toFixed(0)} funding gap`;