diff --git a/infrastructure/postgres/property_table.py b/infrastructure/postgres/property_table.py index c333cad4..56d0b2fa 100644 --- a/infrastructure/postgres/property_table.py +++ b/infrastructure/postgres/property_table.py @@ -1,5 +1,6 @@ from __future__ import annotations +from datetime import datetime from typing import ClassVar, Optional from sqlalchemy import Column @@ -48,3 +49,10 @@ class PropertyRow(SQLModel, table=True): user_inputted_address: Optional[str] = Field(default=None) user_inputted_postcode: Optional[str] = Field(default=None) lexiscore: Optional[float] = Field(default=None) + + # FE-owned columns the modelling pipeline now WRITES to record a run: the old + # engine set `has_recommendations` (engine.py); we mirror that, and bump + # `updated_at` so a run is datable (a first-run under the new process is + # `updated_at >= 2026-06-01`, the cutoff the old pipeline predates). + has_recommendations: Optional[bool] = Field(default=None) + updated_at: Optional[datetime] = Field(default=None) diff --git a/orchestration/modelling_orchestrator.py b/orchestration/modelling_orchestrator.py index 867cb8b2..55ae531d 100644 --- a/orchestration/modelling_orchestrator.py +++ b/orchestration/modelling_orchestrator.py @@ -126,6 +126,7 @@ class ModellingOrchestrator: solar_potential: Optional[SolarPotential] = _solar_potential_for( uow.solar, prop.identity.uprn ) + has_recommendations = False for scenario in scenarios: plan = self._plan_for( scorer, @@ -145,6 +146,13 @@ class ModellingOrchestrator: portfolio_id=portfolio_id, is_default=scenario.is_default, ) + has_recommendations = has_recommendations or bool(plan.measures) + # Record the run on the Property: the old engine's per-Property + # `has_recommendations` marker (true if any Scenario yielded a + # measure), with `updated_at` bumped so the run is datable. + uow.property.mark_modelled( + property_id, has_recommendations=has_recommendations + ) uow.commit() def _plan_for( diff --git a/repositories/property/property_postgres_repository.py b/repositories/property/property_postgres_repository.py index 3549d0fc..cca62df6 100644 --- a/repositories/property/property_postgres_repository.py +++ b/repositories/property/property_postgres_repository.py @@ -2,8 +2,9 @@ from __future__ import annotations from typing import Optional, cast -from sqlalchemy import Table +from sqlalchemy import Table, func from sqlalchemy import select as sa_select +from sqlalchemy import update as sa_update from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlmodel import Session, col, select @@ -116,6 +117,17 @@ class PropertyPostgresRepository(PropertyRepository): return {} return self._spatial_repo.get_for_uprns(uprns) + def mark_modelled(self, property_id: int, *, has_recommendations: bool) -> None: + # The old engine set `has_recommendations` per Property; we mirror it and + # bump `updated_at` (DB clock) so a new-process run is datable against the + # 2026-06-01 cutoff. Does not commit — the Unit of Work owns the txn. + stmt = ( + sa_update(self._table) + .where(self._table.c.id == property_id) + .values(has_recommendations=has_recommendations, updated_at=func.now()) + ) + self._session.execute(stmt) # pyright: ignore[reportDeprecated] + def insert_all(self, rows: list[PropertyIdentityInsert]) -> int: if not rows: return 0 diff --git a/repositories/property/property_repository.py b/repositories/property/property_repository.py index 5b22c874..e2f8284a 100644 --- a/repositories/property/property_repository.py +++ b/repositories/property/property_repository.py @@ -47,6 +47,15 @@ class PropertyRepository(ABC): 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)`` diff --git a/scripts/run_modelling_e2e.py b/scripts/run_modelling_e2e.py index a9e39fa6..6dab17d4 100644 --- a/scripts/run_modelling_e2e.py +++ b/scripts/run_modelling_e2e.py @@ -334,6 +334,12 @@ def _persist( portfolio_id=portfolio_id, is_default=scenario.is_default, ) + # Mark the Property as run under the new process (old engine's + # `has_recommendations` marker + a bumped `updated_at`); the modelling + # compute above runs on in-memory fakes, so this DB UoW must set it. + uow.property.mark_modelled( + property_id, has_recommendations=bool(plan.measures) + ) uow.commit() diff --git a/tests/orchestration/fakes.py b/tests/orchestration/fakes.py index 72f7cb4b..7b180ca3 100644 --- a/tests/orchestration/fakes.py +++ b/tests/orchestration/fakes.py @@ -63,6 +63,11 @@ class FakePropertyRepo(PropertyRepository): def get_many(self, property_ids: list[int]) -> Properties: return Properties([self._hydrate(property_id) for property_id in property_ids]) + def mark_modelled(self, property_id: int, *, has_recommendations: bool) -> None: + # Record the marker so tests can assert the pipeline set it. + self.modelled: dict[int, bool] = getattr(self, "modelled", {}) + self.modelled[property_id] = has_recommendations + def insert_all(self, rows: list[PropertyIdentityInsert]) -> int: self.inserted: list[PropertyIdentityInsert] = list(rows) return len(rows) diff --git a/tests/orchestration/test_bulk_upload_finaliser_orchestrator.py b/tests/orchestration/test_bulk_upload_finaliser_orchestrator.py index 94742dca..335a3e91 100644 --- a/tests/orchestration/test_bulk_upload_finaliser_orchestrator.py +++ b/tests/orchestration/test_bulk_upload_finaliser_orchestrator.py @@ -50,6 +50,11 @@ class FakePropertyRepository(PropertyRepository): def get_many(self, property_ids: list[int]) -> Properties: # pragma: no cover raise NotImplementedError + def mark_modelled( # pragma: no cover + self, property_id: int, *, has_recommendations: bool + ) -> None: + raise NotImplementedError + class FakeStatusWriter(BulkUploadStatusWriter): def __init__(self) -> None: diff --git a/tests/repositories/property/test_property_repository.py b/tests/repositories/property/test_property_repository.py index c075964f..ab757bde 100644 --- a/tests/repositories/property/test_property_repository.py +++ b/tests/repositories/property/test_property_repository.py @@ -111,3 +111,32 @@ def test_get_many_defaults_to_unrestricted_when_uprn_has_no_spatial_row( # Assert — an uncovered UPRN means unrestricted, not blocked (per legacy # `empty_spatial_df`; ADR-0020). assert properties.items[0].planning_restrictions == PlanningRestrictions() + + +def test_mark_modelled_sets_has_recommendations_and_bumps_updated_at( + db_engine: Engine, +) -> None: + # Arrange — a freshly-inserted property with no run recorded yet. + with Session(db_engine) as session: + row = PropertyRow(portfolio_id=7, uprn=12345) + session.add(row) + session.commit() + property_id = row.id + assert property_id is not None + assert row.has_recommendations is None + assert row.updated_at is None + + # Act — record a run that produced recommendations. + with Session(db_engine) as session: + PropertyPostgresRepository(session).mark_modelled( + property_id, has_recommendations=True + ) + session.commit() + + # Assert — the marker is set and updated_at is stamped, so the run is datable + # against the 2026-06-01 new-process cutoff. + with Session(db_engine) as session: + refreshed = session.get(PropertyRow, property_id) + assert refreshed is not None + assert refreshed.has_recommendations is True + assert refreshed.updated_at is not None