from __future__ import annotations from abc import ABC, abstractmethod from dataclasses import dataclass from typing import Optional from domain.property.properties import Properties from domain.property.property import Property @dataclass(frozen=True) class PropertyIdentityInsert: """One row inserted into the FE-owned ``property`` table at Finalise (ADR-0013). Mirrors the exact column set today's Next.js ``/finalize`` writes: nine fields plus ``creation_status='READY'``. ``address``/``postcode`` are the resolved (matched ?? user-inputted) values and may be ``None``. """ portfolio_id: int uprn: Optional[int] landlord_property_id: Optional[str] address: Optional[str] postcode: Optional[str] user_inputted_address: Optional[str] user_inputted_postcode: Optional[str] lexiscore: Optional[float] creation_status: str = "READY" class PropertyRepository(ABC): """Reads and writes the FE-owned ``property`` table. Reads hydrate the Property aggregate whole — never half a Property — from the identity row plus its source-data slices (EPC today; Site Notes / enrichments as later slices land) (ADR-0002, ADR-0012). Writes bulk-insert identity rows at Finalise (ADR-0013). One repository per aggregate. """ @abstractmethod def get(self, property_id: int) -> Property: ... @abstractmethod def get_many(self, property_ids: list[int]) -> Properties: """Load a batch of Properties whole, in a handful of per-table queries rather than one round-trip per property (ADR-0012). Order follows the input ids.""" ... @abstractmethod def mark_modelled(self, property_id: int, *, has_recommendations: bool) -> None: """Record that a Property has been run through the modelling pipeline: set ``has_recommendations`` (the old engine's per-Property marker — true when the Plan carries measures) and bump ``updated_at`` so the run is datable (a first-run under the new process is ``updated_at >= 2026-06-01``). Idempotent — re-running overwrites the same row.""" ... @abstractmethod def insert_all(self, rows: list[PropertyIdentityInsert]) -> int: """Bulk-insert identity rows, skipping any whose ``(portfolio_id, uprn)`` already exists (the FE partial unique index, ``WHERE uprn IS NOT NULL``). Rows with no UPRN are always inserted. Returns the number actually inserted; an empty list is a no-op returning 0 (ADR-0013).""" ... @abstractmethod def ids_by_uprn(self, portfolio_id: int, uprns: list[int]) -> dict[int, int]: """Map each given UPRN to its ``property.id`` within a portfolio. Used by the finaliser to attach ``property_overrides`` to UPRN-matched rows (ADR-0006) without a query per row — covers both rows just inserted and pre-existing ones (re-found by ``(portfolio_id, uprn)``). UPRNs with no matching property are simply absent from the result; an empty input is a no-op returning ``{}``.""" ...