feat(portfolio): resolve Main Fuel column + filter via override precedence

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) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-10 23:51:40 +00:00
parent 558e20f2fe
commit fc2f7d5b30
5 changed files with 114 additions and 14 deletions

View file

@ -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<typeof sql> | 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.

View file

@ -119,19 +119,24 @@ export const YEAR_BUILT_OPTIONS: EnumOption[] = [
"20072011", "20122022", "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"] },
];

View file

@ -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({

View file

@ -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);

View file

@ -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