diff --git a/domain/abri/abandonment.py b/domain/abri/abandonment.py index abf6e638e..e75ca224d 100644 --- a/domain/abri/abandonment.py +++ b/domain/abri/abandonment.py @@ -18,11 +18,80 @@ 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}") + + +# Only outcomes that trip the abandonment trigger (HubspotDealDiffer's +# NEGATIVE_OUTCOMES) can reach here. Keyed on the outcome lower-cased, matching +# how the differ compares. Tenant Refusal -> CRA is the client's steer but is +# ambiguous (REFSURV/REFUSAL also fit) - confirmation pending, see the codes below. +_REASON_BY_OUTCOME: dict[str, AbandonReason] = { + "no answer": AbandonReason.CARDED, + "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 + reason = _REASON_BY_OUTCOME.get((outcome or "").strip().lower()) + if reason is None: + raise UnmappableOutcomeError(outcome) + return reason + + +# 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..dc81e0ba7 100644 --- a/domain/abri/models.py +++ b/domain/abri/models.py @@ -11,11 +11,14 @@ SlotCode = Literal["AM", "PM", "AD"] 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/tests/domain/abri/test_abandonment.py b/tests/domain/abri/test_abandonment.py index 2fbeeb3e5..9ccd1cf03 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,27 @@ 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), + ("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 / no show"], +) +def test_an_unmappable_outcome_raises(outcome: Optional[str]) -> None: + with pytest.raises(UnmappableOutcomeError): + abandon_reason_for_outcome(outcome)