assessment-model/src/lib/postcodeSearch/model.ts
Khalim Conn-Kowlessar fac425762e 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>
2026-07-09 10:33:07 +00:00

199 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Postcode search domain model — pure logic for the add-properties-by-postcode
* journey. See CONTEXT.md (Postcode search) and ADR-0007.
*/
/** Canonical form: uppercase, single space before the inward code. Null if implausible. */
export function normalisePostcode(input: string): string | null {
const m = input.trim().toUpperCase().replace(/\s+/g, "").match(
/^([A-Z]{1,2}\d[A-Z\d]?)(\d[A-Z]{2})$/,
);
return m ? `${m[1]} ${m[2]}` : null;
}
export interface OsClassification {
selectable: boolean;
propertyType: string | null;
builtForm: string | null;
}
/**
* Deterministic OS classification-code mapping (ADR-0007). Only codes with a
* confident enum equivalent write facts; "Unknown" is never produced — an
* unmappable residential code stays selectable with no facts. RD10
* (residential institutions) and all non-RD codes are not addable.
*/
const OS_CODE_FACTS: Record<string, { propertyType: string; builtForm: string | null }> = {
RD02: { propertyType: "House", builtForm: "Detached" },
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 (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;
/** Read-through cache rule: serve postcode_search rows younger than 30 days. */
export function isCacheFresh(fetchedAt: Date | null, now: Date): boolean {
return !!fetchedAt && now.getTime() - fetchedAt.getTime() < CACHE_MAX_AGE_MS;
}
type OsItem = { DPA?: Record<string, unknown>; LPI?: Record<string, unknown> };
export interface AddressCandidate {
uprn: string;
address: string;
postcode: string;
classificationCode: string | null;
lat: number | null;
lng: number | null;
selectable: boolean;
propertyType: string | null;
builtForm: string | null;
}
const titleCase = (s: string) =>
s.toLowerCase().replace(/\b[a-z]/g, (c) => c.toUpperCase());
/**
* Flatten OS Places items into unique address candidates: DPA preferred over
* LPI for the same UPRN; address rendered in title case with the postcode
* suffix stripped (it lives in its own column); ordered naturally so
* "Flat 2" sorts before "Flat 10".
*/
export function toAddressCandidates(
items: OsItem[],
postcode: string,
): AddressCandidate[] {
const byUprn = new Map<string, { rec: Record<string, unknown>; isDpa: boolean }>();
for (const item of items) {
const rec = item.DPA ?? item.LPI;
if (!rec || rec.UPRN == null) continue;
const uprn = String(rec.UPRN);
const existing = byUprn.get(uprn);
if (!existing || (item.DPA && !existing.isDpa)) {
byUprn.set(uprn, { rec, isDpa: !!item.DPA });
}
}
return [...byUprn.values()].map(({ rec }) => {
const code = (rec.CLASSIFICATION_CODE as string) ?? null;
const cls = classifyOsCode(code);
const raw = String(rec.ADDRESS ?? "");
const address = titleCase(
raw.replace(new RegExp(`,?\\s*${postcode.replace(" ", "\\s*")}\\s*$`, "i"), ""),
);
return {
uprn: String(rec.UPRN),
address,
postcode,
classificationCode: code,
lat: typeof rec.LAT === "number" ? rec.LAT : null,
lng: typeof rec.LNG === "number" ? rec.LNG : null,
...cls,
};
}).sort((a, b) =>
a.address.localeCompare(b.address, "en", { numeric: true, sensitivity: "base" }),
);
}
export interface PostcodeGeo {
localAuthority: string | null;
constituency: string | null;
}
/** The rows a selected candidate produces: one property + 02 override facts (ADR-0007). */
export function buildWritePlan(c: AddressCandidate, geo: PostcodeGeo) {
const overrides: {
component: "property_type" | "built_form_type";
value: string;
originalDescription: string;
buildingPart: number;
}[] = [];
if (c.propertyType && c.classificationCode) {
overrides.push({
component: "property_type",
value: c.propertyType,
originalDescription: c.classificationCode,
buildingPart: 0,
});
}
if (c.builtForm && c.classificationCode) {
overrides.push({
component: "built_form_type",
value: c.builtForm,
originalDescription: c.classificationCode,
buildingPart: 0,
});
}
return {
property: {
address: c.address,
postcode: c.postcode,
uprn: BigInt(c.uprn),
latitude: c.lat,
longitude: c.lng,
localAuthority: geo.localAuthority,
constituency: geo.constituency,
},
overrides,
};
}
/**
* Presentation of a candidate's classification: the type chip label plus an
* optional explanatory note (why terraced has no built form; why an unmapped
* residential code still adds cleanly).
*/
export function describeCandidate(c: {
selectable: boolean;
propertyType?: string | null;
builtForm?: string | null;
classificationCode?: string | null;
}): { label: string; note: string | null } {
if (!c.selectable) return { label: "Non-residential", note: null };
if (c.propertyType && c.builtForm) {
return { label: `${c.propertyType} · ${c.builtForm}`, note: null };
}
if (c.propertyType) {
return {
label: c.propertyType,
note: c.classificationCode?.toUpperCase() === "RD04"
? "Terraced — mid or end unknown"
: null,
};
}
return { label: "Residential", note: "No classification — type comes with modelling" };
}
/**
* postcode_search cache keys to read, canonical spaced form first. Legacy rows
* were keyed without the space; new rows are written under the canonical form.
*/
export function postcodeCacheKeys(postcode: string): string[] {
return [postcode, postcode.replace(" ", "")];
}
/** Whole days since the cache row was refreshed (for the "N days old" line). */
export function cacheAgeDays(fetchedAt: Date, now: Date): number {
return Math.max(0, Math.floor((now.getTime() - fetchedAt.getTime()) / (24 * 60 * 60 * 1000)));
}
export function classifyOsCode(code: string | null): OsClassification {
if (!code) return { selectable: false, propertyType: null, builtForm: null };
const c = code.trim().toUpperCase();
// 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 {
selectable: true,
propertyType: facts?.propertyType ?? null,
builtForm: facts?.builtForm ?? null,
};
}