From da0ef3e5e0abdbd5e0bd227eeb09d5aae8be62aa Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 08:24:11 +0000 Subject: [PATCH 01/32] =?UTF-8?q?Abri=20tenant=20data=20fetch=20triggers?= =?UTF-8?q?=20when=20commencement=20date=20is=20first=20set=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/hubspot_deal_differ.py | 8 +++++ etl/hubspot/tests/test_hubspot_deal_differ.py | 33 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index c4e9c0148..0a20c12d8 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -206,6 +206,14 @@ class HubspotDealDiffer: return old_deal.confirmed_survey_time != new_deal.get("confirmed_survey_time") + @staticmethod + def check_for_abri_tenant_data_fetch( + new_deal: Dict[str, str], + new_project: Optional[ProjectData], + old_deal: HubspotDealData, + ) -> bool: + raise NotImplementedError + @staticmethod def _is_abri_condition_deal( new_deal: Dict[str, str], new_project: Optional[ProjectData] diff --git a/etl/hubspot/tests/test_hubspot_deal_differ.py b/etl/hubspot/tests/test_hubspot_deal_differ.py index 544242623..8c47f97c8 100644 --- a/etl/hubspot/tests/test_hubspot_deal_differ.py +++ b/etl/hubspot/tests/test_hubspot_deal_differ.py @@ -687,6 +687,39 @@ def test_abri_survey_amendment__date_first_set_on_abri_deal__returns_false() -> assert result is False +# ==================================== +# ABRI TENANT DATA FETCH TRIGGER TESTS +# ==================================== + + +def test_abri_tenant_data_fetch__commencement_date_first_set_on_abri_deal__returns_true() -> ( + None +): + deal_id = uuid.uuid4() + + # Arrange + old_deal = make_old_deal( + id=deal_id, + project_code="Abri Condition", + expected_commencement_date=None, + ) + new_deal = make_new_deal( + deal_id, + project_code="Abri Condition", + expected_commencement_date="2026-08-01", + ) + + # Act + result = HubspotDealDiffer.check_for_abri_tenant_data_fetch( + new_deal=new_deal, + new_project=None, + old_deal=old_deal, + ) + + # Assert + assert result is True + + # ======================= # DB UPDATE TRIGGER TESTS # ======================= From 2f1dfaa5a6f0f3ccf5b64fb0c9a1b775c00c7962 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 08:24:36 +0000 Subject: [PATCH 02/32] =?UTF-8?q?Abri=20tenant=20data=20fetch=20triggers?= =?UTF-8?q?=20when=20commencement=20date=20is=20first=20set=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/hubspot_deal_differ.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index 0a20c12d8..4ebed8f3d 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -212,7 +212,7 @@ class HubspotDealDiffer: new_project: Optional[ProjectData], old_deal: HubspotDealData, ) -> bool: - raise NotImplementedError + return True @staticmethod def _is_abri_condition_deal( From 0b8f0f17a9f5762206f24656e4c4b2c36e6cd9e2 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 08:25:07 +0000 Subject: [PATCH 03/32] =?UTF-8?q?Abri=20tenant=20data=20fetch=20does=20not?= =?UTF-8?q?=20trigger=20for=20non-abri=20deals=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/tests/test_hubspot_deal_differ.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/etl/hubspot/tests/test_hubspot_deal_differ.py b/etl/hubspot/tests/test_hubspot_deal_differ.py index 8c47f97c8..e57007684 100644 --- a/etl/hubspot/tests/test_hubspot_deal_differ.py +++ b/etl/hubspot/tests/test_hubspot_deal_differ.py @@ -720,6 +720,32 @@ def test_abri_tenant_data_fetch__commencement_date_first_set_on_abri_deal__retur assert result is True +def test_abri_tenant_data_fetch__non_abri_deal__returns_false() -> None: + deal_id = uuid.uuid4() + + # Arrange + old_deal = make_old_deal( + id=deal_id, + project_code="Southern Retrofit", + expected_commencement_date=None, + ) + new_deal = make_new_deal( + deal_id, + project_code="Southern Retrofit", + expected_commencement_date="2026-08-01", + ) + + # Act + result = HubspotDealDiffer.check_for_abri_tenant_data_fetch( + new_deal=new_deal, + new_project=None, + old_deal=old_deal, + ) + + # Assert + assert result is False + + # ======================= # DB UPDATE TRIGGER TESTS # ======================= From 426aa9a15e40af10af9a4795cba601c71e110cff Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 08:25:26 +0000 Subject: [PATCH 04/32] =?UTF-8?q?Abri=20tenant=20data=20fetch=20does=20not?= =?UTF-8?q?=20trigger=20for=20non-abri=20deals=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/hubspot_deal_differ.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index 4ebed8f3d..134a6b80e 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -212,7 +212,7 @@ class HubspotDealDiffer: new_project: Optional[ProjectData], old_deal: HubspotDealData, ) -> bool: - return True + return HubspotDealDiffer._is_abri_condition_deal(new_deal, new_project) @staticmethod def _is_abri_condition_deal( From c18f4feb3efd1140f534a9436c4ac791219daa57 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 08:26:02 +0000 Subject: [PATCH 05/32] =?UTF-8?q?Abri=20tenant=20data=20fetch=20ignores=20?= =?UTF-8?q?already-set=20commencement=20dates=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/tests/test_hubspot_deal_differ.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/etl/hubspot/tests/test_hubspot_deal_differ.py b/etl/hubspot/tests/test_hubspot_deal_differ.py index e57007684..a241c5189 100644 --- a/etl/hubspot/tests/test_hubspot_deal_differ.py +++ b/etl/hubspot/tests/test_hubspot_deal_differ.py @@ -746,6 +746,32 @@ def test_abri_tenant_data_fetch__non_abri_deal__returns_false() -> None: assert result is False +def test_abri_tenant_data_fetch__commencement_date_already_set__returns_false() -> None: + deal_id = uuid.uuid4() + + # Arrange + old_deal = make_old_deal( + id=deal_id, + project_code="Abri Condition", + expected_commencement_date=datetime(2026, 7, 1, tzinfo=timezone.utc), + ) + new_deal = make_new_deal( + deal_id, + project_code="Abri Condition", + expected_commencement_date="2026-08-01", + ) + + # Act + result = HubspotDealDiffer.check_for_abri_tenant_data_fetch( + new_deal=new_deal, + new_project=None, + old_deal=old_deal, + ) + + # Assert + assert result is False + + # ======================= # DB UPDATE TRIGGER TESTS # ======================= From b4d2d9cd3743b689ed79019ff7c387f3000f868c Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 08:26:20 +0000 Subject: [PATCH 06/32] =?UTF-8?q?Abri=20tenant=20data=20fetch=20ignores=20?= =?UTF-8?q?already-set=20commencement=20dates=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/hubspot_deal_differ.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index 134a6b80e..7b2e70e27 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -212,7 +212,10 @@ class HubspotDealDiffer: new_project: Optional[ProjectData], old_deal: HubspotDealData, ) -> bool: - return HubspotDealDiffer._is_abri_condition_deal(new_deal, new_project) + if not HubspotDealDiffer._is_abri_condition_deal(new_deal, new_project): + return False + + return old_deal.expected_commencement_date is None @staticmethod def _is_abri_condition_deal( From 47842da7b93e96f276004322cd23cbac95427621 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 08:27:22 +0000 Subject: [PATCH 07/32] =?UTF-8?q?Abri=20tenant=20data=20fetch=20requires?= =?UTF-8?q?=20a=20parseable=20new=20commencement=20date=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/tests/test_hubspot_deal_differ.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/etl/hubspot/tests/test_hubspot_deal_differ.py b/etl/hubspot/tests/test_hubspot_deal_differ.py index a241c5189..0a5e96e9b 100644 --- a/etl/hubspot/tests/test_hubspot_deal_differ.py +++ b/etl/hubspot/tests/test_hubspot_deal_differ.py @@ -772,6 +772,42 @@ def test_abri_tenant_data_fetch__commencement_date_already_set__returns_false() assert result is False +@pytest.mark.parametrize( + "new_overrides", + [ + {}, + {"expected_commencement_date": ""}, + {"expected_commencement_date": "not-a-date"}, + ], +) +def test_abri_tenant_data_fetch__no_parseable_new_commencement_date__returns_false( + new_overrides: Dict[str, str], +) -> None: + deal_id = uuid.uuid4() + + # Arrange + old_deal = make_old_deal( + id=deal_id, + project_code="Abri Condition", + expected_commencement_date=None, + ) + new_deal = make_new_deal( + deal_id, + project_code="Abri Condition", + **new_overrides, + ) + + # Act + result = HubspotDealDiffer.check_for_abri_tenant_data_fetch( + new_deal=new_deal, + new_project=None, + old_deal=old_deal, + ) + + # Assert + assert result is False + + # ======================= # DB UPDATE TRIGGER TESTS # ======================= From 5df1237b909d58bd92711ac5c42eac50caceec16 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 08:27:42 +0000 Subject: [PATCH 08/32] =?UTF-8?q?Abri=20tenant=20data=20fetch=20requires?= =?UTF-8?q?=20a=20parseable=20new=20commencement=20date=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/hubspot_deal_differ.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index 7b2e70e27..c138182d9 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -215,7 +215,10 @@ class HubspotDealDiffer: if not HubspotDealDiffer._is_abri_condition_deal(new_deal, new_project): return False - return old_deal.expected_commencement_date is None + if old_deal.expected_commencement_date is not None: + return False + + return parse_hs_date(new_deal.get("expected_commencement_date")) is not None @staticmethod def _is_abri_condition_deal( From a36fe0013224eeed681b970fa865382ff3d143e3 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 08:28:25 +0000 Subject: [PATCH 09/32] =?UTF-8?q?Abri=20tenant=20data=20fetch=20resolves?= =?UTF-8?q?=20abri=20deals=20by=20associated=20project=20id=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/tests/test_hubspot_deal_differ.py | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/etl/hubspot/tests/test_hubspot_deal_differ.py b/etl/hubspot/tests/test_hubspot_deal_differ.py index 0a5e96e9b..660481dd2 100644 --- a/etl/hubspot/tests/test_hubspot_deal_differ.py +++ b/etl/hubspot/tests/test_hubspot_deal_differ.py @@ -808,6 +808,65 @@ def test_abri_tenant_data_fetch__no_parseable_new_commencement_date__returns_fal assert result is False +def test_abri_tenant_data_fetch__associated_abri_project_id__returns_true() -> None: + deal_id = uuid.uuid4() + + # Arrange + old_deal = make_old_deal( + id=deal_id, + project_code="Southern Retrofit", + expected_commencement_date=None, + ) + new_deal = make_new_deal( + deal_id, + project_code="Southern Retrofit", + expected_commencement_date="2026-08-01", + ) + new_project = ProjectData( + project_id=HubspotDealDiffer.ABRI_CONDITION_ASSOCIATED_PROJECT_ID, + name="Abri Condition", + ) + + # Act + result = HubspotDealDiffer.check_for_abri_tenant_data_fetch( + new_deal=new_deal, + new_project=new_project, + old_deal=old_deal, + ) + + # Assert + assert result is True + + +def test_abri_tenant_data_fetch__non_abri_project_id_with_abri_code__returns_false() -> ( + None +): + deal_id = uuid.uuid4() + + # Arrange + old_deal = make_old_deal( + id=deal_id, + project_code="Abri Condition", + expected_commencement_date=None, + ) + new_deal = make_new_deal( + deal_id, + project_code="Abri Condition", + expected_commencement_date="2026-08-01", + ) + new_project = ProjectData(project_id="999999", name="Southern Retrofit") + + # Act + result = HubspotDealDiffer.check_for_abri_tenant_data_fetch( + new_deal=new_deal, + new_project=new_project, + old_deal=old_deal, + ) + + # Assert + assert result is False + + # ======================= # DB UPDATE TRIGGER TESTS # ======================= From 05ca0bb2801a134f85165034c458c1e331e3569e Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 08:33:10 +0000 Subject: [PATCH 10/32] move static method below non-statics --- etl/hubspot/hubspot_deal_differ.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index c138182d9..fbdc69b61 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -220,6 +220,14 @@ class HubspotDealDiffer: return parse_hs_date(new_deal.get("expected_commencement_date")) is not None + @staticmethod + def check_for_magicplan_trigger( + new_deal: Dict[str, str], old_deal: HubspotDealData + ) -> bool: + new_outcome = (new_deal.get("outcome") or "").lower() + old_outcome = (old_deal.outcome or "").lower() + return new_outcome == "surveyed" and old_outcome != "surveyed" + @staticmethod def _is_abri_condition_deal( new_deal: Dict[str, str], new_project: Optional[ProjectData] @@ -233,14 +241,6 @@ class HubspotDealDiffer: new_project_code = (new_deal.get("project_code") or "").lower() return new_project_code == HubspotDealDiffer.ABRI_CONDITION_PROJECT_CODE.lower() - @staticmethod - def check_for_magicplan_trigger( - new_deal: Dict[str, str], old_deal: HubspotDealData - ) -> bool: - new_outcome = (new_deal.get("outcome") or "").lower() - old_outcome = (old_deal.outcome or "").lower() - return new_outcome == "surveyed" and old_outcome != "surveyed" - @staticmethod def _has_valid_pashub_link(new_pashub_link: str) -> bool: return bool(new_pashub_link) From 5e7e51d8b2e12262c27bc84c66c4a148f2969363 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 3 Jul 2026 09:11:38 +0000 Subject: [PATCH 11/32] =?UTF-8?q?An=20electric=20room=20heaters=20override?= =?UTF-8?q?=20sets=20the=20room-heater=20heating=20category=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 --- .../epc/test_main_heating_system_overlay.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/domain/epc/test_main_heating_system_overlay.py b/tests/domain/epc/test_main_heating_system_overlay.py index dae992cba..27eb6625b 100644 --- a/tests/domain/epc/test_main_heating_system_overlay.py +++ b/tests/domain/epc/test_main_heating_system_overlay.py @@ -58,6 +58,22 @@ def test_electric_room_heaters_assume_a_dual_economy7_meter() -> None: assert simulation.heating.meter_type == "Dual" +def test_electric_room_heaters_overlay_the_room_heater_category() -> None: + # A landlord "Electric room heaters" override replaces the lodged system, so + # the overlay must stamp the room-heater heating category (SAP Table 4a + # Category 10). Leaving it unset lets a replaced storage heater's category 7 + # survive onto the effective cert, which the SAP 10.2 Table 12a resolver + # (keyed on category) mis-reads as off-peak storage and bills the peaky room + # heaters at the all-night low rate. + # Act + simulation = main_heating_overlay_for("Electric room heaters", 0) + + # Assert — SAP Table 4a Category 10 (Room heaters). + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.main_heating_category == 10 + + @pytest.mark.parametrize( ("main_heating_value", "code"), [ From bf032d17d09d571ba1ef10804f6c23011993c666 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 3 Jul 2026 09:12:19 +0000 Subject: [PATCH 12/32] =?UTF-8?q?An=20electric=20room=20heaters=20override?= =?UTF-8?q?=20sets=20the=20room-heater=20heating=20category=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Category 10 (SAP Table 4a Room heaters) now overlaid for electric room heaters (691-701), so a replaced storage heater's category 7 no longer survives onto the effective cert and mis-bills via the Table 12a resolver. Co-Authored-By: Claude Opus 4.8 --- domain/epc/property_overlays/main_heating_system_overlay.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/domain/epc/property_overlays/main_heating_system_overlay.py b/domain/epc/property_overlays/main_heating_system_overlay.py index 466466594..2e046dd90 100644 --- a/domain/epc/property_overlays/main_heating_system_overlay.py +++ b/domain/epc/property_overlays/main_heating_system_overlay.py @@ -280,7 +280,7 @@ def _category_for(code: int) -> Optional[int]: category 7); without it a storage override left a stale category behind, which the SAP 10.2 Table 12a resolver (keyed on category) could misread as a non-storage system and price the off-peak storage heating at the peak rate.""" - if code in _CATEGORY_10_ROOM_HEATER_CODES: + if code in _CATEGORY_10_ROOM_HEATER_CODES or code in _ELECTRIC_ROOM_HEATER_CODES: return _ROOM_HEATER_CATEGORY if code in _STORAGE_HEATER_CODES: return _STORAGE_HEATER_CATEGORY From cfd979950015bb61324337b7ce19078cc8381007 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 3 Jul 2026 09:13:03 +0000 Subject: [PATCH 13/32] =?UTF-8?q?An=20electric=20room=20heaters=20override?= =?UTF-8?q?=20sets=20the=20room-heater=20charge=20control=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 --- .../epc/test_main_heating_system_overlay.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/domain/epc/test_main_heating_system_overlay.py b/tests/domain/epc/test_main_heating_system_overlay.py index 27eb6625b..dff74bccf 100644 --- a/tests/domain/epc/test_main_heating_system_overlay.py +++ b/tests/domain/epc/test_main_heating_system_overlay.py @@ -74,6 +74,22 @@ def test_electric_room_heaters_overlay_the_room_heater_category() -> None: assert simulation.heating.main_heating_category == 10 +def test_electric_room_heaters_overlay_the_room_heater_charge_control() -> None: + # A system-replacing override must stamp its own control, not inherit the + # replaced system's. Electric room heaters take the Table 4e Group 6 + # room-heater control 2601 (+0.3 C, the conservative no-thermostat default, + # matching the gas/oil/solid room heaters). Leaving it unset lets a replaced + # storage heater's manual charge control 2401 (+0.7 C) survive and over-warm + # the modelled dwelling. + # Act + simulation = main_heating_overlay_for("Electric room heaters", 0) + + # Assert — SAP Table 4e Group 6 room-heater control. + assert simulation is not None + assert simulation.heating is not None + assert simulation.heating.main_heating_control == 2601 + + @pytest.mark.parametrize( ("main_heating_value", "code"), [ From ffd21689fd96d05b82c2cd127d2097c06f8f1d22 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 3 Jul 2026 09:13:32 +0000 Subject: [PATCH 14/32] =?UTF-8?q?An=20electric=20room=20heaters=20override?= =?UTF-8?q?=20sets=20the=20room-heater=20charge=20control=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Control 2601 (SAP Table 4e Group 6 room-heater, +0.3 C conservative default) now overlaid for electric room heaters, matching the gas/oil/ solid siblings, so a replaced storage heater's manual charge control 2401 (+0.7 C) no longer survives and over-warms the modelled dwelling. Co-Authored-By: Claude Opus 4.8 --- domain/epc/property_overlays/main_heating_system_overlay.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/domain/epc/property_overlays/main_heating_system_overlay.py b/domain/epc/property_overlays/main_heating_system_overlay.py index 2e046dd90..9782174e3 100644 --- a/domain/epc/property_overlays/main_heating_system_overlay.py +++ b/domain/epc/property_overlays/main_heating_system_overlay.py @@ -266,7 +266,7 @@ def _control_for(code: int) -> Optional[int]: return _MANUAL_CHARGE_CONTROL if code in _GAS_BOILER_CODES: return _FULL_BOILER_CONTROL - if code in _CATEGORY_10_ROOM_HEATER_CODES: + if code in _CATEGORY_10_ROOM_HEATER_CODES or code in _ELECTRIC_ROOM_HEATER_CODES: return _ROOM_HEATER_CONTROL return None From ea113b58fbb03380d701116041a562975cd872cb Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 3 Jul 2026 09:15:31 +0000 Subject: [PATCH 15/32] =?UTF-8?q?The=20overlay=20logs=20an=20error=20when?= =?UTF-8?q?=20it=20cannot=20complete=20a=20heating=20companion=20set=20?= =?UTF-8?q?=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 --- .../epc/test_main_heating_system_overlay.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/domain/epc/test_main_heating_system_overlay.py b/tests/domain/epc/test_main_heating_system_overlay.py index dff74bccf..266e14415 100644 --- a/tests/domain/epc/test_main_heating_system_overlay.py +++ b/tests/domain/epc/test_main_heating_system_overlay.py @@ -6,6 +6,8 @@ calculator reads from the primary system; the overlay is whole-dwelling. from __future__ import annotations +import logging + import pytest from domain.epc.property_overrides.main_heating_system_type import MainHeatingSystemType @@ -90,6 +92,22 @@ def test_electric_room_heaters_overlay_the_room_heater_charge_control() -> None: assert simulation.heating.main_heating_control == 2601 +def test_overlay_logs_when_it_cannot_complete_a_companion_set( + caplog: pytest.LogCaptureFixture, +) -> None: + # An archetype the overlay cannot yet fully companion — here an air-source + # heat pump, whose Table 4e Group 2 conservative control is not yet mapped — + # is still overlaid (log-and-continue), but the incomplete companion set is + # logged as an error so the known gap is visible (CloudWatch) rather than + # silently inheriting the replaced system's stale value. + # Act + with caplog.at_level(logging.ERROR): + main_heating_overlay_for("Air source heat pump", 0) + + # Assert — the gap is reported and names the archetype. + assert "Air source heat pump" in caplog.text + + @pytest.mark.parametrize( ("main_heating_value", "code"), [ From 390e3e89d791a765ea0dd0be4acffbb313557d6f Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 3 Jul 2026 09:16:25 +0000 Subject: [PATCH 16/32] =?UTF-8?q?The=20overlay=20logs=20an=20error=20when?= =?UTF-8?q?=20it=20cannot=20complete=20a=20heating=20companion=20set=20?= =?UTF-8?q?=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A system-replacing override whose category or control is not yet mapped now logs an error naming the archetype and code, so the incomplete companion set (which otherwise silently inherits the replaced system's value) is visible for review — log-and-continue, matching flag_fuel_mismatch. Co-Authored-By: Claude Opus 4.8 --- .../main_heating_system_overlay.py | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/domain/epc/property_overlays/main_heating_system_overlay.py b/domain/epc/property_overlays/main_heating_system_overlay.py index 9782174e3..d0c498320 100644 --- a/domain/epc/property_overlays/main_heating_system_overlay.py +++ b/domain/epc/property_overlays/main_heating_system_overlay.py @@ -28,6 +28,7 @@ are left UNKNOWN until modelled. Unresolvable values produce no overlay. from __future__ import annotations +import logging from typing import Optional from domain.modelling.simulation import EpcSimulation, HeatingOverlay @@ -35,6 +36,8 @@ from domain.sap10_calculator.tables.table_12a import ( OFF_PEAK_IMPLYING_HEATING_CODES, ) +logger = logging.getLogger(__name__) + # Off-peak (Economy 7) meter. Electric storage / CPSU systems charge overnight at # the low rate and cannot run economically on a single-rate meter; "Dual" lets # the §12 dispatch resolve the specific tariff (storage 7-hour, CPSU 10-hour). @@ -351,13 +354,32 @@ def main_heating_overlay_for( return None if code in _GAS_BOILER_CODES: return EpcSimulation(heating=_gas_boiler_overlay(code)) + category = _category_for(code) + control = _control_for(code) + if category is None or control is None: + # A system-replacing override should stamp a complete, coherent companion + # set; an unmapped category or control leaves the field unset, so the + # effective cert inherits the REPLACED system's value (a silent mis-rating + # — e.g. a room heater keeping a storage heater's category/control). We do + # not yet have confident defaults for every archetype, so log-and-continue + # (matching `flag_fuel_mismatch`) to make the gap visible for review + # rather than raise mid-run. + logger.error( + "Landlord main_heating_system %r (SAP code %d) overlaid with an " + "incomplete companion set (category=%s, control=%s); the effective " + "cert may inherit the replaced system's value — known gap, review", + main_heating_value, + code, + category, + control, + ) return EpcSimulation( heating=HeatingOverlay( sap_main_heating_code=code, - main_heating_category=_category_for(code), + main_heating_category=category, main_fuel_type=_natural_fuel_for(code), meter_type=_meter_for(code), - main_heating_control=_control_for(code), + main_heating_control=control, # A landlord override describes the existing dwelling, so its assumed # off-peak meter must not downgrade a more-off-peak cert meter # (e.g. a 24-hour all-low tariff). Measures, which re-meter for real, From 962a443ed223920205390f8d9d938d74784fb726 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 3 Jul 2026 09:17:06 +0000 Subject: [PATCH 17/32] =?UTF-8?q?A=20complete=20room-heater=20overlay=20do?= =?UTF-8?q?es=20not=20log=20a=20companion=20gap=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../domain/epc/test_main_heating_system_overlay.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/domain/epc/test_main_heating_system_overlay.py b/tests/domain/epc/test_main_heating_system_overlay.py index 266e14415..40998c7d7 100644 --- a/tests/domain/epc/test_main_heating_system_overlay.py +++ b/tests/domain/epc/test_main_heating_system_overlay.py @@ -108,6 +108,20 @@ def test_overlay_logs_when_it_cannot_complete_a_companion_set( assert "Air source heat pump" in caplog.text +def test_complete_room_heater_overlay_does_not_log_a_companion_gap( + caplog: pytest.LogCaptureFixture, +) -> None: + # Electric room heaters now resolve a full companion set (category 10 + + # control 2601), so the overlay must NOT log an incomplete-companion gap — + # guarding that the fix keeps the set complete. + # Act + with caplog.at_level(logging.ERROR): + main_heating_overlay_for("Electric room heaters", 0) + + # Assert + assert "incomplete companion set" not in caplog.text + + @pytest.mark.parametrize( ("main_heating_value", "code"), [ From 244c5b65edf0b0561cd04f7e0bb5eacb738bee5e Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Fri, 3 Jul 2026 09:21:47 +0000 Subject: [PATCH 18/32] Document heating-companion coherence: category/control forced, fuel/meter deferred (ADR-0048) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0048 records why a system-replacing heating override must emit a complete companion set — category and control archetype-forced for coherence, fuel and meter deferred where multiple values are valid, and an unmapped companion logged rather than silently inherited. Adds the Heating Companion Set glossary term to CONTEXT.md. Co-Authored-By: Claude Opus 4.8 --- CONTEXT.md | 4 + ...egory-and-control-defers-fuel-and-meter.md | 132 ++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 docs/adr/0048-heating-override-forces-category-and-control-defers-fuel-and-meter.md diff --git a/CONTEXT.md b/CONTEXT.md index e0b4a8493..182959e66 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -103,6 +103,10 @@ _Avoid_: patches (deprecated), corrections, manual EPC, edits **Landlord-Description Classification**: Resolving a **Landlord Description** (unbounded free-text a landlord supplies for one component — "CWI" / "Cav filled" / "cavity insulated" all name one thing) onto a **Recognised Internal Description** via an LLM classifier, persisted in the `landlord_*_overrides` table (`source=classifier`) as a reviewed cache. Four vocabularies are kept **distinct** and must not be conflated: a **Landlord Description** (unbounded input); a **Recognised Internal Description** (the closed target taxonomy — e.g. a `MainHeatingSystemType` archetype — each binding to a Simulation Overlay); a **Lodged Description** (the gov-EPC `main_heating[].description` rendering, e.g. "Room heaters, electric" — only an example of which system *types* occur, never a map key); and the **SAP main heating code** (Table 4a/4b, what the calculator consumes). The classifier maps Landlord → Recognised Internal → SAP code. When it cannot confidently place the text it emits **`None`** (no overlay → the lodged EPC stands, surfaced to the user as "no suitable match"), **never the nearest wrong archetype** — the target taxonomy must be complete enough that a real system always has a correct home, so the classifier never overflows into a garbage-drawer archetype (ADR-0041). + +**Heating Companion Set**: +The coherent SAP inputs a **main heating system** override drags alongside the **SAP main heating code** so the **Effective EPC** reads as one internally-consistent system, not a hybrid of the new code and the *replaced* system's leftovers — heating **category** (SAP Table 4a group: gas→2, heat pump→4, network→6, storage→7, electric underfloor→8, warm air→9, room heater→10), **charge/heating control** (Table 4e group), **natural fuel**, and **meter** (see **Off-Peak Meter**). Companions divide by how firmly the archetype fixes them (ADR-0048): **category and control are archetype-forced** — always written, never inherited, because a wrong value silently mis-scores or mis-bills (a leftover storage **category 7** makes the Table 12a resolver bill peaky room heaters at the all-night rate; a leftover storage **control 2401** adds a +0.7 °C Table-4e penalty a room heater should not carry). **Fuel and meter defer** only where multiple values are genuinely coherent — fuel for an ambiguous carrier (community heating: gas CHP / biomass / waste heat), meter for a meter-flexible system (room heaters run on either; screed underfloor per ADR-0046) — while a meter-*locked* system still forces (storage/CPSU/HHRSH→Dual, non-electric→Single, ADR-0035). Because the overlay composes last-wins, an **unset (`None`)** forced companion is *inherited from the replaced system*, not cleared — so an archetype the overlay cannot yet fully companion (heat pump / community / underfloor / electric boiler control defaults are unmapped) is **logged as an error and continues** (log-not-raise, matching `flag_fuel_mismatch`), making the gap visible for review rather than shipping a silently-incoherent cert; the intended end-state is to fill each and flip the log to a raise. +_Avoid_: coherent companions (informal), dragged fields, heating defaults _Avoid_: "the LLM mapper is unreliable" (the failure mode is a too-small target taxonomy, not LLM language ability); conflating the landlord input vocabulary with the gov-EPC lodged rendering or the RdSAP entry-tool catalogue; treating a deterministic dict as a *replacement* for the LLM rather than a reviewed cache of its output **Lodgement Sentinels (ND / NI / AB)**: diff --git a/docs/adr/0048-heating-override-forces-category-and-control-defers-fuel-and-meter.md b/docs/adr/0048-heating-override-forces-category-and-control-defers-fuel-and-meter.md new file mode 100644 index 000000000..5b2a1870f --- /dev/null +++ b/docs/adr/0048-heating-override-forces-category-and-control-defers-fuel-and-meter.md @@ -0,0 +1,132 @@ +# A system-replacing heating override forces category and control for coherence, and defers fuel and meter where multiple values are valid + +## Status + +accepted + +## Context + +A **Landlord Override** naming a main heating system resolves to a SAP Table 4a +code, and `main_heating_system_overlay` synthesises the **coherent companions** +that code implies (ADR-0035): heating category, charge control, natural fuel, +meter. The overlay is applied over the lodged cert by last-wins composition, so a +companion the overlay leaves **unset (`None`)** is *inherited from the replaced +system* — not cleared. + +That inheritance is a silent mis-rating when the archetype genuinely determines +the companion. A portfolio-796 audit found the case end-to-end on **86+ +properties**: a lodged **slimline storage heater** (SAP 402, category 7, control +2401, off-peak) overridden to **"Electric room heaters"** (SAP 691). The overlay +stamped code 691, fuel, and a Dual meter — but `_category_for(691)` and +`_control_for(691)` both returned `None`, because their room-heater branch keyed +on `_CATEGORY_10_ROOM_HEATER_CODES`, a set that held only the *fuel-burning* +(gas/oil/solid) room heaters and **excluded electric 691–701** despite electric +room heaters also being Table 4a **Category 10**. + +Two independent mis-ratings followed from the surviving storage companions: + +1. **Category 7 survived.** The SAP 10.2 Table 12a resolver keys on category + first, read `OTHER_STORAGE_HEATERS`, and — with the dwelling on the §12 Rule 3 + 10-hour tariff — hit `(OTHER_STORAGE_HEATERS, TEN_HOUR)`, a combination the + Grid-1 table does not carry, so it fell to the all-low-rate fallback. The + peaky room heaters were billed **100% at the night rate** — the precise + "all-low over-credit" the overlay's own Dual-meter comment claims to avoid. + Effect: baseline SAP over-rated **64.1 vs the spec-correct 56.4**; the HHRSH + upgrade measure showed a **negative** bill saving (−£13/yr) because the + baseline it was compared against was itself under-billed. +2. **Control 2401 survived.** `main_heating_control` fed Table 4e as the storage + **manual charge control → +0.7 °C** mean-internal-temperature adjustment — the + largest in the table — riding on a direct-acting room heater, which should + carry the Group 6 room-heater control **2601 → +0.3 °C**. + +The companions are **not uniform** in how strongly the archetype determines +them, which is the crux of the decision: + +- **Category is deterministic** — SAP Table 4a assigns exactly one category per + code (gas→2, heat pump→4, network→6, storage→7, **electric underfloor→8**, + warm-air→9, **room heater→10**). A `None` category is never valid. +- **Control is an assumption but always archetype-supplied** — the Table 4e + *Group* is fixed by category; the specific conservative default within the + group is a modelling choice (storage→manual 2401, HHRSH→2404 per + [ADR-0044](0044-hhrsh-baseline-archetype-drags-its-intrinsic-controls.md), + room heater→2601, gas boiler→full controls). It is never "inherit the replaced + system". +- **Fuel is sometimes genuinely ambiguous** — electric/gas/storage/heat-pump + archetypes pin their fuel, but **community heating** could be gas CHP, biomass, + or waste heat, so its fuel legitimately **defers** to a `main_fuel` override, + else the lodged value (ADR-0041 last-wins). +- **Meter is physics-locked for some systems, flexible for others** — storage / + CPSU / HHRSH **require** Dual (they charge overnight); non-electric systems are + stamped Single to stop an Economy-7 split bleeding onto a gas boiler + ([ADR-0035](0035-coherent-heating-system-synthesis.md)); screed underfloor + defers to the cert ([ADR-0046](0046-electric-underfloor-scored-by-its-own-codes-meter-deferred-when-tariff-ambiguous.md)). + +## Decision + +A system-replacing heating override must emit a **complete, coherent companion +set**. Companions split by whether the archetype *determines* the value: + +1. **Category and control are archetype-forced** — always written, never + inherited, because a wrong value mis-scores or mis-bills the cert. Electric + room heaters (Table 4a 691–701) join the Category-10 room-heater branch of + both `_category_for` (→ category **10**) and `_control_for` (→ control + **2601**, the Group 6 conservative no-thermostat default, matching the + gas/oil/solid siblings). Their fuel (electricity 29) and Dual meter were + already correct and are unchanged. + +2. **Fuel and meter defer where multiple values are coherent** — this is *not* + a gap. Fuel defers only for genuinely ambiguous carriers (community heating); + meter defers only for meter-flexible systems (room heaters run coherently on + either meter; screed underfloor per ADR-0046). Meter-locked systems still + force (storage/CPSU/HHRSH → Dual; non-electric → Single). + +3. **An incomplete companion set is logged, not raised (for now).** Where the + overlay cannot yet supply a confident category or control — heat pumps + (Group 2), community (Group 3), electric underfloor (Group 7), electric + boiler / CPSU (Group 1) — `main_heating_overlay_for` **logs an error** naming + the archetype and code and continues, matching the `flag_fuel_mismatch` + log-not-raise convention. Log-and-continue keeps a mid-portfolio run alive + and surfaces the gap (CloudWatch) for review, rather than turning a latent + inheritance bug into an outage. The intended end-state is to fill each + archetype's category/control with spec sight and then **flip the log to a + raise**, so the coherence invariant is enforced. + +Category expansion for the still-`None` archetypes (electric boiler/CPSU→2, +underfloor→8) is deterministic from Table 4a but **deferred** to its own change: +it flows into the category-keyed Table 12a resolver and needs a corpus check, so +it does not ride on the room-heater fix. + +## Consequences + +- The 86+ room-heater dwellings score against a correctly-billed baseline: the + space-heating high-rate fraction is the spec **0.50** (10-hour direct-acting), + not 0.0; baseline SAP corrects **64.1 → 56.4**; the HHRSH measure's bill saving + flips **−£13/yr → +£235/yr** (property 710788, uprn 22003310, scenario 1268). +- The `refetch_solar`-corrected portfolio's `negative-bill-savings` audit group + loses its ~86-property HHRSH-on-room-heater cluster after re-model. +- The overlay now emits a visible error for any archetype it cannot fully + companion — a standing signal (wire a CloudWatch metric filter on the log + string) for which control default to fill next. +- Extends [ADR-0035](0035-coherent-heating-system-synthesis.md) (companions drag + from the code) and [ADR-0041](0041-landlord-heating-classification-targets-a-complete-modellable-taxonomy.md) + (complete taxonomy); sits beside + [ADR-0044](0044-hhrsh-baseline-archetype-drags-its-intrinsic-controls.md) + (a control named by the archetype) and + [ADR-0046](0046-electric-underfloor-scored-by-its-own-codes-meter-deferred-when-tariff-ambiguous.md) + (meter deferred where the tariff is ambiguous). + +### Alternatives rejected + +- **Make every companion total (force fuel and meter too).** Rejected: it breaks + the deliberate deferrals — community-heating fuel (ambiguous carrier) and the + ADR-0046 screed-underfloor meter — whose `None` is a load-bearing "defer to the + cert/override" signal, not a gap. +- **Fix the Table 12a resolver to prefer the SAP code over a stale category.** + A sound defence-in-depth follow-up, but it treats the symptom; the overlay + emitting an incoherent cert is the root cause, and category/control coherence + must hold for every consumer (predicted EPCs, raw lodged certs), not only the + billing resolver. +- **Raise on an incomplete companion set now.** Rejected for this change: heat + pump / community / underfloor overrides model today (by luck of inheritance), + so an immediate raise is a blast-radius change mid-campaign. Log now, raise once + the common archetypes are filled. From aca507cc47d49664a950fe2a062a02fc6a36c5ad Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 10:03:42 +0000 Subject: [PATCH 19/32] =?UTF-8?q?Abri=20deal=20abandonment=20triggers=20af?= =?UTF-8?q?ter=20three=20attempts=20with=20a=20negative=20outcome=20?= =?UTF-8?q?=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/hubspot_deal_differ.py | 8 +++++ etl/hubspot/tests/test_hubspot_deal_differ.py | 34 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index fbdc69b61..65e4cb056 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -220,6 +220,14 @@ class HubspotDealDiffer: return parse_hs_date(new_deal.get("expected_commencement_date")) is not None + @staticmethod + def check_for_abri_deal_abandonment( + new_deal: Dict[str, str], + new_project: Optional[ProjectData], + old_deal: HubspotDealData, + ) -> bool: + raise NotImplementedError + @staticmethod def check_for_magicplan_trigger( new_deal: Dict[str, str], old_deal: HubspotDealData diff --git a/etl/hubspot/tests/test_hubspot_deal_differ.py b/etl/hubspot/tests/test_hubspot_deal_differ.py index 660481dd2..f5ddd7538 100644 --- a/etl/hubspot/tests/test_hubspot_deal_differ.py +++ b/etl/hubspot/tests/test_hubspot_deal_differ.py @@ -867,6 +867,40 @@ def test_abri_tenant_data_fetch__non_abri_project_id_with_abri_code__returns_fal assert result is False +# ==================================== +# ABRI DEAL ABANDONMENT TRIGGER TESTS +# ==================================== + + +def test_abri_deal_abandonment__third_attempt_with_negative_outcome__returns_true() -> ( + None +): + deal_id = uuid.uuid4() + + # Arrange + old_deal = make_old_deal( + id=deal_id, + project_code="Abri Condition", + outcome=None, + ) + new_deal = make_new_deal( + deal_id, + project_code="Abri Condition", + number_of_attempts="3", + outcome="No Answer", + ) + + # Act + result = HubspotDealDiffer.check_for_abri_deal_abandonment( + new_deal=new_deal, + new_project=None, + old_deal=old_deal, + ) + + # Assert + assert result is True + + # ======================= # DB UPDATE TRIGGER TESTS # ======================= From 262f22764a2bf85dc26646bd43328324891314da Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 10:03:57 +0000 Subject: [PATCH 20/32] =?UTF-8?q?Abri=20deal=20abandonment=20triggers=20af?= =?UTF-8?q?ter=20three=20attempts=20with=20a=20negative=20outcome=20?= =?UTF-8?q?=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/hubspot_deal_differ.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index 65e4cb056..2ab4994d4 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -226,7 +226,7 @@ class HubspotDealDiffer: new_project: Optional[ProjectData], old_deal: HubspotDealData, ) -> bool: - raise NotImplementedError + return True @staticmethod def check_for_magicplan_trigger( From 33848a76fe1c0323207da0fddb0376c0f03769f0 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 10:04:45 +0000 Subject: [PATCH 21/32] =?UTF-8?q?Abri=20deal=20abandonment=20ignores=20dea?= =?UTF-8?q?ls=20that=20are=20not=20Abri=20condition=20deals=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/tests/test_hubspot_deal_differ.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/etl/hubspot/tests/test_hubspot_deal_differ.py b/etl/hubspot/tests/test_hubspot_deal_differ.py index f5ddd7538..e5b2f87c9 100644 --- a/etl/hubspot/tests/test_hubspot_deal_differ.py +++ b/etl/hubspot/tests/test_hubspot_deal_differ.py @@ -901,6 +901,33 @@ def test_abri_deal_abandonment__third_attempt_with_negative_outcome__returns_tru assert result is True +def test_abri_deal_abandonment__non_abri_deal__returns_false() -> None: + deal_id = uuid.uuid4() + + # Arrange + old_deal = make_old_deal( + id=deal_id, + project_code="Southern Retrofit", + outcome=None, + ) + new_deal = make_new_deal( + deal_id, + project_code="Southern Retrofit", + number_of_attempts="3", + outcome="No Answer", + ) + + # Act + result = HubspotDealDiffer.check_for_abri_deal_abandonment( + new_deal=new_deal, + new_project=None, + old_deal=old_deal, + ) + + # Assert + assert result is False + + # ======================= # DB UPDATE TRIGGER TESTS # ======================= From 79423f594cfcc480417aa0b9a2efbcabf73687b7 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 10:06:53 +0000 Subject: [PATCH 22/32] =?UTF-8?q?Abri=20deal=20abandonment=20ignores=20dea?= =?UTF-8?q?ls=20that=20are=20not=20Abri=20condition=20deals=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/hubspot_deal_differ.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index 2ab4994d4..3997014dd 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -226,7 +226,7 @@ class HubspotDealDiffer: new_project: Optional[ProjectData], old_deal: HubspotDealData, ) -> bool: - return True + return HubspotDealDiffer._is_abri_condition_deal(new_deal, new_project) @staticmethod def check_for_magicplan_trigger( From af85e0ed2b1aaa1466f37117efe82162c53a232b Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 10:07:38 +0000 Subject: [PATCH 23/32] =?UTF-8?q?Abri=20deal=20abandonment=20requires=20at?= =?UTF-8?q?=20least=20three=20parseable=20attempts=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/tests/test_hubspot_deal_differ.py | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/etl/hubspot/tests/test_hubspot_deal_differ.py b/etl/hubspot/tests/test_hubspot_deal_differ.py index e5b2f87c9..5699262fb 100644 --- a/etl/hubspot/tests/test_hubspot_deal_differ.py +++ b/etl/hubspot/tests/test_hubspot_deal_differ.py @@ -928,6 +928,44 @@ def test_abri_deal_abandonment__non_abri_deal__returns_false() -> None: assert result is False +@pytest.mark.parametrize( + "new_overrides", + [ + {"number_of_attempts": "2"}, + {"number_of_attempts": ""}, + {"number_of_attempts": "not-a-number"}, + {}, + ], +) +def test_abri_deal_abandonment__fewer_than_three_parseable_attempts__returns_false( + new_overrides: Dict[str, str], +) -> None: + deal_id = uuid.uuid4() + + # Arrange + old_deal = make_old_deal( + id=deal_id, + project_code="Abri Condition", + outcome=None, + ) + new_deal = make_new_deal( + deal_id, + project_code="Abri Condition", + outcome="No Answer", + **new_overrides, + ) + + # Act + result = HubspotDealDiffer.check_for_abri_deal_abandonment( + new_deal=new_deal, + new_project=None, + old_deal=old_deal, + ) + + # Assert + assert result is False + + # ======================= # DB UPDATE TRIGGER TESTS # ======================= From 88093eb7cd0f1cc3c4f57ece16015039214f998e Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 10:10:03 +0000 Subject: [PATCH 24/32] =?UTF-8?q?Abri=20deal=20abandonment=20requires=20at?= =?UTF-8?q?=20least=20three=20parseable=20attempts=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/hubspot_deal_differ.py | 8 ++++++-- etl/hubspot/utils.py | 9 +++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index 3997014dd..1ce82ba3a 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -2,7 +2,7 @@ from typing import Dict, List, Optional from backend.app.db.models.hubspot_deal_data import HubspotDealData from etl.hubspot.project_data import ProjectData -from etl.hubspot.utils import parse_hs_bool, parse_hs_date +from etl.hubspot.utils import parse_hs_bool, parse_hs_date, parse_hs_int class HubspotDealDiffer: @@ -226,7 +226,11 @@ class HubspotDealDiffer: new_project: Optional[ProjectData], old_deal: HubspotDealData, ) -> bool: - return HubspotDealDiffer._is_abri_condition_deal(new_deal, new_project) + if not HubspotDealDiffer._is_abri_condition_deal(new_deal, new_project): + return False + + new_attempts = parse_hs_int(new_deal.get("number_of_attempts")) + return new_attempts is not None and new_attempts >= 3 @staticmethod def check_for_magicplan_trigger( diff --git a/etl/hubspot/utils.py b/etl/hubspot/utils.py index 6077727ee..dea173324 100644 --- a/etl/hubspot/utils.py +++ b/etl/hubspot/utils.py @@ -16,6 +16,15 @@ def parse_hs_date(value: Optional[str]) -> Optional[datetime]: return None +def parse_hs_int(value: Optional[str]) -> Optional[int]: + if not value: + return None + try: + return int(value) + except ValueError: + return None + + def parse_hs_bool(value: Optional[str]) -> Optional[bool]: if value is None or value == "": return None From 41d62ce2b2fd7dd63917b9551d42b72e14b84cd6 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 10:10:26 +0000 Subject: [PATCH 25/32] =?UTF-8?q?Abri=20deal=20abandonment=20requires=20a?= =?UTF-8?q?=20negative=20outcome=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/tests/test_hubspot_deal_differ.py | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/etl/hubspot/tests/test_hubspot_deal_differ.py b/etl/hubspot/tests/test_hubspot_deal_differ.py index 5699262fb..754385c5b 100644 --- a/etl/hubspot/tests/test_hubspot_deal_differ.py +++ b/etl/hubspot/tests/test_hubspot_deal_differ.py @@ -966,6 +966,83 @@ def test_abri_deal_abandonment__fewer_than_three_parseable_attempts__returns_fal assert result is False +@pytest.mark.parametrize( + "new_overrides", + [ + {"outcome": "Surveyed"}, + {"outcome": ""}, + {}, + ], +) +def test_abri_deal_abandonment__outcome_not_negative__returns_false( + new_overrides: Dict[str, str], +) -> None: + deal_id = uuid.uuid4() + + # Arrange + old_deal = make_old_deal( + id=deal_id, + project_code="Abri Condition", + outcome=None, + ) + new_deal = make_new_deal( + deal_id, + project_code="Abri Condition", + number_of_attempts="3", + **new_overrides, + ) + + # Act + result = HubspotDealDiffer.check_for_abri_deal_abandonment( + new_deal=new_deal, + new_project=None, + old_deal=old_deal, + ) + + # Assert + assert result is False + + +@pytest.mark.parametrize( + "outcome", + [ + "No Answer", + "Cancelled", + "No Show", + "Tenant Refusal", + "Not Viable", + "NO ANSWER", + ], +) +def test_abri_deal_abandonment__each_negative_outcome__returns_true( + outcome: str, +) -> None: + deal_id = uuid.uuid4() + + # Arrange + old_deal = make_old_deal( + id=deal_id, + project_code="Abri Condition", + outcome=None, + ) + new_deal = make_new_deal( + deal_id, + project_code="Abri Condition", + number_of_attempts="3", + outcome=outcome, + ) + + # Act + result = HubspotDealDiffer.check_for_abri_deal_abandonment( + new_deal=new_deal, + new_project=None, + old_deal=old_deal, + ) + + # Assert + assert result is True + + # ======================= # DB UPDATE TRIGGER TESTS # ======================= From 6de0da869e664a9f49c0d81954d2355283e44be4 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 10:11:14 +0000 Subject: [PATCH 26/32] =?UTF-8?q?Abri=20deal=20abandonment=20requires=20a?= =?UTF-8?q?=20negative=20outcome=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/hubspot_deal_differ.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index 1ce82ba3a..96a771a12 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -13,6 +13,13 @@ class HubspotDealDiffer: ] RETROFIT_DESIGN_COMPLETE = "uploaded" LODGEMENT_COMPLETE: List[str] = ["lodgement complete", "measures lodged"] + NEGATIVE_OUTCOMES: List[str] = [ + "no answer", + "cancelled", + "no show", + "tenant refusal", + "not viable", + ] ABRI_CONDITION_ASSOCIATED_PROJECT_ID = "123456" ABRI_CONDITION_PROJECT_CODE = "Abri Condition" @@ -230,7 +237,11 @@ class HubspotDealDiffer: return False new_attempts = parse_hs_int(new_deal.get("number_of_attempts")) - return new_attempts is not None and new_attempts >= 3 + if new_attempts is None or new_attempts < 3: + return False + + new_outcome = (new_deal.get("outcome") or "").lower() + return new_outcome in HubspotDealDiffer.NEGATIVE_OUTCOMES @staticmethod def check_for_magicplan_trigger( From 9559f237417ba818fa64f242ad58fce791b7f109 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 10:12:59 +0000 Subject: [PATCH 27/32] =?UTF-8?q?Abri=20deal=20abandonment=20fires=20only?= =?UTF-8?q?=20when=20the=20deal=20newly=20becomes=20abandoned=20?= =?UTF-8?q?=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- backend/app/db/models/hubspot_deal_data.py | 1 + etl/hubspot/tests/test_hubspot_deal_differ.py | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/backend/app/db/models/hubspot_deal_data.py b/backend/app/db/models/hubspot_deal_data.py index caf1df5d5..48a6f1ba0 100644 --- a/backend/app/db/models/hubspot_deal_data.py +++ b/backend/app/db/models/hubspot_deal_data.py @@ -23,6 +23,7 @@ class HubspotDealData(SQLModel, table=True): landlord_property_id: Optional[str] = Field(default=None) uprn: Optional[str] = Field(default=None) outcome: Optional[str] = Field(default=None) + number_of_attempts: Optional[str] = Field(default=None) outcome_notes: Optional[str] = Field(default=None) booking_status: Optional[str] = Field(default=None) diff --git a/etl/hubspot/tests/test_hubspot_deal_differ.py b/etl/hubspot/tests/test_hubspot_deal_differ.py index 754385c5b..970f5e0ca 100644 --- a/etl/hubspot/tests/test_hubspot_deal_differ.py +++ b/etl/hubspot/tests/test_hubspot_deal_differ.py @@ -1043,6 +1043,34 @@ def test_abri_deal_abandonment__each_negative_outcome__returns_true( assert result is True +def test_abri_deal_abandonment__deal_already_abandoned__returns_false() -> None: + deal_id = uuid.uuid4() + + # Arrange + old_deal = make_old_deal( + id=deal_id, + project_code="Abri Condition", + number_of_attempts="3", + outcome="No Answer", + ) + new_deal = make_new_deal( + deal_id, + project_code="Abri Condition", + number_of_attempts="4", + outcome="No Answer", + ) + + # Act + result = HubspotDealDiffer.check_for_abri_deal_abandonment( + new_deal=new_deal, + new_project=None, + old_deal=old_deal, + ) + + # Assert + assert result is False + + # ======================= # DB UPDATE TRIGGER TESTS # ======================= From 58ce855e0b7e1800e91c0c48b2a698ac69a101dd Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 10:13:58 +0000 Subject: [PATCH 28/32] =?UTF-8?q?Abri=20deal=20abandonment=20fires=20only?= =?UTF-8?q?=20when=20the=20deal=20newly=20becomes=20abandoned=20?= =?UTF-8?q?=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/hubspot_deal_differ.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index 96a771a12..67f840a9e 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -236,12 +236,14 @@ class HubspotDealDiffer: if not HubspotDealDiffer._is_abri_condition_deal(new_deal, new_project): return False - new_attempts = parse_hs_int(new_deal.get("number_of_attempts")) - if new_attempts is None or new_attempts < 3: + if HubspotDealDiffer._is_abandoned( + old_deal.number_of_attempts, old_deal.outcome + ): return False - new_outcome = (new_deal.get("outcome") or "").lower() - return new_outcome in HubspotDealDiffer.NEGATIVE_OUTCOMES + return HubspotDealDiffer._is_abandoned( + new_deal.get("number_of_attempts"), new_deal.get("outcome") + ) @staticmethod def check_for_magicplan_trigger( @@ -251,6 +253,16 @@ class HubspotDealDiffer: old_outcome = (old_deal.outcome or "").lower() return new_outcome == "surveyed" and old_outcome != "surveyed" + @staticmethod + def _is_abandoned( + number_of_attempts: Optional[str], outcome: Optional[str] + ) -> bool: + attempts = parse_hs_int(number_of_attempts) + if attempts is None or attempts < 3: + return False + + return (outcome or "").lower() in HubspotDealDiffer.NEGATIVE_OUTCOMES + @staticmethod def _is_abri_condition_deal( new_deal: Dict[str, str], new_project: Optional[ProjectData] From c00ae894ee9de2c9ef71f0c3e04958d765bfe491 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 10:14:22 +0000 Subject: [PATCH 29/32] =?UTF-8?q?Deal=20differ=20detects=20number=20of=20a?= =?UTF-8?q?ttempts=20changes=20for=20db=20update=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/tests/test_hubspot_deal_differ.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/etl/hubspot/tests/test_hubspot_deal_differ.py b/etl/hubspot/tests/test_hubspot_deal_differ.py index 970f5e0ca..d1feb9a90 100644 --- a/etl/hubspot/tests/test_hubspot_deal_differ.py +++ b/etl/hubspot/tests/test_hubspot_deal_differ.py @@ -1128,6 +1128,32 @@ def test_db_update_trigger__dealname_changed__returns_true() -> None: assert result is True +def test_db_update_trigger__number_of_attempts_changed__returns_true() -> None: + deal_id = uuid.uuid4() + + # Arrange + old_deal = make_old_deal( + id=deal_id, + number_of_attempts="2", + ) + new_deal = make_new_deal( + deal_id, + hs_object_id="1", + number_of_attempts="3", + ) + + # Act + result = HubspotDealDiffer.check_for_db_update_trigger( + new_deal=new_deal, + new_company=None, + new_listing=None, + old_deal=old_deal, + ) + + # Assert + assert result is True + + def test_db_update_trigger__company_changed__returns_true() -> None: deal_id = uuid.uuid4() From 18989252f495fd98dcd1eab6d2a3a58f0104e7f1 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 10:17:51 +0000 Subject: [PATCH 30/32] =?UTF-8?q?Deal=20differ=20detects=20number=20of=20a?= =?UTF-8?q?ttempts=20changes=20for=20db=20update=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/hubspotClient.py | 1 + etl/hubspot/hubspotDataTodB.py | 2 ++ etl/hubspot/hubspot_deal_differ.py | 1 + 3 files changed, 4 insertions(+) diff --git a/etl/hubspot/hubspotClient.py b/etl/hubspot/hubspotClient.py index 28abf3030..918701cac 100644 --- a/etl/hubspot/hubspotClient.py +++ b/etl/hubspot/hubspotClient.py @@ -294,6 +294,7 @@ class HubspotClient: "dealstage", "pipeline", "outcome", + "number_of_attempts", "outcome_notes", "booking_status", "project_code", diff --git a/etl/hubspot/hubspotDataTodB.py b/etl/hubspot/hubspotDataTodB.py index ae2daa491..f72564c69 100644 --- a/etl/hubspot/hubspotDataTodB.py +++ b/etl/hubspot/hubspotDataTodB.py @@ -211,6 +211,7 @@ class HubspotDataToDb: ), "uprn": listing.get("national_uprn", None) if listing else None, "outcome": deal_data.get("outcome"), + "number_of_attempts": deal_data.get("number_of_attempts"), "outcome_notes": deal_data.get("outcome_notes"), "booking_status": deal_data.get("booking_status"), "project_code": deal_data.get("project_code"), @@ -322,6 +323,7 @@ class HubspotDataToDb: ), uprn=listing.get("national_uprn") if listing else None, outcome=deal_data.get("outcome"), + number_of_attempts=deal_data.get("number_of_attempts"), outcome_notes=deal_data.get("outcome_notes"), booking_status=deal_data.get("booking_status"), project_code=deal_data.get("project_code"), diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index 67f840a9e..abb0f3d54 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -59,6 +59,7 @@ class HubspotDealDiffer: # --- Field mappings --- FIELD_MAP = { "outcome": "outcome", + "number_of_attempts": "number_of_attempts", "dealstage": "dealstage", "dealname": "dealname", "project_code": "project_code", From f4f915cfb71d9923d23e3f73b2e408b4792b196f Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 10:19:06 +0000 Subject: [PATCH 31/32] =?UTF-8?q?Abri=20deal=20abandonment=20names=20its?= =?UTF-8?q?=20attempt=20threshold=20=F0=9F=9F=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/hubspot_deal_differ.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index abb0f3d54..de9680e65 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -13,6 +13,7 @@ class HubspotDealDiffer: ] RETROFIT_DESIGN_COMPLETE = "uploaded" LODGEMENT_COMPLETE: List[str] = ["lodgement complete", "measures lodged"] + ABANDONMENT_ATTEMPTS_THRESHOLD = 3 NEGATIVE_OUTCOMES: List[str] = [ "no answer", "cancelled", @@ -259,7 +260,10 @@ class HubspotDealDiffer: number_of_attempts: Optional[str], outcome: Optional[str] ) -> bool: attempts = parse_hs_int(number_of_attempts) - if attempts is None or attempts < 3: + if ( + attempts is None + or attempts < HubspotDealDiffer.ABANDONMENT_ATTEMPTS_THRESHOLD + ): return False return (outcome or "").lower() in HubspotDealDiffer.NEGATIVE_OUTCOMES From 1a5368571f782915efc5e7e130d1b1ea9e76718e Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 3 Jul 2026 10:46:24 +0000 Subject: [PATCH 32/32] add typehint --- etl/hubspot/hubspot_deal_differ.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index de9680e65..1a3dfc409 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -259,7 +259,7 @@ class HubspotDealDiffer: def _is_abandoned( number_of_attempts: Optional[str], outcome: Optional[str] ) -> bool: - attempts = parse_hs_int(number_of_attempts) + attempts: Optional[int] = parse_hs_int(number_of_attempts) if ( attempts is None or attempts < HubspotDealDiffer.ABANDONMENT_ATTEMPTS_THRESHOLD