Merge pull request #1445 from Hestia-Homes/feature/abri-api-integration

Abri deal abandonment check
This commit is contained in:
Daniel Roth 2026-07-03 11:48:37 +01:00 committed by GitHub
commit 7abf466b56
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 284 additions and 1 deletions

View file

@ -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)

View file

@ -294,6 +294,7 @@ class HubspotClient:
"dealstage",
"pipeline",
"outcome",
"number_of_attempts",
"outcome_notes",
"booking_status",
"project_code",

View file

@ -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"),

View file

@ -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]

View file

@ -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()

View file

@ -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