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