Model/domain/abri/abandonment.py
Daniel Roth 4ac4dd8683 Collapse cancelled/no show to HubSpot's single 'Cancelled / No Show' value
'Cancelled' and 'no show' were separate strings in the outcome vocabulary,
but HubSpot only ever emits the one dropdown value 'Cancelled / No Show' -
so neither phantom string could ever match. Replace both with the real
value (mapped to NOACCESS, best-guess pending client), which also fixes the
long-standing differ mismatch: the abandonment trigger now actually fires on
Cancelled / No Show. Drops the now-unused REQT enum member.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:33:59 +00:00

104 lines
4 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 / no show" -> NOACCESS (No access; alt REQT/MYABRI92 if read as
# a tenant cancellation rather than a no-show)
# - "tenant refusal" -> CRA (Customer Refused Access; alt REFSURV/REFUSAL)
# - "not viable" -> NOACCESS (No access; alt JOBD/ERROR)
# See the reason-code reference below.
_REASON_BY_OUTCOME: dict[UnsuccessfulOutcome, AbandonReason] = {
"no answer": AbandonReason.CARDED,
"cancelled / 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