mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-12 13:28:55 +00:00
fix(reporting): age-band and property-type breakdowns for new-approach properties
getCountByAgeBand and getCountByPropertyType read year_built / property_type straight off the property row, which is NULL for new-approach properties — every one fell into the Unknown bucket on /portfolio/[slug]/reporting. Branch them like their already-migrated siblings (getCountByEpcBand, getEstimatedCounts): - propertyTypeSql: epc_property (lodged, else predicted) with RdSAP code→label mapping and dwelling_type fallback, mirroring resolvePropertyDescriptors; legacy stays on property.property_type. - constructionYearSql: the main building part's construction age band mapped to a representative start year (correlated subquery, since extension parts would multiply a plain JOIN), so both paths share the same year→band bucketing; legacy stays on numeric year_built. - The RdSAP label maps move into the shared-fragments section as the single source of truth; AGE_BAND_YEARS becomes CONSTRUCTION_AGE_BANDS carrying label + representative year. Band I (1996–2002) straddles the 1976–1999 / 2000+ boundary and buckets by its start year. Verified live against portfolio 814 (all 338 properties resolve, buckets match the epc_building_part distribution exactly) and legacy portfolios 419/433 (output identical to the pre-fix SQL). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
9bf36ad4b6
commit
051927dda4
3 changed files with 200 additions and 59 deletions
|
|
@ -11,6 +11,8 @@ import {
|
|||
isExpiredSql,
|
||||
effectiveSapSql,
|
||||
effectiveEpcBandSql,
|
||||
propertyTypeSql,
|
||||
constructionYearSql,
|
||||
} from "@/lib/services/epcSources";
|
||||
|
||||
import type {
|
||||
|
|
@ -71,20 +73,21 @@ export async function getCountByAgeBand(
|
|||
const result = await db.execute<AgeBandCount>(sql`
|
||||
SELECT
|
||||
CASE
|
||||
WHEN year_built ~ '^[0-9]+$' THEN
|
||||
CASE
|
||||
WHEN CAST(year_built AS int) < 1900 THEN '<1900'
|
||||
WHEN CAST(year_built AS int) BETWEEN 1900 AND 1929 THEN '1900–1929'
|
||||
WHEN CAST(year_built AS int) BETWEEN 1930 AND 1949 THEN '1930–1949'
|
||||
WHEN CAST(year_built AS int) BETWEEN 1950 AND 1975 THEN '1950–1975'
|
||||
WHEN CAST(year_built AS int) BETWEEN 1976 AND 1999 THEN '1976–1999'
|
||||
ELSE '2000+'
|
||||
END
|
||||
WHEN q.built_year < 1900 THEN '<1900'
|
||||
WHEN q.built_year BETWEEN 1900 AND 1929 THEN '1900–1929'
|
||||
WHEN q.built_year BETWEEN 1930 AND 1949 THEN '1930–1949'
|
||||
WHEN q.built_year BETWEEN 1950 AND 1975 THEN '1950–1975'
|
||||
WHEN q.built_year BETWEEN 1976 AND 1999 THEN '1976–1999'
|
||||
WHEN q.built_year >= 2000 THEN '2000+'
|
||||
ELSE 'Unknown'
|
||||
END AS age_band,
|
||||
COUNT(*)::int AS count
|
||||
FROM property
|
||||
WHERE portfolio_id = ${portfolioId}
|
||||
FROM (
|
||||
SELECT ${constructionYearSql} AS built_year
|
||||
FROM property p
|
||||
${newApproachJoins}
|
||||
WHERE p.portfolio_id = ${portfolioId}
|
||||
) q
|
||||
GROUP BY age_band
|
||||
ORDER BY age_band;
|
||||
`);
|
||||
|
|
@ -149,10 +152,11 @@ export async function getCountByPropertyType(
|
|||
portfolioId: number,
|
||||
): Promise<PropertyTypeCount[]> {
|
||||
const result = await db.execute<PropertyTypeCount>(sql`
|
||||
SELECT property_type AS type, COUNT(*)::int AS count
|
||||
FROM property
|
||||
WHERE portfolio_id = ${portfolioId}
|
||||
GROUP BY property_type
|
||||
SELECT ${propertyTypeSql} AS type, COUNT(*)::int AS count
|
||||
FROM property p
|
||||
${newApproachJoins}
|
||||
WHERE p.portfolio_id = ${portfolioId}
|
||||
GROUP BY type
|
||||
ORDER BY count DESC;
|
||||
`);
|
||||
|
||||
|
|
|
|||
68
src/lib/services/epcSources.test.ts
Normal file
68
src/lib/services/epcSources.test.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
CONSTRUCTION_AGE_BANDS,
|
||||
PROPERTY_TYPE_LABELS,
|
||||
} from "./epcSources";
|
||||
import { PROPERTY_TYPE_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
|
||||
// the buckets the chart renders.
|
||||
function reportingAgeBand(year: number): string {
|
||||
if (year < 1900) return "<1900";
|
||||
if (year <= 1929) return "1900–1929";
|
||||
if (year <= 1949) return "1930–1949";
|
||||
if (year <= 1975) return "1950–1975";
|
||||
if (year <= 1999) return "1976–1999";
|
||||
return "2000+";
|
||||
}
|
||||
|
||||
describe("CONSTRUCTION_AGE_BANDS", () => {
|
||||
it("keeps each representative year consistent with the band's label", () => {
|
||||
for (const { label, year } of Object.values(CONSTRUCTION_AGE_BANDS)) {
|
||||
if (label.startsWith("before")) {
|
||||
expect(year).toBeLessThan(1900);
|
||||
} else {
|
||||
const start = Number(label.match(/\d{4}/)![0]);
|
||||
expect(year).toBe(start);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("buckets every RdSAP band into the expected reporting age band", () => {
|
||||
const buckets = Object.fromEntries(
|
||||
Object.entries(CONSTRUCTION_AGE_BANDS).map(([code, { year }]) => [
|
||||
code,
|
||||
reportingAgeBand(year),
|
||||
]),
|
||||
);
|
||||
expect(buckets).toEqual({
|
||||
A: "<1900",
|
||||
B: "1900–1929",
|
||||
C: "1930–1949",
|
||||
D: "1950–1975",
|
||||
E: "1950–1975",
|
||||
F: "1976–1999",
|
||||
G: "1976–1999",
|
||||
H: "1976–1999",
|
||||
// I (1996–2002) straddles the 1976–1999 / 2000+ boundary; it buckets by
|
||||
// its start year.
|
||||
I: "1976–1999",
|
||||
J: "2000+",
|
||||
K: "2000+",
|
||||
L: "2000+",
|
||||
M: "2000+",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("PROPERTY_TYPE_LABELS", () => {
|
||||
it("maps codes to the labels the property-type filter options use", () => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -193,6 +193,116 @@ export const isExpiredSql = (e: Alias) =>
|
|||
export const mainfuelSql = (e: Alias) =>
|
||||
sql`CASE WHEN ${isNewApproachSql} THEN NULL ELSE ${e}.mainfuel END`;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 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. Shared by the SQL fragments below and resolvePropertyDescriptors —
|
||||
// the single source of truth for code→label mapping.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export 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",
|
||||
};
|
||||
// SAP tenure codes → the text labels already used in the data. NOTE: best-effort
|
||||
// (standard SAP 1/2/3); most rows store the text label directly, only a few use
|
||||
// codes. Confirm against the backend tenure enum.
|
||||
const TENURE_LABELS: Record<string, string> = {
|
||||
"1": "Owner Occupied",
|
||||
"2": "Rented Social",
|
||||
"3": "Rented Private",
|
||||
};
|
||||
/**
|
||||
* Matches the backend ConstructionAgeBand enum (RdSAP England & Wales letters).
|
||||
* `year` is a representative construction year (the band's start) for reporting
|
||||
* queries that bucket by year — band I (1996–2002) straddles the reporting
|
||||
* 1976–1999 / 2000+ boundary, so it buckets with its start year.
|
||||
*/
|
||||
export const CONSTRUCTION_AGE_BANDS: Record<
|
||||
string,
|
||||
{ label: string; year: number }
|
||||
> = {
|
||||
A: { label: "before 1900", year: 1899 }, // no start year — any pre-1900 value
|
||||
B: { label: "1900–1929", year: 1900 },
|
||||
C: { label: "1930–1949", year: 1930 },
|
||||
D: { label: "1950–1966", year: 1950 },
|
||||
E: { label: "1967–1975", year: 1967 },
|
||||
F: { label: "1976–1982", year: 1976 },
|
||||
G: { label: "1983–1990", year: 1983 },
|
||||
H: { label: "1991–1995", year: 1991 },
|
||||
I: { label: "1996–2002", year: 1996 },
|
||||
J: { label: "2003–2006", year: 2003 },
|
||||
K: { label: "2007–2011", year: 2007 },
|
||||
L: { label: "2012–2022", year: 2012 },
|
||||
M: { label: "2023 onwards", year: 2023 },
|
||||
};
|
||||
|
||||
/** `CASE <col> WHEN <code> THEN <label> … ELSE <col> END` from a code→label map. */
|
||||
const codeLabelCaseSql = (col: Alias, labels: Record<string, string>) =>
|
||||
sql`CASE ${col} ${sql.join(
|
||||
Object.entries(labels).map(([code, label]) => sql`WHEN ${code} THEN ${label}`),
|
||||
sql` `,
|
||||
)} ELSE ${col} END`;
|
||||
|
||||
/** Property-type label from one epc_property alias (mirrors resolvePropertyDescriptors). */
|
||||
const epcPropertyTypeSql = (ep: Alias) =>
|
||||
sql`COALESCE(${codeLabelCaseSql(sql`${ep}.property_type`, PROPERTY_TYPE_LABELS)}, ${ep}.dwelling_type)`;
|
||||
|
||||
/**
|
||||
* Property type label. New: epc_property (lodged, else predicted) — RdSAP codes
|
||||
* mapped via PROPERTY_TYPE_LABELS, text passed through, dwelling_type fallback,
|
||||
* mirroring resolvePropertyDescriptors. Legacy: property.property_type.
|
||||
*/
|
||||
export const propertyTypeSql = sql`CASE
|
||||
WHEN ${isNewApproachSql} THEN (CASE WHEN epl.id IS NOT NULL THEN ${epcPropertyTypeSql(sql`epl`)} ELSE ${epcPropertyTypeSql(sql`epp`)} END)
|
||||
ELSE p.property_type
|
||||
END`;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* plain JOIN on epc_building_part would multiply rows and break COUNTs.
|
||||
*/
|
||||
const mainPartAgeBandSql = (ep: Alias) => sql`(
|
||||
SELECT ebp.construction_age_band
|
||||
FROM epc_building_part ebp
|
||||
WHERE ebp.epc_property_id = ${ep}.id
|
||||
ORDER BY CASE WHEN ebp.identifier = 'main' THEN 0 ELSE 1 END, ebp.id
|
||||
LIMIT 1
|
||||
)`;
|
||||
|
||||
const ageBandYearCaseSql = (band: Alias) =>
|
||||
sql`CASE ${band} ${sql.join(
|
||||
Object.entries(CONSTRUCTION_AGE_BANDS).map(
|
||||
([code, { year }]) => sql`WHEN ${code} THEN ${year}::int`,
|
||||
),
|
||||
sql` `,
|
||||
)} END`;
|
||||
|
||||
/**
|
||||
* Representative construction year (int) for age-band reporting buckets. New:
|
||||
* the main building part's RdSAP construction age band (lodged EPC, else
|
||||
* predicted) mapped to the band's start year via CONSTRUCTION_AGE_BANDS.
|
||||
* Legacy: property.year_built when numeric. NULL → the caller's Unknown bucket.
|
||||
*/
|
||||
export const constructionYearSql = sql`CASE
|
||||
WHEN ${isNewApproachSql} THEN (CASE WHEN epl.id IS NOT NULL THEN ${ageBandYearCaseSql(mainPartAgeBandSql(sql`epl`))} ELSE ${ageBandYearCaseSql(mainPartAgeBandSql(sql`epp`))} END)
|
||||
WHEN p.year_built ~ '^[0-9]+$' THEN CAST(p.year_built AS int)
|
||||
ELSE NULL
|
||||
END`;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Per-property resolvers (TypeScript object building).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -586,49 +696,6 @@ 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",
|
||||
};
|
||||
// SAP tenure codes → the text labels already used in the data. NOTE: best-effort
|
||||
// (standard SAP 1/2/3); most rows store the text label directly, only a few use
|
||||
// codes. Confirm against the backend tenure enum.
|
||||
const TENURE_LABELS: Record<string, string> = {
|
||||
"1": "Owner Occupied",
|
||||
"2": "Rented Social",
|
||||
"3": "Rented Private",
|
||||
};
|
||||
// Matches the backend ConstructionAgeBand enum (RdSAP England & Wales letters).
|
||||
const AGE_BAND_YEARS: Record<string, string> = {
|
||||
A: "before 1900",
|
||||
B: "1900–1929",
|
||||
C: "1930–1949",
|
||||
D: "1950–1966",
|
||||
E: "1967–1975",
|
||||
F: "1976–1982",
|
||||
G: "1983–1990",
|
||||
H: "1991–1995",
|
||||
I: "1996–2002",
|
||||
J: "2003–2006",
|
||||
K: "2007–2011",
|
||||
L: "2012–2022",
|
||||
M: "2023 onwards",
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the descriptive property fields (type / built form / age) from the
|
||||
* epc_property graph for new-approach properties, since the property-row text
|
||||
|
|
@ -692,7 +759,9 @@ async function resolvePropertyDescriptors(
|
|||
? BUILT_FORM_LABELS[ep.builtForm] ?? ep.builtForm
|
||||
: null;
|
||||
const ageBand = mainPart?.constructionAgeBand ?? null;
|
||||
const yearBuilt = ageBand ? AGE_BAND_YEARS[ageBand] ?? ageBand : null;
|
||||
const yearBuilt = ageBand
|
||||
? CONSTRUCTION_AGE_BANDS[ageBand]?.label ?? ageBand
|
||||
: null;
|
||||
|
||||
const tenure = ep.tenure
|
||||
? TENURE_LABELS[ep.tenure] ?? ep.tenure
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue