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/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 fbdc69b61..1a3dfc409 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: @@ -13,6 +13,14 @@ 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", + "no show", + "tenant refusal", + "not viable", + ] ABRI_CONDITION_ASSOCIATED_PROJECT_ID = "123456" ABRI_CONDITION_PROJECT_CODE = "Abri Condition" @@ -52,6 +60,7 @@ class HubspotDealDiffer: # --- Field mappings --- FIELD_MAP = { "outcome": "outcome", + "number_of_attempts": "number_of_attempts", "dealstage": "dealstage", "dealname": "dealname", "project_code": "project_code", @@ -220,6 +229,24 @@ 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: + if not HubspotDealDiffer._is_abri_condition_deal(new_deal, new_project): + return False + + if HubspotDealDiffer._is_abandoned( + old_deal.number_of_attempts, old_deal.outcome + ): + return False + + return HubspotDealDiffer._is_abandoned( + new_deal.get("number_of_attempts"), new_deal.get("outcome") + ) + @staticmethod def check_for_magicplan_trigger( new_deal: Dict[str, str], old_deal: HubspotDealData @@ -228,6 +255,19 @@ 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: Optional[int] = parse_hs_int(number_of_attempts) + if ( + attempts is None + or attempts < HubspotDealDiffer.ABANDONMENT_ATTEMPTS_THRESHOLD + ): + 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] diff --git a/etl/hubspot/tests/test_hubspot_deal_differ.py b/etl/hubspot/tests/test_hubspot_deal_differ.py index 660481dd2..d1feb9a90 100644 --- a/etl/hubspot/tests/test_hubspot_deal_differ.py +++ b/etl/hubspot/tests/test_hubspot_deal_differ.py @@ -867,6 +867,210 @@ 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 + + +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 + + +@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 + + +@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 + + +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 # ======================= @@ -924,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() 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