Merge remote-tracking branch 'origin/main' into feature/portfolio-tagging

# Conflicts:
#	src/app/portfolio/[slug]/components/PropertyFilters.tsx
#	src/app/portfolio/[slug]/utils.ts
#	src/app/utils/propertyFilters.ts
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-13 19:49:47 +00:00
commit 7cd33ab18d
14 changed files with 1458 additions and 107 deletions

View file

@ -206,6 +206,7 @@ _Avoid_: estimation notification, banner (those are component names)
## Flagged ambiguities
- The portfolio **Property table**'s **Property Type**, **Built Form**, **Construction Age**, **Main Fuel**, **Wall Type**, **Roof Type** and **Heating System** columns show the **landlord-override-resolved** value — the same precedence the modelling **Run filter** applies (override fact → EPC-derived → **Unknown**) — not the raw `epc_property` value or the legacy property-row column. See [ADR-0012](./docs/adr/0012-portfolio-list-resolves-descriptors-via-override-precedence.md). All seven are also **filters**, and the per-part ones (wall/roof) read the **main building part**. Wall/Roof/Heating filter by **coarse bucket** rather than the full vocabulary — their resolved values are free-text (override vocab / EPC `epc_energy_element.description` / legacy column) with hundreds of variants, so a fixed set of prefix/substring buckets (Wall → Cavity / Solid Brick / Timber Frame / Stone / …; Roof → Pitched / Flat / Room-in-Roof / …; Heating → Gas Boiler / Community / Electric Storage / Heat Pump / …) matches them, while the columns still **display the full description**. `classifyDescriptor` is the pure twin of the SQL bucketing. Two label subtleties: **Construction Age** is an RdSAP band (e.g. "19001929", "before 1900"), never a single year (it was formerly the "Year Built" column); and **Main Fuel** for new-approach properties reads the EPC's `epc_main_heating_detail.main_fuel_type` (an RdSAP `main_fuel` code or an EPR string, folded to a coarse label) beneath any landlord override — it only reads **Unknown** when neither is present.
- The UI surfaces labelled **"Current EPC"** and **"Current Efficiency State"** (property table, building-passport card) show **Effective performance**, not Lodged — despite the glossary advising against "current performance" for Effective. The label is a product choice ("current" = the property's true present-day modelled state); the underlying datum is still **Effective performance**. The **"Lodged EPC"** column/badge is the only surface showing **Lodged performance**, and only when a real certificate exists (`source = lodged`).
- "Upload" is used in the codebase to mean both the file-on-S3 and the BulkUpload row. We standardise on **BulkUpload** for the row; the file is just "the source file."
- "Onboarding" appears in some route paths (`bulk_onboarding_inputs/...`) but isn't part of this glossary — we use **BulkUpload** end-to-end.

View file

@ -0,0 +1,89 @@
# 12. The portfolio property-list resolves descriptors through the landlord-override precedence
Date: 2026-07-10
## Status
Accepted
## Context
The portfolio property-list's **Property Type**, **Built Form**, **Year Built**
and **Main Fuel** columns and filters read raw `property` row columns (and, for
main fuel, the legacy `property_details_epc` table). Those columns are NULL for
**new-approach** properties (`property.updated_at >= 2026-06-01`, the EPC-graph
cutoff — see `epcSources.ts`), so for any new portfolio the columns render blank
and the filters match nothing. The already-migrated columns (current/lodged EPC,
floor area, CO₂, provenance, expiry) route through `epcSources` fragments and
work; these four were simply never migrated.
Separately, [ADR-0008](./0008-modelling-runs-filters-to-distributor.md)
established the resolution precedence for the modelling **Run filter**:
**landlord-override fact → EPC-derived value (new-approach graph, lodged over
predicted, RdSAP codes mapped; legacy property-row fallback) → Unknown**. It is
implemented in-app inline in `previewModellingRun` and mirrored by the
distributor; divergence between the two is defined as a bug.
## Decision
The portfolio list resolves Property Type, Built Form, Construction Age and Main
Fuel through the **same override precedence as the Run filter**, so the browse
surface agrees with what a modelling run would actually select. Concretely:
- **Shared fragments, one home for the rule.** `epcSources.ts` gains
`overrideJoins` (LEFT JOINs of `property_overrides` aliased per component at
`building_part = 0`) and `resolvedPropertyTypeSql` / `resolvedBuiltFormSql` /
`resolvedConstructionBandSql` / `resolvedMainFuelSql`. Both the list query and
`previewModellingRun` consume them; the inline COALESCE in
`previewModellingRun` is refactored onto the shared fragments.
- **Never-null.** Each fragment is `COALESCE(override, epcSql, 'Unknown')`. Every
property resolves to a concrete, filterable string; the **Unknown** filter chip
matches "no resolvable value at all" — character-for-character the semantics
CONTEXT.md already gives the Run filter's Unknown. The list columns render an
explicit **Unknown** pill instead of a blank `—`.
- **Construction Age is band-native, not a year.** Override and EPC both store
the RdSAP band **letter** (`A..M`, `Unknown`); the field resolves in letter
space and displays the band **label** ("before 1900", "19001929", … "2023
onwards"). A numeric year cannot represent an `Unknown` override and misreads a
band as a precise year. The column is **renamed "Year Built" → "Construction
Age"** and its filter options become the bands, not years; legacy raw years are
bucketed into bands.
- **Main Fuel is override-only for new-approach.** The EPC graph exposes no clean
main-fuel string (a documented `epcSources` gap → NULL), so for new-approach
properties the override is the *only* source; unoverridden properties read
**Unknown**. The override vocabulary (`"mains gas"`, `"LPG (bulk)"`, …) is
folded into the existing collapsed filter labels (Mains Gas, LPG, …).
- **`tenure` is removed from the list**, not fixed. It has no override component,
and its `epc_property.tenure` values don't cleanly reconcile with the filter's
`dbValues`. It is dropped from the list column and filter only;
`resolvePropertyMeta`/`resolvePropertyDescriptors` still resolve it for the
per-property building passport, and `property.tenure` is retained.
## Alternatives considered
- **EPC-derived only, no overrides.** Smaller, and consistent with how the other
already-migrated columns behave. Rejected: the list would then disagree with
the modelling Run filter — e.g. show "Flat" for a property a landlord override
makes (and modelling treats as) "Maisonette" — reintroducing exactly the
browse-vs-model mismatch this work set out to remove.
- **Construction Age as a numeric year.** Familiar and a smaller diff. Rejected:
cannot represent an `Unknown` override (collapses to `—`, breaking the
never-null contract) and misrepresents a band as a point year.
- **Keep the precedence rule inline in `previewModellingRun` and duplicate it in
the list.** Avoids touching a working feature. Rejected: it re-creates the
two-homes divergence ADR-0008 explicitly warns against — now within the app.
## Consequences
- The count query (`getPropertiesCount`) must join `property_overrides` when any
of these fields is filtered — `EPC_JOIN_FILTER_FIELDS` (or a sibling set) is
extended. Joins are perf-sensitive (the count path already brushes Vercel's
15s limit), so the joins stay conditional on an active filter.
- Every new-approach property without a main-fuel override shows **Unknown** — a
lot of Unknown pills, but an honest signal of the EPC gap rather than a blank
implying "not applicable".
- Filter option lists grow: Property Type gains **Park home** + **Unknown**;
Built Form gains **Unknown** (keeping **Not Recorded** for the explicit
override); Main Fuel folds the override vocab into existing labels.
- `previewModellingRun` and the portfolio list are now provably in sync — the
shared fragment is the single reference implementation ADR-0008 asked for.

View file

@ -2,7 +2,10 @@ import { NextRequest, NextResponse } from "next/server";
import { getProperties, getPropertiesCount } from "@/app/portfolio/[slug]/utils";
import { FilterGroups } from "@/app/utils/propertyFilters";
const DEFAULT_LIMIT = 1000;
// The property list paginates at 7 rows/page and isn't scrolled through in bulk,
// so a small initial page keeps the (join-heavy) query fast. Export fetches its
// own larger batch on demand (see EXPORT_LIMIT in PropertyTable).
const DEFAULT_LIMIT = 250;
export async function POST(req: NextRequest) {
const body = await req.json();

View file

@ -410,11 +410,13 @@ export interface PropertyWithRelations extends Record<string, unknown> {
// Optional columns (hidden by default)
propertyType: string | null;
builtForm: string | null;
tenure: string | null;
yearBuilt: string | null;
totalFloorArea: number | null;
co2Emissions: number | null;
mainfuel: string | null;
wallType: string | null;
roofType: string | null;
heatingSystem: string | null;
lexiscore: number | null;
// Portfolio Tags on this property (ADR-0013), name-ordered; [] when untagged.
tags: PropertyTag[];

View file

@ -12,13 +12,14 @@ import {
FilterField,
FilterOperator,
DatePreset,
EnumOption,
PROPERTY_TYPE_OPTIONS,
BUILT_FORM_OPTIONS,
TENURE_OPTIONS,
YEAR_BUILT_OPTIONS,
MAINFUEL_OPTIONS,
PROVENANCE_OPTIONS,
WALL_TYPE_OPTIONS,
ROOF_TYPE_OPTIONS,
HEATING_SYSTEM_OPTIONS,
} from "@/app/utils/propertyFilters";
/* -----------------------------------------------------------------------
@ -34,12 +35,14 @@ const FIELD_OPTIONS: { value: FilterField; label: string }[] = [
{ value: "epcExpiryDate", label: "EPC Expiry Date" },
{ value: "propertyType", label: "Property Type" },
{ value: "builtForm", label: "Built Form" },
{ value: "tenure", label: "Tenure" },
{ value: "yearBuilt", label: "Year Built" },
{ value: "yearBuilt", label: "Construction Age" },
{ value: "provenance", label: "Provenance" },
{ value: "floorArea", label: "Floor Area (m²)" },
{ value: "co2Emissions", label: "CO₂ Emissions (kg/m²/yr)" },
{ value: "mainfuel", label: "Main Fuel" },
{ value: "wallType", label: "Wall Type" },
{ value: "roofType", label: "Roof Type" },
{ value: "heatingSystem", label: "Heating System" },
{ value: "tags", label: "Tags" },
];
@ -77,13 +80,18 @@ const NUM_OPERATOR_OPTIONS: { value: FilterOperator; label: string }[] = [
{ value: "num_equals", label: "= (equals)" },
];
const ENUM_FIELD_OPTIONS: Record<string, EnumOption[]> = {
propertyType: PROPERTY_TYPE_OPTIONS,
builtForm: BUILT_FORM_OPTIONS,
tenure: TENURE_OPTIONS,
yearBuilt: YEAR_BUILT_OPTIONS,
provenance: PROVENANCE_OPTIONS,
mainfuel: MAINFUEL_OPTIONS,
// Options need only a `label` for the dropdown: the exact-enum fields carry
// EnumOption[] (dbValues used server-side), the free-text descriptor fields carry
// DescriptorBucket[] (needles used server-side). Both satisfy `{ label }`.
const ENUM_FIELD_OPTIONS: Record<string, { label: string }[]> = {
propertyType: PROPERTY_TYPE_OPTIONS,
builtForm: BUILT_FORM_OPTIONS,
yearBuilt: YEAR_BUILT_OPTIONS,
provenance: PROVENANCE_OPTIONS,
mainfuel: MAINFUEL_OPTIONS,
wallType: WALL_TYPE_OPTIONS,
roofType: ROOF_TYPE_OPTIONS,
heatingSystem: HEATING_SYSTEM_OPTIONS,
};
/* -----------------------------------------------------------------------
@ -304,7 +312,7 @@ function EnumMultiDropdown({
selectedLabels,
onChange,
}: {
options: EnumOption[];
options: { label: string }[];
selectedLabels: string[];
onChange: (labels: string[]) => void;
}) {

View file

@ -22,11 +22,7 @@ import BulkUploadComingSoonModal from "@/app/components/portfolio/BulkUploadComi
import { sapToEpc } from "@/app/utils";
import { columns } from "@/app/portfolio/[slug]/components/propertyTableColumns";
import { PropertyWithRelations } from "@/app/db/schema/property";
import {
TENURE_OPTIONS,
MAINFUEL_OPTIONS,
EnumOption,
} from "@/app/utils/propertyFilters";
import { MAINFUEL_OPTIONS, EnumOption } from "@/app/utils/propertyFilters";
import {
OPTIONAL_COLUMN_IDS,
OPTIONAL_COLUMN_LABELS,
@ -62,7 +58,7 @@ import { Button } from "@/app/shadcn_components/ui/button";
/* ----------------------------------------
Export helpers
----------------------------------------- */
const EXPORT_LIMIT = 1000;
const EXPORT_LIMIT = 6000;
function resolveEnumLabel(
options: EnumOption[],
@ -86,11 +82,13 @@ function exportToCsv(data: PropertyWithRelations[]) {
"Plan Cost (£)",
"Property Type",
"Built Form",
"Tenure",
"Year Built",
"Construction Age",
"Floor Area (m²)",
"CO₂ Emissions (kg/m²/yr)",
"Main Fuel",
"Wall Type",
"Roof Type",
"Heating System",
];
const rows = data.map((p) => {
@ -120,11 +118,13 @@ function exportToCsv(data: PropertyWithRelations[]) {
p.totalRecommendationCost ? p.totalRecommendationCost.toFixed(2) : "",
p.propertyType ?? "",
p.builtForm ?? "",
resolveEnumLabel(TENURE_OPTIONS, p.tenure),
p.yearBuilt ?? "",
p.totalFloorArea != null ? p.totalFloorArea.toFixed(1) : "",
p.co2Emissions != null ? p.co2Emissions.toFixed(1) : "",
resolveEnumLabel(MAINFUEL_OPTIONS, p.mainfuel),
p.wallType ?? "",
p.roofType ?? "",
p.heatingSystem ?? "",
];
});
@ -416,7 +416,7 @@ export default function PropertyTable({
});
const totalCount = allResponse?.total ?? 0;
const DISPLAY_LIMIT = 1000;
const DISPLAY_LIMIT = 250;
const LOAD_MORE_SIZE = 100;
// ── Extra rows (lazy-loaded pages beyond the initial 1 000) ──────────────
@ -509,6 +509,31 @@ export default function PropertyTable({
if (isOnLastPage && hasMore) loadMore();
}, [isOnLastPage, hasMore, loadMore]);
// Export fetches its own batch (up to EXPORT_LIMIT) for the current filters,
// independent of the on-screen page — the display fetch is deliberately small.
const [isExporting, setIsExporting] = useState(false);
const handleExport = useCallback(async () => {
if (isExporting) return;
setIsExporting(true);
try {
const res = await fetch("/api/properties", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
portfolioId,
filters: allFilterGroups,
limit: EXPORT_LIMIT,
offset: 0,
}),
});
if (!res.ok) return;
const json: { data: PropertyWithRelations[] } = await res.json();
exportToCsv(json.data);
} finally {
setIsExporting(false);
}
}, [isExporting, portfolioId, allFilterGroups]);
/* ----------------------------------------
Delete preview state
----------------------------------------- */
@ -630,12 +655,12 @@ export default function PropertyTable({
</Tooltip>
) : (
<button
onClick={() => exportToCsv(tableData)}
disabled={isLoading || tableData.length === 0}
onClick={handleExport}
disabled={isLoading || isExporting || filteredTotal === 0}
className="flex items-center gap-1.5 h-8 px-3 rounded-lg border border-slate-200 bg-slate-100 text-xs font-semibold text-primary hover:bg-slate-200 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
<ArrowDownTrayIcon className="h-3.5 w-3.5" />
Export
{isExporting ? "Exporting…" : "Export"}
</button>
)}

View file

@ -17,11 +17,7 @@ import { cn } from "@/lib/utils";
import { PropertyWithRelations } from "@/app/db/schema/property";
import { expectedEpcRating } from "./expectedEpc";
import { X } from "lucide-react";
import {
EnumOption,
TENURE_OPTIONS,
MAINFUEL_OPTIONS,
} from "@/app/utils/propertyFilters";
import { EnumOption, MAINFUEL_OPTIONS } from "@/app/utils/propertyFilters";
import { CurrentEpcTooltip } from "./CurrentEpcTooltip";
import { TagsCell } from "./TagsCell";
import { Checkbox } from "@/app/shadcn_components/ui/checkbox";
@ -38,13 +34,6 @@ function resolveEnumLabel(
return opt?.label ?? dbValue;
}
function tenureBadgeClass(label: string): string {
if (label.toLowerCase().includes("owner")) return "bg-blue-50 text-blue-700";
if (label.toLowerCase().includes("private")) return "bg-violet-50 text-violet-700";
if (label.toLowerCase().includes("social")) return "bg-emerald-50 text-emerald-700";
return "bg-slate-100 text-slate-500";
}
function Pill({
children,
className = "bg-slate-100 text-slate-600",
@ -171,11 +160,13 @@ export const OPTIONAL_COLUMN_IDS = [
"tags",
"propertyType",
"builtForm",
"tenure",
"yearBuilt",
"totalFloorArea",
"co2Emissions",
"mainfuel",
"wallType",
"roofType",
"heatingSystem",
] as const;
export type OptionalColumnId = (typeof OPTIONAL_COLUMN_IDS)[number];
@ -184,11 +175,13 @@ const OPTIONAL_COLUMN_LABELS: Record<OptionalColumnId, string> = {
tags: "Tags",
propertyType: "Property Type",
builtForm: "Built Form",
tenure: "Tenure",
yearBuilt: "Year Built",
yearBuilt: "Construction Age",
totalFloorArea: "Floor Area (m²)",
co2Emissions: "CO₂ Emissions",
mainfuel: "Main Fuel",
wallType: "Wall Type",
roofType: "Roof Type",
heatingSystem: "Heating System",
};
export { OPTIONAL_COLUMN_LABELS };
@ -458,20 +451,10 @@ const optionalColumns: ColumnDef<PropertyWithRelations>[] = [
return val ? <Pill>{val}</Pill> : <span className="text-slate-300 text-xs"></span>;
},
},
{
id: "tenure",
accessorKey: "tenure",
header: () => <div className="text-xs">Tenure</div>,
cell: ({ row }) => {
const label = resolveEnumLabel(TENURE_OPTIONS, row.original.tenure);
if (!label) return <span className="text-slate-300 text-xs"></span>;
return <Pill className={tenureBadgeClass(label)}>{label}</Pill>;
},
},
{
id: "yearBuilt",
accessorKey: "yearBuilt",
header: () => <div className="text-xs">Year Built</div>,
header: () => <div className="text-xs">Construction Age</div>,
cell: ({ row }) => (
<div className="text-sm text-gray-700">{row.original.yearBuilt ?? "—"}</div>
),
@ -511,8 +494,42 @@ const optionalColumns: ColumnDef<PropertyWithRelations>[] = [
return label ? <Pill>{label}</Pill> : <span className="text-slate-300 text-xs"></span>;
},
},
{
id: "wallType",
accessorKey: "wallType",
header: () => <div className="text-xs">Wall Type</div>,
cell: ({ row }) => <DescriptorCell value={row.original.wallType} />,
},
{
id: "roofType",
accessorKey: "roofType",
header: () => <div className="text-xs">Roof Type</div>,
cell: ({ row }) => <DescriptorCell value={row.original.roofType} />,
},
{
id: "heatingSystem",
accessorKey: "heatingSystem",
header: () => <div className="text-xs">Heating System</div>,
cell: ({ row }) => <DescriptorCell value={row.original.heatingSystem} />,
},
];
/** Long free-text descriptor (wall/roof/heating): truncate with a full-value tooltip; muted when Unknown. */
function DescriptorCell({ value }: { value: string | null }) {
const val = value ?? "Unknown";
return (
<div
className={cn(
"text-sm max-w-[220px] truncate",
val === "Unknown" ? "text-slate-300" : "text-gray-700",
)}
title={val}
>
{val}
</div>
);
}
export const columns: ColumnDef<PropertyWithRelations>[] = [
...coreColumns,
...optionalColumns,

View file

@ -25,12 +25,25 @@ import {
totalFloorAreaSql,
lodgementDateSql,
isExpiredSql,
mainfuelSql,
effectiveSapSql,
effectiveEpcBandSql,
lodgedEpcBandSql,
lodgedSapSql,
provenanceSignalSql,
propertyTypeOverrideJoin,
resolvedPropertyTypeSql,
builtFormOverrideJoin,
resolvedBuiltFormSql,
constructionAgeOverrideJoin,
resolvedConstructionAgeSql,
mainFuelOverrideJoin,
resolvedMainFuelSql,
wallTypeOverrideJoin,
resolvedWallTypeSql,
roofTypeOverrideJoin,
resolvedRoofTypeSql,
mainHeatingOverrideJoin,
resolvedHeatingSystemSql,
} from "@/lib/services/epcSources";
import {
FilterGroups,
@ -38,18 +51,19 @@ import {
PropertyFilter,
PROPERTY_TYPE_OPTIONS,
BUILT_FORM_OPTIONS,
TENURE_OPTIONS,
YEAR_BUILT_OPTIONS,
MAINFUEL_OPTIONS,
PROVENANCE_OPTIONS,
EnumOption,
DESCRIPTOR_FILTER_CONFIG,
DescriptorBucket,
DescriptorMatch,
} from "@/app/utils/propertyFilters";
import { EPC_TO_SAP_MIN, EPC_TO_SAP_MAX } from "@/app/utils/epc";
const ENUM_FIELD_DB_OPTIONS: Record<string, EnumOption[]> = {
propertyType: PROPERTY_TYPE_OPTIONS,
builtForm: BUILT_FORM_OPTIONS,
tenure: TENURE_OPTIONS,
yearBuilt: YEAR_BUILT_OPTIONS,
provenance: PROVENANCE_OPTIONS,
mainfuel: MAINFUEL_OPTIONS,
@ -508,6 +522,50 @@ function buildEpcSapCondition(
return null;
}
/** An ILIKE pattern for a needle: prefix ("cavity%") or substring ("%mains gas%"). */
function descriptorLikePattern(needle: string, match: DescriptorMatch): string {
return match === "prefix" ? `${needle}%` : `%${needle}%`;
}
/**
* WHERE fragment matching the free-text wall/roof/heating column against the
* selected coarse buckets (mirrors classifyDescriptor). A non-empty bucket
* matches any of its needles; the "Unknown" bucket matches the complement
* rows matching NO known needle (which includes the resolved 'Unknown'
* terminal). NULL is impossible here (resolvedXSql COALESCEs to 'Unknown').
*/
function buildDescriptorBucketCondition(
col: ReturnType<typeof sql>,
buckets: DescriptorBucket[],
match: DescriptorMatch,
selectedLabels: string[],
): ReturnType<typeof sql> | null {
const allNeedles = buckets.flatMap((b) => b.needles);
const parts: ReturnType<typeof sql>[] = [];
for (const label of selectedLabels) {
const bucket = buckets.find((b) => b.label === label);
if (!bucket) continue;
if (bucket.needles.length === 0) {
// "Unknown" = the complement of every known needle.
if (allNeedles.length === 0) return null;
const nots = allNeedles.map(
(n) => sql`${col} NOT ILIKE ${descriptorLikePattern(n, match)}`,
);
parts.push(sql`(${sql.join(nots, sql` AND `)})`);
} else {
const likes = bucket.needles.map(
(n) => sql`${col} ILIKE ${descriptorLikePattern(n, match)}`,
);
parts.push(sql`(${sql.join(likes, sql` OR `)})`);
}
}
if (parts.length === 0) return null;
if (parts.length === 1) return parts[0];
return sql`(${sql.join(parts, sql` OR `)})`;
}
function buildConditionSql(filter: PropertyFilter): ReturnType<typeof sql> | null {
switch (filter.field) {
case "address":
@ -579,7 +637,6 @@ function buildConditionSql(filter: PropertyFilter): ReturnType<typeof sql> | nul
case "propertyType":
case "builtForm":
case "tenure":
case "yearBuilt":
case "provenance":
case "mainfuel": {
@ -595,14 +652,15 @@ function buildConditionSql(filter: PropertyFilter): ReturnType<typeof sql> | nul
const options = ENUM_FIELD_DB_OPTIONS[filter.field];
const colMap: Record<string, ReturnType<typeof sql>> = {
propertyType: sql`p.property_type`,
builtForm: sql`p.built_form`,
tenure: sql`p.tenure`,
yearBuilt: sql`p.year_built`,
// Override-resolved to match the column + the Run filter (ADR-0012).
propertyType: resolvedPropertyTypeSql,
builtForm: resolvedBuiltFormSql,
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];
@ -688,6 +746,36 @@ function buildConditionSql(filter: PropertyFilter): ReturnType<typeof sql> | nul
if (parts.length === 1) return parts[0];
return sql`(${sql.join(parts, sql` OR `)})`;
}
case "wallType":
case "roofType":
case "heatingSystem": {
// Free-text descriptors: bucket the resolved value by prefix/substring
// needles (ADR-0012), mirroring classifyDescriptor. The resolved SQL folds
// override → EPC element description → legacy → 'Unknown'.
if (filter.operator !== "enum_one_of") return null;
let selectedLabels: string[];
try {
selectedLabels = JSON.parse(filter.value);
} catch {
return null;
}
if (selectedLabels.length === 0) return null;
const { buckets, match } = DESCRIPTOR_FILTER_CONFIG[filter.field];
const colMap: Record<string, ReturnType<typeof sql>> = {
wallType: resolvedWallTypeSql(sql`epc`),
roofType: resolvedRoofTypeSql(sql`epc`),
heatingSystem: resolvedHeatingSystemSql(sql`epc`),
};
return buildDescriptorBucketCondition(
colMap[filter.field],
buckets,
match,
selectedLabels,
);
}
}
return null;
}
@ -727,6 +815,15 @@ const EPC_JOIN_FILTER_FIELDS = new Set<FilterField>([
"floorArea",
"epcExpiryDate",
"mainfuel",
// Override-resolved (ADR-0012): needs the EPC graph joins + the override join.
"propertyType",
"builtForm",
"yearBuilt",
// Free-text descriptors (ADR-0012): resolved SQL reads the EPC element
// description subquery + the override join.
"wallType",
"roofType",
"heatingSystem",
]);
const PLAN_JOIN_FILTER_FIELDS = new Set<FilterField>(["expectedEpc"]);
@ -752,7 +849,14 @@ export async function getPropertiesCount(
const epcJoins = needsEpcJoins
? sql`LEFT JOIN property_details_epc epc ON epc.property_id = p.id
${newApproachJoins}`
${newApproachJoins}
${propertyTypeOverrideJoin}
${builtFormOverrideJoin}
${constructionAgeOverrideJoin}
${mainFuelOverrideJoin}
${wallTypeOverrideJoin}
${roofTypeOverrideJoin}
${mainHeatingOverrideJoin}`
: sql``;
const planJoin = needsPlanJoin
? sql`LEFT JOIN LATERAL (
@ -864,15 +968,26 @@ export async function getProperties(
COALESCE(rec.cost, 0) AS "totalRecommendationCost",
p.landlord_property_id AS "landlordPropertyId",
p.original_sap_points AS "originalSapPoints",
p.property_type AS "propertyType",
p.built_form AS "builtForm",
p.tenure AS tenure,
p.year_built AS "yearBuilt",
-- Property Type is resolved through the landlord-override precedence
-- (override EPC-derived 'Unknown'), matching the modelling Run filter.
-- See resolvePropertyType / ADR-0012.
${resolvedPropertyTypeSql} AS "propertyType",
${resolvedBuiltFormSql} AS "builtForm",
-- Construction Age is band-native and override-resolved (ADR-0012); the
-- "yearBuilt" key is retained to avoid a wide rename. See resolveConstructionAge.
${resolvedConstructionAgeSql} AS "yearBuilt",
${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,
-- 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,
-- Wall / roof / heating: override EPC element description legacy 'Unknown'
-- (ADR-0012). Per-part components (wall/roof) read the main building part.
${resolvedWallTypeSql(sql`epc`)} AS "wallType",
${resolvedRoofTypeSql(sql`epc`)} AS "roofType",
${resolvedHeatingSystemSql(sql`epc`)} AS "heatingSystem",
p.lexiscore AS lexiscore,
-- Portfolio Tags on this property, name-ordered, as JSON (ADR-0013). A
-- correlated subquery keeps the main query at one row per property (a JOIN
@ -920,6 +1035,13 @@ export async function getProperties(
LEFT JOIN property_details_epc epc
ON epc.property_id = p.id
${newApproachJoins}
${propertyTypeOverrideJoin}
${builtFormOverrideJoin}
${constructionAgeOverrideJoin}
${mainFuelOverrideJoin}
${wallTypeOverrideJoin}
${roofTypeOverrideJoin}
${mainHeatingOverrideJoin}
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

@ -8,12 +8,14 @@ export type FilterField =
| "propertyRef"
| "propertyType"
| "builtForm"
| "tenure"
| "yearBuilt"
| "provenance"
| "floorArea"
| "co2Emissions"
| "mainfuel"
| "wallType"
| "roofType"
| "heatingSystem"
| "tags";
export type FilterOperator =
@ -65,13 +67,23 @@ export interface EnumOption {
dbValues: string[];
}
// dbValues are the label vocabulary emitted by resolvedPropertyTypeSql (override
// snapshot / EPC-derived label). "Park home" is reachable from an RdSAP code (4)
// or an override; "Unknown" is the never-null terminal + an explicit override —
// it selects properties with no resolvable value at all (ADR-0012).
export const PROPERTY_TYPE_OPTIONS: EnumOption[] = [
{ label: "House", dbValues: ["House"] },
{ label: "Flat", dbValues: ["Flat"] },
{ label: "Bungalow", dbValues: ["Bungalow"] },
{ label: "Maisonette", dbValues: ["Maisonette"] },
{ label: "Park home", dbValues: ["Park home"] },
{ label: "Unknown", dbValues: ["Unknown"] },
];
// dbValues are the label vocabulary emitted by resolvedBuiltFormSql (override
// snapshot / EPC-derived label). "Not Recorded" matches only an explicit
// override of that literal; "Unknown" is the never-null terminal + an explicit
// override — it selects properties with no resolvable value at all (ADR-0012).
export const BUILT_FORM_OPTIONS: EnumOption[] = [
{ label: "Detached", dbValues: ["Detached"] },
{ label: "Semi-Detached", dbValues: ["Semi-Detached"] },
@ -80,15 +92,7 @@ export const BUILT_FORM_OPTIONS: EnumOption[] = [
{ label: "Enclosed End-Terrace", dbValues: ["Enclosed End-Terrace"] },
{ label: "Enclosed Mid-Terrace", dbValues: ["Enclosed Mid-Terrace"] },
{ label: "Not Recorded", dbValues: ["Not Recorded"] },
];
export const TENURE_OPTIONS: EnumOption[] = [
{ label: "Owner-occupied", dbValues: ["Owner-occupied", "owner-occupied"] },
{ label: "Rented (Private)", dbValues: ["Rented (private)", "rental (private)", "rented (private)"] },
{ label: "Rented (Social)", dbValues: ["Rented (social)", "rental (social)"] },
{ label: "Not Defined", dbValues: ["Not defined - use in the case of a new dwelling for which the intended tenure in not known. It is not to be used for an existing dwelling"] },
{ label: "Unknown", dbValues: ["unknown"] },
{ label: "Not Recorded", dbValues: ["__null__"] },
{ label: "Unknown", dbValues: ["Unknown"] },
];
// Provenance signal (ADR-0002). dbValues are the raw signal strings emitted by
@ -99,26 +103,210 @@ export const PROVENANCE_OPTIONS: EnumOption[] = [
{ label: "Standard", dbValues: ["none"] },
];
// Construction Age is band-native (ADR-0012): labels + dbValues are the RdSAP
// band labels emitted by resolvedConstructionAgeSql. These MUST match
// CONSTRUCTION_AGE_BANDS[*].label in epcSources.ts exactly (note the en-dash);
// epcSources.test.ts guards against drift. "Unknown" is the never-null terminal.
export const YEAR_BUILT_OPTIONS: EnumOption[] = [
"1900","1930","1950","1967","1976","1983","1991","1996",
"2003","2007","2008","2009","2010","2011","2012","2013",
"2014","2015","2016","2017","2018","2019","2020","2021",
"2022","2023","2024","2025",
].map((y) => ({ label: y, dbValues: [y] }));
"before 1900", "19001929", "19301949", "19501966", "19671975",
"19761982", "19831990", "19911995", "19962002", "20032006",
"20072011", "20122022", "2023 onwards", "Unknown",
].map((label) => ({ label, dbValues: [label] }));
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"] },
// RdSAP `main_fuel` code → coarse label. The new EPC graph stores the main fuel
// as this numeric code on epc_main_heating_detail.main_fuel_type (see mainfuelSql
// / resolveMainFuel), so every code that can appear must fold into a filter
// option or an overridden/EPC-derived fuel would display but be unfilterable
// (ADR-0012). Codes are the authoritative Model `epc_codes.csv` `main_fuel` table
// (RdSAP), cross-checked against production epc rows: 26→mains gas, 29/39→electric,
// 0/20/25/31/57→"Community scheme", 5/15/33→coal, 6→wood logs, 9→dual fuel. All
// "(community)"/heat-network codes bucket to "Community Heat Network" — being on a
// communal scheme is the salient fact; the scheme's own fuel is a secondary detail.
export const RDSAP_MAIN_FUEL_CODE_LABELS: Record<string, string> = {
// Mains gas
"1": "Mains Gas", "26": "Mains Gas",
// LPG
"2": "LPG", "3": "LPG", "17": "LPG", "27": "LPG", "38": "LPG",
// Oil
"4": "Oil", "28": "Oil",
// Electricity
"10": "Electricity", "29": "Electricity", "39": "Electricity",
// Coal / mineral solid fuel (anthracite, smokeless, house coal)
"5": "Coal", "14": "Coal", "15": "Coal", "33": "Coal",
// Biomass / wood (logs, pellets, chips)
"6": "Biomass", "7": "Biomass", "8": "Biomass", "12": "Biomass", "16": "Biomass",
// Dual fuel
"9": "Dual Fuel (Mineral + Wood)",
// Biogas
"13": "Biogas", "51": "Biogas",
// Bioethanol
"19": "Bioethanol", "76": "Bioethanol",
// Biodiesel (from biomass / used cooking oil / vegetable / rape seed oil)
"34": "Biodiesel", "35": "Biodiesel", "36": "Biodiesel", "37": "Biodiesel",
"71": "Biodiesel", "72": "Biodiesel", "73": "Biodiesel", "74": "Biodiesel",
// Biodiesel blend
"18": "B30k (Biodiesel blend)", "75": "B30k (Biodiesel blend)",
// Waste combustion
"11": "Waste Combustion",
// Community / heat-network (the dwelling is on a communal scheme)
"0": "Community Heat Network", "20": "Community Heat Network", "21": "Community Heat Network",
"22": "Community Heat Network", "23": "Community Heat Network", "24": "Community Heat Network",
"25": "Community Heat Network", "30": "Community Heat Network", "31": "Community Heat Network",
"32": "Community Heat Network", "41": "Community Heat Network", "42": "Community Heat Network",
"43": "Community Heat Network", "44": "Community Heat Network", "45": "Community Heat Network",
"46": "Community Heat Network", "47": "Community Heat Network", "48": "Community Heat Network",
"49": "Community Heat Network", "50": "Community Heat Network", "52": "Community Heat Network",
"53": "Community Heat Network", "54": "Community Heat Network", "55": "Community Heat Network",
"56": "Community Heat Network", "57": "Community Heat Network", "58": "Community Heat Network",
"99": "Community Heat Network",
};
// EPR free-text main_fuel_type strings (Elmhurst/PASHub, ~5% of new-approach
// rows) folded to the same labels. The empty-string sentinel is NULLIF'd to NULL
// upstream and resolves to 'Unknown'.
export const EPR_MAIN_FUEL_STRING_LABELS: Record<string, string> = {
"Mains gas": "Mains Gas",
"Electricity": "Electricity",
"Bulk LPG": "LPG",
"Heating oil": "Oil",
};
// dbValues fold several vocabularies onto one user-facing label: the raw legacy
// EPC mainfuel strings (property_details_epc), the landlord main_fuel override
// values ("mains gas", "LPG (bulk)", … — see MainFuelValues), the new-approach
// RdSAP numeric codes (RDSAP_MAIN_FUEL_CODE_LABELS) and the EPR strings
// (EPR_MAIN_FUEL_STRING_LABELS). resolvedMainFuelSql emits whichever applies; all
// must resolve to the same chip (ADR-0012). descriptorResolution.test asserts
// every override value, RdSAP code and EPR string is covered here.
const MAINFUEL_OPTIONS_BASE: 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", "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: "Bioethanol", dbValues: [] },
{ 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: "Waste Combustion", dbValues: [] },
{ 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"] },
];
/** Fold the RdSAP code + EPR string vocabularies into each label's dbValues. */
export const MAINFUEL_OPTIONS: EnumOption[] = MAINFUEL_OPTIONS_BASE.map((opt) => {
const codes = Object.entries(RDSAP_MAIN_FUEL_CODE_LABELS)
.filter(([, label]) => label === opt.label)
.map(([code]) => code);
const strings = Object.entries(EPR_MAIN_FUEL_STRING_LABELS)
.filter(([, label]) => label === opt.label)
.map(([str]) => str);
return { ...opt, dbValues: [...opt.dbValues, ...codes, ...strings] };
});
/* -----------------------------------------------------------------------
Free-text descriptor buckets: Wall Type / Roof Type / Heating System (ADR-0012)
Unlike the enum fields above (exact dbValues), the resolved wall/roof/heating
value is a free-text description (override vocabulary, EPC
epc_energy_element.description, or legacy property_details_epc column) with
HIGH cardinality 88+ distinct EPC wall descriptions, 245 legacy, plus
insulation-thickness variants, "Average thermal transmittance …" U-values and
encoding corruption. Exact matching can't scale, so each coarse bucket is a set
of case-insensitive NEEDLES matched by prefix (wall/roof) or substring
(heating, whose fuel follows a comma). classifyDescriptor is the pure twin of
the SQL bucketing in the portfolio query's buildConditionSql keep in sync.
Ordering matters: the first bucket whose needle matches wins.
------------------------------------------------------------------------ */
export interface DescriptorBucket {
label: string;
/** Lowercase needles, matched per the field's DescriptorMatch mode. Empty = the Unknown terminal (matches the complement). */
needles: string[];
}
export type DescriptorMatch = "prefix" | "contains";
// Wall construction. Prefix-matched on the leading construction term. "Stone"
// folds sandstone/limestone + granite/whinstone; "Average thermal transmittance …"
// (a U-value, not a construction type) and corruption fall through to Unknown.
export const WALL_TYPE_OPTIONS: DescriptorBucket[] = [
{ label: "Cavity", needles: ["cavity"] },
{ label: "Solid Brick", needles: ["solid brick"] },
{ label: "Timber Frame", needles: ["timber frame"] },
{ label: "Stone", needles: ["sandstone", "granite"] },
{ label: "System Built", needles: ["system built"] },
{ label: "Park Home", needles: ["park home"] },
{ label: "Cob", needles: ["cob"] },
{ label: "Curtain Wall", needles: ["curtain"] },
{ label: "Basement", needles: ["basement"] },
{ label: "Unknown", needles: [] },
];
// Roof form. Prefix-matched. "Premises Above" folds the "(another dwelling
// above)" / "(other premises above)" family (a flat with a dwelling above it, so
// no own roof). U-values fall through to Unknown.
export const ROOF_TYPE_OPTIONS: DescriptorBucket[] = [
{ label: "Pitched", needles: ["pitched"] },
{ label: "Flat", needles: ["flat"] },
{ label: "Room-in-Roof", needles: ["roof room"] },
{ label: "Thatched", needles: ["thatched"] },
{ label: "Premises Above", needles: ["(another", "(other", "(same", "another dwelling", "another premises", "other premises"] },
{ label: "Unknown", needles: [] },
];
// Heating system. Substring-matched (the fuel follows a comma, e.g. "Boiler and
// radiators, mains gas"). Order is load-bearing: Heat Pump / Community / Solid
// Fuel / Oil / LPG are checked before the gas + electric buckets so a
// heat-pump/community/wood/oil/LPG system isn't miscaught by "gas"/"electric";
// Gas Room Heater before Gas Boiler; Electric Storage before Direct Electric.
export const HEATING_SYSTEM_OPTIONS: DescriptorBucket[] = [
{ label: "Heat Pump", needles: ["heat pump"] },
{ label: "Community", needles: ["community"] },
{ label: "Solid Fuel", needles: ["coal", "anthracite", "smokeless", "wood", "dual fuel", "biomass", "solid fuel"] },
// NB: bare "oil" is a substring of "b(oil)er" — match the fuel token only (", oil"
// as in "Boiler and radiators, oil") and the leading override "Oil room heater".
{ label: "Oil", needles: [", oil", "oil room heater"] },
{ label: "LPG", needles: ["lpg"] },
{ label: "Gas Room Heater", needles: ["gas room heater", "room heaters, mains gas", "room heaters, gas"] },
{ label: "Gas Boiler", needles: ["mains gas", "gas boiler", "gas cpsu"] },
{ label: "Electric Storage", needles: ["storage heater"] },
{ label: "Direct Electric", needles: ["electric"] },
{ label: "Unknown", needles: [] },
];
/** Filter config per descriptor field: its buckets + how needles are matched. */
export const DESCRIPTOR_FILTER_CONFIG: Record<
"wallType" | "roofType" | "heatingSystem",
{ buckets: DescriptorBucket[]; match: DescriptorMatch }
> = {
wallType: { buckets: WALL_TYPE_OPTIONS, match: "prefix" },
roofType: { buckets: ROOF_TYPE_OPTIONS, match: "prefix" },
heatingSystem: { buckets: HEATING_SYSTEM_OPTIONS, match: "contains" },
};
/**
* Classify a resolved free-text descriptor into its coarse filter bucket: the
* first bucket with a matching needle, else "Unknown". Pure twin of the SQL
* bucketing in buildConditionSql (portfolio query) keep in sync.
*/
export function classifyDescriptor(
value: string | null | undefined,
buckets: DescriptorBucket[],
match: DescriptorMatch,
): string {
if (value == null) return "Unknown";
const v = value.toLowerCase();
for (const bucket of buckets) {
for (const needle of bucket.needles) {
if (match === "prefix" ? v.startsWith(needle) : v.includes(needle)) {
return bucket.label;
}
}
}
return "Unknown";
}

View file

@ -11,7 +11,12 @@ import {
parseRunConfig,
selectionSummary,
} from "./model";
import { builtFormTypeSql, propertyTypeSql } from "@/lib/services/epcSources";
import {
builtFormOverrideJoin,
propertyTypeOverrideJoin,
resolvedBuiltFormSql,
resolvedPropertyTypeSql,
} from "@/lib/services/epcSources";
// Distributor entry point on the Model backend. It fans the run out to
// workers, attaching execution sub-tasks to the task_id we pass (ADR-0008).
@ -154,14 +159,14 @@ export async function previewModellingRun(args: {
per_scenario: { scenario_id: string; n: number }[] | null;
}>(sql`
WITH resolved AS (
-- Resolution rule shared with the portfolio list via epcSources fragments
-- so the preview and the list can never drift (ADR-0008, ADR-0012).
SELECT p.id, p.postcode,
COALESCE(pot.override_value, ${propertyTypeSql}, 'Unknown') AS ptype,
COALESCE(pobf.override_value, ${builtFormTypeSql}, 'Unknown') AS bform
${resolvedPropertyTypeSql} AS ptype,
${resolvedBuiltFormSql} AS bform
FROM property p
LEFT JOIN property_overrides pot ON pot.property_id = p.id
AND pot.override_component = 'property_type' AND pot.building_part = 0
LEFT JOIN property_overrides pobf ON pobf.property_id = p.id
AND pobf.override_component = 'built_form_type' AND pobf.building_part = 0
${propertyTypeOverrideJoin}
${builtFormOverrideJoin}
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'
WHERE p.portfolio_id = ${pid} AND p.marked_for_deletion = false

View file

@ -0,0 +1,521 @@
import { describe, expect, it } from "vitest";
import {
resolveBuiltForm,
resolveConstructionAge,
resolveMainFuel,
resolveOverrideDescriptor,
resolvePropertyType,
} from "./descriptorResolution";
import {
MAINFUEL_OPTIONS,
RDSAP_MAIN_FUEL_CODE_LABELS,
EPR_MAIN_FUEL_STRING_LABELS,
WALL_TYPE_OPTIONS,
ROOF_TYPE_OPTIONS,
HEATING_SYSTEM_OPTIONS,
DESCRIPTOR_FILTER_CONFIG,
classifyDescriptor,
} from "@/app/utils/propertyFilters";
import {
MainFuelValues,
WallTypeValues,
RoofTypeValues,
MainHeatingSystemValues,
} from "@/app/db/schema/landlord_overrides";
describe("resolvePropertyType", () => {
it("prefers a landlord override over the EPC-derived value", () => {
expect(
resolvePropertyType({
override: "Maisonette",
isNewApproach: true,
lodgedPropertyType: "2", // EPC code for Flat
lodgedDwellingType: null,
predictedPropertyType: null,
predictedDwellingType: null,
legacyPropertyType: null,
}),
).toBe("Maisonette");
});
it("maps an RdSAP property-type code to its label when there is no override", () => {
expect(
resolvePropertyType({
override: null,
isNewApproach: true,
lodgedPropertyType: "0", // RdSAP code for House
lodgedDwellingType: null,
predictedPropertyType: null,
predictedDwellingType: null,
legacyPropertyType: null,
}),
).toBe("House");
});
it("falls back to the predicted EPC when there is no lodged value", () => {
expect(
resolvePropertyType({
override: null,
isNewApproach: true,
lodgedPropertyType: null,
lodgedDwellingType: null,
predictedPropertyType: "2", // RdSAP code for Flat
predictedDwellingType: null,
legacyPropertyType: null,
}),
).toBe("Flat");
});
it("prefers the lodged EPC over the predicted EPC when both exist", () => {
expect(
resolvePropertyType({
override: null,
isNewApproach: true,
lodgedPropertyType: "0", // House
lodgedDwellingType: null,
predictedPropertyType: "2", // Flat
predictedDwellingType: null,
legacyPropertyType: null,
}),
).toBe("House");
});
it("falls back to dwelling_type when the lodged property_type is absent", () => {
expect(
resolvePropertyType({
override: null,
isNewApproach: true,
lodgedPropertyType: null,
lodgedDwellingType: "Flat",
predictedPropertyType: null,
predictedDwellingType: null,
legacyPropertyType: null,
}),
).toBe("Flat");
});
it("reads the legacy property-row value for a legacy property", () => {
expect(
resolvePropertyType({
override: null,
isNewApproach: false,
lodgedPropertyType: "0", // must be ignored for legacy
lodgedDwellingType: null,
predictedPropertyType: null,
predictedDwellingType: null,
legacyPropertyType: "Bungalow",
}),
).toBe("Bungalow");
});
it("never reads the EPC graph nor the legacy row across the cutoff (new-approach ignores the legacy row)", () => {
expect(
resolvePropertyType({
override: null,
isNewApproach: true,
lodgedPropertyType: null,
lodgedDwellingType: null,
predictedPropertyType: null,
predictedDwellingType: null,
legacyPropertyType: "Bungalow", // legacy row must NOT surface for a new-approach property
}),
).toBe("Unknown");
});
// Spec locks for decisions taken in ADR-0012 — an override always wins (even an
// explicit "Unknown"), and an unrecognised RdSAP code is passed through as-is.
it("lets an explicit 'Unknown' override win over a known EPC value", () => {
expect(
resolvePropertyType({
override: "Unknown",
isNewApproach: true,
lodgedPropertyType: "0", // EPC knows "House" — the override still wins
lodgedDwellingType: null,
predictedPropertyType: null,
predictedDwellingType: null,
legacyPropertyType: null,
}),
).toBe("Unknown");
});
it("passes an unrecognised RdSAP code through unchanged", () => {
expect(
resolvePropertyType({
override: null,
isNewApproach: true,
lodgedPropertyType: "9", // not in PROPERTY_TYPE_LABELS
lodgedDwellingType: null,
predictedPropertyType: null,
predictedDwellingType: null,
legacyPropertyType: null,
}),
).toBe("9");
});
});
describe("resolveBuiltForm", () => {
it("prefers a landlord override over the EPC-derived value", () => {
expect(
resolveBuiltForm({
override: "Detached",
isNewApproach: true,
lodgedBuiltForm: "4", // EPC code for Mid-Terrace
predictedBuiltForm: null,
legacyBuiltForm: null,
}),
).toBe("Detached");
});
it("maps an RdSAP built-form code to its label when there is no override", () => {
expect(
resolveBuiltForm({
override: null,
isNewApproach: true,
lodgedBuiltForm: "2", // RdSAP code for Semi-Detached
predictedBuiltForm: null,
legacyBuiltForm: null,
}),
).toBe("Semi-Detached");
});
it("falls back to the predicted EPC when there is no lodged value", () => {
expect(
resolveBuiltForm({
override: null,
isNewApproach: true,
lodgedBuiltForm: null,
predictedBuiltForm: "1", // Detached
legacyBuiltForm: null,
}),
).toBe("Detached");
});
it("reads the legacy property-row value for a legacy property", () => {
expect(
resolveBuiltForm({
override: null,
isNewApproach: false,
lodgedBuiltForm: "1", // must be ignored for legacy
predictedBuiltForm: null,
legacyBuiltForm: "End-Terrace",
}),
).toBe("End-Terrace");
});
it("resolves to 'Unknown' when nothing is available (never null)", () => {
expect(
resolveBuiltForm({
override: null,
isNewApproach: true,
lodgedBuiltForm: null,
predictedBuiltForm: null,
legacyBuiltForm: "Detached", // legacy row must NOT surface for new-approach
}),
).toBe("Unknown");
});
});
describe("resolveConstructionAge", () => {
it("maps a landlord override band letter to its band label", () => {
expect(
resolveConstructionAge({
override: "B",
isNewApproach: true,
lodgedAgeBand: "F", // EPC says a different band — override still wins
predictedAgeBand: null,
legacyYearBuilt: null,
}),
).toBe("19001929");
});
it("maps the lodged EPC band letter to its label when there is no override", () => {
expect(
resolveConstructionAge({
override: null,
isNewApproach: true,
lodgedAgeBand: "C",
predictedAgeBand: null,
legacyYearBuilt: null,
}),
).toBe("19301949");
});
it("falls back to the predicted EPC band when there is no lodged band", () => {
expect(
resolveConstructionAge({
override: null,
isNewApproach: true,
lodgedAgeBand: null,
predictedAgeBand: "M",
legacyYearBuilt: null,
}),
).toBe("2023 onwards");
});
it("buckets a legacy year_built into its band (mid-range)", () => {
expect(legacyAge("1985")).toBe("19831990");
});
it("buckets a legacy year before 1900 into band A", () => {
expect(legacyAge("1899")).toBe("before 1900");
});
it("buckets the first year of a band correctly (boundary)", () => {
expect(legacyAge("1900")).toBe("19001929");
});
it("buckets a recent legacy year into the open-ended final band", () => {
expect(legacyAge("2025")).toBe("2023 onwards");
});
it("resolves a non-numeric legacy year_built to 'Unknown'", () => {
expect(legacyAge("N/A")).toBe("Unknown");
});
it("resolves to 'Unknown' when nothing is available; ignores the legacy row for new-approach", () => {
expect(
resolveConstructionAge({
override: null,
isNewApproach: true,
lodgedAgeBand: null,
predictedAgeBand: null,
legacyYearBuilt: "1985", // must NOT surface for a new-approach property
}),
).toBe("Unknown");
});
it("lets an explicit 'Unknown' override win", () => {
expect(
resolveConstructionAge({
override: "Unknown",
isNewApproach: true,
lodgedAgeBand: "C",
predictedAgeBand: null,
legacyYearBuilt: null,
}),
).toBe("Unknown");
});
});
describe("resolveMainFuel", () => {
const base = {
override: null,
isNewApproach: true,
lodgedMainFuel: null,
predictedMainFuel: null,
legacyMainFuel: null,
};
it("prefers a landlord override over the EPC-derived value", () => {
expect(
resolveMainFuel({ ...base, override: "mains gas", lodgedMainFuel: "26" }),
).toBe("mains gas");
});
it("reads the new-approach EPC main-fuel code (lodged) as a raw value", () => {
expect(resolveMainFuel({ ...base, lodgedMainFuel: "26" })).toBe("26");
});
it("falls back to the predicted EPC main fuel when there is no lodged one", () => {
expect(resolveMainFuel({ ...base, predictedMainFuel: "29" })).toBe("29");
});
it("prefers the lodged EPC main fuel over the predicted one", () => {
expect(
resolveMainFuel({ ...base, lodgedMainFuel: "26", predictedMainFuel: "29" }),
).toBe("26");
});
it("passes an EPR string main fuel through unchanged", () => {
expect(resolveMainFuel({ ...base, lodgedMainFuel: "Mains gas" })).toBe("Mains gas");
});
it("resolves to 'Unknown' for a new-approach property with no EPC main fuel; ignores the legacy row", () => {
expect(
resolveMainFuel({ ...base, legacyMainFuel: "Gas mains gas" }),
).toBe("Unknown");
});
it("reads the legacy EPC main fuel for a legacy property", () => {
expect(
resolveMainFuel({
...base,
isNewApproach: false,
lodgedMainFuel: "26", // must be ignored for legacy
legacyMainFuel: "Gas mains gas",
}),
).toBe("Gas mains gas");
});
it("resolves to 'Unknown' when nothing is available (never null)", () => {
expect(resolveMainFuel({ ...base, isNewApproach: false })).toBe("Unknown");
});
});
describe("MAINFUEL_OPTIONS coverage", () => {
const covered = new Set(MAINFUEL_OPTIONS.flatMap((o) => o.dbValues));
const labels = new Set(MAINFUEL_OPTIONS.map((o) => o.label));
// 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", () => {
for (const value of MainFuelValues) {
expect(covered).toContain(value);
}
});
// Every RdSAP main_fuel code the new EPC graph can store must fold into a
// filter option under a real label (a typo'd label would be silently dropped
// by the MAINFUEL_OPTIONS merge — assert both the code AND its label).
it("folds every RdSAP main_fuel code under a real label", () => {
for (const [code, label] of Object.entries(RDSAP_MAIN_FUEL_CODE_LABELS)) {
expect(labels).toContain(label);
expect(covered).toContain(code);
}
});
// Every EPR free-text main_fuel string must fold in too.
it("folds every EPR main-fuel string under a real label", () => {
for (const [str, label] of Object.entries(EPR_MAIN_FUEL_STRING_LABELS)) {
expect(labels).toContain(label);
expect(covered).toContain(str);
}
});
});
describe("resolveOverrideDescriptor (wall / roof / heating free-text)", () => {
const base = {
override: null,
isNewApproach: true,
lodgedDescription: null,
predictedDescription: null,
legacyDescription: null,
};
it("prefers a landlord override over the EPC description", () => {
expect(
resolveOverrideDescriptor({
...base,
override: "Cavity wall, filled cavity",
lodgedDescription: "Solid brick, as built, no insulation (assumed)",
}),
).toBe("Cavity wall, filled cavity");
});
it("uses the lodged EPC description when there is no override", () => {
expect(
resolveOverrideDescriptor({
...base,
lodgedDescription: "Pitched, 250 mm loft insulation",
}),
).toBe("Pitched, 250 mm loft insulation");
});
it("falls back to the predicted EPC description when there is no lodged one", () => {
expect(
resolveOverrideDescriptor({ ...base, predictedDescription: "Flat, insulated (assumed)" }),
).toBe("Flat, insulated (assumed)");
});
it("reads the legacy description for a legacy property", () => {
expect(
resolveOverrideDescriptor({
...base,
isNewApproach: false,
lodgedDescription: "ignored for legacy",
legacyDescription: "Boiler and radiators, mains gas",
}),
).toBe("Boiler and radiators, mains gas");
});
it("resolves to 'Unknown' when nothing is available; ignores legacy for new-approach", () => {
expect(
resolveOverrideDescriptor({ ...base, legacyDescription: "must not surface" }),
).toBe("Unknown");
});
});
describe("classifyDescriptor — wall / roof / heating buckets", () => {
const wall = (v: string) =>
classifyDescriptor(v, WALL_TYPE_OPTIONS, DESCRIPTOR_FILTER_CONFIG.wallType.match);
const roof = (v: string) =>
classifyDescriptor(v, ROOF_TYPE_OPTIONS, DESCRIPTOR_FILTER_CONFIG.roofType.match);
const heat = (v: string) =>
classifyDescriptor(v, HEATING_SYSTEM_OPTIONS, DESCRIPTOR_FILTER_CONFIG.heatingSystem.match);
// Every landlord override value must fall into a real (non-Unknown) bucket, or
// an overridden component would display but be unfilterable (ADR-0012). The
// literal "Unknown" is the sole exception — it maps to the Unknown bucket.
const assertVocabCovered = (
values: readonly string[],
classify: (v: string) => string,
labels: Set<string>,
) => {
for (const value of values) {
const bucket = classify(value);
expect(labels).toContain(bucket);
if (value !== "Unknown") expect(bucket).not.toBe("Unknown");
}
};
it("buckets every WallType override value", () => {
assertVocabCovered(WallTypeValues, wall, new Set(WALL_TYPE_OPTIONS.map((b) => b.label)));
});
it("buckets every RoofType override value", () => {
assertVocabCovered(RoofTypeValues, roof, new Set(ROOF_TYPE_OPTIONS.map((b) => b.label)));
});
it("buckets every MainHeatingSystem override value", () => {
assertVocabCovered(
MainHeatingSystemValues,
heat,
new Set(HEATING_SYSTEM_OPTIONS.map((b) => b.label)),
);
});
// Representative EPC / legacy descriptions (from production) bucket as expected.
it("buckets representative EPC/legacy WALL descriptions", () => {
expect(wall("Cavity wall, filled cavity")).toBe("Cavity");
expect(wall("Solid brick, as built, no insulation (assumed)")).toBe("Solid Brick");
expect(wall("Timber frame, as built, insulated (assumed)")).toBe("Timber Frame");
expect(wall("Sandstone or limestone, as built, no insulation (assumed)")).toBe("Stone");
expect(wall("Granite or whinstone, as built, no insulation (assumed)")).toBe("Stone");
expect(wall("System built, with external insulation")).toBe("System Built");
// A bare U-value is not a construction type → Unknown.
expect(wall("Average thermal transmittance 0.21 W/m²K")).toBe("Unknown");
});
it("buckets representative EPC/legacy ROOF descriptions", () => {
expect(roof("Pitched, 200 mm loft insulation")).toBe("Pitched");
expect(roof("Flat, no insulation (assumed)")).toBe("Flat");
expect(roof("Roof room(s), insulated (assumed)")).toBe("Room-in-Roof");
expect(roof("(another dwelling above)")).toBe("Premises Above");
expect(roof("(other premises above)")).toBe("Premises Above");
expect(roof("Another Premises Above")).toBe("Premises Above");
expect(roof("Average thermal transmittance 0.11 W/m²K")).toBe("Unknown");
});
it("buckets representative EPC/legacy HEATING descriptions", () => {
expect(heat("Boiler and radiators, mains gas")).toBe("Gas Boiler");
expect(heat("Room heaters, mains gas")).toBe("Gas Room Heater");
expect(heat("Community scheme")).toBe("Community");
expect(heat("Community scheme, biomass")).toBe("Community");
expect(heat("Electric storage heaters")).toBe("Electric Storage");
expect(heat("Room heaters, electric")).toBe("Direct Electric");
expect(heat("Air source heat pump, radiators, electric")).toBe("Heat Pump");
// "oil" is a substring of "boiler" — a gas boiler must NOT bucket as Oil.
expect(heat("Boiler and radiators, oil")).toBe("Oil");
expect(heat("Boiler and radiators, dual fuel (mineral and wood)")).toBe("Solid Fuel");
expect(heat("Boiler and radiators, lpg")).toBe("LPG");
});
});
/** Legacy-property helper: only year_built is set, cutoff is false. */
function legacyAge(year: string): string {
return resolveConstructionAge({
override: null,
isNewApproach: false,
lodgedAgeBand: null,
predictedAgeBand: null,
legacyYearBuilt: year,
});
}

View file

@ -0,0 +1,179 @@
/**
* Pure precedence resolvers for the portfolio-list property descriptors.
*
* Mirrors the provenance.ts split: the DB query gathers the raw inputs (landlord
* override snapshot, lodged/predicted EPC values, legacy property-row value) and
* these functions own the precedence + RdSAP codelabel mapping. The SQL twins
* in epcSources.ts (resolvedPropertyTypeSql, ) mirror this logic keep in sync.
*
* Precedence (ADR-0008, extended to the list by ADR-0012):
* landlord override EPC-derived (lodged over predicted, codes mapped;
* legacy property-row fallback) 'Unknown' (never null)
*/
import {
BUILT_FORM_LABELS,
CONSTRUCTION_AGE_BANDS,
PROPERTY_TYPE_LABELS,
} from "./epcSources";
export interface PropertyTypeInputs {
/** property_overrides.override_value for component 'property_type', building_part 0. */
override: string | null;
/** Whether the property reads the new EPC graph (updated_at >= cutoff). */
isNewApproach: boolean;
/** epc_property(source='lodged').property_type — RdSAP code or text. */
lodgedPropertyType: string | null;
/** epc_property(source='lodged').dwelling_type — fallback when property_type is absent. */
lodgedDwellingType: string | null;
predictedPropertyType: string | null;
predictedDwellingType: string | null;
/** property.property_type — the legacy row value. */
legacyPropertyType: string | null;
}
export function resolvePropertyType(i: PropertyTypeInputs): string {
if (i.override) return i.override;
const derived = i.isNewApproach
? epcPropertyType(i.lodgedPropertyType, i.lodgedDwellingType) ??
epcPropertyType(i.predictedPropertyType, i.predictedDwellingType)
: i.legacyPropertyType;
return derived ?? "Unknown";
}
/**
* One EPC source's property-type label: the RdSAP code mapped to a label (text
* passes through), falling back to dwelling_type. Mirrors epcPropertyTypeSql.
*/
function epcPropertyType(
propertyType: string | null,
dwellingType: string | null,
): string | null {
if (propertyType != null) return PROPERTY_TYPE_LABELS[propertyType] ?? propertyType;
return dwellingType;
}
export interface BuiltFormInputs {
/** property_overrides.override_value for component 'built_form_type', building_part 0. */
override: string | null;
isNewApproach: boolean;
/** epc_property(source='lodged').built_form — RdSAP code or text. */
lodgedBuiltForm: string | null;
predictedBuiltForm: string | null;
/** property.built_form — the legacy row value. */
legacyBuiltForm: string | null;
}
export function resolveBuiltForm(i: BuiltFormInputs): string {
if (i.override) return i.override;
const derived = i.isNewApproach
? epcBuiltForm(i.lodgedBuiltForm) ?? epcBuiltForm(i.predictedBuiltForm)
: i.legacyBuiltForm;
return derived ?? "Unknown";
}
/** One EPC source's built-form label: RdSAP code → label, text passes through. Mirrors builtFormTypeSql. */
function epcBuiltForm(builtForm: string | null): string | null {
if (builtForm == null) return null;
return BUILT_FORM_LABELS[builtForm] ?? builtForm;
}
export interface ConstructionAgeInputs {
/** property_overrides.override_value for 'construction_age_band', building_part 0 — an RdSAP letter (A..M) or "Unknown". */
override: string | null;
isNewApproach: boolean;
/** The main building part's epc_building_part.construction_age_band — an RdSAP letter. */
lodgedAgeBand: string | null;
predictedAgeBand: string | null;
/** property.year_built — a raw year string (legacy). Bucketed into a band. */
legacyYearBuilt: string | null;
}
/**
* Construction age as an RdSAP band LABEL ("before 1900" "2023 onwards",
* "Unknown") the band is the underlying datum, never a single year (ADR-0012).
* Override and EPC both store the band letter; legacy year_built is bucketed into
* a band. Mirrors resolvedConstructionAgeSql.
*/
export function resolveConstructionAge(i: ConstructionAgeInputs): string {
if (i.override) return bandLetterToLabel(i.override);
const band = i.isNewApproach
? i.lodgedAgeBand ?? i.predictedAgeBand
: bucketLegacyYearToBand(i.legacyYearBuilt);
return band != null ? bandLetterToLabel(band) : "Unknown";
}
/** RdSAP band letter → band label; unrecognised values (e.g. "Unknown") pass through. */
function bandLetterToLabel(letter: string): string {
return CONSTRUCTION_AGE_BANDS[letter]?.label ?? letter;
}
/**
* Bucket a legacy raw year_built into its RdSAP band letter: the band with the
* greatest start year not after the given year. Non-numeric text null (Unknown);
* a year below the earliest start is pre-1900 band A. Mirrors the legacy branch
* of resolvedConstructionAgeSql.
*/
export interface OverrideDescriptorInputs {
/** property_overrides.override_value for this component, building_part 0. */
override: string | null;
isNewApproach: boolean;
/** epc_energy_element.description for the element on the lodged EPC. */
lodgedDescription: string | null;
predictedDescription: string | null;
/** The legacy property_details_epc column (walls / roof / heating). */
legacyDescription: string | null;
}
/**
* Free-text component descriptor (wall type / roof type / heating system):
* override EPC-derived (lodged over predicted, from epc_energy_element)
* legacy property_details_epc column 'Unknown'. No codelabel mapping the
* value is the description string itself. Mirrors resolvedWallTypeSql etc.
*/
export function resolveOverrideDescriptor(i: OverrideDescriptorInputs): string {
if (i.override) return i.override;
const derived = i.isNewApproach
? i.lodgedDescription ?? i.predictedDescription
: i.legacyDescription;
return derived ?? "Unknown";
}
export interface MainFuelInputs {
/** property_overrides.override_value for component 'main_fuel', building_part 0. */
override: string | null;
isNewApproach: boolean;
/** epc_main_heating_detail.main_fuel_type scalar on the lodged EPC — an RdSAP code ("26") or an EPR string ("Mains gas"). */
lodgedMainFuel: string | null;
predictedMainFuel: string | null;
/** 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):
* override EPC-derived (new: epc_main_heating_detail scalar, lodged over
* predicted; legacy: the property_details_epc string) 'Unknown'. The
* new-approach scalar is an RdSAP `main_fuel` code or an EPR string; both fold
* into MAINFUEL_OPTIONS. Never null 'Unknown'. Mirrors resolvedMainFuelSql.
*/
export function resolveMainFuel(i: MainFuelInputs): string {
if (i.override) return i.override;
const derived = i.isNewApproach
? i.lodgedMainFuel ?? i.predictedMainFuel
: 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);
let best: string | null = null;
let bestStart = -Infinity;
for (const [letter, { year: start }] of Object.entries(CONSTRUCTION_AGE_BANDS)) {
if (start <= year && start > bestStart) {
best = letter;
bestStart = start;
}
}
return best ?? "A";
}

View file

@ -3,7 +3,10 @@ import {
CONSTRUCTION_AGE_BANDS,
PROPERTY_TYPE_LABELS,
} from "./epcSources";
import { PROPERTY_TYPE_OPTIONS } from "@/app/utils/propertyFilters";
import {
PROPERTY_TYPE_OPTIONS,
YEAR_BUILT_OPTIONS,
} from "@/app/utils/propertyFilters";
// The reporting age-band bucketing in getCountByAgeBand (databaseFunctions.ts),
// mirrored so the CONSTRUCTION_AGE_BANDS representative years stay anchored to
@ -57,12 +60,26 @@ describe("CONSTRUCTION_AGE_BANDS", () => {
});
describe("PROPERTY_TYPE_LABELS", () => {
it("maps codes to the labels the property-type filter options use", () => {
it("maps every code to a label the property-type filter offers as an option", () => {
const optionLabels = new Set(PROPERTY_TYPE_OPTIONS.map((o) => o.label));
for (const label of Object.values(PROPERTY_TYPE_LABELS)) {
// Park home has no filter option — it's rare and charts render it as-is.
if (label === "Park home") continue;
expect(optionLabels).toContain(label);
}
});
});
describe("YEAR_BUILT_OPTIONS (Construction Age)", () => {
// Guards the band-native filter (ADR-0012) against label drift — a mismatched
// en-dash or reworded band would silently make the filter match nothing.
it("is exactly the RdSAP band labels in order, plus Unknown", () => {
const bandLabels = Object.values(CONSTRUCTION_AGE_BANDS).map((b) => b.label);
expect(YEAR_BUILT_OPTIONS.map((o) => o.label)).toEqual([
...bandLabels,
"Unknown",
]);
});
it("uses each band label as its own dbValue", () => {
for (const o of YEAR_BUILT_OPTIONS) expect(o.dbValues).toEqual([o.label]);
});
});

View file

@ -192,9 +192,35 @@ export const estimatedSql = (e: Alias) =>
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. */
/**
* The main-fuel scalar for one epc_property alias, read from the JSONB
* `epc_main_heating_detail.main_fuel_type` as a correlated subquery. The stored
* scalar is either a numeric RdSAP `main_fuel` code ("26", "29", ~95% of
* rows, from the gov-EPC API) or an EPR text string ("Mains gas", "Bulk LPG",
* from Elmhurst/PASHub). `#>> '{}'` extracts the JSON scalar as text; NULLIF
* drops the empty-string sentinel to NULL. Codes/strings are folded to a label
* downstream by MAINFUEL_OPTIONS the fragment emits the raw value, like
* resolvedMainFuelSql's legacy branch. (No index on epc_property_id yet see
* ix_epc_main_heating_detail_epc_property in the schema.)
*/
const epcMainFuelSql = (ep: Alias) => sql`(
SELECT NULLIF(mhd.main_fuel_type #>> '{}', '')
FROM epc_main_heating_detail mhd
WHERE mhd.epc_property_id = ${ep}.id
LIMIT 1
)`;
/**
* Main fuel raw value. New: the epc_main_heating_detail scalar (lodged over
* predicted) an RdSAP code or an EPR string, folded to a label by
* MAINFUEL_OPTIONS. Legacy: e.mainfuel. Mirrors the new-approach branch of
* resolveMainFuel.
*/
export const mainfuelSql = (e: Alias) =>
sql`CASE WHEN ${isNewApproachSql} THEN NULL ELSE ${e}.mainfuel END`;
sql`CASE
WHEN ${isNewApproachSql} THEN (CASE WHEN epl.id IS NOT NULL THEN ${epcMainFuelSql(sql`epl`)} ELSE ${epcMainFuelSql(sql`epp`)} END)
ELSE ${e}.mainfuel
END`;
// ─────────────────────────────────────────────────────────────────────────────
// RdSAP code → label maps for the descriptive fields the new pipeline stores as
@ -283,6 +309,108 @@ export const builtFormTypeSql = sql`CASE
ELSE p.built_form
END`;
// ─────────────────────────────────────────────────────────────────────────────
// Landlord-override-resolved descriptors (ADR-0012). Each fragment wraps the
// EPC-derived expression above with the property_overrides snapshot and a
// never-null 'Unknown' terminal — the same precedence previewModellingRun
// applies (override → EPC-derived → Unknown). The TS twins in
// descriptorResolution.ts (resolvePropertyType, …) own and unit-test this
// precedence; keep the two in sync.
//
// A resolved fragment references an override alias that its join provides — add
// the matching *OverrideJoin to any query that selects/filters on the fragment.
// Overrides are whole-dwelling for these components, so the join pins
// building_part = 0 (the main building) and never multiplies rows.
// ─────────────────────────────────────────────────────────────────────────────
/** LEFT JOIN exposing the property_type override snapshot (alias `pot`). */
export const propertyTypeOverrideJoin = sql`
LEFT JOIN property_overrides pot
ON pot.property_id = p.id
AND pot.override_component = 'property_type'
AND pot.building_part = 0
`;
/** property_type: override → EPC-derived → 'Unknown'. Mirrors resolvePropertyType. */
export const resolvedPropertyTypeSql = sql`COALESCE(pot.override_value, ${propertyTypeSql}, 'Unknown')`;
/** LEFT JOIN exposing the built_form_type override snapshot (alias `pobf`). */
export const builtFormOverrideJoin = sql`
LEFT JOIN property_overrides pobf
ON pobf.property_id = p.id
AND pobf.override_component = 'built_form_type'
AND pobf.building_part = 0
`;
/** 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')`;
// ─── Free-text component descriptors: wall / roof / heating (ADR-0012) ────────
// override → EPC-derived (epc_energy_element description, lodged over predicted)
// → legacy property_details_epc column → 'Unknown'. Mirrors resolveOverrideDescriptor.
/** One EPC source's description for an energy element, as a correlated subquery. */
const epcElementDescSql = (ep: Alias, elementType: string) => sql`(
SELECT ee.description FROM epc_energy_element ee
WHERE ee.epc_property_id = ${ep}.id AND ee.element_type = ${elementType}
LIMIT 1
)`;
/** EPC-or-legacy element description: new = lodged over predicted; legacy = the property_details_epc column. */
const epcElementSql = (elementType: string, legacyCol: Alias) => sql`CASE
WHEN ${isNewApproachSql} THEN (CASE WHEN epl.id IS NOT NULL THEN ${epcElementDescSql(sql`epl`, elementType)} ELSE ${epcElementDescSql(sql`epp`, elementType)} END)
ELSE ${legacyCol}
END`;
/** LEFT JOIN exposing the wall_type override snapshot at the main building part (alias `pwall`). */
export const wallTypeOverrideJoin = sql`
LEFT JOIN property_overrides pwall
ON pwall.property_id = p.id
AND pwall.override_component = 'wall_type'
AND pwall.building_part = 0
`;
/** LEFT JOIN exposing the roof_type override snapshot at the main building part (alias `proof`). */
export const roofTypeOverrideJoin = sql`
LEFT JOIN property_overrides proof
ON proof.property_id = p.id
AND proof.override_component = 'roof_type'
AND proof.building_part = 0
`;
/** LEFT JOIN exposing the main_heating_system override snapshot (alias `pheat`). */
export const mainHeatingOverrideJoin = sql`
LEFT JOIN property_overrides pheat
ON pheat.property_id = p.id
AND pheat.override_component = 'main_heating_system'
AND pheat.building_part = 0
`;
/** wall type: override → EPC 'wall' description → legacy walls → 'Unknown'. Mirrors resolveOverrideDescriptor. */
export const resolvedWallTypeSql = (e: Alias) =>
sql`COALESCE(pwall.override_value, ${epcElementSql("wall", sql`${e}.walls`)}, 'Unknown')`;
/** roof type: override → EPC 'roof' description → legacy roof → 'Unknown'. */
export const resolvedRoofTypeSql = (e: Alias) =>
sql`COALESCE(proof.override_value, ${epcElementSql("roof", sql`${e}.roof`)}, 'Unknown')`;
/** heating system: override → EPC 'main_heating' description → legacy heating → 'Unknown'. */
export const resolvedHeatingSystemSql = (e: Alias) =>
sql`COALESCE(pheat.override_value, ${epcElementSql("main_heating", sql`${e}.heating`)}, '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
@ -316,6 +444,52 @@ export const constructionYearSql = sql`CASE
ELSE NULL
END`;
// ─── Construction age as a band LABEL (ADR-0012) ─────────────────────────────
// Unlike constructionYearSql (which flattens the band to a representative year
// for reporting buckets), the portfolio list keeps the band itself. Mirrors
// resolveConstructionAge in descriptorResolution.ts — keep in sync.
/** RdSAP band letter → band label (e.g. B → "19001929"). */
const AGE_BAND_LABELS: Record<string, string> = Object.fromEntries(
Object.entries(CONSTRUCTION_AGE_BANDS).map(([letter, { label }]) => [letter, label]),
);
/** Bucket a legacy year_built expression into its band label; non-numeric → NULL. */
const legacyYearBandLabelSql = (yearExpr: Alias) => {
const y = sql`CAST(${yearExpr} AS int)`;
const thresholds = Object.entries(CONSTRUCTION_AGE_BANDS)
.filter(([letter]) => letter !== "A")
.sort((a, b) => b[1].year - a[1].year)
.map(([, { year, label }]) => sql`WHEN ${y} >= ${year} THEN ${label}`);
// The numeric guard fires the CAST only on digit strings; below the earliest
// band start falls through to band A.
return sql`CASE WHEN ${yearExpr} ~ '^[0-9]+$' THEN (CASE ${sql.join(
thresholds,
sql` `,
)} ELSE ${CONSTRUCTION_AGE_BANDS.A.label} END) ELSE NULL END`;
};
/** EPC-or-legacy band label: new-approach main-part band (lodged over predicted); legacy year bucketed. */
const constructionAgeBandLabelSql = sql`CASE
WHEN ${isNewApproachSql} THEN (CASE WHEN epl.id IS NOT NULL THEN ${codeLabelCaseSql(mainPartAgeBandSql(sql`epl`), AGE_BAND_LABELS)} ELSE ${codeLabelCaseSql(mainPartAgeBandSql(sql`epp`), AGE_BAND_LABELS)} END)
ELSE ${legacyYearBandLabelSql(sql`p.year_built`)}
END`;
/** LEFT JOIN exposing the construction_age_band override snapshot (alias `pab`). */
export const constructionAgeOverrideJoin = sql`
LEFT JOIN property_overrides pab
ON pab.property_id = p.id
AND pab.override_component = 'construction_age_band'
AND pab.building_part = 0
`;
/** construction age: override → EPC band → legacy year bucket → 'Unknown'. Mirrors resolveConstructionAge. */
export const resolvedConstructionAgeSql = sql`COALESCE(
${codeLabelCaseSql(sql`pab.override_value`, AGE_BAND_LABELS)},
${constructionAgeBandLabelSql},
'Unknown'
)`;
// ─────────────────────────────────────────────────────────────────────────────
// Per-property resolvers (TypeScript object building).
// ─────────────────────────────────────────────────────────────────────────────