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 <noreply@anthropic.com>
This commit is contained in:
Daniel Roth 2026-07-08 14:23:33 +00:00
parent 693356a220
commit 13e3e78749
3 changed files with 31 additions and 17 deletions

View file

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

View file

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

View file

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