fix(assessment): fill property type / built form / age / floor height from EPC graph

For new-approach properties the property-row text columns (property_type,
built_form, year_built) are null, and the condition report had no floor height,
so the assessment page showed Unknown for several general features.

- property-meta: backfill property type / built form / age from epc_property
  (+ epc_building_part construction age band), mapping RdSAP codes to labels
  (property_type 2 -> Flat, built_form 3 -> End-Terrace, age band F -> 1976–1982),
  falling back to the raw value for unmapped codes.
- condition report: source floor height from the main building part's
  epc_floor_dimension.room_height_m.
- formatGeneralFeatures: use ?? instead of || so a real 0 (open fireplaces /
  extensions) shows as 0 rather than "Unknown".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-06-23 10:02:53 +00:00
parent 5dd2179bef
commit b7cbd5c03f
2 changed files with 118 additions and 9 deletions

View file

@ -316,26 +316,27 @@ export function formatGeneralFeatures(
},
{
feature: "Number of storeys",
description: conditionReportData.numberStoreys || "Unknown",
description: conditionReportData.numberStoreys ?? "Unknown",
},
];
const generealFeatures: GeneralFeature[] = [
{
feature: "Floor Height",
description: conditionReportData.floorHeight || "Unknown",
description: conditionReportData.floorHeight ?? "Unknown",
},
{
feature: "Number of heated rooms",
description: conditionReportData.numberHeatedRooms || "Unknown",
description: conditionReportData.numberHeatedRooms ?? "Unknown",
},
{
feature: "Number of open fire places",
description: conditionReportData.numberOpenFireplaces || "Unknown",
// ?? so a genuine 0 (no fireplaces) shows as 0, not "Unknown".
description: conditionReportData.numberOpenFireplaces ?? "Unknown",
},
{
feature: "Number of extensions",
description: conditionReportData.numberExtensions || "Unknown",
description: conditionReportData.numberExtensions ?? "Unknown",
},
{
feature: "Mains gas",

View file

@ -33,6 +33,8 @@ import {
epcProperty,
epcEnergyElement,
epcFlatDetails,
epcBuildingPart,
epcFloorDimension,
propertyBaselinePerformance,
propertyDetailsEpc,
property,
@ -235,7 +237,7 @@ async function resolveNewConditionReport(
if (!chosen) return null;
const [elements, flat, baseline] = await Promise.all([
const [elements, flat, baseline, buildingParts] = await Promise.all([
db.query.epcEnergyElement.findMany({
where: eq(epcEnergyElement.epcPropertyId, chosen.id),
}),
@ -245,8 +247,22 @@ async function resolveNewConditionReport(
db.query.propertyBaselinePerformance.findFirst({
where: eq(propertyBaselinePerformance.propertyId, propertyId),
}),
db.query.epcBuildingPart.findMany({
columns: { id: true, identifier: true },
where: eq(epcBuildingPart.epcPropertyId, chosen.id),
}),
]);
// Floor height: room height of the main building part (fall back to any part).
const mainPart =
buildingParts.find((b) => b.identifier === "main") ?? buildingParts[0];
const mainFloor = mainPart
? await db.query.epcFloorDimension.findFirst({
columns: { roomHeightM: true },
where: eq(epcFloorDimension.epcBuildingPartId, mainPart.id),
})
: null;
const byType = (t: string) => elements.find((el) => el.elementType === t);
const desc = (t: string) => byType(t)?.description ?? null;
const rating = (t: string) => safeRating(byType(t)?.energyEfficiencyRating);
@ -290,8 +306,9 @@ async function resolveNewConditionReport(
flat?.heatLossCorridor != null ? flat.heatLossCorridor > 0 : null,
unheatedCorridorLength: flat?.unheatedCorridorLengthM ?? null,
numberStoreys: chosen.numberOfStoreys ?? null,
// GAP: no single dwelling floor height (only per-building-part) — omitted.
floorHeight: null,
// Room height of the main building part (the new graph stores height per
// building part rather than one dwelling value).
floorHeight: mainFloor?.roomHeightM ?? null,
numberHeatedRooms: chosen.heatedRoomsCount ?? null,
numberOpenFireplaces: chosen.openChimneysCount ?? null,
numberExtensions: chosen.extensionsCount ?? null,
@ -465,6 +482,90 @@ export async function resolveDetailsEpcMeta(
};
}
// RdSAP code → label maps for the descriptive fields the new pipeline stores as
// codes on epc_property / epc_building_part (the property-row text columns aren't
// written for new-approach properties). Unknown codes fall back to the raw value.
const PROPERTY_TYPE_LABELS: Record<string, string> = {
"0": "House",
"1": "Bungalow",
"2": "Flat",
"3": "Maisonette",
"4": "Park home",
};
const BUILT_FORM_LABELS: Record<string, string> = {
"1": "Detached",
"2": "Semi-Detached",
"3": "End-Terrace",
"4": "Mid-Terrace",
"5": "Enclosed End-Terrace",
"6": "Enclosed Mid-Terrace",
};
const AGE_BAND_YEARS: Record<string, string> = {
A: "before 1900",
B: "19001929",
C: "19301949",
D: "19501966",
E: "19671975",
F: "19761982",
G: "19831990",
H: "19911995",
I: "19962002",
J: "20032006",
K: "20072011",
L: "2012 onwards",
};
/**
* Resolve the descriptive property fields (type / built form / age) from the
* epc_property graph for new-approach properties, since the property-row text
* columns aren't written. Returns nulls when there's no EPC graph.
*/
async function resolvePropertyDescriptors(
propertyId: bigint,
): Promise<{
propertyType: string | null;
builtForm: string | null;
yearBuilt: string | null;
}> {
const ep =
(await db.query.epcProperty.findFirst({
columns: { id: true, propertyType: true, builtForm: true, dwellingType: true },
where: and(
eq(epcProperty.propertyId, propertyId),
eq(epcProperty.source, "lodged"),
),
})) ??
(await db.query.epcProperty.findFirst({
columns: { id: true, propertyType: true, builtForm: true, dwellingType: true },
where: and(
eq(epcProperty.propertyId, propertyId),
eq(epcProperty.source, "predicted"),
),
}));
if (!ep) {
return { propertyType: null, builtForm: null, yearBuilt: null };
}
const mainPart = await db.query.epcBuildingPart.findFirst({
columns: { constructionAgeBand: true },
where: eq(epcBuildingPart.epcPropertyId, ep.id),
});
const propertyType =
(ep.propertyType ? PROPERTY_TYPE_LABELS[ep.propertyType] : null) ??
ep.dwellingType ??
ep.propertyType ??
null;
const builtForm = ep.builtForm
? BUILT_FORM_LABELS[ep.builtForm] ?? ep.builtForm
: null;
const ageBand = mainPart?.constructionAgeBand ?? null;
const yearBuilt = ageBand ? AGE_BAND_YEARS[ageBand] ?? ageBand : null;
return { propertyType, builtForm, yearBuilt };
}
/**
* Resolve the full property-meta object (the property row + branched
* `detailsEpc` + headline SAP/EPC). Shared by the `/api/property-meta` route and
@ -501,13 +602,20 @@ export async function resolvePropertyMeta(propertyId: string) {
if (!propertyMeta) return null;
const [detailsEpc, headline] = await Promise.all([
const newApproach = isNewApproach(propertyMeta.updatedAt);
const [detailsEpc, headline, descriptors] = await Promise.all([
resolveDetailsEpcMeta(propertyMeta.id, propertyMeta.updatedAt),
resolvePropertyHeadline(propertyMeta.id, propertyMeta.updatedAt),
newApproach
? resolvePropertyDescriptors(propertyMeta.id)
: Promise.resolve(null),
]);
return {
...propertyMeta,
propertyType: descriptors?.propertyType ?? propertyMeta.propertyType,
builtForm: descriptors?.builtForm ?? propertyMeta.builtForm,
yearBuilt: descriptors?.yearBuilt ?? propertyMeta.yearBuilt,
currentSapPoints:
headline.currentSapPoints ?? propertyMeta.currentSapPoints,
currentEpcRating: