From fac425762e84999dafd148c692a1d36eac581dac Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Thu, 9 Jul 2026 10:33:07 +0000 Subject: [PATCH] fix(postcode-search): classify RH (HMO) family as addable residential MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 7 Leyton Avenue (UPRN 100031252876) came back from OS Places as RH03 ("HMO Not Further Divided") and rendered as "Non-residential", so it couldn't be added from the postcode-search "add properties" journey. classifyOsCode decided "residential?" purely on an RD prefix, dropping the whole RH (HMO) family — even though RD07 (the RD-family HMO code) was already mapped to House. Extend the residential check to RH and map RH01/RH02/RH03 to House (built form unknowable), consistent with RD07. Updates ADR-0007's enumerated mapping table to stay truthful. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...0007-postcode-search-creates-properties.md | 6 +++- src/lib/postcodeSearch/model.test.ts | 34 +++++++++++++++++++ src/lib/postcodeSearch/model.ts | 11 ++++-- 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/docs/adr/0007-postcode-search-creates-properties.md b/docs/adr/0007-postcode-search-creates-properties.md index ca158d4b..614d110a 100644 --- a/docs/adr/0007-postcode-search-creates-properties.md +++ b/docs/adr/0007-postcode-search-creates-properties.md @@ -35,9 +35,13 @@ modelling exists. distinguishable. - **Deterministic mapping** (fixed table, tested): RD02→House+Detached, RD03→House+Semi-Detached, RD04→House (Mid/End unknowable — no built form), - RD06→Flat, RD07→House. Every other code maps nothing; `Unknown` is never + RD06→Flat, RD07→House, RH01/RH02/RH03→House (the dedicated HMO family, built + form unknowable). Every other code maps nothing; `Unknown` is never written (absence, not noise) — preserving the finaliser's invariant. The backend's predict-EPC service consumes these for archetype matching. +- **Addable codes**: the RD (dwelling) family except RD10 (institutions), plus + the RH (HMO) family. All other codes — commercial, institutional, land — are + not addable and render "Non-residential". - **Read-through cache** on the existing `postcode_search` jsonb table: serve when fresher than 30 days, else refresh from OS Places and upsert. diff --git a/src/lib/postcodeSearch/model.test.ts b/src/lib/postcodeSearch/model.test.ts index 3c1449eb..e18b6d9e 100644 --- a/src/lib/postcodeSearch/model.test.ts +++ b/src/lib/postcodeSearch/model.test.ts @@ -20,6 +20,13 @@ describe("classifyOsCode", () => { expect(classifyOsCode("RD07")).toEqual({ selectable: true, propertyType: "House", builtForm: null }); }); + it("maps the RH (HMO) family to House, like RD07", () => { + // e.g. UPRN 100031252876, 7 Leyton Avenue — RH03 must not read as non-residential. + expect(classifyOsCode("RH01")).toEqual({ selectable: true, propertyType: "House", builtForm: null }); + expect(classifyOsCode("RH02")).toEqual({ selectable: true, propertyType: "House", builtForm: null }); + expect(classifyOsCode("RH03")).toEqual({ selectable: true, propertyType: "House", builtForm: null }); + }); + it("keeps other residential codes selectable but writes no facts — never Unknown", () => { for (const code of ["RD", "RD01", "RD08"]) { expect(classifyOsCode(code)).toEqual({ selectable: true, propertyType: null, builtForm: null }); @@ -63,6 +70,21 @@ describe("toAddressCandidates", () => { expect(out[1]).toMatchObject({ uprn: "101", selectable: false }); }); + it("classifies an RH03 HMO as a selectable House, not Non-residential", () => { + // Regression: 7 Leyton Avenue (UPRN 100031252876) came back RH03 ("HMO Not + // Further Divided") on a street of RD02/RD03 houses and rendered as + // "Non-residential", so it couldn't be added. + const hmo = [ + { DPA: { UPRN: "100031252876", ADDRESS: "7, LEYTON AVENUE, SUTTON-IN-ASHFIELD, NG17 3BD", + POSTCODE: "NG17 3BD", CLASSIFICATION_CODE: "RH03", LAT: 53.1352313, LNG: -1.2522665 } }, + ]; + const out = toAddressCandidates(hmo as never, "NG17 3BD"); + expect(out[0]).toMatchObject({ + uprn: "100031252876", address: "7, Leyton Avenue, Sutton-In-Ashfield", + classificationCode: "RH03", selectable: true, propertyType: "House", builtForm: null, + }); + }); + it("orders addresses numerically, so Flat 2 comes before Flat 10", () => { const flats = [ { DPA: { UPRN: "1", ADDRESS: "FLAT 10, BIRCH COURT, M20 4TF", CLASSIFICATION_CODE: "RD06" } }, @@ -102,6 +124,18 @@ describe("buildWritePlan", () => { { component: "built_form_type", value: "Detached", originalDescription: "RD02", buildingPart: 0 }, ]); }); + it("writes a House property_type override for an RH03 HMO, keyed to the OS code", () => { + const plan = buildWritePlan( + { uprn: "100031252876", address: "7, Leyton Avenue, Sutton-In-Ashfield", postcode: "NG17 3BD", + classificationCode: "RH03", lat: 53.1352313, lng: -1.2522665, selectable: true, + propertyType: "House", builtForm: null }, + geo, + ); + // HMO built form is unknowable, so only the property_type fact is written. + expect(plan.overrides).toEqual([ + { component: "property_type", value: "House", originalDescription: "RH03", buildingPart: 0 }, + ]); + }); it("writes no overrides when the code maps nothing — never Unknown", () => { const plan = buildWritePlan( { uprn: "200", address: "Birch Court", postcode: "M20 4UD", classificationCode: "RD", diff --git a/src/lib/postcodeSearch/model.ts b/src/lib/postcodeSearch/model.ts index 289cdb2d..fd1c226e 100644 --- a/src/lib/postcodeSearch/model.ts +++ b/src/lib/postcodeSearch/model.ts @@ -28,7 +28,11 @@ const OS_CODE_FACTS: Record