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 <noreply@anthropic.com>
This commit is contained in:
Daniel Roth 2026-07-08 14:14:06 +00:00
parent 358fa1b01d
commit 693356a220
3 changed files with 104 additions and 15 deletions

View file

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

View file

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

View file

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