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