mirror of
https://github.com/Hestia-Homes/assessment-model.git
synced 2026-07-19 17:02:59 +00:00
feat(postcode-search): pure domain core — normalisation, OS-code mapping, write plan
TDD'd (9 tests): postcode normalisation to canonical form; the ADR-0007 deterministic OS classification mapping (RD02/03/04/06/07; other residential codes selectable with no facts, Unknown never written; RD10 + non-RD unselectable); 30-day cache freshness for postcode_search; DPA-preferred UPRN-deduped address candidates with postcode-stripped title-case addresses; buildWritePlan producing the property row (incl. local_authority/constituency from postcodes.io — closing the no-source gap) and 0-2 property_overrides facts keyed to the OS code. Adds 'os_places' to OverrideSourceValues — enum migration still needs generating via generate_migration.sh before deploy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
b86efddfdf
commit
5b224677dc
3 changed files with 258 additions and 0 deletions
|
|
@ -262,6 +262,8 @@ export const MainHeatingSystemValues: [string, ...string[]] = [
|
|||
export const OverrideSourceValues: [string, ...string[]] = [
|
||||
"classifier",
|
||||
"user",
|
||||
// Deterministic OS Places classification-code mapping (postcode search, ADR-0007)
|
||||
"os_places",
|
||||
];
|
||||
|
||||
export const propertyTypeEnum = pgEnum("property_type", PropertyTypeValues);
|
||||
|
|
|
|||
106
src/lib/postcodeSearch/model.test.ts
Normal file
106
src/lib/postcodeSearch/model.test.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildWritePlan,
|
||||
classifyOsCode,
|
||||
isCacheFresh,
|
||||
normalisePostcode,
|
||||
toAddressCandidates,
|
||||
} from "./model";
|
||||
|
||||
describe("classifyOsCode", () => {
|
||||
it("maps the five confident residential codes per ADR-0007", () => {
|
||||
expect(classifyOsCode("RD02")).toEqual({ selectable: true, propertyType: "House", builtForm: "Detached" });
|
||||
expect(classifyOsCode("RD03")).toEqual({ selectable: true, propertyType: "House", builtForm: "Semi-Detached" });
|
||||
// Terraced: mid/end unknowable — type only
|
||||
expect(classifyOsCode("RD04")).toEqual({ selectable: true, propertyType: "House", builtForm: null });
|
||||
expect(classifyOsCode("RD06")).toEqual({ selectable: true, propertyType: "Flat", builtForm: null });
|
||||
expect(classifyOsCode("RD07")).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 });
|
||||
}
|
||||
});
|
||||
|
||||
it("marks non-residential and institutional codes unselectable", () => {
|
||||
expect(classifyOsCode("CR08").selectable).toBe(false); // commercial retail
|
||||
expect(classifyOsCode("RD10").selectable).toBe(false); // residential institution
|
||||
expect(classifyOsCode(null).selectable).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isCacheFresh", () => {
|
||||
it("serves cache younger than 30 days, refreshes older", () => {
|
||||
const now = new Date("2026-07-06T12:00:00Z");
|
||||
expect(isCacheFresh(new Date("2026-06-10T12:00:00Z"), now)).toBe(true);
|
||||
expect(isCacheFresh(new Date("2026-06-06T11:00:00Z"), now)).toBe(false);
|
||||
expect(isCacheFresh(null, now)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toAddressCandidates", () => {
|
||||
const items = [
|
||||
{ DPA: { UPRN: "100", ADDRESS: "16 ALDER GROVE, MANCHESTER, M20 4TF", POSTCODE: "M20 4TF",
|
||||
CLASSIFICATION_CODE: "RD02", LAT: 53.43, LNG: -2.23 } },
|
||||
// LPI-only record — still usable
|
||||
{ LPI: { UPRN: "101", ADDRESS: "18 ALDER GROVE, MANCHESTER, M20 4TF", POSTCODE_LOCATOR: "M20 4TF",
|
||||
CLASSIFICATION_CODE: "CR08", LAT: 53.44, LNG: -2.24 } },
|
||||
// duplicate UPRN (DPA + LPI for same address) — deduped, DPA wins
|
||||
{ LPI: { UPRN: "100", ADDRESS: "16 ALDER GROVE ALT, M20 4TF", CLASSIFICATION_CODE: "RD02" } },
|
||||
];
|
||||
it("prefers DPA, dedupes by UPRN, strips the postcode from the address", () => {
|
||||
const out = toAddressCandidates(items as never, "M20 4TF");
|
||||
expect(out).toHaveLength(2);
|
||||
expect(out[0]).toMatchObject({
|
||||
uprn: "100", address: "16 Alder Grove, Manchester", postcode: "M20 4TF",
|
||||
classificationCode: "RD02", lat: 53.43, lng: -2.23, selectable: true,
|
||||
propertyType: "House", builtForm: "Detached",
|
||||
});
|
||||
expect(out[1]).toMatchObject({ uprn: "101", selectable: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWritePlan", () => {
|
||||
const geo = { localAuthority: "Manchester", constituency: "Manchester Withington" };
|
||||
it("builds the property row and both override layers for a mapped candidate", () => {
|
||||
const plan = buildWritePlan(
|
||||
{ uprn: "100", address: "16 Alder Grove, Manchester", postcode: "M20 4TF",
|
||||
classificationCode: "RD02", lat: 53.43, lng: -2.23, selectable: true,
|
||||
propertyType: "House", builtForm: "Detached" },
|
||||
geo,
|
||||
);
|
||||
expect(plan.property).toMatchObject({
|
||||
address: "16 Alder Grove, Manchester", postcode: "M20 4TF", uprn: 100n,
|
||||
latitude: 53.43, longitude: -2.23,
|
||||
localAuthority: "Manchester", constituency: "Manchester Withington",
|
||||
});
|
||||
// both facts, snapshot keyed to the OS code, main building part
|
||||
expect(plan.overrides).toEqual([
|
||||
{ component: "property_type", value: "House", originalDescription: "RD02", buildingPart: 0 },
|
||||
{ component: "built_form_type", value: "Detached", originalDescription: "RD02", 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",
|
||||
lat: null, lng: null, selectable: true, propertyType: null, builtForm: null },
|
||||
geo,
|
||||
);
|
||||
expect(plan.overrides).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalisePostcode", () => {
|
||||
it("uppercases and collapses to the canonical single-space form", () => {
|
||||
expect(normalisePostcode("m20 4tf")).toBe("M20 4TF");
|
||||
expect(normalisePostcode(" sw1a1aa ")).toBe("SW1A 1AA");
|
||||
expect(normalisePostcode("M20 4TF")).toBe("M20 4TF");
|
||||
});
|
||||
|
||||
it("returns null for strings that are not plausible postcodes", () => {
|
||||
expect(normalisePostcode("")).toBeNull();
|
||||
expect(normalisePostcode("not a postcode")).toBeNull();
|
||||
expect(normalisePostcode("12345")).toBeNull();
|
||||
});
|
||||
});
|
||||
150
src/lib/postcodeSearch/model.ts
Normal file
150
src/lib/postcodeSearch/model.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
/**
|
||||
* 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
|
||||
};
|
||||
|
||||
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).
|
||||
*/
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export interface PostcodeGeo {
|
||||
localAuthority: string | null;
|
||||
constituency: string | null;
|
||||
}
|
||||
|
||||
/** The rows a selected candidate produces: one property + 0–2 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,
|
||||
};
|
||||
}
|
||||
|
||||
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");
|
||||
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,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue