Model/domain/abri/abandonment.py
Daniel Roth 9a669dfcea 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 <noreply@anthropic.com>
2026-07-08 14:28:47 +00:00

107 lines
4.1 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}")
# 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,
}
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