Send the deal's third-party surveyor as the OpenHousing resource

Carry third_party_surveyor_identifier on the Abri trigger message and
through dispatch into the log and amend flows, booking the job against
that resource. LogJobRequest gains the optional resource field
AmendJobRequest already had; the client falls back to the configured
default surveyor when the deal names no third-party resource, so
existing deals behave unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Roth 2026-07-17 13:00:47 +00:00
parent 80113205c8
commit c8fe4585bf
12 changed files with 185 additions and 1 deletions

View file

@ -37,6 +37,9 @@ class AbriTriggerRequest(BaseModel):
confirmed_survey_time: Optional[str] = None
last_submission_date: Optional[date] = None
outcome: Optional[str] = None
# OpenHousing's bookable resource for the survey; optional because the
# Abri client falls back to the configured default surveyor when absent.
third_party_surveyor_identifier: Optional[str] = None
@model_validator(mode="after")
def _each_fired_flow_has_its_fields(self) -> "AbriTriggerRequest":

View file

@ -88,6 +88,7 @@ def _run_amend(request: AbriTriggerRequest, flows: AbriFlows) -> str:
deal_id=request.hubspot_deal_id,
confirmed_survey_date=request.confirmed_survey_date,
confirmed_survey_time=request.confirmed_survey_time,
third_party_surveyor_identifier=request.third_party_surveyor_identifier,
)
)
if isinstance(outcome, AbriRequestRejected):
@ -121,6 +122,9 @@ def _run_log(
deal_name=request.deal_name,
confirmed_survey_date=request.confirmed_survey_date,
confirmed_survey_time=request.confirmed_survey_time,
third_party_surveyor_identifier=(
request.third_party_surveyor_identifier
),
)
)
except LogJobWriteBackError as error:

View file

@ -44,6 +44,7 @@ class LogJobRequest:
appointment_time: SlotCode
short_description: str
long_description: str
resource: Optional[str] = None
@dataclass(frozen=True)

View file

@ -267,6 +267,12 @@ class HubspotDealDiffer:
else None
),
"confirmed_survey_time": new_deal.get("confirmed_survey_time"),
# Carried for the log/amend flows: sent to OpenHousing as the
# bookable resource, falling back to the configured default
# surveyor when absent.
"third_party_surveyor_identifier": new_deal.get(
"third_party_surveyor_identifier"
),
# Carried for the abandon flow: it dates the cancel to the confirmed
# survey date, falling back to the last submission date.
"last_submission_date": (

View file

@ -66,9 +66,33 @@ def test_a_first_confirmed_survey_date_builds_a_log_job_message() -> None:
"confirmed_survey_time": "14:30",
"last_submission_date": None,
"outcome": None,
"third_party_surveyor_identifier": None,
}
def test_the_message_carries_the_deals_third_party_surveyor_identifier() -> None:
# Arrange
old_deal = make_old_deal(project_code=ABRI_PROJECT_CODE, confirmed_survey_date=None)
new_deal = make_new_deal(
confirmed_survey_date="2026-06-24",
confirmed_survey_time="14:30",
third_party_surveyor_identifier="THIRDPARTY",
)
# Act
message = HubspotDealDiffer.check_abri_triggers_and_construct_message(
hubspot_deal_id=DEAL_ID,
new_deal=new_deal,
new_project=None,
new_listing=LISTING,
old_deal=old_deal,
)
# Assert
assert message is not None
assert message["third_party_surveyor_identifier"] == "THIRDPARTY"
def test_a_changed_confirmed_survey_date_builds_an_amend_job_message() -> None:
# Arrange
old_deal = make_old_deal(

View file

@ -56,6 +56,14 @@ class AbriClient:
self._session = requests.Session()
def log_job(self, request: LogJobRequest) -> LogJobResult:
# OpenHousing needs a bookable resource, so fall back to the
# configured surveyor when the request carries no explicit override
# (mirrors amend_job).
resource = (
request.resource
if request.resource is not None
else self._config.default_resource
)
outcome = self._exchange(
request_type="logjob",
parameters=[
@ -67,7 +75,7 @@ class AbriClient:
("client_ref", request.client_ref),
("appointment_date", _format_appointment_date(request.appointment_date)),
("appointment_time", request.appointment_time),
("resource", self._config.default_resource),
("resource", resource),
("resource_group", RESOURCE_GROUP),
],
)

View file

@ -112,6 +112,9 @@ class ConfirmedSurveyBooking:
deal_name: str
confirmed_survey_date: date
confirmed_survey_time: Optional[str]
# OpenHousing calls it the resource; the HubSpot deal property (and the
# database column) call it the third-party surveyor identifier.
third_party_surveyor_identifier: Optional[str] = None
@dataclass(frozen=True)
@ -138,6 +141,9 @@ class AppointmentChange:
deal_id: str
confirmed_survey_date: date
confirmed_survey_time: Optional[str]
# OpenHousing calls it the resource; the HubSpot deal property (and the
# database column) call it the third-party surveyor identifier.
third_party_surveyor_identifier: Optional[str] = None
AmendJobOrchestrationResult = Union[AppointmentAmended, AbriRequestRejected]
@ -283,6 +289,7 @@ class AbriOrchestrator:
appointment_time=slot_for_confirmed_time(
change.confirmed_survey_time
),
resource=change.third_party_surveyor_identifier,
)
)
@ -319,6 +326,7 @@ class AbriOrchestrator:
),
short_description=descriptions.short_description,
long_description=descriptions.long_description,
resource=booking.third_party_surveyor_identifier,
)
)

View file

@ -97,6 +97,27 @@ def test_a_message_naming_abandon_job_validates_with_only_the_deal_id() -> None:
assert request.flows == ["abandon_job"]
def test_a_message_carries_the_third_party_surveyor_identifier() -> None:
# Arrange
message = _full_message()
message["third_party_surveyor_identifier"] = "THIRDPARTY"
# Act
request = AbriTriggerRequest.model_validate(message)
# Assert
assert request.third_party_surveyor_identifier == "THIRDPARTY"
def test_a_message_without_a_surveyor_identifier_parses_with_none() -> None:
# Arrange: optional because the Abri client falls back to the configured
# default surveyor when the deal names no third-party resource.
request = AbriTriggerRequest.model_validate(_full_message())
# Assert
assert request.third_party_surveyor_identifier is None
def test_a_message_carries_the_outcome_for_the_abandonment_reason_mapping() -> None:
# Arrange
message = _full_message()

View file

@ -40,6 +40,7 @@ def _request(flows: List[str]) -> AbriTriggerRequest:
"confirmed_survey_time": "14:30",
"last_submission_date": "2026-07-01",
"outcome": "no answer",
"third_party_surveyor_identifier": "THIRDPARTY",
}
)
@ -171,6 +172,7 @@ def test_flows_receive_the_deal_fields_carried_by_the_message(
deal_id=DEAL_ID,
confirmed_survey_date=date(2026, 6, 24),
confirmed_survey_time="14:30",
third_party_surveyor_identifier="THIRDPARTY",
),
)
assert log_call == (
@ -181,6 +183,7 @@ def test_flows_receive_the_deal_fields_carried_by_the_message(
deal_name="49 Admers Crescent",
confirmed_survey_date=date(2026, 6, 24),
confirmed_survey_time="14:30",
third_party_surveyor_identifier="THIRDPARTY",
),
)

View file

@ -145,6 +145,61 @@ def test_log_job_sends_the_recorded_logjob_envelope_to_the_relay_endpoint(
)
def test_log_job_sends_the_requested_resource_instead_of_the_configured_default(
client: AbriClient, mock_session: MagicMock
) -> None:
# Arrange: a deal carrying a third-party surveyor identifier books the
# job against that resource, not the configured default surveyor.
mock_session.post.return_value.content = _load_fixture(
"abri_relay_logjob_success_response.xml"
)
# Act
client.log_job(
LogJobRequest(
client_ref="DOM51111",
place_ref=PlaceRef("1007165"),
appointment_date=date(2026, 6, 18),
appointment_time="PM",
short_description="Christian's SCS External Test.",
long_description="Christian's SCS External Test.",
resource="THIRDPARTY",
)
)
# Assert
sent_body: bytes = mock_session.post.call_args.kwargs["data"]
resources = [
parameter.get("attribute_value")
for parameter in ET.fromstring(sent_body).findall(".//Parameters")
if parameter.get("attribute") == "resource"
]
assert resources == ["THIRDPARTY"]
def test_log_job_defaults_the_resource_to_the_configured_surveyor(
client: AbriClient, mock_session: MagicMock
) -> None:
# Arrange: OpenHousing needs a bookable resource, so a log without an
# explicit surveyor falls back to the configured default rather than
# omitting the parameter.
mock_session.post.return_value.content = _load_fixture(
"abri_relay_logjob_success_response.xml"
)
# Act
client.log_job(_spec_example_request())
# Assert
sent_body: bytes = mock_session.post.call_args.kwargs["data"]
resources = [
parameter.get("attribute_value")
for parameter in ET.fromstring(sent_body).findall(".//Parameters")
if parameter.get("attribute") == "resource"
]
assert resources == [CONFIG.default_resource]
def test_log_job_declares_the_xml_body_content_type_the_relay_requires(
client: AbriClient, mock_session: MagicMock
) -> None:

View file

@ -1,4 +1,5 @@
import xml.etree.ElementTree as ET
from dataclasses import replace
from datetime import date
from pathlib import Path
from typing import Dict, List, Optional, Tuple
@ -120,6 +121,32 @@ def test_amend_job_sends_an_amendoptiappt_envelope_for_the_deals_recorded_job(
}
def test_amend_job_books_the_deals_third_party_surveyor_as_the_resource(
orchestrator: AbriOrchestrator,
mock_session: MagicMock,
deal_database: FakeDealDatabase,
) -> None:
# Arrange: a deal carrying a third-party surveyor identifier amends the
# appointment against that resource instead of the configured default.
deal_database.job_nos[DEAL_ID] = JOB_NO
mock_session.post.return_value.content = _load_fixture(
"abri_relay_amendoptiappt_success_response.xml"
)
# Act
orchestrator.amend_job(
replace(CHANGE, third_party_surveyor_identifier="THIRDPARTY")
)
# Assert
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 sent_parameters["resource"] == "THIRDPARTY"
# --- happy path: the amended appointment is confirmed back ---

View file

@ -1,6 +1,7 @@
import json
import traceback
import xml.etree.ElementTree as ET
from dataclasses import replace
from datetime import date
from pathlib import Path
from typing import List, Optional, Tuple
@ -141,6 +142,29 @@ def test_log_job_sends_a_logjob_envelope_built_from_the_deals_fields(
}
def test_log_job_books_the_deals_third_party_surveyor_as_the_resource(
orchestrator: AbriOrchestrator, mock_session: MagicMock
) -> None:
# Arrange: a deal carrying a third-party surveyor identifier books the
# job against that resource instead of the configured default surveyor.
mock_session.post.return_value.content = _load_fixture(
"abri_relay_logjob_success_response.xml"
)
# Act
orchestrator.log_job(
replace(BOOKING, third_party_surveyor_identifier="THIRDPARTY")
)
# Assert
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 sent_parameters["resource"] == "THIRDPARTY"
# --- happy path: the returned job_no lands in HubSpot, then the database ---