From a6a48dd33746b6f7fbf68ff414ab536506e519fb Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Mon, 13 Jul 2026 15:13:06 +0000 Subject: [PATCH] =?UTF-8?q?Run=20a=20Calico=20load=20end=20to=20end=20and?= =?UTF-8?q?=20return=20its=20reconciliation=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- applications/condition/calico_load.py | 17 +++++ repositories/condition/condition_writer.py | 13 ++++ tests/condition/test_calico_load.py | 88 ++++++++++++++++++++++ 3 files changed, 118 insertions(+) create mode 100644 applications/condition/calico_load.py create mode 100644 repositories/condition/condition_writer.py create mode 100644 tests/condition/test_calico_load.py diff --git a/applications/condition/calico_load.py b/applications/condition/calico_load.py new file mode 100644 index 000000000..a9eb018e4 --- /dev/null +++ b/applications/condition/calico_load.py @@ -0,0 +1,17 @@ +from typing import BinaryIO + +from applications.condition.load_report import LoadReport +from repositories.condition.condition_writer import ConditionWriter +from repositories.condition.property_uprn_lookup import PropertyUprnLookup + + +def run_calico_load( + file_stream: BinaryIO, + uprn_lookup: PropertyUprnLookup, + export_year: int, + writer: ConditionWriter, +) -> LoadReport: + """Parse a Calico export, map each roof-covering observation to a condition + survey keyed by UPRN, persist them, and return the reconciliation report + (ADR-0064). ``export_year`` anchors renewal years so they don't drift.""" + raise NotImplementedError diff --git a/repositories/condition/condition_writer.py b/repositories/condition/condition_writer.py new file mode 100644 index 000000000..6434fcb36 --- /dev/null +++ b/repositories/condition/condition_writer.py @@ -0,0 +1,13 @@ +from typing import List, Protocol + +from domain.condition.property_condition_survey import PropertyConditionSurvey + + +class ConditionWriter(Protocol): + """Persists condition surveys. ``ConditionPostgres`` is the production + implementation; a load orchestrator depends on this port so it can be + exercised without a database.""" + + def bulk_insert_surveys( + self, surveys: List[PropertyConditionSurvey] + ) -> None: ... diff --git a/tests/condition/test_calico_load.py b/tests/condition/test_calico_load.py new file mode 100644 index 000000000..2735cef05 --- /dev/null +++ b/tests/condition/test_calico_load.py @@ -0,0 +1,88 @@ +from datetime import date +from io import BytesIO +from typing import List + +from openpyxl import Workbook + +from applications.condition.calico_load import run_calico_load +from applications.condition.load_report import LoadReport +from domain.condition.property_condition_survey import PropertyConditionSurvey +from repositories.condition.property_reference_reader import ( + PropertyReference, + PropertyReferenceReader, +) +from repositories.condition.property_uprn_lookup import PropertyUprnLookup + +HEADERS = [ + "Asset Reference", + "Asset Type", + "Address", + "Path", + "Type", + "Fitted / Renewed Date", + "Quantity", + "Remaining Life", +] + + +class FakePropertyReferenceReader(PropertyReferenceReader): + def __init__(self, references: List[PropertyReference]) -> None: + self._references = references + + def references_for_portfolio(self, portfolio_id: int) -> List[PropertyReference]: + return self._references + + +class FakeConditionWriter: + def __init__(self) -> None: + self.surveys: List[PropertyConditionSurvey] = [] + + def bulk_insert_surveys(self, surveys: List[PropertyConditionSurvey]) -> None: + self.surveys.extend(surveys) + + +def _workbook(rows: List[List[object]]) -> BytesIO: + wb = Workbook() + ws = wb.active + assert ws is not None + ws.append(HEADERS) + for row in rows: + ws.append(row) + buffer = BytesIO() + wb.save(buffer) + buffer.seek(0) + return buffer + + +def test_run_calico_load_persists_surveys_and_reports_reconciliation(): + # Arrange -- one loaded (443), one blank placeholder (438), one unmatched (777) + workbook = _workbook( + [ + [443, "HOUSE", "31 Venice Avenue", "Roof Covering", "Concrete Tiles", + date(1998, 4, 1), 55, 36], + [438, "FLAT", "137 Brownhill Avenue", "Roof Covering", "N/A", + date(2000, 4, 1), 0, 973], + [777, "HOUSE", "8 Marine Avenue", "Roof Covering", "Natural Slate", + date(2010, 4, 1), 60, 50], + ] + ) + reader = FakePropertyReferenceReader( + [ + PropertyReference(landlord_property_id="443", uprn=100093305101), + PropertyReference(landlord_property_id="999", uprn=None), + ] + ) + lookup = PropertyUprnLookup(reader, portfolio_id=824) + writer = FakeConditionWriter() + + # Act + report = run_calico_load(workbook, lookup, export_year=2026, writer=writer) + + # Assert + assert report == LoadReport( + loaded=1, unmatched_to_portfolio=1, null_uprn=1, blank_placeholder=1 + ) + assert len(writer.surveys) == 1 + persisted = writer.surveys[0] + assert persisted.uprn == 100093305101 + assert persisted.elements[0].aspect_conditions[0].renewal_year == 2026 + 36