From 693356a220e1a9e868104cbdc456351b213fe698 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 8 Jul 2026 14:14:06 +0000 Subject: [PATCH 1/5] Map Abri abandonment outcomes to their reason codes Replace the CARDED placeholder in abandon_reason_for_outcome with the real outcome-to-code table from the client: No Answer->CARDED, Tenant Refusal->CRA (ambiguous, client follow-up pending), Not Viable->NOACCESS. Only these three outcomes trip the abandonment trigger today; any empty/unrecognised outcome raises UnmappableOutcomeError since abandon_reason is mandatory. Co-Authored-By: Claude Opus 4.8 --- domain/abri/abandonment.py | 77 +++++++++++++++++++++++++-- domain/abri/models.py | 7 ++- tests/domain/abri/test_abandonment.py | 35 ++++++++---- 3 files changed, 104 insertions(+), 15 deletions(-) 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) From 13e3e7874936d6ce69fe363c8bb776ae3c4deddf Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 8 Jul 2026 14:23:33 +0000 Subject: [PATCH 2/5] Share the unsuccessful-outcome vocabulary between differ and abandonment Extract the abandonment outcome strings to a single UnsuccessfulOutcome Literal + UNSUCCESSFUL_OUTCOMES tuple in domain.abri.models, alongside SlotCode. The differ's NEGATIVE_OUTCOMES now references the tuple and the reason-code mapping is keyed on the Literal, so the two can no longer drift and a mistyped outcome fails type-checking. Co-Authored-By: Claude Opus 4.8 --- domain/abri/abandonment.py | 23 +++++++++++++---------- domain/abri/models.py | 16 ++++++++++++++++ etl/hubspot/hubspot_deal_differ.py | 9 ++------- 3 files changed, 31 insertions(+), 17 deletions(-) diff --git a/domain/abri/abandonment.py b/domain/abri/abandonment.py index e75ca224d..c112949f7 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( @@ -31,11 +31,13 @@ class UnmappableOutcomeError(Exception): 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] = { +# Only UNSUCCESSFUL_OUTCOMES can reach here (they trip the abandonment trigger), +# keyed lower-cased to match how the differ compares. "cancelled"/"no show" are +# deliberately unmapped for now - they never match the real "Cancelled / No Show" +# dropdown value, so they cannot occur, and would raise loudly if they ever did. +# Tenant Refusal -> CRA is the client's steer but is ambiguous (REFSURV/REFUSAL +# also fit) - confirmation pending, see the reason-code reference below. +_REASON_BY_OUTCOME: dict[UnsuccessfulOutcome, AbandonReason] = { "no answer": AbandonReason.CARDED, "tenant refusal": AbandonReason.CRA, "not viable": AbandonReason.NOACCESS, @@ -49,10 +51,11 @@ def abandon_reason_for_outcome(outcome: Optional[str]) -> AbandonReason: case- and whitespace-insensitively. Raises UnmappableOutcomeError for an empty or unrecognised outcome, since abandon_reason is mandatory. """ - reason = _REASON_BY_OUTCOME.get((outcome or "").strip().lower()) - if reason is None: - raise UnmappableOutcomeError(outcome) - return reason + 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 diff --git a/domain/abri/models.py b/domain/abri/models.py index dc81e0ba7..0c32a78dc 100644 --- a/domain/abri/models.py +++ b/domain/abri/models.py @@ -7,6 +7,22 @@ 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. diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index 22d7fb83c..9e2bd88fd 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,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", - "no show", - "tenant refusal", - "not viable", - ] + NEGATIVE_OUTCOMES = UNSUCCESSFUL_OUTCOMES # 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 From 9a669dfcea9459ba0bee7388c1fc4fcc70e4abac Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 8 Jul 2026 14:28:47 +0000 Subject: [PATCH 3/5] Map every unsuccessful outcome to a reason code (best-guess, client to confirm) Complete the outcome-to-code table so all five UNSUCCESSFUL_OUTCOMES map: cancelled -> REQT (new enum member) and no show -> NOACCESS join the existing three. Only no answer -> CARDED is firm; the rest are best-guesses for client confirmation, noted inline with alternatives. Co-Authored-By: Claude Opus 4.8 --- domain/abri/abandonment.py | 19 +++++++++++++------ domain/abri/models.py | 1 + tests/domain/abri/test_abandonment.py | 2 ++ 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/domain/abri/abandonment.py b/domain/abri/abandonment.py index c112949f7..ab2bce2cc 100644 --- a/domain/abri/abandonment.py +++ b/domain/abri/abandonment.py @@ -31,14 +31,21 @@ class UnmappableOutcomeError(Exception): super().__init__(f"no abandonment reason code for outcome {outcome!r}") -# Only UNSUCCESSFUL_OUTCOMES can reach here (they trip the abandonment trigger), -# keyed lower-cased to match how the differ compares. "cancelled"/"no show" are -# deliberately unmapped for now - they never match the real "Cancelled / No Show" -# dropdown value, so they cannot occur, and would raise loudly if they ever did. -# Tenant Refusal -> CRA is the client's steer but is ambiguous (REFSURV/REFUSAL -# also fit) - confirmation pending, see the reason-code reference below. +# 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" -> REQT (Requested by Tenant; alt MYABRI92/OPTI) +# - "no show" -> NOACCESS (No access; alt CARDED/NOADULT) +# - "tenant refusal" -> CRA (Customer Refused Access; alt REFSURV/REFUSAL) +# - "not viable" -> NOACCESS (No access; alt JOBD/ERROR) +# NB "cancelled"/"no show" are our internal split of HubSpot's single +# "Cancelled / No Show" dropdown value - the client may prefer one code for both. +# See the reason-code reference below. _REASON_BY_OUTCOME: dict[UnsuccessfulOutcome, AbandonReason] = { "no answer": AbandonReason.CARDED, + "cancelled": AbandonReason.REQT, + "no show": AbandonReason.NOACCESS, "tenant refusal": AbandonReason.CRA, "not viable": AbandonReason.NOACCESS, } diff --git a/domain/abri/models.py b/domain/abri/models.py index 0c32a78dc..9eef5887b 100644 --- a/domain/abri/models.py +++ b/domain/abri/models.py @@ -35,6 +35,7 @@ class AbandonReason(str, Enum): CARDED = "CARDED" CRA = "CRA" NOACCESS = "NOACCESS" + REQT = "REQT" @dataclass(frozen=True) diff --git a/tests/domain/abri/test_abandonment.py b/tests/domain/abri/test_abandonment.py index 9ccd1cf03..eb60e736f 100644 --- a/tests/domain/abri/test_abandonment.py +++ b/tests/domain/abri/test_abandonment.py @@ -32,6 +32,8 @@ def test_neither_date_leaves_the_abandonment_undated() -> None: ("No Answer", AbandonReason.CARDED), ("NO ANSWER", AbandonReason.CARDED), (" no answer ", AbandonReason.CARDED), + ("Cancelled", AbandonReason.REQT), + ("No Show", AbandonReason.NOACCESS), ("Tenant Refusal", AbandonReason.CRA), (" tenant refusal ", AbandonReason.CRA), ("Not Viable", AbandonReason.NOACCESS), From 4ac4dd8683ce69a7f203a1a40f0a3fa3f52495b9 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 8 Jul 2026 14:33:59 +0000 Subject: [PATCH 4/5] Collapse cancelled/no show to HubSpot's single 'Cancelled / No Show' value 'Cancelled' and 'no show' were separate strings in the outcome vocabulary, but HubSpot only ever emits the one dropdown value 'Cancelled / No Show' - so neither phantom string could ever match. Replace both with the real value (mapped to NOACCESS, best-guess pending client), which also fixes the long-standing differ mismatch: the abandonment trigger now actually fires on Cancelled / No Show. Drops the now-unused REQT enum member. Co-Authored-By: Claude Opus 4.8 --- domain/abri/abandonment.py | 13 +++++-------- domain/abri/models.py | 6 ++---- etl/hubspot/tests/test_hubspot_deal_differ.py | 3 +-- tests/domain/abri/test_abandonment.py | 6 +++--- 4 files changed, 11 insertions(+), 17 deletions(-) diff --git a/domain/abri/abandonment.py b/domain/abri/abandonment.py index ab2bce2cc..43ac0b221 100644 --- a/domain/abri/abandonment.py +++ b/domain/abri/abandonment.py @@ -35,17 +35,14 @@ class UnmappableOutcomeError(Exception): # 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" -> REQT (Requested by Tenant; alt MYABRI92/OPTI) -# - "no show" -> NOACCESS (No access; alt CARDED/NOADULT) -# - "tenant refusal" -> CRA (Customer Refused Access; alt REFSURV/REFUSAL) -# - "not viable" -> NOACCESS (No access; alt JOBD/ERROR) -# NB "cancelled"/"no show" are our internal split of HubSpot's single -# "Cancelled / No Show" dropdown value - the client may prefer one code for both. +# - "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": AbandonReason.REQT, - "no show": AbandonReason.NOACCESS, + "cancelled / no show": AbandonReason.NOACCESS, "tenant refusal": AbandonReason.CRA, "not viable": AbandonReason.NOACCESS, } diff --git a/domain/abri/models.py b/domain/abri/models.py index 9eef5887b..e0cd20c98 100644 --- a/domain/abri/models.py +++ b/domain/abri/models.py @@ -12,13 +12,12 @@ SlotCode = Literal["AM", "PM", "AD"] # 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" + "no answer", "cancelled / no show", "tenant refusal", "not viable" ] UNSUCCESSFUL_OUTCOMES: tuple[UnsuccessfulOutcome, ...] = ( "no answer", - "cancelled", - "no show", + "cancelled / no show", "tenant refusal", "not viable", ) @@ -35,7 +34,6 @@ class AbandonReason(str, Enum): CARDED = "CARDED" CRA = "CRA" NOACCESS = "NOACCESS" - REQT = "REQT" @dataclass(frozen=True) 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 eb60e736f..5d0789054 100644 --- a/tests/domain/abri/test_abandonment.py +++ b/tests/domain/abri/test_abandonment.py @@ -32,8 +32,8 @@ def test_neither_date_leaves_the_abandonment_undated() -> None: ("No Answer", AbandonReason.CARDED), ("NO ANSWER", AbandonReason.CARDED), (" no answer ", AbandonReason.CARDED), - ("Cancelled", AbandonReason.REQT), - ("No Show", AbandonReason.NOACCESS), + ("Cancelled / No Show", AbandonReason.NOACCESS), + ("cancelled / no show", AbandonReason.NOACCESS), ("Tenant Refusal", AbandonReason.CRA), (" tenant refusal ", AbandonReason.CRA), ("Not Viable", AbandonReason.NOACCESS), @@ -48,7 +48,7 @@ def test_a_triggering_outcome_maps_to_its_reason_code( @pytest.mark.parametrize( "outcome", - [None, "", " ", "surveyed", "epc completed", "not attempted", "cancelled / no show"], + [None, "", " ", "surveyed", "epc completed", "not attempted", "cancelled"], ) def test_an_unmappable_outcome_raises(outcome: Optional[str]) -> None: with pytest.raises(UnmappableOutcomeError): From 31fc7ee1151c9db7993a5b2251a80e431f5dd4fd Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Wed, 8 Jul 2026 14:37:15 +0000 Subject: [PATCH 5/5] =?UTF-8?q?use=20unsuccesseful=20outcoems=20constant?= =?UTF-8?q?=20directly=20in=20hubspot=20deal=20differ=20=F0=9F=9F=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- etl/hubspot/hubspot_deal_differ.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index 9e2bd88fd..def629947 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -16,7 +16,6 @@ class HubspotDealDiffer: RETROFIT_DESIGN_COMPLETE = "uploaded" LODGEMENT_COMPLETE: List[str] = ["lodgement complete", "measures lodged"] ABANDONMENT_ATTEMPTS_THRESHOLD = 3 - NEGATIVE_OUTCOMES = UNSUCCESSFUL_OUTCOMES # 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 @@ -328,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(