Merge pull request #276 from Hestia-Homes/faeture/landlord_overrides
Some checks are pending
Test Suite / unit-tests (push) Waiting to run

Faeture/landlord overrides
This commit is contained in:
Jun-te Kim 2026-05-26 11:34:29 +01:00 committed by GitHub
commit 41d33ff399
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 9773 additions and 0 deletions

View file

@ -31,6 +31,20 @@ _Avoid_: aggregator, merger
The terminal action that reads the combiner output, inserts rows as Properties on the Portfolio, and decides whether the BulkUpload needs further review.
_Avoid_: import, commit, ingest
### Landlord overrides
**Landlord**:
The housing association supplying a Portfolio's BulkUploads. A Landlord knows facts about their properties that EPC data doesn't (e.g. that a cavity has been filled), and those facts take precedence when computing an assessment.
_Avoid_: customer, client, owner, organisation (Organisation is a separate, broader entity)
**Landlord override**:
A landlord-supplied fact about a property that takes precedence over EPC-derived defaults when computing an assessment. The end-to-end Landlord override journey has two layers — a **VocabularyMapping** layer (this glossary entry below) and a per-Property fact layer (not yet modelled).
_Avoid_: customer data, manual override, landlord data
**VocabularyMapping**:
The translation from a Landlord's free-text description in a BulkUpload column (e.g. `"cavity: filledcavity"`) to a canonical domain enum value (e.g. `WallType.CAVITY`). Produced by a `ColumnClassifier` (today an LLM, tomorrow possibly a lookup table or rules engine) in the Model service. Stored per-Portfolio, one row per `(category, description)`. A row carries provenance (`classifier` or `user`) so user overrides survive re-classification.
_Avoid_: column mapping (that's a separate concept — see `ColumnMapping` above), classification, dictionary
## Lifecycle
A **BulkUpload** moves through these statuses:
@ -58,6 +72,7 @@ See [ADR-0001](./docs/adr/0001-bulk-upload-state-machine.md) for the deliberate
- A **Portfolio** has many **BulkUploads**.
- A **BulkUpload** produces zero or more **Properties** when finalised.
- A **BulkUpload** has at most one **Task** (the orchestration handle for the FastAPI pipeline run); a Task has many **SubTasks** (one per pipeline stage: address matching, combiner).
- A **Portfolio** has many **VocabularyMappings** — one row per `(category, description)` it has ever encountered across all its BulkUploads. See [ADR-0002](./docs/adr/0002-landlord-override-vocabulary.md).
## Example dialogue

View file

@ -0,0 +1,33 @@
-- ENUM definition
CREATE TYPE "public"."override_source" AS ENUM('classifier', 'user');--> statement-breakpoint
CREATE TYPE "public"."property_type" AS ENUM('House', 'Bungalow', 'Flat', 'Maisonette', 'Park home', 'Unknown');--> statement-breakpoint
CREATE TYPE "public"."wall_type" AS ENUM('Cavity', 'Solid Brick', 'Timber frame', 'Sandstone', 'Unknown');--> statement-breakpoint
-- Lanlord Property Type overides
CREATE TABLE "landlord_property_type_overrides" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"portfolio_id" bigint NOT NULL,
"description" text NOT NULL,
"value" "property_type" NOT NULL,
"source" "override_source" NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "landlord_property_type_overrides_portfolio_description_unique" UNIQUE("portfolio_id","description")
);
-- Lanlodrd Wall type overrides
CREATE TABLE "landlord_wall_type_overrides" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"portfolio_id" bigint NOT NULL,
"description" text NOT NULL,
"value" "wall_type" NOT NULL,
"source" "override_source" NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "landlord_wall_type_overrides_portfolio_description_unique" UNIQUE("portfolio_id","description")
);
-- Add portfolio_id, so we know which portfolio its from
ALTER TABLE "landlord_property_type_overrides" ADD CONSTRAINT "landlord_property_type_overrides_portfolio_id_portfolio_id_fk" FOREIGN KEY ("portfolio_id") REFERENCES "public"."portfolio"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "landlord_wall_type_overrides" ADD CONSTRAINT "landlord_wall_type_overrides_portfolio_id_portfolio_id_fk" FOREIGN KEY ("portfolio_id") REFERENCES "public"."portfolio"("id") ON DELETE cascade ON UPDATE no action;

File diff suppressed because it is too large Load diff

View file

@ -1436,6 +1436,13 @@
"when": 1779092260406,
"tag": "0204_bizarre_black_bird",
"breakpoints": true
},
{
"idx": 205,
"version": "7",
"when": 1779791011744,
"tag": "0205_wonderful_pixie",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,92 @@
import {
bigint,
pgEnum,
pgTable,
text,
timestamp,
unique,
uuid,
} from "drizzle-orm/pg-core";
import { portfolio } from "./portfolio";
// Enum string values mirror /workspaces/home/github/Model/domain/sal/*.py
// exactly (PropertyType.value, WallType.value). Keep in sync — see
// docs/adr/0002-landlord-override-vocabulary.md.
export const PropertyTypeValues: [string, ...string[]] = [
"House",
"Bungalow",
"Flat",
"Maisonette",
"Park home",
"Unknown",
];
export const WallTypeValues: [string, ...string[]] = [
"Cavity",
"Solid Brick",
"Timber frame",
"Sandstone",
"Unknown",
];
export const OverrideSourceValues: [string, ...string[]] = [
"classifier",
"user",
];
export const propertyTypeEnum = pgEnum("property_type", PropertyTypeValues);
export const wallTypeEnum = pgEnum("wall_type", WallTypeValues);
export const overrideSourceEnum = pgEnum(
"override_source",
OverrideSourceValues,
);
export const landlordPropertyTypeOverrides = pgTable(
"landlord_property_type_overrides",
{
id: uuid("id").defaultRandom().primaryKey(),
portfolioId: bigint("portfolio_id", { mode: "bigint" })
.notNull()
.references(() => portfolio.id, { onDelete: "cascade" }),
description: text("description").notNull(),
value: propertyTypeEnum("value").notNull(),
source: overrideSourceEnum("source").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
(table) => ({
portfolioDescriptionUnique: unique(
"landlord_property_type_overrides_portfolio_description_unique",
).on(table.portfolioId, table.description),
}),
);
export const landlordWallTypeOverrides = pgTable(
"landlord_wall_type_overrides",
{
id: uuid("id").defaultRandom().primaryKey(),
portfolioId: bigint("portfolio_id", { mode: "bigint" })
.notNull()
.references(() => portfolio.id, { onDelete: "cascade" }),
description: text("description").notNull(),
value: wallTypeEnum("value").notNull(),
source: overrideSourceEnum("source").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
(table) => ({
portfolioDescriptionUnique: unique(
"landlord_wall_type_overrides_portfolio_description_unique",
).on(table.portfolioId, table.description),
}),
);