From fc2f7d5b30ffb9fc7bc2b6beb3fa398375368262 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 10 Jul 2026 23:51:40 +0000 Subject: [PATCH] feat(portfolio): resolve Main Fuel column + filter via override precedence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 4. The new EPC graph exposes no main-fuel string (documented gap), so for new-approach properties the landlord override is the only source. resolveMainFuel returns the raw value (override → legacy EPC → 'Unknown'); the cell's resolveEnumLabel folds it to a chip. MAINFUEL_OPTIONS.dbValues extended with the override vocabulary so overridden fuels are both displayable and filterable — guarded by a coverage test over MainFuelValues. resolvedMainFuelSql mirrors it. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/portfolio/[slug]/utils.ts | 18 ++++--- src/app/utils/propertyFilters.ts | 21 +++++--- src/lib/services/descriptorResolution.test.ts | 52 +++++++++++++++++++ src/lib/services/descriptorResolution.ts | 20 +++++++ src/lib/services/epcSources.ts | 17 ++++++ 5 files changed, 114 insertions(+), 14 deletions(-) diff --git a/src/app/portfolio/[slug]/utils.ts b/src/app/portfolio/[slug]/utils.ts index b6b2859e..7d32dd53 100644 --- a/src/app/portfolio/[slug]/utils.ts +++ b/src/app/portfolio/[slug]/utils.ts @@ -25,7 +25,6 @@ import { totalFloorAreaSql, lodgementDateSql, isExpiredSql, - mainfuelSql, effectiveSapSql, effectiveEpcBandSql, lodgedEpcBandSql, @@ -37,6 +36,8 @@ import { resolvedBuiltFormSql, constructionAgeOverrideJoin, resolvedConstructionAgeSql, + mainFuelOverrideJoin, + resolvedMainFuelSql, } from "@/lib/services/epcSources"; import { FilterGroups, @@ -607,9 +608,10 @@ function buildConditionSql(filter: PropertyFilter): ReturnType | nul tenure: sql`p.tenure`, yearBuilt: resolvedConstructionAgeSql, provenance: provenanceSignalSql, - // 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`), + // Override-resolved (ADR-0012): the new EPC graph has no main fuel, so a + // new-approach property matches a fuel filter only via a landlord override; + // otherwise it resolves to 'Unknown'. + mainfuel: resolvedMainFuelSql(sql`epc`), }; const col = colMap[filter.field]; @@ -734,7 +736,8 @@ export async function getPropertiesCount( ${newApproachJoins} ${propertyTypeOverrideJoin} ${builtFormOverrideJoin} - ${constructionAgeOverrideJoin}` + ${constructionAgeOverrideJoin} + ${mainFuelOverrideJoin}` : sql``; const planJoin = needsPlanJoin ? sql`LEFT JOIN LATERAL ( @@ -816,7 +819,9 @@ export async function getProperties( ${isExpiredSql(sql`epc`)} AS "epcIsExpired", ${totalFloorAreaSql(sql`epc`)} AS "totalFloorArea", ${carbonSql(sql`epc`)} AS "co2Emissions", - ${mainfuelSql(sql`epc`)} AS mainfuel, + -- Main Fuel: override → EPC-derived → 'Unknown'. The new EPC graph has no + -- main fuel, so overrides are the only new-approach source (ADR-0012). + ${resolvedMainFuelSql(sql`epc`)} AS mainfuel, p.lexiscore AS lexiscore FROM property p -- LATERAL one-row lookups keep this query at "N properties × index probe" @@ -855,6 +860,7 @@ export async function getProperties( ${propertyTypeOverrideJoin} ${builtFormOverrideJoin} ${constructionAgeOverrideJoin} + ${mainFuelOverrideJoin} WHERE p.portfolio_id = ${portfolioId} -- Unmatched (no-UPRN) properties live in the "Needs attention" tab until -- resolved, so they're excluded from the main table. diff --git a/src/app/utils/propertyFilters.ts b/src/app/utils/propertyFilters.ts index b1db3479..792d4d0b 100644 --- a/src/app/utils/propertyFilters.ts +++ b/src/app/utils/propertyFilters.ts @@ -119,19 +119,24 @@ export const YEAR_BUILT_OPTIONS: EnumOption[] = [ "2007–2011", "2012–2022", "2023 onwards", "Unknown", ].map((label) => ({ label, dbValues: [label] })); +// dbValues fold two vocabularies onto one user-facing label: the raw EPC +// mainfuel strings (legacy) AND the landlord main_fuel override values +// ("mains gas", "LPG (bulk)", … — see MainFuelValues). resolvedMainFuelSql emits +// whichever applies; both must resolve to the same chip (ADR-0012). A +// descriptorResolution.test asserts every override value is covered here. export const MAINFUEL_OPTIONS: EnumOption[] = [ - { label: "Mains Gas", dbValues: ["Gas mains gas", "Mains gas community", "Mains gas not community", "Mains gas this is for backwards compatibility only and should not be used"] }, - { label: "LPG", dbValues: ["Bottled lpg", "Lpg community", "Lpg not community", "Lpg this is for backwards compatibility only and should not be used"] }, - { label: "Oil", dbValues: ["Oil heating oil", "Oil community", "Oil not community", "Oil this is for backwards compatibility only and should not be used"] }, - { label: "Electricity", dbValues: ["Electricity electricity unspecified tariff", "Electricity community", "Electricity not community", "Electricity this is for backwards compatibility only and should not be used"] }, - { label: "Biomass", dbValues: ["Bulk wood pellets", "Wood chips", "Wood logs", "Biomass community", "Biomass this is for backwards compatibility only and should not be used"] }, - { label: "Coal", dbValues: ["Anthracite", "House coal not community", "House coal this is for backwards compatibility only and should not be used", "Smokeless coal", "Coal community"] }, - { label: "Dual Fuel (Mineral + Wood)", dbValues: ["Dual fuel mineral wood"] }, + { label: "Mains Gas", dbValues: ["Gas mains gas", "Mains gas community", "Mains gas not community", "Mains gas this is for backwards compatibility only and should not be used", "mains gas", "mains gas (community)"] }, + { label: "LPG", dbValues: ["Bottled lpg", "Lpg community", "Lpg not community", "Lpg this is for backwards compatibility only and should not be used", "LPG (bulk)", "bottled LPG", "LPG special condition"] }, + { label: "Oil", dbValues: ["Oil heating oil", "Oil community", "Oil not community", "Oil this is for backwards compatibility only and should not be used", "oil"] }, + { label: "Electricity", dbValues: ["Electricity electricity unspecified tariff", "Electricity community", "Electricity not community", "Electricity this is for backwards compatibility only and should not be used", "electricity", "electricity (community)"] }, + { label: "Biomass", dbValues: ["Bulk wood pellets", "Wood chips", "Wood logs", "Biomass community", "Biomass this is for backwards compatibility only and should not be used", "biomass (community)", "wood logs"] }, + { label: "Coal", dbValues: ["Anthracite", "House coal not community", "House coal this is for backwards compatibility only and should not be used", "Smokeless coal", "Coal community", "house coal", "smokeless coal"] }, + { label: "Dual Fuel (Mineral + Wood)", dbValues: ["Dual fuel mineral wood", "dual fuel (mineral and wood)"] }, { label: "Biogas", dbValues: ["Biogas not community"] }, { label: "Biodiesel", dbValues: ["Heat from boilers using biodiesel from any biomass source community"] }, { label: "B30d (Biodiesel blend)", dbValues: ["B30d community"] }, { label: "B30k (Biodiesel blend)", dbValues: ["B30k not community"] }, { label: "Community Heat Network", dbValues: ["From heat network data community"] }, { label: "No Heating System", dbValues: ["To be used only when there is no heatinghotwater system", "To be used only when there is no heatinghotwater system or data is from a community network"] }, - { label: "Unknown / No Data", dbValues: ["UNKNOWN", "NO DATA!"] }, + { label: "Unknown / No Data", dbValues: ["UNKNOWN", "NO DATA!", "Unknown"] }, ]; diff --git a/src/lib/services/descriptorResolution.test.ts b/src/lib/services/descriptorResolution.test.ts index 763ee857..8839ec7f 100644 --- a/src/lib/services/descriptorResolution.test.ts +++ b/src/lib/services/descriptorResolution.test.ts @@ -2,8 +2,11 @@ import { describe, expect, it } from "vitest"; import { resolveBuiltForm, resolveConstructionAge, + resolveMainFuel, resolvePropertyType, } from "./descriptorResolution"; +import { MAINFUEL_OPTIONS } from "@/app/utils/propertyFilters"; +import { MainFuelValues } from "@/app/db/schema/landlord_overrides"; describe("resolvePropertyType", () => { it("prefers a landlord override over the EPC-derived value", () => { @@ -279,6 +282,55 @@ describe("resolveConstructionAge", () => { }); }); +describe("resolveMainFuel", () => { + it("prefers a landlord override (the only main-fuel source for new-approach)", () => { + expect( + resolveMainFuel({ + override: "mains gas", + isNewApproach: true, + legacyMainFuel: null, + }), + ).toBe("mains gas"); + }); + + it("has no EPC main fuel for new-approach, so resolves to 'Unknown' without an override", () => { + expect( + resolveMainFuel({ + override: null, + isNewApproach: true, + legacyMainFuel: "Gas mains gas", // legacy value must NOT surface for new-approach + }), + ).toBe("Unknown"); + }); + + it("reads the legacy EPC main fuel for a legacy property", () => { + expect( + resolveMainFuel({ + override: null, + isNewApproach: false, + legacyMainFuel: "Gas mains gas", + }), + ).toBe("Gas mains gas"); + }); + + it("resolves to 'Unknown' when nothing is available (never null)", () => { + expect( + resolveMainFuel({ override: null, isNewApproach: false, legacyMainFuel: null }), + ).toBe("Unknown"); + }); +}); + +describe("MAINFUEL_OPTIONS coverage", () => { + // Every landlord main-fuel override value must fold into a filter option, or an + // overridden fuel would display but be unfilterable (ADR-0012). + it("covers every landlord main-fuel override value", () => { + const covered = new Set(MAINFUEL_OPTIONS.flatMap((o) => o.dbValues)); + for (const value of MainFuelValues) { + expect(covered).toContain(value); + } + }); +}); + /** Legacy-property helper: only year_built is set, cutoff is false. */ function legacyAge(year: string): string { return resolveConstructionAge({ diff --git a/src/lib/services/descriptorResolution.ts b/src/lib/services/descriptorResolution.ts index 5fd3a45d..cea0da3c 100644 --- a/src/lib/services/descriptorResolution.ts +++ b/src/lib/services/descriptorResolution.ts @@ -113,6 +113,26 @@ function bandLetterToLabel(letter: string): string { * a year below the earliest start is pre-1900 → band A. Mirrors the legacy branch * of resolvedConstructionAgeSql. */ +export interface MainFuelInputs { + /** property_overrides.override_value for component 'main_fuel', building_part 0. */ + override: string | null; + isNewApproach: boolean; + /** property_details_epc.mainfuel — the raw legacy EPC string. */ + legacyMainFuel: string | null; +} + +/** + * Main fuel raw value (folded to a label by the cell's resolveEnumLabel). The + * new EPC graph exposes no main-fuel string (documented gap in mainfuelSql), so + * for new-approach properties the landlord override is the only source; legacy + * reads the EPC string. Never null → 'Unknown'. Mirrors resolvedMainFuelSql. + */ +export function resolveMainFuel(i: MainFuelInputs): string { + if (i.override) return i.override; + const derived = i.isNewApproach ? null : i.legacyMainFuel; + return derived ?? "Unknown"; +} + function bucketLegacyYearToBand(yearBuilt: string | null): string | null { if (yearBuilt == null || !/^[0-9]+$/.test(yearBuilt)) return null; const year = parseInt(yearBuilt, 10); diff --git a/src/lib/services/epcSources.ts b/src/lib/services/epcSources.ts index 5f8466c3..e4743d70 100644 --- a/src/lib/services/epcSources.ts +++ b/src/lib/services/epcSources.ts @@ -319,6 +319,23 @@ export const builtFormOverrideJoin = sql` /** built_form: override → EPC-derived → 'Unknown'. Mirrors resolveBuiltForm. */ export const resolvedBuiltFormSql = sql`COALESCE(pobf.override_value, ${builtFormTypeSql}, 'Unknown')`; +/** LEFT JOIN exposing the main_fuel override snapshot (alias `pmf`). */ +export const mainFuelOverrideJoin = sql` + LEFT JOIN property_overrides pmf + ON pmf.property_id = p.id + AND pmf.override_component = 'main_fuel' + AND pmf.building_part = 0 +`; + +/** + * main fuel (raw value; folded to a label by the cell's resolveEnumLabel): + * override → EPC-derived → 'Unknown'. The new EPC graph has no main fuel, so + * mainfuelSql is NULL for new-approach — the override is the only new-approach + * source. Takes the legacy `property_details_epc` alias. Mirrors resolveMainFuel. + */ +export const resolvedMainFuelSql = (e: Alias) => + sql`COALESCE(pmf.override_value, ${mainfuelSql(e)}, 'Unknown')`; + /** * The main building part's construction age band for one epc_property alias, as * a correlated scalar subquery — properties can also have extension parts, so a