mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
Date an abandonment to the deal's own dates, not now() 🟩
date_abandoned now resolves to the third failed attempt's confirmed survey date, falling back to its last submission date, rather than the processing date. This dates the OpenHousing cancellation to when the job actually lapsed even if the trigger message lags or redelivers. - domain: abandonment_date() encodes the confirmed-survey -> last- submission fallback - DealAbandonment carries both dates; abandon_job raises AbandonmentDateUnknownError when a deal has neither - last_submission_date now flows through the trigger message and request - the injected clock is dropped: nothing reads now() any more Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
9ec629daf1
commit
84d0e1e0d5
10 changed files with 180 additions and 24 deletions
|
|
@ -14,8 +14,10 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|||
AbriFlow = Literal["amend_job", "log_job", "sync_tenant_data", "abandon_job"]
|
||||
|
||||
# The deal fields each flow reads; hubspot_deal_id is always required.
|
||||
# abandon_job needs no field beyond the deal id (the outcome is optional, only
|
||||
# feeding the reason-mapping seam).
|
||||
# abandon_job requires no single field: it dates the cancel to whichever of the
|
||||
# confirmed survey date or last submission date is present, so requiring either
|
||||
# alone would reject valid messages; the orchestrator fails the flow if a deal
|
||||
# carries neither. Its outcome is optional too, only feeding the reason seam.
|
||||
_FIELDS_BY_FLOW: Dict[AbriFlow, Tuple[str, ...]] = {
|
||||
"amend_job": ("confirmed_survey_date",),
|
||||
"log_job": ("place_ref", "deal_name", "confirmed_survey_date"),
|
||||
|
|
@ -33,6 +35,7 @@ class AbriTriggerRequest(BaseModel):
|
|||
deal_name: Optional[str] = None
|
||||
confirmed_survey_date: Optional[date] = None
|
||||
confirmed_survey_time: Optional[str] = None
|
||||
last_submission_date: Optional[date] = None
|
||||
outcome: Optional[str] = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
|
|
|
|||
|
|
@ -140,6 +140,8 @@ def _run_abandon(request: AbriTriggerRequest, flows: AbriFlows) -> str:
|
|||
DealAbandonment(
|
||||
deal_id=request.hubspot_deal_id,
|
||||
outcome=request.outcome,
|
||||
confirmed_survey_date=request.confirmed_survey_date,
|
||||
last_submission_date=request.last_submission_date,
|
||||
)
|
||||
)
|
||||
if isinstance(outcome, AbriRequestRejected):
|
||||
|
|
|
|||
|
|
@ -1,8 +1,23 @@
|
|||
from datetime import date
|
||||
from typing import Optional
|
||||
|
||||
from domain.abri.models import AbandonReason
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def abandon_reason_for_outcome(outcome: Optional[str]) -> AbandonReason:
|
||||
"""The OpenHousing abandonment reason code for a HubSpot deal outcome.
|
||||
|
||||
|
|
|
|||
|
|
@ -219,6 +219,7 @@ class HubspotDealDiffer:
|
|||
return None
|
||||
|
||||
confirmed_survey_date = parse_hs_date(new_deal.get("confirmed_survey_date"))
|
||||
last_submission_date = parse_hs_date(new_deal.get("last_submission_date"))
|
||||
listing = new_listing or {}
|
||||
return {
|
||||
"hubspot_deal_id": hubspot_deal_id,
|
||||
|
|
@ -231,6 +232,13 @@ class HubspotDealDiffer:
|
|||
else None
|
||||
),
|
||||
"confirmed_survey_time": new_deal.get("confirmed_survey_time"),
|
||||
# Carried for the abandon flow: it dates the cancel to the confirmed
|
||||
# survey date, falling back to the last submission date.
|
||||
"last_submission_date": (
|
||||
last_submission_date.date().isoformat()
|
||||
if last_submission_date is not None
|
||||
else None
|
||||
),
|
||||
# Carried for the abandon flow's reason-mapping seam; harmless to
|
||||
# the other flows, which ignore it.
|
||||
"outcome": new_deal.get("outcome"),
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ def test_a_first_confirmed_survey_date_builds_a_log_job_message() -> None:
|
|||
"deal_name": "49 Admers Crescent",
|
||||
"confirmed_survey_date": "2026-06-24",
|
||||
"confirmed_survey_time": "14:30",
|
||||
"last_submission_date": None,
|
||||
"outcome": None,
|
||||
}
|
||||
|
||||
|
|
@ -192,7 +193,11 @@ def test_a_deal_crossing_into_abandonment_builds_an_abandon_job_message() -> Non
|
|||
old_deal = make_old_deal(
|
||||
project_code=ABRI_PROJECT_CODE, number_of_attempts="2", outcome=None
|
||||
)
|
||||
new_deal = make_new_deal(number_of_attempts="3", outcome="no answer")
|
||||
# No confirmed survey date (an abandonment that never got surveyed), so the
|
||||
# last submission date is what the cancel will be dated to.
|
||||
new_deal = make_new_deal(
|
||||
number_of_attempts="3", outcome="no answer", last_submission_date="2026-07-01"
|
||||
)
|
||||
|
||||
# Act
|
||||
message = HubspotDealDiffer.check_abri_triggers_and_construct_message(
|
||||
|
|
@ -203,10 +208,12 @@ def test_a_deal_crossing_into_abandonment_builds_an_abandon_job_message() -> Non
|
|||
old_deal=old_deal,
|
||||
)
|
||||
|
||||
# Assert
|
||||
# Assert: the abandon flow carries the date it will date the cancel from.
|
||||
assert message is not None
|
||||
assert message["flows"] == ["abandon_job"]
|
||||
assert message["outcome"] == "no answer"
|
||||
assert message["confirmed_survey_date"] is None
|
||||
assert message["last_submission_date"] == "2026-07-01"
|
||||
|
||||
|
||||
# ==========================
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from typing import Callable, Dict, List, Optional, Protocol, Tuple, Union
|
||||
from typing import Dict, List, Optional, Protocol, Tuple, Union
|
||||
|
||||
from domain.abri.abandonment import abandon_reason_for_outcome
|
||||
from domain.abri.abandonment import abandon_reason_for_outcome, abandonment_date
|
||||
from domain.abri.descriptions import build_job_descriptions
|
||||
from domain.abri.models import (
|
||||
AbandonJobRequest,
|
||||
|
|
@ -149,11 +149,29 @@ class DealAbandonment:
|
|||
|
||||
deal_id: str
|
||||
outcome: Optional[str]
|
||||
confirmed_survey_date: Optional[date]
|
||||
last_submission_date: Optional[date]
|
||||
|
||||
|
||||
AbandonJobOrchestrationResult = Union[JobAbandoned, AbriRequestRejected]
|
||||
|
||||
|
||||
class AbandonmentDateUnknownError(Exception):
|
||||
"""An abandonment whose real-world date cannot be resolved from the deal.
|
||||
|
||||
The cancel is dated to the third attempt's confirmed survey date, falling
|
||||
back to its last submission date; a deal carrying neither leaves nothing to
|
||||
date the cancellation to, so the flow fails rather than invent a date.
|
||||
"""
|
||||
|
||||
def __init__(self, deal_id: str) -> None:
|
||||
self.deal_id = deal_id
|
||||
super().__init__(
|
||||
f"deal {deal_id} has no confirmed survey date or last submission "
|
||||
"date to date its abandonment to"
|
||||
)
|
||||
|
||||
|
||||
class JobNoNotYetRecordedError(Exception):
|
||||
"""An amendment arrived before the deal's job_no landed in the database.
|
||||
|
||||
|
|
@ -208,13 +226,11 @@ class AbriOrchestrator:
|
|||
deal_contacts: HubspotDealContactsClient,
|
||||
deal_properties: HubspotDealPropertiesClient,
|
||||
deal_database: DealDatabaseGateway,
|
||||
clock: Callable[[], date] = date.today,
|
||||
) -> None:
|
||||
self._abri = abri_client
|
||||
self._deal_contacts = deal_contacts
|
||||
self._deal_properties = deal_properties
|
||||
self._deal_database = deal_database
|
||||
self._clock = clock
|
||||
|
||||
def sync_tenant_data(
|
||||
self, place_ref: PlaceRef, deal_id: str
|
||||
|
|
@ -277,11 +293,17 @@ class AbriOrchestrator:
|
|||
if job_no is None:
|
||||
raise JobNoNotYetRecordedError(deal_id=abandonment.deal_id)
|
||||
|
||||
date_abandoned = abandonment_date(
|
||||
abandonment.confirmed_survey_date, abandonment.last_submission_date
|
||||
)
|
||||
if date_abandoned is None:
|
||||
raise AbandonmentDateUnknownError(deal_id=abandonment.deal_id)
|
||||
|
||||
return self._abri.abandon_job(
|
||||
AbandonJobRequest(
|
||||
job_no=job_no,
|
||||
reason=abandon_reason_for_outcome(abandonment.outcome),
|
||||
date_abandoned=self._clock(),
|
||||
date_abandoned=date_abandoned,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -110,6 +110,19 @@ def test_a_message_carries_the_outcome_for_the_abandonment_reason_mapping() -> N
|
|||
assert request.outcome == "no answer"
|
||||
|
||||
|
||||
def test_a_message_carries_the_last_submission_date_for_abandonment_dating() -> None:
|
||||
# Arrange
|
||||
message = _full_message()
|
||||
message["flows"] = ["abandon_job"]
|
||||
message["last_submission_date"] = "2026-07-01"
|
||||
|
||||
# Act
|
||||
request = AbriTriggerRequest.model_validate(message)
|
||||
|
||||
# Assert
|
||||
assert request.last_submission_date == date(2026, 7, 1)
|
||||
|
||||
|
||||
def test_a_message_naming_an_unknown_flow_fails_validation() -> None:
|
||||
# Arrange
|
||||
message = _full_message()
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ def _request(flows: List[str]) -> AbriTriggerRequest:
|
|||
"deal_name": "49 Admers Crescent",
|
||||
"confirmed_survey_date": "2026-06-24",
|
||||
"confirmed_survey_time": "14:30",
|
||||
"last_submission_date": "2026-07-01",
|
||||
"outcome": "no answer",
|
||||
}
|
||||
)
|
||||
|
|
@ -275,7 +276,7 @@ def test_abandon_runs_last_in_the_fixed_dispatch_order(
|
|||
]
|
||||
|
||||
|
||||
def test_the_abandon_flow_receives_the_deal_id_and_outcome_from_the_message(
|
||||
def test_the_abandon_flow_receives_the_deal_fields_it_dates_the_cancel_from(
|
||||
orchestrator: FakeOrchestrator, deal_database: FakeDealDatabase
|
||||
) -> None:
|
||||
# Arrange
|
||||
|
|
@ -286,7 +287,15 @@ def test_the_abandon_flow_receives_the_deal_id_and_outcome_from_the_message(
|
|||
|
||||
# Assert
|
||||
assert orchestrator.calls == [
|
||||
("abandon_job", DealAbandonment(deal_id=DEAL_ID, outcome="no answer"))
|
||||
(
|
||||
"abandon_job",
|
||||
DealAbandonment(
|
||||
deal_id=DEAL_ID,
|
||||
outcome="no answer",
|
||||
confirmed_survey_date=date(2026, 6, 24),
|
||||
last_submission_date=date(2026, 7, 1),
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,26 @@
|
|||
from datetime import date
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
|
||||
from domain.abri.abandonment import abandon_reason_for_outcome
|
||||
from domain.abri.abandonment import abandon_reason_for_outcome, abandonment_date
|
||||
from domain.abri.models import AbandonReason
|
||||
|
||||
CONFIRMED = date(2026, 6, 24)
|
||||
SUBMITTED = date(2026, 7, 1)
|
||||
|
||||
|
||||
def test_the_confirmed_survey_date_dates_the_abandonment() -> None:
|
||||
assert abandonment_date(CONFIRMED, SUBMITTED) == CONFIRMED
|
||||
|
||||
|
||||
def test_the_last_submission_date_stands_in_without_a_confirmed_survey_date() -> None:
|
||||
assert abandonment_date(None, SUBMITTED) == SUBMITTED
|
||||
|
||||
|
||||
def test_neither_date_leaves_the_abandonment_undated() -> None:
|
||||
assert abandonment_date(None, None) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"outcome",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from infrastructure.abri.config import AbriConfig
|
|||
from infrastructure.hubspot.deal_contacts_client import HubspotDealContactsClient
|
||||
from infrastructure.hubspot.deal_properties_client import HubspotDealPropertiesClient
|
||||
from orchestration.abri_orchestrator import (
|
||||
AbandonmentDateUnknownError,
|
||||
AbriOrchestrator,
|
||||
DealAbandonment,
|
||||
JobNoNotYetRecordedError,
|
||||
|
|
@ -21,7 +22,8 @@ FIXTURE_DIR = Path(__file__).parents[1] / "abri"
|
|||
ENDPOINT_URL = "https://relay.example.test/api/DomnaRelay?code=test-function-key"
|
||||
DEAL_ID = "9876543210"
|
||||
JOB_NO = "AC0439951"
|
||||
TODAY = date(2026, 7, 7)
|
||||
CONFIRMED_SURVEY_DATE = date(2026, 6, 24)
|
||||
LAST_SUBMISSION_DATE = date(2026, 7, 1)
|
||||
|
||||
CONFIG = AbriConfig(
|
||||
endpoint_url=ENDPOINT_URL,
|
||||
|
|
@ -30,7 +32,12 @@ CONFIG = AbriConfig(
|
|||
default_resource="NAULKH",
|
||||
)
|
||||
|
||||
ABANDONMENT = DealAbandonment(deal_id=DEAL_ID, outcome="no answer")
|
||||
ABANDONMENT = DealAbandonment(
|
||||
deal_id=DEAL_ID,
|
||||
outcome="no answer",
|
||||
confirmed_survey_date=CONFIRMED_SURVEY_DATE,
|
||||
last_submission_date=LAST_SUBMISSION_DATE,
|
||||
)
|
||||
|
||||
|
||||
def _load_fixture(name: str) -> bytes:
|
||||
|
|
@ -78,14 +85,21 @@ def orchestrator(
|
|||
deal_contacts=HubspotDealContactsClient(sdk_client=sdk_client),
|
||||
deal_properties=HubspotDealPropertiesClient(sdk_client=sdk_client),
|
||||
deal_database=deal_database,
|
||||
clock=lambda: TODAY,
|
||||
)
|
||||
|
||||
|
||||
# --- outbound request: canceljob carries the job, reason and today's date ---
|
||||
# --- outbound request: canceljob dates the abandonment to the survey date ---
|
||||
|
||||
|
||||
def test_abandon_job_sends_a_canceljob_envelope_for_the_deals_recorded_job(
|
||||
def _sent_parameters(mock_session: MagicMock) -> Dict[Optional[str], Optional[str]]:
|
||||
sent_body: bytes = mock_session.post.call_args.kwargs["data"]
|
||||
return {
|
||||
parameter.get("attribute"): parameter.get("attribute_value")
|
||||
for parameter in ET.fromstring(sent_body).findall("Body/Request/Parameters")
|
||||
}
|
||||
|
||||
|
||||
def test_abandon_job_sends_a_canceljob_dated_to_the_confirmed_survey_date(
|
||||
orchestrator: AbriOrchestrator,
|
||||
mock_session: MagicMock,
|
||||
deal_database: FakeDealDatabase,
|
||||
|
|
@ -101,19 +115,66 @@ def test_abandon_job_sends_a_canceljob_envelope_for_the_deals_recorded_job(
|
|||
|
||||
# Assert
|
||||
(url,) = mock_session.post.call_args.args
|
||||
sent_body: bytes = mock_session.post.call_args.kwargs["data"]
|
||||
sent_parameters = {
|
||||
parameter.get("attribute"): parameter.get("attribute_value")
|
||||
for parameter in ET.fromstring(sent_body).findall("Body/Request/Parameters")
|
||||
}
|
||||
assert url == ENDPOINT_URL
|
||||
assert sent_parameters == {
|
||||
assert _sent_parameters(mock_session) == {
|
||||
"job_no": JOB_NO,
|
||||
"abandon_reason": "CARDED",
|
||||
"date_abandoned": "07/07/2026",
|
||||
"date_abandoned": "24/06/2026",
|
||||
}
|
||||
|
||||
|
||||
# --- fallback: no confirmed survey date, so the last submission date dates it ---
|
||||
|
||||
|
||||
def test_abandon_job_falls_back_to_the_last_submission_date(
|
||||
orchestrator: AbriOrchestrator,
|
||||
mock_session: MagicMock,
|
||||
deal_database: FakeDealDatabase,
|
||||
) -> None:
|
||||
# Arrange
|
||||
deal_database.job_nos[DEAL_ID] = JOB_NO
|
||||
mock_session.post.return_value.content = _load_fixture(
|
||||
"abri_relay_canceljob_success_response.xml"
|
||||
)
|
||||
|
||||
# Act
|
||||
orchestrator.abandon_job(
|
||||
DealAbandonment(
|
||||
deal_id=DEAL_ID,
|
||||
outcome="no answer",
|
||||
confirmed_survey_date=None,
|
||||
last_submission_date=LAST_SUBMISSION_DATE,
|
||||
)
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert _sent_parameters(mock_session)["date_abandoned"] == "01/07/2026"
|
||||
|
||||
|
||||
# --- neither date present: nothing to date the abandonment to ---
|
||||
|
||||
|
||||
def test_abandon_job_raises_when_no_date_can_be_resolved(
|
||||
orchestrator: AbriOrchestrator,
|
||||
mock_session: MagicMock,
|
||||
deal_database: FakeDealDatabase,
|
||||
) -> None:
|
||||
# Arrange
|
||||
deal_database.job_nos[DEAL_ID] = JOB_NO
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(AbandonmentDateUnknownError):
|
||||
orchestrator.abandon_job(
|
||||
DealAbandonment(
|
||||
deal_id=DEAL_ID,
|
||||
outcome="no answer",
|
||||
confirmed_survey_date=None,
|
||||
last_submission_date=None,
|
||||
)
|
||||
)
|
||||
assert mock_session.post.call_count == 0
|
||||
|
||||
|
||||
# --- happy path: the cancelled job is confirmed back ---
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue