diff --git a/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts b/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts index c1048dd5..db61d9df 100644 --- a/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts +++ b/src/app/portfolio/[slug]/(portfolio)/reporting/databaseFunctions.ts @@ -9,6 +9,8 @@ import { energyConsumptionSql, estimatedSql, isExpiredSql, + sapSql, + epcBandSql, } from "@/lib/services/epcSources"; import type { @@ -36,7 +38,7 @@ export async function getAverages( ): Promise { const result = await db.execute(sql` SELECT - AVG(p.current_sap_points)::float AS avg_sap, + AVG(${sapSql})::float AS avg_sap, AVG(${carbonSql(sql`e`)})::float AS avg_carbon, AVG(${billsSql(sql`e`)})::float AS avg_bills, AVG(${energyConsumptionSql(sql`e`)})::float AS avg_energy_consumption @@ -97,7 +99,7 @@ export async function getCountByEpcBand( SELECT * FROM ( SELECT - COALESCE(p.current_epc_rating::text, 'Unknown') AS epc, + COALESCE((${epcBandSql})::text, 'Unknown') AS epc, COUNT(*) FILTER ( WHERE ${estimatedSql(sql`e`)} = false )::int AS actual, diff --git a/src/app/portfolio/[slug]/utils.ts b/src/app/portfolio/[slug]/utils.ts index 316f67a8..971a700b 100644 --- a/src/app/portfolio/[slug]/utils.ts +++ b/src/app/portfolio/[slug]/utils.ts @@ -682,7 +682,14 @@ export async function getPropertiesCount( FROM property p LEFT JOIN property_details_epc epc ON epc.property_id = p.id ${newApproachJoins} - LEFT JOIN plan pl ON pl.property_id = p.id AND pl.is_default = true + LEFT JOIN LATERAL ( + SELECT id, post_sap_points FROM plan + WHERE property_id = p.id + AND portfolio_id = p.portfolio_id + AND is_default = true + ORDER BY created_at DESC + LIMIT 1 + ) pl ON true WHERE p.portfolio_id = ${portfolioId} ${combinedWhere} `); @@ -714,8 +721,8 @@ export async function getProperties( t.epc AS "targetEpc", pl.id AS "planId", fp.scheme AS "fundingScheme", - COALESCE(SUM(r.sap_points), 0) AS "totalRecommendationSapPoints", - COALESCE(SUM(r.estimated_cost), 0) AS "totalRecommendationCost", + COALESCE(rec.sap_points, 0) AS "totalRecommendationSapPoints", + COALESCE(rec.cost, 0) AS "totalRecommendationCost", p.landlord_property_id AS "landlordPropertyId", p.original_sap_points AS "originalSapPoints", p.property_type AS "propertyType", @@ -729,57 +736,47 @@ export async function getProperties( ${mainfuelSql(sql`epc`)} AS mainfuel, p.lexiscore AS lexiscore FROM property p - LEFT JOIN property_targets t - ON t.property_id = p.id - LEFT JOIN plan pl - ON pl.property_id = p.id - AND pl.is_default = true - LEFT JOIN funding_package fp - ON fp.plan_id = pl.id - -- Recommendations link to their plan via recommendation.plan_id (the - -- plan_recommendations join table is retired). Using plan_id is what makes - -- Plan Cost / recommendation SAP populate for new-approach properties. - LEFT JOIN recommendation r - ON r.plan_id = pl.id - AND r.default = true - AND r.already_installed = false + -- LATERAL one-row lookups keep this query at "12 properties × index probe" + -- instead of hash-joining the multi-million-row plan/recommendation tables. + LEFT JOIN LATERAL ( + SELECT epc FROM property_targets + WHERE property_id = p.id + ORDER BY created_at DESC + LIMIT 1 + ) t ON true + LEFT JOIN LATERAL ( + SELECT * FROM plan + WHERE property_id = p.id + AND portfolio_id = p.portfolio_id + AND is_default = true + ORDER BY created_at DESC + LIMIT 1 + ) pl ON true + LEFT JOIN LATERAL ( + SELECT scheme FROM funding_package + WHERE plan_id = pl.id + LIMIT 1 + ) fp ON true + -- Active (default, not-yet-installed) recommendations for the property, + -- read denormalised. We aggregate by recommendation.property_id — the only + -- indexed access path (there is NO index on recommendation.plan_id, and the + -- table has tens of millions of rows) — and rely on the partial index on + -- (default, already_installed). The retired plan_recommendations join table + -- is intentionally not used. + LEFT JOIN LATERAL ( + SELECT + COALESCE(SUM(sap_points), 0) AS sap_points, + COALESCE(SUM(estimated_cost), 0) AS cost + FROM recommendation + WHERE property_id = p.id + AND "default" = true + AND already_installed = false + ) rec ON true LEFT JOIN property_details_epc epc ON epc.property_id = p.id ${newApproachJoins} WHERE p.portfolio_id = ${portfolioId} ${combinedWhere} - GROUP BY - p.id, - p.portfolio_id, - p.address, - p.postcode, - p.status, - p.creation_status, - p.current_epc_rating, - p.current_sap_points, - t.epc, - pl.id, - fp.scheme, - p.landlord_property_id, - p.original_sap_points, - p.property_type, - p.built_form, - p.tenure, - p.year_built, - -- legacy + new-source columns referenced by the branched EPC expressions - p.updated_at, - epc.lodgement_date, - epc.is_expired, - epc.total_floor_area, - epc.co2_emissions, - epc.mainfuel, - epl.id, - epp.id, - epl.registration_date, - epl.inspection_date, - epl.total_floor_area_m2, - bp.lodged_co2_emissions_t_per_yr, - p.lexiscore LIMIT ${limit} OFFSET ${offset}; `); diff --git a/src/lib/services/epcSources.ts b/src/lib/services/epcSources.ts index b5c3d78a..c7377f34 100644 --- a/src/lib/services/epcSources.ts +++ b/src/lib/services/epcSources.ts @@ -85,6 +85,17 @@ export const carbonSql = (e: Alias) => export const energyConsumptionSql = (e: Alias) => sql`CASE WHEN ${isNewApproachSql} THEN bp.lodged_primary_energy_intensity_kwh_per_m2_yr ELSE ${e}.primary_energy_consumption END`; +/** + * Baseline SAP score. New: the lodged (gov-registry) score from + * property_baseline_performance (the property-row current_sap_points isn't + * written for new-approach properties). Legacy: property.current_sap_points. + * Swap to `effective_sap_score` for the modelling-adjusted baseline. + */ +export const sapSql = sql`CASE WHEN ${isNewApproachSql} THEN bp.lodged_sap_score ELSE p.current_sap_points END`; + +/** Baseline EPC band. New: lodged band from property_baseline_performance. Legacy: property.current_epc_rating. */ +export const epcBandSql = sql`CASE WHEN ${isNewApproachSql} THEN bp.lodged_epc_band ELSE p.current_epc_rating END`; + /** * Annual energy bill (£). New: sum of the individual cost columns on * property_baseline_performance (NULL when no baseline row exists, so it's