Model/domain/abri/abandonment.py
Daniel Roth 13e3e78749 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>
2026-07-08 14:23:33 +00:00

100 lines
3.8 KiB
Python

from datetime import date
from typing import Optional
from domain.abri.models import AbandonReason, UnsuccessfulOutcome
def abandonment_date(
confirmed_survey_date: Optional[date],
last_submission_date: Optional[date],
) -> Optional[date]:
"""The real-world date a deal's job is abandoned on.
The confirmed survey date of the failed third attempt, since that is when
the job actually lapsed; if no survey date was ever confirmed, the date the
third attempt was last submitted stands in. Returns None when the deal
carries neither date, leaving the caller to decide how to surface the gap.
"""
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 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,
}
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, matched
case- and whitespace-insensitively. Raises UnmappableOutcomeError for an
empty or unrecognised outcome, since abandon_reason is mandatory.
"""
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
# 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