mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
Merge pull request #1514 from Hestia-Homes/feature/abri-api-integration
Map Hubspot outcomes to their Abri abandonment reason codes
This commit is contained in:
commit
d7648fd5e0
5 changed files with 132 additions and 26 deletions
|
|
@ -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(
|
||||
|
|
@ -18,11 +18,87 @@ def abandonment_date(
|
|||
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. For now
|
||||
every outcome maps to CARDED; the real outcome-to-code table is a follow-up
|
||||
(needs Abri's code list), and swapping it in is a one-place change here.
|
||||
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.
|
||||
"""
|
||||
return AbandonReason.CARDED
|
||||
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
|
||||
|
|
|
|||
|
|
@ -7,15 +7,33 @@ 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.
|
||||
|
||||
Only CARDED is used for now; the full HubSpot-outcome-to-code table is a
|
||||
documented follow-up - enum based on Abri codes needs creating and mapping from hubspot
|
||||
Values are Abri's own reason codes. Only the members reachable from a
|
||||
HubSpot abandonment outcome are defined; the outcome-to-code mapping lives
|
||||
in domain.abri.abandonment.
|
||||
"""
|
||||
|
||||
CARDED = "CARDED"
|
||||
CRA = "CRA"
|
||||
NOACCESS = "NOACCESS"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
|
|||
|
|
@ -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,6 @@ 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",
|
||||
]
|
||||
# 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
|
||||
|
|
@ -333,7 +327,7 @@ class HubspotDealDiffer:
|
|||
):
|
||||
return False
|
||||
|
||||
return (outcome or "").lower() in HubspotDealDiffer.NEGATIVE_OUTCOMES
|
||||
return (outcome or "").lower() in UNSUCCESSFUL_OUTCOMES
|
||||
|
||||
@staticmethod
|
||||
def _is_abri_condition_deal(
|
||||
|
|
|
|||
|
|
@ -1040,8 +1040,7 @@ def test_abri_deal_abandonment__outcome_not_negative__returns_false(
|
|||
"outcome",
|
||||
[
|
||||
"No Answer",
|
||||
"Cancelled",
|
||||
"No Show",
|
||||
"Cancelled / No Show",
|
||||
"Tenant Refusal",
|
||||
"Not Viable",
|
||||
"NO ANSWER",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,11 @@ from typing import Optional
|
|||
|
||||
import pytest
|
||||
|
||||
from domain.abri.abandonment import abandon_reason_for_outcome, abandonment_date
|
||||
from domain.abri.abandonment import (
|
||||
UnmappableOutcomeError,
|
||||
abandon_reason_for_outcome,
|
||||
abandonment_date,
|
||||
)
|
||||
from domain.abri.models import AbandonReason
|
||||
|
||||
CONFIRMED = date(2026, 6, 24)
|
||||
|
|
@ -23,14 +27,29 @@ def test_neither_date_leaves_the_abandonment_undated() -> None:
|
|||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"outcome",
|
||||
["no answer", "cancelled", "no show", "tenant refusal", "not viable", None],
|
||||
("outcome", "expected"),
|
||||
[
|
||||
("No Answer", AbandonReason.CARDED),
|
||||
("NO ANSWER", AbandonReason.CARDED),
|
||||
(" no answer ", AbandonReason.CARDED),
|
||||
("Cancelled / No Show", AbandonReason.NOACCESS),
|
||||
("cancelled / no show", AbandonReason.NOACCESS),
|
||||
("Tenant Refusal", AbandonReason.CRA),
|
||||
(" tenant refusal ", AbandonReason.CRA),
|
||||
("Not Viable", AbandonReason.NOACCESS),
|
||||
("not viable", AbandonReason.NOACCESS),
|
||||
],
|
||||
)
|
||||
def test_every_outcome_currently_maps_to_the_carded_reason(
|
||||
outcome: Optional[str],
|
||||
def test_a_triggering_outcome_maps_to_its_reason_code(
|
||||
outcome: str, expected: AbandonReason
|
||||
) -> None:
|
||||
# Act
|
||||
reason = abandon_reason_for_outcome(outcome)
|
||||
assert abandon_reason_for_outcome(outcome) == expected
|
||||
|
||||
# Assert
|
||||
assert reason == AbandonReason.CARDED
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"outcome",
|
||||
[None, "", " ", "surveyed", "epc completed", "not attempted", "cancelled"],
|
||||
)
|
||||
def test_an_unmappable_outcome_raises(outcome: Optional[str]) -> None:
|
||||
with pytest.raises(UnmappableOutcomeError):
|
||||
abandon_reason_for_outcome(outcome)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue