fix(postcode-search): classify RH (HMO) family as addable residential

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) <noreply@anthropic.com>
This commit is contained in:
Khalim Conn-Kowlessar 2026-07-09 10:33:07 +00:00
parent 8bbcd77974
commit fac425762e
3 changed files with 48 additions and 3 deletions

View file

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

View file

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

View file

@ -28,7 +28,11 @@ const OS_CODE_FACTS: Record<string, { propertyType: string; builtForm: string |
RD03: { propertyType: "House", builtForm: "Semi-Detached" },
RD04: { propertyType: "House", builtForm: null }, // terraced — mid/end unknowable
RD06: { propertyType: "Flat", builtForm: null },
RD07: { propertyType: "House", builtForm: null }, // HMO
RD07: { propertyType: "House", builtForm: null }, // HMO (RD-family code)
// RH is the dedicated HMO family; treat as House like RD07, built form unknowable.
RH01: { propertyType: "House", builtForm: null }, // HMO parent
RH02: { propertyType: "House", builtForm: null }, // HMO bedsit / non-self-contained
RH03: { propertyType: "House", builtForm: null }, // HMO not further divided
};
const CACHE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
@ -181,7 +185,10 @@ export function cacheAgeDays(fetchedAt: Date, now: Date): number {
export function classifyOsCode(code: string | null): OsClassification {
if (!code) return { selectable: false, propertyType: null, builtForm: null };
const c = code.trim().toUpperCase();
const residential = c === "RD" || (c.startsWith("RD") && c !== "RD10");
// Addable dwellings: the RD (dwelling) family except RD10 (institutions),
// plus the RH (HMO) family. Everything else — commercial, institutional,
// land — is not addable.
const residential = (c.startsWith("RD") && c !== "RD10") || c.startsWith("RH");
if (!residential) return { selectable: false, propertyType: null, builtForm: null };
const facts = OS_CODE_FACTS[c];
return {