mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-12 13:28:55 +00:00
feat(epc): read new EPC graph for new-approach properties via resolver
The new modelling pipeline no longer writes property_details_epc. Route all EPC reads through src/lib/services/epcSources.ts, which branches on the cutoff (property.updated_at >= 2026-06-01): legacy rows keep reading property_details_epc; new-approach rows read epc_property (+ children), property_baseline_performance, and plan/recommendation. - per-property: resolveConditionReport (building passport), resolveDetailsEpcMeta (/api/property-meta) - aggregates: shared carbon/bills/energy/estimated/expired/floor-area/ lodgement/mainfuel SQL fragments + newApproachJoins, wired into the portfolio list + filters, reporting metrics, and both scenario metrics routes estimated = predicted-only EPC. Gracefully omits gap fields for new properties (mainfuel, energyTariff, floorHeight, installed-measures bill adjustment, SAP-05 likely-downgrade). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e606006e6b
commit
6026944fe5
7 changed files with 527 additions and 93 deletions
|
|
@ -3,6 +3,7 @@ import { sql } from "drizzle-orm";
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { sapToEpc } from "@/app/utils";
|
||||
import type { PortfolioGoalType } from "@/app/db/schema/portfolio";
|
||||
import { newApproachJoins, carbonSql, billsSql } from "@/lib/services/epcSources";
|
||||
|
||||
/* =======================
|
||||
Types
|
||||
|
|
@ -208,26 +209,19 @@ export async function GET(
|
|||
/* ---------- Carbon ---------- */
|
||||
CASE
|
||||
WHEN lp.id IS NOT NULL THEN lp.post_co2_emissions
|
||||
ELSE e.co2_emissions
|
||||
ELSE ${carbonSql(sql`e`)}
|
||||
END AS effective_carbon,
|
||||
|
||||
/* ---------- Bills ---------- */
|
||||
CASE
|
||||
WHEN lp.id IS NOT NULL THEN lp.post_energy_bill
|
||||
ELSE (
|
||||
e.heating_cost_current +
|
||||
e.hot_water_cost_current +
|
||||
e.lighting_cost_current +
|
||||
e.appliances_cost_current +
|
||||
e.gas_standing_charge +
|
||||
e.electricity_standing_charge -
|
||||
COALESCE(e.installed_measures_total_energy_bill_adjustment, 0)
|
||||
)
|
||||
ELSE ${billsSql(sql`e`)}
|
||||
END AS effective_bills
|
||||
|
||||
FROM property p
|
||||
LEFT JOIN property_details_epc e
|
||||
ON e.property_id = p.id
|
||||
${newApproachJoins}
|
||||
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT *
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { sql } from "drizzle-orm";
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { sapToEpc } from "@/app/utils";
|
||||
import type { PortfolioGoalType } from "@/app/db/schema/portfolio";
|
||||
import { newApproachJoins, carbonSql, billsSql } from "@/lib/services/epcSources";
|
||||
|
||||
/* =======================
|
||||
Types
|
||||
|
|
@ -150,26 +151,19 @@ export async function GET(
|
|||
/* ---------- Carbon ---------- */
|
||||
CASE
|
||||
WHEN lp.id IS NOT NULL THEN lp.post_co2_emissions
|
||||
ELSE e.co2_emissions
|
||||
ELSE ${carbonSql(sql`e`)}
|
||||
END AS effective_carbon,
|
||||
|
||||
/* ---------- Bills ---------- */
|
||||
CASE
|
||||
WHEN lp.id IS NOT NULL THEN lp.post_energy_bill
|
||||
ELSE (
|
||||
e.heating_cost_current +
|
||||
e.hot_water_cost_current +
|
||||
e.lighting_cost_current +
|
||||
e.appliances_cost_current +
|
||||
e.gas_standing_charge +
|
||||
e.electricity_standing_charge -
|
||||
COALESCE(e.installed_measures_total_energy_bill_adjustment, 0)
|
||||
)
|
||||
ELSE ${billsSql(sql`e`)}
|
||||
END AS effective_bills
|
||||
|
||||
FROM property p
|
||||
LEFT JOIN property_details_epc e
|
||||
ON e.property_id = p.id
|
||||
${newApproachJoins}
|
||||
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT *
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { db } from "@/app/db/db";
|
||||
import { property } from "@/app/db/schema/property";
|
||||
import { serializeBigInt } from "@/app/utils";
|
||||
import { resolveDetailsEpcMeta } from "@/lib/services/epcSources";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
|
|
@ -31,20 +32,21 @@ export async function GET(request: NextRequest, props: { params: Promise<{ prope
|
|||
currentValuation: true,
|
||||
},
|
||||
where: eq(property.id, BigInt(propertyId)),
|
||||
with: {
|
||||
detailsEpc: {
|
||||
columns: {
|
||||
currentEnergyDemand: true,
|
||||
co2Emissions: true,
|
||||
estimated: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!propertyMeta) {
|
||||
return new NextResponse(null, { status: 404 });
|
||||
}
|
||||
|
||||
return new NextResponse(JSON.stringify(propertyMeta, serializeBigInt));
|
||||
// `detailsEpc` is resolved via the cutoff: legacy properties read
|
||||
// property_details_epc; new-approach ones read epc_property +
|
||||
// property_baseline_performance. See src/lib/services/epcSources.ts.
|
||||
const detailsEpc = await resolveDetailsEpcMeta(
|
||||
propertyMeta.id,
|
||||
propertyMeta.updatedAt,
|
||||
);
|
||||
|
||||
return new NextResponse(
|
||||
JSON.stringify({ ...propertyMeta, detailsEpc }, serializeBigInt),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
import { db } from "@/app/db/db";
|
||||
import { sql, eq } from "drizzle-orm";
|
||||
import { scenario } from "@/app/db/schema/recommendations";
|
||||
import {
|
||||
NEW_APPROACH_CUTOFF,
|
||||
newApproachJoins,
|
||||
carbonSql,
|
||||
billsSql,
|
||||
energyConsumptionSql,
|
||||
estimatedSql,
|
||||
isExpiredSql,
|
||||
} from "@/lib/services/epcSources";
|
||||
|
||||
import type {
|
||||
AverageMetrics,
|
||||
|
|
@ -28,19 +37,12 @@ export async function getAverages(
|
|||
const result = await db.execute<AverageMetrics>(sql`
|
||||
SELECT
|
||||
AVG(p.current_sap_points)::float AS avg_sap,
|
||||
AVG(e.co2_emissions)::float AS avg_carbon,
|
||||
AVG(
|
||||
e.heating_cost_current +
|
||||
e.hot_water_cost_current +
|
||||
e.lighting_cost_current +
|
||||
e.appliances_cost_current +
|
||||
e.gas_standing_charge +
|
||||
e.electricity_standing_charge -
|
||||
COALESCE(e.installed_measures_total_energy_bill_adjustment, 0)
|
||||
)::float AS avg_bills,
|
||||
AVG(e.primary_energy_consumption)::float AS avg_energy_consumption
|
||||
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
|
||||
FROM property p
|
||||
LEFT JOIN property_details_epc e ON e.property_id = p.id
|
||||
${newApproachJoins}
|
||||
WHERE p.portfolio_id = ${portfolioId};
|
||||
`);
|
||||
|
||||
|
|
@ -50,18 +52,11 @@ export async function getAverages(
|
|||
export async function getTotals(portfolioId: number): Promise<TotalMetrics> {
|
||||
const result = await db.execute<TotalMetrics>(sql`
|
||||
SELECT
|
||||
SUM(e.co2_emissions)::float AS total_carbon,
|
||||
SUM(
|
||||
e.heating_cost_current +
|
||||
e.hot_water_cost_current +
|
||||
e.lighting_cost_current +
|
||||
e.appliances_cost_current +
|
||||
e.gas_standing_charge +
|
||||
e.electricity_standing_charge -
|
||||
COALESCE(e.installed_measures_total_energy_bill_adjustment, 0)
|
||||
)::float AS total_bills
|
||||
SUM(${carbonSql(sql`e`)})::float AS total_carbon,
|
||||
SUM(${billsSql(sql`e`)})::float AS total_bills
|
||||
FROM property p
|
||||
LEFT JOIN property_details_epc e ON e.property_id = p.id
|
||||
${newApproachJoins}
|
||||
WHERE p.portfolio_id = ${portfolioId};
|
||||
`);
|
||||
|
||||
|
|
@ -104,14 +99,15 @@ export async function getCountByEpcBand(
|
|||
SELECT
|
||||
COALESCE(p.current_epc_rating::text, 'Unknown') AS epc,
|
||||
COUNT(*) FILTER (
|
||||
WHERE e.estimated = false OR e.estimated IS NULL
|
||||
WHERE ${estimatedSql(sql`e`)} = false
|
||||
)::int AS actual,
|
||||
COUNT(*) FILTER (
|
||||
WHERE e.estimated = true
|
||||
WHERE ${estimatedSql(sql`e`)} = true
|
||||
)::int AS estimated
|
||||
FROM property p
|
||||
LEFT JOIN property_details_epc e
|
||||
ON e.property_id = p.id
|
||||
${newApproachJoins}
|
||||
WHERE p.portfolio_id = ${portfolioId}
|
||||
GROUP BY epc
|
||||
) q
|
||||
|
|
@ -136,10 +132,12 @@ export async function getEstimatedCounts(
|
|||
): Promise<EstimatedCounts> {
|
||||
const result = await db.execute<EstimatedCounts>(sql`
|
||||
SELECT
|
||||
SUM(CASE WHEN e.estimated = true THEN 1 ELSE 0 END)::int AS estimated,
|
||||
SUM(CASE WHEN e.estimated = false THEN 1 ELSE 0 END)::int AS actual
|
||||
FROM property_details_epc e
|
||||
WHERE e.portfolio_id = ${portfolioId};
|
||||
SUM(CASE WHEN ${estimatedSql(sql`e`)} = true THEN 1 ELSE 0 END)::int AS estimated,
|
||||
SUM(CASE WHEN ${estimatedSql(sql`e`)} = false THEN 1 ELSE 0 END)::int AS actual
|
||||
FROM property p
|
||||
LEFT JOIN property_details_epc e ON e.property_id = p.id
|
||||
${newApproachJoins}
|
||||
WHERE p.portfolio_id = ${portfolioId};
|
||||
`);
|
||||
|
||||
return result.rows[0];
|
||||
|
|
@ -163,14 +161,16 @@ export async function getExpiredEpcCount(portfolioId: number): Promise<number> {
|
|||
const result = await db.execute<{ expired: number }>(sql`
|
||||
SELECT
|
||||
SUM(
|
||||
CASE
|
||||
WHEN is_expired = true AND estimated = false
|
||||
THEN 1
|
||||
ELSE 0
|
||||
CASE
|
||||
WHEN ${isExpiredSql(sql`e`)} = true AND ${estimatedSql(sql`e`)} = false
|
||||
THEN 1
|
||||
ELSE 0
|
||||
END
|
||||
)::int AS expired
|
||||
FROM property_details_epc
|
||||
WHERE portfolio_id = ${portfolioId};
|
||||
FROM property p
|
||||
LEFT JOIN property_details_epc e ON e.property_id = p.id
|
||||
${newApproachJoins}
|
||||
WHERE p.portfolio_id = ${portfolioId};
|
||||
`);
|
||||
|
||||
return result.rows[0].expired;
|
||||
|
|
@ -186,6 +186,9 @@ export async function getLikelyDowngrades(
|
|||
JOIN property_details_epc e
|
||||
ON e.property_id = p.id
|
||||
WHERE p.portfolio_id = ${portfolioId}
|
||||
-- GAP: the SAP-05 "likely downgrade" signal has no equivalent in the new
|
||||
-- pipeline (handled there via Rebaseline). Restrict to legacy properties.
|
||||
AND p.updated_at < ${NEW_APPROACH_CUTOFF}::date
|
||||
AND e.sap_05_overwritten = true
|
||||
AND p.current_sap_points IS NOT NULL
|
||||
AND e.sap_05_score IS NOT NULL
|
||||
|
|
|
|||
|
|
@ -13,14 +13,16 @@ import { surveyDB } from "@/app/db/surveyDB/connection";
|
|||
import {
|
||||
Feature,
|
||||
GeneralFeature,
|
||||
PropertyDetailsEpc,
|
||||
PropertyDetailsSpatial,
|
||||
PropertyMeta,
|
||||
propertyDetailsEpc,
|
||||
propertyDetailsSpatial,
|
||||
NonIntrusiveSurveyData,
|
||||
nonInstrusiveSurvey,
|
||||
} from "@/app/db/schema/property";
|
||||
import {
|
||||
ConditionReport,
|
||||
resolveConditionReport,
|
||||
} from "@/lib/services/epcSources";
|
||||
import { getRating } from "@/app/utils";
|
||||
import { eq, desc, and } from "drizzle-orm";
|
||||
import {
|
||||
|
|
@ -250,16 +252,11 @@ export async function getPropertyMeta(
|
|||
|
||||
export async function getConditionReport(
|
||||
propertyId: string
|
||||
): Promise<PropertyDetailsEpc> {
|
||||
const data = await db.query.propertyDetailsEpc.findFirst({
|
||||
where: eq(propertyDetailsEpc.propertyId, BigInt(propertyId)),
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
|
||||
return data;
|
||||
): Promise<ConditionReport> {
|
||||
// Branches on the new-approach cutoff: legacy properties read
|
||||
// property_details_epc; new-approach ones read the epc_property graph +
|
||||
// property_baseline_performance. See src/lib/services/epcSources.ts.
|
||||
return resolveConditionReport(propertyId);
|
||||
}
|
||||
|
||||
export async function getSpatialData(
|
||||
|
|
@ -298,7 +295,7 @@ export async function getNonIntrusiveSurvey(
|
|||
}
|
||||
|
||||
export function formatGeneralFeatures(
|
||||
conditionReportData: PropertyDetailsEpc,
|
||||
conditionReportData: ConditionReport,
|
||||
propertyType: string
|
||||
): GeneralFeature[] {
|
||||
// if a property is a flat/maisonette, we show heat loss coridoor information, otherwise we won't show it
|
||||
|
|
@ -353,7 +350,7 @@ export function formatGeneralFeatures(
|
|||
}
|
||||
|
||||
export function formatRetrofitFeatures(
|
||||
conditionReportData: PropertyDetailsEpc
|
||||
conditionReportData: ConditionReport
|
||||
): Feature[] {
|
||||
// Helper function to convert rating number to string
|
||||
|
||||
|
|
@ -437,7 +434,7 @@ export function formatRetrofitFeatures(
|
|||
}
|
||||
|
||||
export function formatHeatDemandFeatures(
|
||||
conditionReportData: PropertyDetailsEpc
|
||||
conditionReportData: ConditionReport
|
||||
): GeneralFeature[] {
|
||||
return [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -19,6 +19,14 @@ import {
|
|||
ScenarioSelect,
|
||||
} from "@/app/db/schema/recommendations";
|
||||
import { sql } from "drizzle-orm";
|
||||
import {
|
||||
newApproachJoins,
|
||||
carbonSql,
|
||||
totalFloorAreaSql,
|
||||
lodgementDateSql,
|
||||
isExpiredSql,
|
||||
mainfuelSql,
|
||||
} from "@/lib/services/epcSources";
|
||||
import {
|
||||
FilterGroups,
|
||||
PropertyFilter,
|
||||
|
|
@ -528,8 +536,11 @@ function buildConditionSql(filter: PropertyFilter): ReturnType<typeof sql> | nul
|
|||
}
|
||||
|
||||
case "epcExpiryDate": {
|
||||
const expiryExpr = sql`(epc.lodgement_date + INTERVAL '10 years')`;
|
||||
const guard = sql`epc.lodgement_date IS NOT NULL`;
|
||||
// Branches on the cutoff: new-approach properties derive the lodgement
|
||||
// date from the lodged epc_property row. See epcSources.ts.
|
||||
const lodgementExpr = lodgementDateSql(sql`epc`);
|
||||
const expiryExpr = sql`(${lodgementExpr} + INTERVAL '10 years')`;
|
||||
const guard = sql`${lodgementExpr} IS NOT NULL`;
|
||||
|
||||
if (filter.operator === "date_before") {
|
||||
return sql`${guard} AND ${expiryExpr} < ${filter.value}::date`;
|
||||
|
|
@ -543,7 +554,7 @@ function buildConditionSql(filter: PropertyFilter): ReturnType<typeof sql> | nul
|
|||
if (filter.operator === "date_preset") {
|
||||
switch (filter.value) {
|
||||
case "expired":
|
||||
return sql`epc.is_expired = true`;
|
||||
return sql`${isExpiredSql(sql`epc`)} = true`;
|
||||
case "expires_this_year":
|
||||
return sql`${guard} AND EXTRACT(YEAR FROM ${expiryExpr}) = EXTRACT(YEAR FROM CURRENT_DATE)`;
|
||||
case "expires_within_1_year":
|
||||
|
|
@ -578,7 +589,9 @@ function buildConditionSql(filter: PropertyFilter): ReturnType<typeof sql> | nul
|
|||
builtForm: sql`p.built_form`,
|
||||
tenure: sql`p.tenure`,
|
||||
yearBuilt: sql`p.year_built`,
|
||||
mainfuel: sql`epc.mainfuel`,
|
||||
// GAP: new-approach properties have no main-fuel string → NULL, so they
|
||||
// only match the "no value" filter branch. See epcSources.ts.
|
||||
mainfuel: mainfuelSql(sql`epc`),
|
||||
};
|
||||
const col = colMap[filter.field];
|
||||
|
||||
|
|
@ -616,18 +629,20 @@ function buildConditionSql(filter: PropertyFilter): ReturnType<typeof sql> | nul
|
|||
case "floorArea": {
|
||||
const n = parseFloat(filter.value);
|
||||
if (isNaN(n)) return null;
|
||||
if (filter.operator === "num_gte") return sql`epc.total_floor_area >= ${n}`;
|
||||
if (filter.operator === "num_lte") return sql`epc.total_floor_area <= ${n}`;
|
||||
if (filter.operator === "num_equals") return sql`epc.total_floor_area = ${n}`;
|
||||
const area = totalFloorAreaSql(sql`epc`);
|
||||
if (filter.operator === "num_gte") return sql`${area} >= ${n}`;
|
||||
if (filter.operator === "num_lte") return sql`${area} <= ${n}`;
|
||||
if (filter.operator === "num_equals") return sql`${area} = ${n}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
case "co2Emissions": {
|
||||
const n = parseFloat(filter.value);
|
||||
if (isNaN(n)) return null;
|
||||
if (filter.operator === "num_gte") return sql`epc.co2_emissions >= ${n}`;
|
||||
if (filter.operator === "num_lte") return sql`epc.co2_emissions <= ${n}`;
|
||||
if (filter.operator === "num_equals") return sql`epc.co2_emissions = ${n}`;
|
||||
const carbon = carbonSql(sql`epc`);
|
||||
if (filter.operator === "num_gte") return sql`${carbon} >= ${n}`;
|
||||
if (filter.operator === "num_lte") return sql`${carbon} <= ${n}`;
|
||||
if (filter.operator === "num_equals") return sql`${carbon} = ${n}`;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -666,6 +681,7 @@ export async function getPropertiesCount(
|
|||
SELECT COUNT(DISTINCT p.id)::int AS count
|
||||
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
|
||||
WHERE p.portfolio_id = ${portfolioId}
|
||||
${combinedWhere}
|
||||
|
|
@ -706,11 +722,11 @@ export async function getProperties(
|
|||
p.built_form AS "builtForm",
|
||||
p.tenure AS tenure,
|
||||
p.year_built AS "yearBuilt",
|
||||
epc.lodgement_date::text AS "epcLodgementDate",
|
||||
epc.is_expired AS "epcIsExpired",
|
||||
epc.total_floor_area AS "totalFloorArea",
|
||||
epc.co2_emissions AS "co2Emissions",
|
||||
epc.mainfuel AS mainfuel,
|
||||
${lodgementDateSql(sql`epc`)}::text AS "epcLodgementDate",
|
||||
${isExpiredSql(sql`epc`)} AS "epcIsExpired",
|
||||
${totalFloorAreaSql(sql`epc`)} AS "totalFloorArea",
|
||||
${carbonSql(sql`epc`)} AS "co2Emissions",
|
||||
${mainfuelSql(sql`epc`)} AS mainfuel,
|
||||
p.lexiscore AS lexiscore
|
||||
FROM property p
|
||||
LEFT JOIN property_targets t
|
||||
|
|
@ -728,6 +744,7 @@ export async function getProperties(
|
|||
AND r.already_installed = false
|
||||
LEFT JOIN property_details_epc epc
|
||||
ON epc.property_id = p.id
|
||||
${newApproachJoins}
|
||||
WHERE p.portfolio_id = ${portfolioId}
|
||||
${combinedWhere}
|
||||
GROUP BY
|
||||
|
|
@ -748,11 +765,19 @@ export async function getProperties(
|
|||
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.effective_co2_emissions_t_per_yr,
|
||||
p.lexiscore
|
||||
LIMIT ${limit} OFFSET ${offset};
|
||||
`);
|
||||
|
|
|
|||
419
src/lib/services/epcSources.ts
Normal file
419
src/lib/services/epcSources.ts
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
/**
|
||||
* EPC read-model resolver.
|
||||
*
|
||||
* The legacy plan engine wrote a flat per-property table `property_details_epc`.
|
||||
* The new modelling pipeline (Hestia-Homes/Model) does NOT write that table —
|
||||
* it writes the normalized EPC graph (`epc_property` + children), the modelling
|
||||
* output (`plan` / `recommendation`) and `property_baseline_performance`.
|
||||
*
|
||||
* A property is "new approach" when `property.updated_at >= 2026-06-01` (the
|
||||
* cutoff — the old pipeline predates it). For such properties the app must read
|
||||
* the new sources and must NOT touch `property_details_epc`. Legacy properties
|
||||
* keep reading the old table unchanged.
|
||||
*
|
||||
* This module is the single place that knows the cutoff and the field→source
|
||||
* mapping, so callers (building-passport, property-meta, portfolio list,
|
||||
* reporting + scenario metrics) branch consistently.
|
||||
*
|
||||
* Mapping applied (see the handover report for the full table and flagged gaps):
|
||||
* - EPC fabric descriptions + 1–5 ratings → `epc_energy_element`
|
||||
* - whole-dwelling counts / flags → `epc_property` (+ `epc_flat_details`)
|
||||
* - baseline carbon / energy / bills → `property_baseline_performance`
|
||||
* - post-retrofit data → `plan` / `recommendation`
|
||||
* - "estimated" flag → property has only a `source='predicted'`
|
||||
* EPC (no `source='lodged'` row)
|
||||
*
|
||||
* Gracefully-omitted gaps for new properties (no clean equivalent — reported to
|
||||
* the owner): `mainfuel`, `energyTariff`, `floorHeight`, the installed-measures
|
||||
* bill adjustment, and the SAP-05 "likely downgrade" metric.
|
||||
*/
|
||||
import { db } from "@/app/db/db";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import {
|
||||
epcProperty,
|
||||
epcEnergyElement,
|
||||
epcFlatDetails,
|
||||
propertyBaselinePerformance,
|
||||
propertyDetailsEpc,
|
||||
property,
|
||||
} from "@/app/db/schema/property";
|
||||
|
||||
/** The discriminator: properties updated on/after this date use the new sources. */
|
||||
export const NEW_APPROACH_CUTOFF = "2026-06-01";
|
||||
|
||||
export function isNewApproach(updatedAt: Date | string | null | undefined): boolean {
|
||||
if (!updatedAt) return false;
|
||||
const d = updatedAt instanceof Date ? updatedAt : new Date(updatedAt);
|
||||
return d >= new Date(`${NEW_APPROACH_CUTOFF}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Shared SQL fragments for set-based aggregate queries.
|
||||
//
|
||||
// These assume the standard aliases:
|
||||
// p = property
|
||||
// bp = property_baseline_performance (new — baseline carbon/energy/bills)
|
||||
// epl = epc_property WHERE source='lodged'
|
||||
// epp = epc_property WHERE source='predicted'
|
||||
// and take the legacy `property_details_epc` alias as a fragment so each query
|
||||
// can keep its own (`e` vs `epc`).
|
||||
//
|
||||
// Add `newApproachJoins` to any query that uses one of the *Sql expressions.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Alias = ReturnType<typeof sql>;
|
||||
|
||||
/** TRUE for new-approach rows. */
|
||||
export const isNewApproachSql = sql`p.updated_at >= ${NEW_APPROACH_CUTOFF}::date`;
|
||||
|
||||
/** LEFT JOINs that expose the new sources alongside the legacy table. */
|
||||
export const newApproachJoins = sql`
|
||||
LEFT JOIN property_baseline_performance bp ON bp.property_id = p.id
|
||||
LEFT JOIN epc_property epl ON epl.property_id = p.id AND epl.source = 'lodged'
|
||||
LEFT JOIN epc_property epp ON epp.property_id = p.id AND epp.source = 'predicted'
|
||||
`;
|
||||
|
||||
/** Baseline carbon (t CO₂/yr). New: effective performance. Legacy: e.co2_emissions. */
|
||||
export const carbonSql = (e: Alias) =>
|
||||
sql`CASE WHEN ${isNewApproachSql} THEN bp.effective_co2_emissions_t_per_yr ELSE ${e}.co2_emissions END`;
|
||||
|
||||
/** Primary energy intensity (kWh/m²/yr). New: effective performance. Legacy: e.primary_energy_consumption. */
|
||||
export const energyConsumptionSql = (e: Alias) =>
|
||||
sql`CASE WHEN ${isNewApproachSql} THEN bp.effective_primary_energy_intensity_kwh_per_m2_yr ELSE ${e}.primary_energy_consumption END`;
|
||||
|
||||
/**
|
||||
* Annual energy bill (£). New: property_baseline_performance.total_annual_bill_gbp
|
||||
* (NULL when the bill block hasn't been derived yet — excluded from AVG/SUM).
|
||||
* Legacy: the old component-sum formula incl. standing charges and the
|
||||
* installed-measures adjustment.
|
||||
*/
|
||||
export const billsSql = (e: Alias) =>
|
||||
sql`CASE WHEN ${isNewApproachSql} THEN bp.total_annual_bill_gbp ELSE (
|
||||
${e}.heating_cost_current +
|
||||
${e}.hot_water_cost_current +
|
||||
${e}.lighting_cost_current +
|
||||
${e}.appliances_cost_current +
|
||||
${e}.gas_standing_charge +
|
||||
${e}.electricity_standing_charge -
|
||||
COALESCE(${e}.installed_measures_total_energy_bill_adjustment, 0)
|
||||
) END`;
|
||||
|
||||
/** Total floor area (m²). New: lodged epc_property. Legacy: e.total_floor_area. */
|
||||
export const totalFloorAreaSql = (e: Alias) =>
|
||||
sql`CASE WHEN ${isNewApproachSql} THEN epl.total_floor_area_m2 ELSE ${e}.total_floor_area END`;
|
||||
|
||||
/** EPC lodgement date. New: lodged epc_property registration/inspection date. Legacy: e.lodgement_date. */
|
||||
export const lodgementDateSql = (e: Alias) =>
|
||||
sql`CASE WHEN ${isNewApproachSql} THEN COALESCE(epl.registration_date, epl.inspection_date) ELSE ${e}.lodgement_date END`;
|
||||
|
||||
/**
|
||||
* "estimated" boolean. New: the property only has a predicted EPC (no lodged
|
||||
* row). Legacy: e.estimated.
|
||||
*/
|
||||
export const estimatedSql = (e: Alias) =>
|
||||
sql`CASE WHEN ${isNewApproachSql} THEN (epl.id IS NULL AND epp.id IS NOT NULL) ELSE COALESCE(${e}.estimated, false) END`;
|
||||
|
||||
/**
|
||||
* "expired" boolean. New: derived from the lodged EPC age (>10 years), since the
|
||||
* new graph stores no is_expired flag. Legacy: e.is_expired.
|
||||
*/
|
||||
export const isExpiredSql = (e: Alias) =>
|
||||
sql`CASE WHEN ${isNewApproachSql} THEN (epl.registration_date IS NOT NULL AND epl.registration_date < (CURRENT_DATE - INTERVAL '10 years')) ELSE ${e}.is_expired END`;
|
||||
|
||||
/** Main fuel. GAP for new properties (no clean equivalent) → NULL. Legacy: e.mainfuel. */
|
||||
export const mainfuelSql = (e: Alias) =>
|
||||
sql`CASE WHEN ${isNewApproachSql} THEN NULL ELSE ${e}.mainfuel END`;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Per-property resolvers (TypeScript object building).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The subset of the old `PropertyDetailsEpc` shape that the building-passport
|
||||
* screens actually consume. Both the legacy row and the new graph map into this.
|
||||
*/
|
||||
export interface ConditionReport {
|
||||
totalFloorArea: number | null;
|
||||
// fabric descriptions + 1–5 ratings
|
||||
walls: string | null;
|
||||
wallsRating: number | null;
|
||||
roof: string | null;
|
||||
roofRating: number | null;
|
||||
windows: string | null;
|
||||
windowsRating: number | null;
|
||||
heating: string | null;
|
||||
heatingRating: number | null;
|
||||
heatingControls: string | null;
|
||||
heatingControlsRating: number | null;
|
||||
hotWater: string | null;
|
||||
hotWaterRating: number | null;
|
||||
lighting: string | null;
|
||||
lightingRating: number | null;
|
||||
floor: string | null;
|
||||
floorRating: number | null;
|
||||
ventilation: string | null;
|
||||
mainfuel: string | null;
|
||||
solarPv: number | null;
|
||||
solarHotWater: boolean | null;
|
||||
windTurbine: number | null;
|
||||
// general features
|
||||
heatLossCorridor: boolean | null;
|
||||
unheatedCorridorLength: number | null;
|
||||
numberStoreys: number | null;
|
||||
floorHeight: number | null;
|
||||
numberHeatedRooms: number | null;
|
||||
numberOpenFireplaces: number | null;
|
||||
numberExtensions: number | null;
|
||||
mainsGas: boolean | null;
|
||||
energyTariff: string | null;
|
||||
// heat demand + costs
|
||||
currentEnergyDemand: number | null;
|
||||
primaryEnergyConsumption: number | null;
|
||||
co2Emissions: number | null;
|
||||
heatingEnergyCostCurrent: number | null;
|
||||
hotWaterEnergyCostCurrent: number | null;
|
||||
lightingEnergyCostCurrent: number | null;
|
||||
appliancesEnergyCostCurrent: number | null;
|
||||
// estimated-data banner
|
||||
estimated: boolean | null;
|
||||
}
|
||||
|
||||
/** Map an `epc_energy_element.energy_efficiency_rating` to a safe 1–5 (or null). */
|
||||
function safeRating(r: number | null | undefined): number | null {
|
||||
return r != null && r >= 1 && r <= 5 ? r : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a property's condition report from the new EPC graph. Prefers the
|
||||
* lodged EPC; falls back to predicted (in which case `estimated = true`).
|
||||
*/
|
||||
async function resolveNewConditionReport(
|
||||
propertyId: bigint,
|
||||
): Promise<ConditionReport | null> {
|
||||
const lodged = await db.query.epcProperty.findFirst({
|
||||
where: and(
|
||||
eq(epcProperty.propertyId, propertyId),
|
||||
eq(epcProperty.source, "lodged"),
|
||||
),
|
||||
});
|
||||
const chosen =
|
||||
lodged ??
|
||||
(await db.query.epcProperty.findFirst({
|
||||
where: and(
|
||||
eq(epcProperty.propertyId, propertyId),
|
||||
eq(epcProperty.source, "predicted"),
|
||||
),
|
||||
}));
|
||||
|
||||
if (!chosen) return null;
|
||||
|
||||
const [elements, flat, baseline] = await Promise.all([
|
||||
db.query.epcEnergyElement.findMany({
|
||||
where: eq(epcEnergyElement.epcPropertyId, chosen.id),
|
||||
}),
|
||||
db.query.epcFlatDetails.findFirst({
|
||||
where: eq(epcFlatDetails.epcPropertyId, chosen.id),
|
||||
}),
|
||||
db.query.propertyBaselinePerformance.findFirst({
|
||||
where: eq(propertyBaselinePerformance.propertyId, propertyId),
|
||||
}),
|
||||
]);
|
||||
|
||||
const byType = (t: string) => elements.find((el) => el.elementType === t);
|
||||
const desc = (t: string) => byType(t)?.description ?? null;
|
||||
const rating = (t: string) => safeRating(byType(t)?.energyEfficiencyRating);
|
||||
|
||||
const sumKwh = baseline
|
||||
? (baseline.heatingKwh ?? baseline.spaceHeatingKwh ?? 0) +
|
||||
(baseline.hotWaterKwh ?? baseline.waterHeatingKwh ?? 0) +
|
||||
(baseline.lightingKwh ?? 0) +
|
||||
(baseline.appliancesKwh ?? 0) +
|
||||
(baseline.cookingKwh ?? 0) +
|
||||
(baseline.pumpsFansKwh ?? 0) +
|
||||
(baseline.coolingKwh ?? 0)
|
||||
: null;
|
||||
|
||||
return {
|
||||
totalFloorArea: chosen.totalFloorAreaM2 ?? null,
|
||||
walls: desc("wall"),
|
||||
wallsRating: rating("wall"),
|
||||
roof: desc("roof"),
|
||||
roofRating: rating("roof"),
|
||||
windows: desc("window"),
|
||||
windowsRating: rating("window"),
|
||||
heating: desc("main_heating"),
|
||||
heatingRating: rating("main_heating"),
|
||||
heatingControls: desc("main_heating_controls"),
|
||||
heatingControlsRating: rating("main_heating_controls"),
|
||||
hotWater: desc("hot_water"),
|
||||
hotWaterRating: rating("hot_water"),
|
||||
lighting: desc("lighting"),
|
||||
lightingRating: rating("lighting"),
|
||||
floor: desc("floor"),
|
||||
floorRating: rating("floor"),
|
||||
ventilation: chosen.ventilationType ?? null,
|
||||
// GAP: no clean main-fuel string in the new graph (jsonb on
|
||||
// epc_main_heating_detail) — omitted for new properties.
|
||||
mainfuel: null,
|
||||
solarPv: chosen.photovoltaicArray ? 1 : 0,
|
||||
solarHotWater: chosen.solarWaterHeating ?? null,
|
||||
windTurbine: chosen.energyWindTurbinesCount ?? null,
|
||||
heatLossCorridor:
|
||||
flat?.heatLossCorridor != null ? flat.heatLossCorridor > 0 : null,
|
||||
unheatedCorridorLength: flat?.unheatedCorridorLengthM ?? null,
|
||||
numberStoreys: chosen.numberOfStoreys ?? null,
|
||||
// GAP: no single dwelling floor height (only per-building-part) — omitted.
|
||||
floorHeight: null,
|
||||
numberHeatedRooms: chosen.heatedRoomsCount ?? null,
|
||||
numberOpenFireplaces: chosen.openChimneysCount ?? null,
|
||||
numberExtensions: chosen.extensionsCount ?? null,
|
||||
mainsGas: chosen.energyMainsGas ?? null,
|
||||
// GAP: no energy tariff in the new graph — omitted.
|
||||
energyTariff: null,
|
||||
currentEnergyDemand: sumKwh,
|
||||
primaryEnergyConsumption:
|
||||
baseline?.effectivePrimaryEnergyIntensityKwhPerM2Yr ?? null,
|
||||
co2Emissions: baseline?.effectiveCo2EmissionsTPerYr ?? null,
|
||||
heatingEnergyCostCurrent: baseline?.heatingCostGbp ?? null,
|
||||
hotWaterEnergyCostCurrent: baseline?.hotWaterCostGbp ?? null,
|
||||
lightingEnergyCostCurrent: baseline?.lightingCostGbp ?? null,
|
||||
appliancesEnergyCostCurrent: baseline?.appliancesCostGbp ?? null,
|
||||
estimated: !lodged,
|
||||
};
|
||||
}
|
||||
|
||||
/** Map a legacy `property_details_epc` row into the `ConditionReport` shape. */
|
||||
function legacyConditionReport(row: Record<string, unknown>): ConditionReport {
|
||||
const r = row as Partial<ConditionReport>;
|
||||
return {
|
||||
totalFloorArea: r.totalFloorArea ?? null,
|
||||
walls: r.walls ?? null,
|
||||
wallsRating: r.wallsRating ?? null,
|
||||
roof: r.roof ?? null,
|
||||
roofRating: r.roofRating ?? null,
|
||||
windows: r.windows ?? null,
|
||||
windowsRating: r.windowsRating ?? null,
|
||||
heating: r.heating ?? null,
|
||||
heatingRating: r.heatingRating ?? null,
|
||||
heatingControls: r.heatingControls ?? null,
|
||||
heatingControlsRating: r.heatingControlsRating ?? null,
|
||||
hotWater: r.hotWater ?? null,
|
||||
hotWaterRating: r.hotWaterRating ?? null,
|
||||
lighting: r.lighting ?? null,
|
||||
lightingRating: r.lightingRating ?? null,
|
||||
floor: r.floor ?? null,
|
||||
floorRating: r.floorRating ?? null,
|
||||
ventilation: r.ventilation ?? null,
|
||||
mainfuel: r.mainfuel ?? null,
|
||||
solarPv: r.solarPv ?? null,
|
||||
solarHotWater: r.solarHotWater ?? null,
|
||||
windTurbine: r.windTurbine ?? null,
|
||||
heatLossCorridor: r.heatLossCorridor ?? null,
|
||||
unheatedCorridorLength: r.unheatedCorridorLength ?? null,
|
||||
numberStoreys: r.numberStoreys ?? null,
|
||||
floorHeight: r.floorHeight ?? null,
|
||||
numberHeatedRooms: r.numberHeatedRooms ?? null,
|
||||
numberOpenFireplaces: r.numberOpenFireplaces ?? null,
|
||||
numberExtensions: r.numberExtensions ?? null,
|
||||
mainsGas: r.mainsGas ?? null,
|
||||
energyTariff: r.energyTariff ?? null,
|
||||
currentEnergyDemand: r.currentEnergyDemand ?? null,
|
||||
primaryEnergyConsumption: r.primaryEnergyConsumption ?? null,
|
||||
co2Emissions: r.co2Emissions ?? null,
|
||||
heatingEnergyCostCurrent: r.heatingEnergyCostCurrent ?? null,
|
||||
hotWaterEnergyCostCurrent: r.hotWaterEnergyCostCurrent ?? null,
|
||||
lightingEnergyCostCurrent: r.lightingEnergyCostCurrent ?? null,
|
||||
appliancesEnergyCostCurrent: r.appliancesEnergyCostCurrent ?? null,
|
||||
estimated: r.estimated ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a property's condition report, branching on the cutoff. New-approach
|
||||
* properties never touch `property_details_epc`.
|
||||
*/
|
||||
export async function resolveConditionReport(
|
||||
propertyId: string,
|
||||
): Promise<ConditionReport> {
|
||||
const id = BigInt(propertyId);
|
||||
const prop = await db.query.property.findFirst({
|
||||
columns: { updatedAt: true },
|
||||
where: eq(property.id, id),
|
||||
});
|
||||
|
||||
if (prop && isNewApproach(prop.updatedAt)) {
|
||||
const resolved = await resolveNewConditionReport(id);
|
||||
if (!resolved) {
|
||||
throw new Error(
|
||||
`No epc_property graph found for new-approach property ${propertyId}`,
|
||||
);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
const legacy = await db.query.propertyDetailsEpc.findFirst({
|
||||
where: eq(propertyDetailsEpc.propertyId, id),
|
||||
});
|
||||
if (!legacy) {
|
||||
throw new Error(`No property_details_epc row for property ${propertyId}`);
|
||||
}
|
||||
return legacyConditionReport(legacy as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/** The minimal EPC meta the `/api/property-meta` route embeds as `detailsEpc`. */
|
||||
export interface DetailsEpcMeta {
|
||||
currentEnergyDemand: number | null;
|
||||
co2Emissions: number | null;
|
||||
estimated: boolean;
|
||||
}
|
||||
|
||||
/** Resolve the `detailsEpc` meta block, branching on the cutoff. */
|
||||
export async function resolveDetailsEpcMeta(
|
||||
propertyId: bigint,
|
||||
updatedAt: Date | string | null | undefined,
|
||||
): Promise<DetailsEpcMeta> {
|
||||
if (isNewApproach(updatedAt)) {
|
||||
const [lodged, predicted, baseline] = await Promise.all([
|
||||
db.query.epcProperty.findFirst({
|
||||
columns: { id: true },
|
||||
where: and(
|
||||
eq(epcProperty.propertyId, propertyId),
|
||||
eq(epcProperty.source, "lodged"),
|
||||
),
|
||||
}),
|
||||
db.query.epcProperty.findFirst({
|
||||
columns: { id: true },
|
||||
where: and(
|
||||
eq(epcProperty.propertyId, propertyId),
|
||||
eq(epcProperty.source, "predicted"),
|
||||
),
|
||||
}),
|
||||
db.query.propertyBaselinePerformance.findFirst({
|
||||
where: eq(propertyBaselinePerformance.propertyId, propertyId),
|
||||
}),
|
||||
]);
|
||||
|
||||
const currentEnergyDemand = baseline
|
||||
? (baseline.heatingKwh ?? baseline.spaceHeatingKwh ?? 0) +
|
||||
(baseline.hotWaterKwh ?? baseline.waterHeatingKwh ?? 0) +
|
||||
(baseline.lightingKwh ?? 0) +
|
||||
(baseline.appliancesKwh ?? 0)
|
||||
: null;
|
||||
|
||||
return {
|
||||
currentEnergyDemand,
|
||||
co2Emissions: baseline?.effectiveCo2EmissionsTPerYr ?? null,
|
||||
estimated: !lodged && !!predicted,
|
||||
};
|
||||
}
|
||||
|
||||
const legacy = await db.query.propertyDetailsEpc.findFirst({
|
||||
columns: { currentEnergyDemand: true, co2Emissions: true, estimated: true },
|
||||
where: eq(propertyDetailsEpc.propertyId, propertyId),
|
||||
});
|
||||
return {
|
||||
currentEnergyDemand: legacy?.currentEnergyDemand ?? null,
|
||||
co2Emissions: legacy?.co2Emissions ?? null,
|
||||
estimated: legacy?.estimated ?? false,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue