diff --git a/domain/abri/abandonment.py b/domain/abri/abandonment.py index abf6e638e..43ac0b221 100644 --- a/domain/abri/abandonment.py +++ b/domain/abri/abandonment.py @@ -1,7 +1,7 @@ from datetime import date from typing import Optional -from domain.abri.models import AbandonReason +from domain.abri.models import AbandonReason, UnsuccessfulOutcome def abandonment_date( @@ -18,11 +18,87 @@ def abandonment_date( return confirmed_survey_date or last_submission_date +class UnmappableOutcomeError(Exception): + """A deal outcome with no abandonment reason code to send to Abri. + + abandon_reason is mandatory on a canceljob, so an empty outcome or one + outside the mapped set (which the abandonment trigger should never let + through) fails loudly rather than inventing a code. + """ + + def __init__(self, outcome: Optional[str]) -> None: + self.outcome = outcome + super().__init__(f"no abandonment reason code for outcome {outcome!r}") + + +# Every UNSUCCESSFUL_OUTCOMES entry maps to a code (they trip the abandonment +# trigger and abandon_reason is mandatory), keyed lower-cased to match how the +# differ compares. Only "no answer" -> CARDED is firm; the rest are best-guesses +# pending client confirmation: +# - "cancelled / no show" -> NOACCESS (No access; alt REQT/MYABRI92 if read as +# a tenant cancellation rather than a no-show) +# - "tenant refusal" -> CRA (Customer Refused Access; alt REFSURV/REFUSAL) +# - "not viable" -> NOACCESS (No access; alt JOBD/ERROR) +# See the reason-code reference below. +_REASON_BY_OUTCOME: dict[UnsuccessfulOutcome, AbandonReason] = { + "no answer": AbandonReason.CARDED, + "cancelled / no show": AbandonReason.NOACCESS, + "tenant refusal": AbandonReason.CRA, + "not viable": AbandonReason.NOACCESS, +} + + def abandon_reason_for_outcome(outcome: Optional[str]) -> AbandonReason: """The OpenHousing abandonment reason code for a HubSpot deal outcome. - The single mapping point from HubSpot's outcome to a coded reason. For now - every outcome maps to CARDED; the real outcome-to-code table is a follow-up - (needs Abri's code list), and swapping it in is a one-place change here. + The single mapping point from HubSpot's outcome to a coded reason, matched + case- and whitespace-insensitively. Raises UnmappableOutcomeError for an + empty or unrecognised outcome, since abandon_reason is mandatory. """ - return AbandonReason.CARDED + normalized = (outcome or "").strip().lower() + for mapped_outcome, reason in _REASON_BY_OUTCOME.items(): + if mapped_outcome == normalized: + return reason + raise UnmappableOutcomeError(outcome) + + +# Reason Code - Description +# 1STCARD - 1st Card - Gas Service only +# 2NDCARD - 2nd Card - Gas Service only +# 2NDMAN - 2nd man required +# ADDIT - Additional Work Required +# APTB - Appointment Bought Forward [Note: I recognise this should read "brought" - they provided the list in a low res image and I'm pretty sure it says Bought not Brought] +# ARCH - Job Archived +# ASB - Anti Social Behaviour +# ASSIGNED - Assigned to Different Operative +# BULKABAN - Bulk Abandonment Request to IT +# CARDED - Carded - Tenant not in +# CONTRACT - Order raised using incorrect contract +# CORON - Coronavirus +# COVI - COVID-19 +# CPAL - CP Arrived Late +# CRA - Customer Refused Access +# DUPLICAT [not a typo] - Duplicate +# ERROR - Issued in error +# ESCA - Escalated to CAT1 +# HOLIDAY - Holiday +# HS - Health & Safety +# JOBD - Job Out of Date +# M - Minor +# MANAGE - Management decision +# MATERIAL - Materials not available +# MATREQ - Materials required +# MYABRI92 - Tenant Cancelled On My Abri +# NO ROOM - Room does not exist - invalid data +# NOACCESS - No access +# NOADULT - No Adult Present +# OPTI - Cancelled in Optimise +# OUTLOOK - Changed in Outlook +# PASSED - Passed to other department +# PLAN - Part of Planned Programme +# RDCANX - Radian Direct Cancelled +# RECALLMO - Recall Mobile Visit +# REFSURV - Refused to Surveyor +# REFUSAL - Agreed Resident Refusal +# REQT - Requested by Tenant +# RSCANX - Radian Services Canceled diff --git a/domain/abri/models.py b/domain/abri/models.py index a17cf82d5..e0cd20c98 100644 --- a/domain/abri/models.py +++ b/domain/abri/models.py @@ -7,15 +7,33 @@ PlaceRef = NewType("PlaceRef", str) SlotCode = Literal["AM", "PM", "AD"] +# HubSpot deal outcomes (lower-cased) that mean a survey was unsuccessful and, +# once the attempt threshold is reached, abandon the job. The single source of +# truth shared by the abandonment trigger (etl HubspotDealDiffer) and the +# reason-code mapping (domain.abri.abandonment). +UnsuccessfulOutcome = Literal[ + "no answer", "cancelled / no show", "tenant refusal", "not viable" +] + +UNSUCCESSFUL_OUTCOMES: tuple[UnsuccessfulOutcome, ...] = ( + "no answer", + "cancelled / no show", + "tenant refusal", + "not viable", +) + class AbandonReason(str, Enum): """OpenHousing's coded reason a job was abandoned. - Only CARDED is used for now; the full HubSpot-outcome-to-code table is a - documented follow-up - enum based on Abri codes needs creating and mapping from hubspot + Values are Abri's own reason codes. Only the members reachable from a + HubSpot abandonment outcome are defined; the outcome-to-code mapping lives + in domain.abri.abandonment. """ CARDED = "CARDED" + CRA = "CRA" + NOACCESS = "NOACCESS" @dataclass(frozen=True) diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index 22d7fb83c..def629947 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -2,6 +2,7 @@ import os from typing import Any, Dict, List, Optional from backend.app.db.models.hubspot_deal_data import HubspotDealData +from domain.abri.models import UNSUCCESSFUL_OUTCOMES from etl.hubspot.project_data import ProjectData from etl.hubspot.utils import parse_hs_bool, parse_hs_date, parse_hs_int @@ -15,13 +16,6 @@ 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", - ] # Verified against the portal (2026-07-07): Abri stock-condition deals # carry no project association, so the project_code match is the branch # that fires in production; the id below is the project object id @@ -333,7 +327,7 @@ class HubspotDealDiffer: ): return False - return (outcome or "").lower() in HubspotDealDiffer.NEGATIVE_OUTCOMES + return (outcome or "").lower() in UNSUCCESSFUL_OUTCOMES @staticmethod def _is_abri_condition_deal( diff --git a/etl/hubspot/tests/test_hubspot_deal_differ.py b/etl/hubspot/tests/test_hubspot_deal_differ.py index 2bd15996f..9a9e8f05e 100644 --- a/etl/hubspot/tests/test_hubspot_deal_differ.py +++ b/etl/hubspot/tests/test_hubspot_deal_differ.py @@ -1040,8 +1040,7 @@ def test_abri_deal_abandonment__outcome_not_negative__returns_false( "outcome", [ "No Answer", - "Cancelled", - "No Show", + "Cancelled / No Show", "Tenant Refusal", "Not Viable", "NO ANSWER", diff --git a/tests/domain/abri/test_abandonment.py b/tests/domain/abri/test_abandonment.py index 2fbeeb3e5..5d0789054 100644 --- a/tests/domain/abri/test_abandonment.py +++ b/tests/domain/abri/test_abandonment.py @@ -3,7 +3,11 @@ from typing import Optional import pytest -from domain.abri.abandonment import abandon_reason_for_outcome, abandonment_date +from domain.abri.abandonment import ( + UnmappableOutcomeError, + abandon_reason_for_outcome, + abandonment_date, +) from domain.abri.models import AbandonReason CONFIRMED = date(2026, 6, 24) @@ -23,14 +27,29 @@ def test_neither_date_leaves_the_abandonment_undated() -> None: @pytest.mark.parametrize( - "outcome", - ["no answer", "cancelled", "no show", "tenant refusal", "not viable", None], + ("outcome", "expected"), + [ + ("No Answer", AbandonReason.CARDED), + ("NO ANSWER", AbandonReason.CARDED), + (" no answer ", AbandonReason.CARDED), + ("Cancelled / No Show", AbandonReason.NOACCESS), + ("cancelled / no show", AbandonReason.NOACCESS), + ("Tenant Refusal", AbandonReason.CRA), + (" tenant refusal ", AbandonReason.CRA), + ("Not Viable", AbandonReason.NOACCESS), + ("not viable", AbandonReason.NOACCESS), + ], ) -def test_every_outcome_currently_maps_to_the_carded_reason( - outcome: Optional[str], +def test_a_triggering_outcome_maps_to_its_reason_code( + outcome: str, expected: AbandonReason ) -> None: - # Act - reason = abandon_reason_for_outcome(outcome) + assert abandon_reason_for_outcome(outcome) == expected - # Assert - assert reason == AbandonReason.CARDED + +@pytest.mark.parametrize( + "outcome", + [None, "", " ", "surveyed", "epc completed", "not attempted", "cancelled"], +) +def test_an_unmappable_outcome_raises(outcome: Optional[str]) -> None: + with pytest.raises(UnmappableOutcomeError): + abandon_reason_for_outcome(outcome)