From 80113205c88f7f116192f5c2e9d1585d3997ef17 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 17 Jul 2026 12:53:26 +0000 Subject: [PATCH 01/14] Sync third_party_surveyor_identifier deal field from HubSpot Fetch the property from the HubSpot deals API, persist it on hubspot_deal_data (column added by the frontend repo's drizzle migration), and include it in the differ's FIELD_MAP so a change to the field triggers a DB reload. Co-Authored-By: Claude Fable 5 --- backend/app/db/models/hubspot_deal_data.py | 1 + etl/hubspot/hubspotClient.py | 1 + etl/hubspot/hubspotDataTodB.py | 6 ++++++ etl/hubspot/hubspot_deal_differ.py | 1 + 4 files changed, 9 insertions(+) diff --git a/backend/app/db/models/hubspot_deal_data.py b/backend/app/db/models/hubspot_deal_data.py index 2445045e4..d2c8c8fe9 100644 --- a/backend/app/db/models/hubspot_deal_data.py +++ b/backend/app/db/models/hubspot_deal_data.py @@ -67,6 +67,7 @@ class HubspotDealData(SQLModel, table=True): lodgement_date: Optional[datetime] = Field(default=None) expected_commencement_date: Optional[datetime] = Field(default=None) surveyor: Optional[str] = Field(default=None) + third_party_surveyor_identifier: Optional[str] = Field(default=None) confirmed_survey_date: Optional[datetime] = Field(default=None) confirmed_survey_time: Optional[str] = Field(default=None) surveyed_date: Optional[datetime] = Field(default=None) diff --git a/etl/hubspot/hubspotClient.py b/etl/hubspot/hubspotClient.py index c886eb1c2..7a97ddce0 100644 --- a/etl/hubspot/hubspotClient.py +++ b/etl/hubspot/hubspotClient.py @@ -291,6 +291,7 @@ class HubspotClient: "lodgement_date", "expected_commencement_date", "surveyor", + "third_party_surveyor_identifier", "confirmed_survey_date", "confirmed_survey_time", "client_booking_reference", diff --git a/etl/hubspot/hubspotDataTodB.py b/etl/hubspot/hubspotDataTodB.py index 68f84477e..d80019b74 100644 --- a/etl/hubspot/hubspotDataTodB.py +++ b/etl/hubspot/hubspotDataTodB.py @@ -270,6 +270,9 @@ class HubspotDataToDb: deal_data.get("expected_commencement_date") ), "surveyor": deal_data.get("surveyor"), + "third_party_surveyor_identifier": deal_data.get( + "third_party_surveyor_identifier" + ), "confirmed_survey_date": parse_hs_date( deal_data.get("confirmed_survey_date") ), @@ -391,6 +394,9 @@ class HubspotDataToDb: deal_data.get("expected_commencement_date") ), surveyor=deal_data.get("surveyor"), + third_party_surveyor_identifier=deal_data.get( + "third_party_surveyor_identifier" + ), confirmed_survey_date=parse_hs_date(deal_data.get("confirmed_survey_date")), confirmed_survey_time=deal_data.get("confirmed_survey_time"), client_booking_reference=deal_data.get("client_booking_reference"), diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index 2842b735c..16a0cb709 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -100,6 +100,7 @@ class HubspotDealDiffer: "lodgement_status": "lodgement_status", "design_type": "design_type", "surveyor": "surveyor", + "third_party_surveyor_identifier": "third_party_surveyor_identifier", "confirmed_survey_time": "confirmed_survey_time", "client_booking_reference": "client_booking_reference", "survey_type": "survey_type", From c8fe4585bfbb80090a96e0731090b23581d3ddc4 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 17 Jul 2026 13:00:47 +0000 Subject: [PATCH 02/14] 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 --- applications/abri/abri_trigger_request.py | 3 + applications/abri/dispatch.py | 4 ++ domain/abri/models.py | 1 + etl/hubspot/hubspot_deal_differ.py | 6 ++ etl/hubspot/tests/test_abri_flow_triggers.py | 24 ++++++++ infrastructure/abri/abri_client.py | 10 +++- orchestration/abri_orchestrator.py | 8 +++ .../abri/test_abri_trigger_request.py | 21 +++++++ tests/applications/abri/test_dispatch.py | 3 + tests/infrastructure/abri/test_abri_client.py | 55 +++++++++++++++++++ .../test_abri_orchestrator_amend_job.py | 27 +++++++++ .../test_abri_orchestrator_log_job.py | 24 ++++++++ 12 files changed, 185 insertions(+), 1 deletion(-) diff --git a/applications/abri/abri_trigger_request.py b/applications/abri/abri_trigger_request.py index 92ec5b8c0..8b89980d9 100644 --- a/applications/abri/abri_trigger_request.py +++ b/applications/abri/abri_trigger_request.py @@ -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": diff --git a/applications/abri/dispatch.py b/applications/abri/dispatch.py index 6187c6ea3..323b07ce3 100644 --- a/applications/abri/dispatch.py +++ b/applications/abri/dispatch.py @@ -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: diff --git a/domain/abri/models.py b/domain/abri/models.py index df726608b..c0801810b 100644 --- a/domain/abri/models.py +++ b/domain/abri/models.py @@ -44,6 +44,7 @@ class LogJobRequest: appointment_time: SlotCode short_description: str long_description: str + resource: Optional[str] = None @dataclass(frozen=True) diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index 16a0cb709..967a8de5b 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -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": ( diff --git a/etl/hubspot/tests/test_abri_flow_triggers.py b/etl/hubspot/tests/test_abri_flow_triggers.py index e6f5631f6..5ca9a5ab1 100644 --- a/etl/hubspot/tests/test_abri_flow_triggers.py +++ b/etl/hubspot/tests/test_abri_flow_triggers.py @@ -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( diff --git a/infrastructure/abri/abri_client.py b/infrastructure/abri/abri_client.py index 66e094eab..e8284e648 100644 --- a/infrastructure/abri/abri_client.py +++ b/infrastructure/abri/abri_client.py @@ -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), ], ) diff --git a/orchestration/abri_orchestrator.py b/orchestration/abri_orchestrator.py index c0aba0c93..64d43265e 100644 --- a/orchestration/abri_orchestrator.py +++ b/orchestration/abri_orchestrator.py @@ -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, ) ) diff --git a/tests/applications/abri/test_abri_trigger_request.py b/tests/applications/abri/test_abri_trigger_request.py index 5d723e3f0..74140656c 100644 --- a/tests/applications/abri/test_abri_trigger_request.py +++ b/tests/applications/abri/test_abri_trigger_request.py @@ -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() diff --git a/tests/applications/abri/test_dispatch.py b/tests/applications/abri/test_dispatch.py index 4b7090ae2..7d898b155 100644 --- a/tests/applications/abri/test_dispatch.py +++ b/tests/applications/abri/test_dispatch.py @@ -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", ), ) diff --git a/tests/infrastructure/abri/test_abri_client.py b/tests/infrastructure/abri/test_abri_client.py index 42ede432a..bfdc5c992 100644 --- a/tests/infrastructure/abri/test_abri_client.py +++ b/tests/infrastructure/abri/test_abri_client.py @@ -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: diff --git a/tests/orchestration/test_abri_orchestrator_amend_job.py b/tests/orchestration/test_abri_orchestrator_amend_job.py index 1a3386a5b..82ee126b4 100644 --- a/tests/orchestration/test_abri_orchestrator_amend_job.py +++ b/tests/orchestration/test_abri_orchestrator_amend_job.py @@ -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 --- diff --git a/tests/orchestration/test_abri_orchestrator_log_job.py b/tests/orchestration/test_abri_orchestrator_log_job.py index c47a7fcd2..9a5d2846d 100644 --- a/tests/orchestration/test_abri_orchestrator_log_job.py +++ b/tests/orchestration/test_abri_orchestrator_log_job.py @@ -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 --- From edd4ec00de23e582db124006b02eadcf0ba162ec Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 17 Jul 2026 13:13:28 +0000 Subject: [PATCH 03/14] Make the OpenHousing resource mandatory, dropping the default fallback The deal's third_party_surveyor_identifier is now required by the log and amend flows: the trigger contract rejects messages without it (blank treated as missing, since a blank resource makes OpenHousing silently drop the appointment), and LogJobRequest/AmendJobRequest carry it as a required field. The ABRI_RELAY_DEFAULT_RESOURCE fallback and its config/terraform/workflow wiring are removed as dead code. Co-Authored-By: Claude Fable 5 --- .github/workflows/_deploy_lambda.yml | 4 -- .github/workflows/deploy_terraform.yml | 1 - applications/abri/abri_trigger_request.py | 26 +++++++-- applications/abri/dispatch.py | 15 ++++- deployment/terraform/lambda/abri_api/main.tf | 7 +-- .../terraform/lambda/abri_api/variables.tf | 5 -- domain/abri/models.py | 10 +++- infrastructure/abri/abri_client.py | 20 +------ infrastructure/abri/config.py | 9 --- orchestration/abri_orchestrator.py | 4 +- scripts/smoke_test_abri_log_amend.py | 12 ++-- scripts/smoke_test_abri_logjob_flow.py | 1 - scripts/smoke_test_tenant_contacts.py | 1 - .../abri/test_abri_trigger_request.py | 31 +++++------ tests/infrastructure/abri/test_abri_client.py | 55 ++----------------- tests/infrastructure/abri/test_abri_config.py | 21 +------ .../test_abri_orchestrator_abandon_job.py | 1 - .../test_abri_orchestrator_amend_job.py | 8 +-- .../test_abri_orchestrator_log_job.py | 2 +- ...test_abri_orchestrator_tenant_data_sync.py | 1 - 20 files changed, 81 insertions(+), 153 deletions(-) diff --git a/.github/workflows/_deploy_lambda.yml b/.github/workflows/_deploy_lambda.yml index 6a3d9c328..d9442a661 100644 --- a/.github/workflows/_deploy_lambda.yml +++ b/.github/workflows/_deploy_lambda.yml @@ -107,8 +107,6 @@ on: required: false TF_VAR_abri_relay_password: required: false - TF_VAR_abri_relay_default_resource: - required: false TF_VAR_test_abri_project_code_override: required: false jobs: @@ -189,7 +187,6 @@ jobs: TF_VAR_abri_relay_url: ${{ secrets.TF_VAR_abri_relay_url }} TF_VAR_abri_relay_username: ${{ secrets.TF_VAR_abri_relay_username }} TF_VAR_abri_relay_password: ${{ secrets.TF_VAR_abri_relay_password }} - TF_VAR_abri_relay_default_resource: ${{ secrets.TF_VAR_abri_relay_default_resource }} TF_VAR_test_abri_project_code_override: ${{ secrets.TF_VAR_test_abri_project_code_override }} run: | ECR_REPO_URL_VAR="" @@ -247,7 +244,6 @@ jobs: TF_VAR_abri_relay_url: ${{ secrets.TF_VAR_abri_relay_url }} TF_VAR_abri_relay_username: ${{ secrets.TF_VAR_abri_relay_username }} TF_VAR_abri_relay_password: ${{ secrets.TF_VAR_abri_relay_password }} - TF_VAR_abri_relay_default_resource: ${{ secrets.TF_VAR_abri_relay_default_resource }} run: | EXTRA_VARS="" if [[ -n "${{ inputs.ecr_repo }}" ]]; then diff --git a/.github/workflows/deploy_terraform.yml b/.github/workflows/deploy_terraform.yml index 38e452512..87bb264a3 100644 --- a/.github/workflows/deploy_terraform.yml +++ b/.github/workflows/deploy_terraform.yml @@ -740,7 +740,6 @@ jobs: TF_VAR_abri_relay_url: ${{ secrets.ABRI_RELAY_URL }} TF_VAR_abri_relay_username: ${{ secrets.ABRI_RELAY_USERNAME }} TF_VAR_abri_relay_password: ${{ secrets.ABRI_RELAY_PASSWORD }} - TF_VAR_abri_relay_default_resource: ${{ secrets.ABRI_RELAY_DEFAULT_RESOURCE }} # ============================================================ # Build Audit Generator image diff --git a/applications/abri/abri_trigger_request.py b/applications/abri/abri_trigger_request.py index 8b89980d9..a41959fee 100644 --- a/applications/abri/abri_trigger_request.py +++ b/applications/abri/abri_trigger_request.py @@ -9,7 +9,7 @@ visibility surface. from datetime import date from typing import Dict, List, Literal, Optional, Tuple -from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator AbriFlow = Literal["amend_job", "log_job", "sync_tenant_data", "abandon_job"] @@ -19,8 +19,13 @@ AbriFlow = Literal["amend_job", "log_job", "sync_tenant_data", "abandon_job"] # 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"), + "amend_job": ("confirmed_survey_date", "third_party_surveyor_identifier"), + "log_job": ( + "place_ref", + "deal_name", + "confirmed_survey_date", + "third_party_surveyor_identifier", + ), "sync_tenant_data": ("place_ref",), "abandon_job": (), } @@ -37,10 +42,21 @@ 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. + # OpenHousing's bookable resource for the survey; required by the log + # and amend flows. A blank resource does not error at the relay — + # OpenHousing silently drops the appointment — so blank is treated as + # missing and fails the per-flow validation instead. third_party_surveyor_identifier: Optional[str] = None + @field_validator("third_party_surveyor_identifier") + @classmethod + def _blank_surveyor_identifier_is_missing( + cls, value: Optional[str] + ) -> Optional[str]: + if value is not None and not value.strip(): + return None + return value + @model_validator(mode="after") def _each_fired_flow_has_its_fields(self) -> "AbriTriggerRequest": missing = [ diff --git a/applications/abri/dispatch.py b/applications/abri/dispatch.py index 323b07ce3..7d03f1120 100644 --- a/applications/abri/dispatch.py +++ b/applications/abri/dispatch.py @@ -81,8 +81,13 @@ def dispatch_abri_flows( def _run_amend(request: AbriTriggerRequest, flows: AbriFlows) -> str: - if request.confirmed_survey_date is None: - raise ValueError("amend_job fired without a confirmed_survey_date") + if ( + request.confirmed_survey_date is None + or request.third_party_surveyor_identifier is None + ): + raise ValueError( + "amend_job fired without a confirmed_survey_date/surveyor identifier" + ) outcome = flows.amend_job( AppointmentChange( deal_id=request.hubspot_deal_id, @@ -112,8 +117,12 @@ def _run_log( request.place_ref is None or request.deal_name is None or request.confirmed_survey_date is None + or request.third_party_surveyor_identifier is None ): - raise ValueError("log_job fired without place_ref/deal_name/survey date") + raise ValueError( + "log_job fired without place_ref/deal_name/survey date/" + "surveyor identifier" + ) try: outcome = flows.log_job( ConfirmedSurveyBooking( diff --git a/deployment/terraform/lambda/abri_api/main.tf b/deployment/terraform/lambda/abri_api/main.tf index a7bb1d6cf..52ed94753 100644 --- a/deployment/terraform/lambda/abri_api/main.tf +++ b/deployment/terraform/lambda/abri_api/main.tf @@ -24,10 +24,9 @@ module "lambda" { # Abri relay credentials are per-stage: the dev/staging stage points at # Abri's UAT relay endpoint; production values are pending from Abri. - ABRI_RELAY_URL = var.abri_relay_url - ABRI_RELAY_USERNAME = var.abri_relay_username - ABRI_RELAY_PASSWORD = var.abri_relay_password - ABRI_RELAY_DEFAULT_RESOURCE = var.abri_relay_default_resource + ABRI_RELAY_URL = var.abri_relay_url + ABRI_RELAY_USERNAME = var.abri_relay_username + ABRI_RELAY_PASSWORD = var.abri_relay_password HUBSPOT_API_KEY = var.hubspot_api_key diff --git a/deployment/terraform/lambda/abri_api/variables.tf b/deployment/terraform/lambda/abri_api/variables.tf index e431db62b..98d7c56c6 100644 --- a/deployment/terraform/lambda/abri_api/variables.tf +++ b/deployment/terraform/lambda/abri_api/variables.tf @@ -58,11 +58,6 @@ variable "abri_relay_password" { sensitive = true } -variable "abri_relay_default_resource" { - type = string - sensitive = true -} - variable "hubspot_api_key" { type = string sensitive = true diff --git a/domain/abri/models.py b/domain/abri/models.py index c0801810b..e045ae203 100644 --- a/domain/abri/models.py +++ b/domain/abri/models.py @@ -44,7 +44,10 @@ class LogJobRequest: appointment_time: SlotCode short_description: str long_description: str - resource: Optional[str] = None + # The bookable OpenHousing resource. Mandatory: a missing or blank + # resource does not error at the relay — OpenHousing logs the job but + # silently drops the appointment. + resource: str @dataclass(frozen=True) @@ -68,7 +71,10 @@ class AmendJobRequest: job_no: str appointment_date: date appointment_time: SlotCode - resource: Optional[str] = None + # The bookable OpenHousing resource. Mandatory: a missing or blank + # resource does not error at the relay — OpenHousing silently drops + # the appointment. + resource: str @dataclass(frozen=True) diff --git a/infrastructure/abri/abri_client.py b/infrastructure/abri/abri_client.py index e8284e648..3aac754a1 100644 --- a/infrastructure/abri/abri_client.py +++ b/infrastructure/abri/abri_client.py @@ -56,14 +56,6 @@ 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=[ @@ -75,7 +67,7 @@ class AbriClient: ("client_ref", request.client_ref), ("appointment_date", _format_appointment_date(request.appointment_date)), ("appointment_time", request.appointment_time), - ("resource", resource), + ("resource", request.resource), ("resource_group", RESOURCE_GROUP), ], ) @@ -86,20 +78,12 @@ class AbriClient: return self._parse_job_logged(outcome) def amend_job(self, request: AmendJobRequest) -> AmendJobResult: - # OpenHousing silently drops the appointment unless a bookable resource - # is sent, so fall back to the configured surveyor when the request - # carries no explicit override (mirrors log_job). - resource = ( - request.resource - if request.resource is not None - else self._config.default_resource - ) parameters = [ ("job_no", request.job_no), ("action", "amend"), ("appointment_date", _format_appointment_date(request.appointment_date)), ("appointment_time", request.appointment_time), - ("resource", resource), + ("resource", request.resource), ] outcome = self._exchange( diff --git a/infrastructure/abri/config.py b/infrastructure/abri/config.py index f29e3f10c..445d63122 100644 --- a/infrastructure/abri/config.py +++ b/infrastructure/abri/config.py @@ -7,20 +7,11 @@ class AbriConfig: endpoint_url: str username: str password: str - default_resource: str @classmethod def from_env(cls, env: Mapping[str, str]) -> "AbriConfig": - # A blank default_resource does not error at the relay — OpenHousing - # logs the job but silently drops the appointment — so reject it here - # rather than let the misconfiguration ship an empty resource. - default_resource = env["ABRI_RELAY_DEFAULT_RESOURCE"] - if not default_resource.strip(): - raise ValueError("ABRI_RELAY_DEFAULT_RESOURCE must not be blank") - return cls( endpoint_url=env["ABRI_RELAY_URL"], username=env["ABRI_RELAY_USERNAME"], password=env["ABRI_RELAY_PASSWORD"], - default_resource=default_resource, ) diff --git a/orchestration/abri_orchestrator.py b/orchestration/abri_orchestrator.py index 64d43265e..9441fdd66 100644 --- a/orchestration/abri_orchestrator.py +++ b/orchestration/abri_orchestrator.py @@ -114,7 +114,7 @@ class ConfirmedSurveyBooking: 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 + third_party_surveyor_identifier: str @dataclass(frozen=True) @@ -143,7 +143,7 @@ class AppointmentChange: 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 + third_party_surveyor_identifier: str AmendJobOrchestrationResult = Union[AppointmentAmended, AbriRequestRejected] diff --git a/scripts/smoke_test_abri_log_amend.py b/scripts/smoke_test_abri_log_amend.py index cc786f449..f02783bd8 100644 --- a/scripts/smoke_test_abri_log_amend.py +++ b/scripts/smoke_test_abri_log_amend.py @@ -13,7 +13,7 @@ production database. Usage: 1. Ensure these are set in the environment or backend/.env: HUBSPOT_API_KEY, ABRI_RELAY_URL (the UAT endpoint), - ABRI_RELAY_USERNAME, ABRI_RELAY_PASSWORD, ABRI_RELAY_DEFAULT_RESOURCE. + ABRI_RELAY_USERNAME, ABRI_RELAY_PASSWORD. 2. Create a throwaway test deal in the HubSpot UI and paste its id into DEAL_ID below; paste Abri's agreed UAT test place reference into PLACE_REF. @@ -46,6 +46,9 @@ from orchestration.abri_orchestrator import ( DEAL_ID = "EDIT-ME" PLACE_REF = PlaceRef("EDIT-ME") +# The bookable OpenHousing resource to book the UAT job against (mandatory: +# in production it comes from the deal's third_party_surveyor_identifier). +SURVEYOR = "EDIT-ME" # Log for the first weekday at least a week out (a slot Abri UAT will take), # then amend to the following day's opposite slot. @@ -73,7 +76,6 @@ def _uat_abri_client() -> AbriClient: endpoint_url=_env("ABRI_RELAY_URL"), username=_env("ABRI_RELAY_USERNAME"), password=_env("ABRI_RELAY_PASSWORD"), - default_resource=_env("ABRI_RELAY_DEFAULT_RESOURCE"), ) ) @@ -94,8 +96,8 @@ class InMemoryDealDatabase: def main() -> None: - if DEAL_ID == "EDIT-ME" or PLACE_REF == PlaceRef("EDIT-ME"): - raise SystemExit("Edit DEAL_ID and PLACE_REF before running") + if "EDIT-ME" in (DEAL_ID, str(PLACE_REF), SURVEYOR): + raise SystemExit("Edit DEAL_ID, PLACE_REF and SURVEYOR before running") sdk_client = _hubspot_sdk_client() deal_database = InMemoryDealDatabase() @@ -114,6 +116,7 @@ def main() -> None: deal_name=f"Domna UAT smoke test {date.today().isoformat()}", confirmed_survey_date=LOG_DATE, confirmed_survey_time=LOG_TIME, + third_party_surveyor_identifier=SURVEYOR, ) ) if isinstance(log_result, AbriRequestRejected): @@ -128,6 +131,7 @@ def main() -> None: deal_id=DEAL_ID, confirmed_survey_date=AMEND_DATE, confirmed_survey_time=AMEND_TIME, + third_party_surveyor_identifier=SURVEYOR, ) ) if isinstance(amend_result, AbriRequestRejected): diff --git a/scripts/smoke_test_abri_logjob_flow.py b/scripts/smoke_test_abri_logjob_flow.py index 61bae5bee..63427a83f 100644 --- a/scripts/smoke_test_abri_logjob_flow.py +++ b/scripts/smoke_test_abri_logjob_flow.py @@ -229,7 +229,6 @@ def main() -> None: endpoint_url="https://stubbed.invalid/relay", username="smoke-test", password="", - default_resource="SMOKE", ) ) abri_client._session = cast( # pyright: ignore[reportPrivateUsage] diff --git a/scripts/smoke_test_tenant_contacts.py b/scripts/smoke_test_tenant_contacts.py index 8dedeedac..9aaa7a606 100644 --- a/scripts/smoke_test_tenant_contacts.py +++ b/scripts/smoke_test_tenant_contacts.py @@ -91,7 +91,6 @@ def _stubbed_abri_client() -> AbriClient: endpoint_url="https://stubbed.invalid/relay", username="smoke-test", password="", - default_resource="", ) ) client._session = cast( diff --git a/tests/applications/abri/test_abri_trigger_request.py b/tests/applications/abri/test_abri_trigger_request.py index 74140656c..928c6973a 100644 --- a/tests/applications/abri/test_abri_trigger_request.py +++ b/tests/applications/abri/test_abri_trigger_request.py @@ -15,6 +15,7 @@ def _full_message() -> Dict[str, Any]: "deal_name": "49 Admers Crescent", "confirmed_survey_date": "2026-06-24", "confirmed_survey_time": "14:30", + "third_party_surveyor_identifier": "THIRDPARTY", } @@ -29,6 +30,7 @@ def test_a_full_message_parses_into_a_typed_request() -> None: assert request.deal_name == "49 Admers Crescent" assert request.confirmed_survey_date == date(2026, 6, 24) assert request.confirmed_survey_time == "14:30" + assert request.third_party_surveyor_identifier == "THIRDPARTY" @pytest.mark.parametrize( @@ -37,7 +39,9 @@ def test_a_full_message_parses_into_a_typed_request() -> None: ("log_job", "place_ref"), ("log_job", "deal_name"), ("log_job", "confirmed_survey_date"), + ("log_job", "third_party_surveyor_identifier"), ("amend_job", "confirmed_survey_date"), + ("amend_job", "third_party_surveyor_identifier"), ("sync_tenant_data", "place_ref"), ], ) @@ -97,25 +101,18 @@ 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 +@pytest.mark.parametrize("blank", ["", " "]) +def test_a_blank_surveyor_identifier_is_treated_as_missing(blank: str) -> None: + # A blank resource does not error at the relay: OpenHousing logs the job + # but silently drops the appointment. Treat blank as missing so the + # per-flow validation fails loudly instead. message = _full_message() - message["third_party_surveyor_identifier"] = "THIRDPARTY" + message["flows"] = ["log_job"] + message["third_party_surveyor_identifier"] = blank - # 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 + # Act / Assert + with pytest.raises(ValidationError, match="third_party_surveyor_identifier"): + AbriTriggerRequest.model_validate(message) def test_a_message_carries_the_outcome_for_the_abandonment_reason_mapping() -> None: diff --git a/tests/infrastructure/abri/test_abri_client.py b/tests/infrastructure/abri/test_abri_client.py index bfdc5c992..cf6308863 100644 --- a/tests/infrastructure/abri/test_abri_client.py +++ b/tests/infrastructure/abri/test_abri_client.py @@ -28,7 +28,6 @@ CONFIG = AbriConfig( endpoint_url=ENDPOINT_URL, username="DomnaWeb", password="", - default_resource="NAULKH", ) @@ -52,6 +51,7 @@ def _spec_example_request() -> LogJobRequest: appointment_time="PM", short_description="Christian's SCS External Test.", long_description="Christian's SCS External Test.", + resource="NAULKH", ) @@ -60,6 +60,7 @@ def _spec_example_amend_request() -> AmendJobRequest: job_no="AC0439951", appointment_date=date(2026, 6, 19), appointment_time="PM", + resource="NAULKH", ) @@ -145,11 +146,11 @@ 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( +def test_log_job_books_the_job_against_the_requests_resource( 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. + # Arrange: the resource is mandatory on the request — the deal's + # third-party surveyor identifier — and is sent verbatim. mock_session.post.return_value.content = _load_fixture( "abri_relay_logjob_success_response.xml" ) @@ -177,29 +178,6 @@ def test_log_job_sends_the_requested_resource_instead_of_the_configured_default( 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: @@ -465,29 +443,6 @@ def test_amend_job_sends_the_recorded_amendoptiappt_envelope_to_the_relay_endpoi ) -def test_amend_job_defaults_the_resource_to_the_configured_surveyor( - client: AbriClient, mock_session: MagicMock -) -> None: - # Arrange: OpenHousing silently drops the appointment unless a bookable - # resource is sent, so an amend 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_amendoptiappt_success_response.xml" - ) - - # Act - client.amend_job(_spec_example_amend_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] - - # --- amend_job: rejection --- diff --git a/tests/infrastructure/abri/test_abri_config.py b/tests/infrastructure/abri/test_abri_config.py index 0884a14a3..63ea9c9f4 100644 --- a/tests/infrastructure/abri/test_abri_config.py +++ b/tests/infrastructure/abri/test_abri_config.py @@ -1,15 +1,12 @@ -import pytest - from infrastructure.abri.config import AbriConfig -def test_config_hydrates_relay_credentials_and_defaults_from_environment() -> None: +def test_config_hydrates_relay_credentials_from_environment() -> None: # Arrange env = { "ABRI_RELAY_URL": "https://relay.example.test/api/DomnaRelay?code=key", "ABRI_RELAY_USERNAME": "DomnaWeb", "ABRI_RELAY_PASSWORD": "secret", - "ABRI_RELAY_DEFAULT_RESOURCE": "CBRYAN", } # Act @@ -20,20 +17,4 @@ def test_config_hydrates_relay_credentials_and_defaults_from_environment() -> No endpoint_url="https://relay.example.test/api/DomnaRelay?code=key", username="DomnaWeb", password="secret", - default_resource="CBRYAN", ) - - -@pytest.mark.parametrize("blank", ["", " "]) -def test_config_rejects_a_blank_default_resource(blank: str) -> None: - # A blank resource does not error at the relay: OpenHousing logs the job - # but silently drops the appointment. Fail loudly at config load instead. - env = { - "ABRI_RELAY_URL": "https://relay.example.test/api/DomnaRelay?code=key", - "ABRI_RELAY_USERNAME": "DomnaWeb", - "ABRI_RELAY_PASSWORD": "secret", - "ABRI_RELAY_DEFAULT_RESOURCE": blank, - } - - with pytest.raises(ValueError, match="ABRI_RELAY_DEFAULT_RESOURCE"): - AbriConfig.from_env(env) diff --git a/tests/orchestration/test_abri_orchestrator_abandon_job.py b/tests/orchestration/test_abri_orchestrator_abandon_job.py index 6243bcb9f..b91157f8f 100644 --- a/tests/orchestration/test_abri_orchestrator_abandon_job.py +++ b/tests/orchestration/test_abri_orchestrator_abandon_job.py @@ -29,7 +29,6 @@ CONFIG = AbriConfig( endpoint_url=ENDPOINT_URL, username="DomnaWeb", password="", - default_resource="NAULKH", ) ABANDONMENT = DealAbandonment( diff --git a/tests/orchestration/test_abri_orchestrator_amend_job.py b/tests/orchestration/test_abri_orchestrator_amend_job.py index 82ee126b4..1fb4fef12 100644 --- a/tests/orchestration/test_abri_orchestrator_amend_job.py +++ b/tests/orchestration/test_abri_orchestrator_amend_job.py @@ -27,13 +27,13 @@ CONFIG = AbriConfig( endpoint_url=ENDPOINT_URL, username="DomnaWeb", password="", - default_resource="NAULKH", ) CHANGE = AppointmentChange( deal_id=DEAL_ID, confirmed_survey_date=date(2026, 6, 24), confirmed_survey_time="14:30", + third_party_surveyor_identifier="NAULKH", ) @@ -115,9 +115,9 @@ def test_amend_job_sends_an_amendoptiappt_envelope_for_the_deals_recorded_job( "action": "amend", "appointment_date": "24/06/2026", "appointment_time": "PM", - # OpenHousing drops the appointment without a bookable resource, so the - # amend defaults to the configured surveyor when none is overridden. - "resource": CONFIG.default_resource, + # OpenHousing drops the appointment without a bookable resource, so + # the deal's third-party surveyor identifier is always sent. + "resource": "NAULKH", } diff --git a/tests/orchestration/test_abri_orchestrator_log_job.py b/tests/orchestration/test_abri_orchestrator_log_job.py index 9a5d2846d..86d22211e 100644 --- a/tests/orchestration/test_abri_orchestrator_log_job.py +++ b/tests/orchestration/test_abri_orchestrator_log_job.py @@ -33,7 +33,6 @@ CONFIG = AbriConfig( endpoint_url=ENDPOINT_URL, username="DomnaWeb", password="", - default_resource="NAULKH", ) BOOKING = ConfirmedSurveyBooking( @@ -42,6 +41,7 @@ BOOKING = ConfirmedSurveyBooking( deal_name="49 Admers Crescent", confirmed_survey_date=date(2026, 6, 18), confirmed_survey_time="14:30", + third_party_surveyor_identifier="NAULKH", ) diff --git a/tests/orchestration/test_abri_orchestrator_tenant_data_sync.py b/tests/orchestration/test_abri_orchestrator_tenant_data_sync.py index 58b82dd82..b5bd17728 100644 --- a/tests/orchestration/test_abri_orchestrator_tenant_data_sync.py +++ b/tests/orchestration/test_abri_orchestrator_tenant_data_sync.py @@ -32,7 +32,6 @@ CONFIG = AbriConfig( endpoint_url=ENDPOINT_URL, username="DomnaWeb", password="", - default_resource="NAULKH", ) From f32628b2932fd08cedf81ba610deb0fde5cbc93c Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 17 Jul 2026 13:20:47 +0000 Subject: [PATCH 04/14] Fix stale comments referencing the removed default-resource fallback Co-Authored-By: Claude Fable 5 --- etl/hubspot/hubspot_deal_differ.py | 4 ++-- tests/orchestration/test_abri_orchestrator_amend_job.py | 4 ++-- tests/orchestration/test_abri_orchestrator_log_job.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index 967a8de5b..46de2314d 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -268,8 +268,8 @@ class HubspotDealDiffer: ), "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. + # bookable resource. Mandatory for those flows — a deal without + # it fails the message contract's validation and dead-letters. "third_party_surveyor_identifier": new_deal.get( "third_party_surveyor_identifier" ), diff --git a/tests/orchestration/test_abri_orchestrator_amend_job.py b/tests/orchestration/test_abri_orchestrator_amend_job.py index 1fb4fef12..86198ca48 100644 --- a/tests/orchestration/test_abri_orchestrator_amend_job.py +++ b/tests/orchestration/test_abri_orchestrator_amend_job.py @@ -126,8 +126,8 @@ def test_amend_job_books_the_deals_third_party_surveyor_as_the_resource( 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. + # Arrange: the deal's third-party surveyor identifier is booked as the + # amended appointment's resource. deal_database.job_nos[DEAL_ID] = JOB_NO mock_session.post.return_value.content = _load_fixture( "abri_relay_amendoptiappt_success_response.xml" diff --git a/tests/orchestration/test_abri_orchestrator_log_job.py b/tests/orchestration/test_abri_orchestrator_log_job.py index 86d22211e..0e8dbdd0b 100644 --- a/tests/orchestration/test_abri_orchestrator_log_job.py +++ b/tests/orchestration/test_abri_orchestrator_log_job.py @@ -145,8 +145,8 @@ 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. + # Arrange: the deal's third-party surveyor identifier is booked as the + # job's resource. mock_session.post.return_value.content = _load_fixture( "abri_relay_logjob_success_response.xml" ) From 18dfda038d75f65a1abb39aef75a4e9bf90b6953 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Fri, 17 Jul 2026 13:21:47 +0000 Subject: [PATCH 05/14] tweaks --- applications/abri/abri_trigger_request.py | 6 ++---- domain/abri/models.py | 6 ------ etl/hubspot/hubspot_deal_differ.py | 7 +------ 3 files changed, 3 insertions(+), 16 deletions(-) diff --git a/applications/abri/abri_trigger_request.py b/applications/abri/abri_trigger_request.py index a41959fee..003fe47bb 100644 --- a/applications/abri/abri_trigger_request.py +++ b/applications/abri/abri_trigger_request.py @@ -42,10 +42,8 @@ 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; required by the log - # and amend flows. A blank resource does not error at the relay — - # OpenHousing silently drops the appointment — so blank is treated as - # missing and fails the per-flow validation instead. + # OpenHousing's bookable resource for the survey; required by the log and + # amend flows. Empty string is also rejected by Abri, so not allowed here third_party_surveyor_identifier: Optional[str] = None @field_validator("third_party_surveyor_identifier") diff --git a/domain/abri/models.py b/domain/abri/models.py index e045ae203..f60729382 100644 --- a/domain/abri/models.py +++ b/domain/abri/models.py @@ -44,9 +44,6 @@ class LogJobRequest: appointment_time: SlotCode short_description: str long_description: str - # The bookable OpenHousing resource. Mandatory: a missing or blank - # resource does not error at the relay — OpenHousing logs the job but - # silently drops the appointment. resource: str @@ -71,9 +68,6 @@ class AmendJobRequest: job_no: str appointment_date: date appointment_time: SlotCode - # The bookable OpenHousing resource. Mandatory: a missing or blank - # resource does not error at the relay — OpenHousing silently drops - # the appointment. resource: str diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index 46de2314d..30651fdf9 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -244,9 +244,7 @@ class HubspotDealDiffer: flows.append("abandon_job") if not flows: - logger.info( - "No Abri flows triggered for HubSpot deal %s", hubspot_deal_id - ) + logger.info("No Abri flows triggered for HubSpot deal %s", hubspot_deal_id) return None logger.info( @@ -267,9 +265,6 @@ 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. Mandatory for those flows — a deal without - # it fails the message contract's validation and dead-letters. "third_party_surveyor_identifier": new_deal.get( "third_party_surveyor_identifier" ), From f91eb468cf53d97fd0fbb7d8a6320605f4f12790 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Mon, 20 Jul 2026 08:33:45 +0000 Subject: [PATCH 06/14] =?UTF-8?q?Amend=20the=20Abri=20booking=20when=20the?= =?UTF-8?q?=20deal's=20surveyor=20changes=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/tests/test_abri_flow_triggers.py | 29 ++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/etl/hubspot/tests/test_abri_flow_triggers.py b/etl/hubspot/tests/test_abri_flow_triggers.py index 5ca9a5ab1..4896da5db 100644 --- a/etl/hubspot/tests/test_abri_flow_triggers.py +++ b/etl/hubspot/tests/test_abri_flow_triggers.py @@ -118,6 +118,35 @@ def test_a_changed_confirmed_survey_date_builds_an_amend_job_message() -> None: assert message["confirmed_survey_date"] == "2026-06-24" +def test_a_changed_surveyor_on_a_booked_deal_builds_an_amend_job_message() -> None: + # Arrange: booking exists; only the surveyor changes. + old_deal = make_old_deal( + project_code=ABRI_PROJECT_CODE, + confirmed_survey_date=datetime(2026, 6, 24, tzinfo=timezone.utc), + confirmed_survey_time="14:30", + third_party_surveyor_identifier="OLDSURVEYOR", + ) + new_deal = make_new_deal( + confirmed_survey_date="2026-06-24", + confirmed_survey_time="14:30", + third_party_surveyor_identifier="NEWSURVEYOR", + ) + + # 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["flows"] == ["amend_job"] + assert message["third_party_surveyor_identifier"] == "NEWSURVEYOR" + + def test_a_first_expected_commencement_date_builds_a_tenant_sync_message() -> None: # Arrange old_deal = make_old_deal( From 26e2a98034761e8549f39026c17eb21fcbfdb6a8 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Mon, 20 Jul 2026 08:34:43 +0000 Subject: [PATCH 07/14] =?UTF-8?q?Amend=20the=20Abri=20booking=20when=20the?= =?UTF-8?q?=20deal's=20surveyor=20changes=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/hubspot_deal_differ.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index 30651fdf9..cf35a77da 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -320,14 +320,19 @@ class HubspotDealDiffer: new_survey_date = parse_hs_date(new_deal.get("confirmed_survey_date")) new_survey_time = new_deal.get("confirmed_survey_time") + new_surveyor = new_deal.get("third_party_surveyor_identifier") logger.info( "Abri job-amendment check: " "old_confirmed_survey_date=%s new_confirmed_survey_date=%s " - "old_confirmed_survey_time=%s new_confirmed_survey_time=%s", + "old_confirmed_survey_time=%s new_confirmed_survey_time=%s " + "old_third_party_surveyor_identifier=%s " + "new_third_party_surveyor_identifier=%s", old_deal.confirmed_survey_date, new_survey_date, old_deal.confirmed_survey_time, new_survey_time, + old_deal.third_party_surveyor_identifier, + new_surveyor, ) # A first-time survey date logs a new job, not an amendment. @@ -341,7 +346,10 @@ class HubspotDealDiffer: if old_deal.confirmed_survey_date != new_survey_date: return True - return old_deal.confirmed_survey_time != new_survey_time + if old_deal.confirmed_survey_time != new_survey_time: + return True + + return old_deal.third_party_surveyor_identifier != new_surveyor @staticmethod def check_for_abri_tenant_data_fetch( From 234270a1ca4a8e6b34877944e2480f59bd31b91c Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Mon, 20 Jul 2026 08:35:02 +0000 Subject: [PATCH 08/14] =?UTF-8?q?Treat=20a=20blank=20surveyor=20as=20unset?= =?UTF-8?q?=20when=20checking=20for=20booking=20amendments=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/tests/test_abri_flow_triggers.py | 27 ++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/etl/hubspot/tests/test_abri_flow_triggers.py b/etl/hubspot/tests/test_abri_flow_triggers.py index 4896da5db..842a4c522 100644 --- a/etl/hubspot/tests/test_abri_flow_triggers.py +++ b/etl/hubspot/tests/test_abri_flow_triggers.py @@ -147,6 +147,33 @@ def test_a_changed_surveyor_on_a_booked_deal_builds_an_amend_job_message() -> No assert message["third_party_surveyor_identifier"] == "NEWSURVEYOR" +def test_a_blank_surveyor_on_a_booked_deal_with_no_stored_surveyor_is_not_a_change() -> None: + # Arrange: booking exists; surveyor unset in the DB, blank in HubSpot. + old_deal = make_old_deal( + project_code=ABRI_PROJECT_CODE, + confirmed_survey_date=datetime(2026, 6, 24, tzinfo=timezone.utc), + confirmed_survey_time="14:30", + third_party_surveyor_identifier=None, + ) + new_deal = make_new_deal( + confirmed_survey_date="2026-06-24", + confirmed_survey_time="14:30", + third_party_surveyor_identifier="", + ) + + # 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 None + + def test_a_first_expected_commencement_date_builds_a_tenant_sync_message() -> None: # Arrange old_deal = make_old_deal( From 137ebd94563554d33454383b3acbbabd3b5c0481 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Mon, 20 Jul 2026 08:35:25 +0000 Subject: [PATCH 09/14] =?UTF-8?q?Treat=20a=20blank=20surveyor=20as=20unset?= =?UTF-8?q?=20when=20checking=20for=20booking=20amendments=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/hubspot_deal_differ.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index cf35a77da..60aece16c 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -320,7 +320,12 @@ class HubspotDealDiffer: new_survey_date = parse_hs_date(new_deal.get("confirmed_survey_date")) new_survey_time = new_deal.get("confirmed_survey_time") - new_surveyor = new_deal.get("third_party_surveyor_identifier") + # HubSpot sends "" for an unset property; the DB stores None. Treat + # them as the same so a booked deal doesn't amend on every scrape. + raw_surveyor = new_deal.get("third_party_surveyor_identifier") + new_surveyor = ( + raw_surveyor if raw_surveyor is not None and raw_surveyor.strip() else None + ) logger.info( "Abri job-amendment check: " "old_confirmed_survey_date=%s new_confirmed_survey_date=%s " From b4ce0ae83f715c8ab3c1f56b6aa308d001509ed5 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Mon, 20 Jul 2026 08:36:05 +0000 Subject: [PATCH 10/14] =?UTF-8?q?An=20unchanged=20surveyor=20on=20a=20book?= =?UTF-8?q?ed=20deal=20fires=20no=20amendment=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/tests/test_abri_flow_triggers.py | 27 ++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/etl/hubspot/tests/test_abri_flow_triggers.py b/etl/hubspot/tests/test_abri_flow_triggers.py index 842a4c522..e85ef2f7b 100644 --- a/etl/hubspot/tests/test_abri_flow_triggers.py +++ b/etl/hubspot/tests/test_abri_flow_triggers.py @@ -174,6 +174,33 @@ def test_a_blank_surveyor_on_a_booked_deal_with_no_stored_surveyor_is_not_a_chan assert message is None +def test_an_unchanged_surveyor_on_a_booked_deal_fires_nothing() -> None: + # Arrange: booking exists; surveyor and appointment are unchanged. + old_deal = make_old_deal( + project_code=ABRI_PROJECT_CODE, + confirmed_survey_date=datetime(2026, 6, 24, tzinfo=timezone.utc), + confirmed_survey_time="14:30", + third_party_surveyor_identifier="SURVEYOR", + ) + new_deal = make_new_deal( + confirmed_survey_date="2026-06-24", + confirmed_survey_time="14:30", + third_party_surveyor_identifier="SURVEYOR", + ) + + # 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 None + + def test_a_first_expected_commencement_date_builds_a_tenant_sync_message() -> None: # Arrange old_deal = make_old_deal( From 91dc359e0d0351932b40f6f1c38b4cb1bb5f9328 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Mon, 20 Jul 2026 08:36:50 +0000 Subject: [PATCH 11/14] =?UTF-8?q?A=20blank=20stored=20surveyor=20also=20co?= =?UTF-8?q?unts=20as=20unset=20in=20the=20amendment=20check=20=F0=9F=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/tests/test_abri_flow_triggers.py | 26 ++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/etl/hubspot/tests/test_abri_flow_triggers.py b/etl/hubspot/tests/test_abri_flow_triggers.py index e85ef2f7b..1e0ecdf52 100644 --- a/etl/hubspot/tests/test_abri_flow_triggers.py +++ b/etl/hubspot/tests/test_abri_flow_triggers.py @@ -174,6 +174,32 @@ def test_a_blank_surveyor_on_a_booked_deal_with_no_stored_surveyor_is_not_a_chan assert message is None +def test_a_blank_stored_surveyor_and_a_missing_incoming_one_are_not_a_change() -> None: + # Arrange: booking exists; DB stored "" for the surveyor, HubSpot omits it. + old_deal = make_old_deal( + project_code=ABRI_PROJECT_CODE, + confirmed_survey_date=datetime(2026, 6, 24, tzinfo=timezone.utc), + confirmed_survey_time="14:30", + third_party_surveyor_identifier="", + ) + new_deal = make_new_deal( + confirmed_survey_date="2026-06-24", + confirmed_survey_time="14:30", + ) + + # 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 None + + def test_an_unchanged_surveyor_on_a_booked_deal_fires_nothing() -> None: # Arrange: booking exists; surveyor and appointment are unchanged. old_deal = make_old_deal( From 1e38b44a9a5f87bbd7f2163d1f106bf2ab7be2b7 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Mon, 20 Jul 2026 08:37:33 +0000 Subject: [PATCH 12/14] =?UTF-8?q?A=20blank=20stored=20surveyor=20also=20co?= =?UTF-8?q?unts=20as=20unset=20in=20the=20amendment=20check=20=F0=9F=9F=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- etl/hubspot/hubspot_deal_differ.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/etl/hubspot/hubspot_deal_differ.py b/etl/hubspot/hubspot_deal_differ.py index 60aece16c..bb1e58674 100644 --- a/etl/hubspot/hubspot_deal_differ.py +++ b/etl/hubspot/hubspot_deal_differ.py @@ -320,11 +320,11 @@ class HubspotDealDiffer: new_survey_date = parse_hs_date(new_deal.get("confirmed_survey_date")) new_survey_time = new_deal.get("confirmed_survey_time") - # HubSpot sends "" for an unset property; the DB stores None. Treat - # them as the same so a booked deal doesn't amend on every scrape. - raw_surveyor = new_deal.get("third_party_surveyor_identifier") - new_surveyor = ( - raw_surveyor if raw_surveyor is not None and raw_surveyor.strip() else None + new_surveyor = HubspotDealDiffer._normalised_surveyor( + new_deal.get("third_party_surveyor_identifier") + ) + old_surveyor = HubspotDealDiffer._normalised_surveyor( + old_deal.third_party_surveyor_identifier ) logger.info( "Abri job-amendment check: " @@ -336,7 +336,7 @@ class HubspotDealDiffer: new_survey_date, old_deal.confirmed_survey_time, new_survey_time, - old_deal.third_party_surveyor_identifier, + old_surveyor, new_surveyor, ) @@ -354,7 +354,7 @@ class HubspotDealDiffer: if old_deal.confirmed_survey_time != new_survey_time: return True - return old_deal.third_party_surveyor_identifier != new_surveyor + return old_surveyor != new_surveyor @staticmethod def check_for_abri_tenant_data_fetch( @@ -420,6 +420,14 @@ class HubspotDealDiffer: old_outcome = (old_deal.outcome or "").lower() return new_outcome == "surveyed" and old_outcome != "surveyed" + @staticmethod + def _normalised_surveyor(value: Optional[str]) -> Optional[str]: + # HubSpot sends "" for an unset property and the DB stores whatever + # the scrape wrote, so blank and missing must compare as equal. + if value is None or not value.strip(): + return None + return value + @staticmethod def _is_abandoned( number_of_attempts: Optional[str], outcome: Optional[str] From 9c0bddd14d8b8ce8edcdf64a5cdab0db3464f581 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Mon, 20 Jul 2026 08:38:05 +0000 Subject: [PATCH 13/14] Add the Abri HubSpot ops guide, covering surveyor-change amendments Co-Authored-By: Claude Fable 5 --- docs/abri-hubspot-ops-guide.md | 91 ++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 docs/abri-hubspot-ops-guide.md diff --git a/docs/abri-hubspot-ops-guide.md b/docs/abri-hubspot-ops-guide.md new file mode 100644 index 000000000..783a125d0 --- /dev/null +++ b/docs/abri-hubspot-ops-guide.md @@ -0,0 +1,91 @@ +# Abri bookings: what your HubSpot changes do + +**Who this is for:** operations staff who work Abri deals in HubSpot — booking surveys, +recording visit outcomes, updating deal fields. + +**The one-line version:** for deals on the Abri Stock Condition project, certain HubSpot +field changes are picked up automatically and sent to Abri's scheduling system +(OpenHousing). You never talk to OpenHousing directly — the deal fields are the controls. + +--- + +## Which deals does this apply to? + +Only deals associated with the **Abri Stock Condition — Privately Funded** project. +Changes to any other deal are ignored by this integration. + +## How fast does it happen? + +Changes are picked up automatically shortly after you save them in HubSpot — usually +within a few minutes. Nothing fires while you are mid-edit; it's the saved change that +counts. + +--- + +## What your changes do in Abri's system + +| When you… | Abri's system… | How you know it worked | +|---|---|---| +| Set **Confirmed survey date** for the first time | Creates the survey booking (a "job") against the property, assigned to the surveyor on the deal | Abri's job number appears in **Client booking reference** on the deal | +| Change **Confirmed survey date**, **Confirmed survey time** or **Third-party surveyor identifier** on a deal that already has a booking | Updates the existing appointment — new date/time and/or reassigns it to the new surveyor | The deal keeps the same Client booking reference | +| Set **Expected commencement date** for the first time | Sends us the tenancy details for the property | Tenant contact records appear in HubSpot, linked to the deal | +| Record a **3rd attempt** (Number of attempts reaches 3) **and** set **Outcome** to an unsuccessful value (see below) | Cancels the booking as abandoned | — (this only fires once per deal) | + +**Unsuccessful outcomes** that count towards abandonment — the wording must match exactly: + +- `no answer` +- `cancelled / no show` +- `tenant refusal` +- `not viable` + +Any other outcome (or fewer than 3 attempts) does **not** cancel anything in Abri's +system. + +## The fields, and why they matter + +| HubSpot deal field | What it feeds | +|---|---| +| **Confirmed survey date** | The appointment date sent to Abri | +| **Confirmed survey time** | The appointment slot: `morning` → AM, `afternoon` → PM, a clock time like `09:30` → AM/PM by whether it's before midday, left blank → all day | +| **Third-party surveyor identifier** | Which surveyor the booking is assigned to in Abri's system. **Required** — a booking or appointment change cannot be sent without it, and it must be one of Abri's valid surveyor codes. Changing it on a deal that already has a booking reassigns that booking to the new surveyor | +| **Expected commencement date** | Setting it the first time triggers the tenant-details fetch | +| **Number of attempts** + **Outcome** | Together they trigger abandonment (3+ attempts and an unsuccessful outcome) | +| **Client booking reference** | Abri's job number, written back **by the system** after the booking is created | +| **Deal name** | Used as the job description Abri's staff see | + +--- + +## Things that will catch you out + +### Clearing the survey date does NOT cancel the booking + +If you delete or blank the confirmed survey date, the booking **still exists in Abri's +system** and the surveyor is still expected. There is currently no way to cancel a +booking from HubSpot other than the 3-attempts abandonment route. If a booking needs +cancelling for any other reason, contact the tech team. + +### Don't edit Client booking reference + +It is written automatically with Abri's job number and is how the system finds the +booking when you later change or abandon it. If you overwrite or clear it, appointment +changes and abandonments will stop working for that deal. + +### The surveyor identifier must be a valid Abri code + +Free-typed or misspelt values will be rejected by Abri's system and the booking won't be +made. The tech team holds the current list of valid surveyor codes from Abri — use a +value from that list. (Longer term this field will be set automatically from the deal +owner; until then it's set by hand.) + +### Outcome wording is exact + +Abandonment only recognises the four unsuccessful outcomes listed above, spelled exactly +that way. A variation like "No Answer - left card" won't trigger it. + +### If the job number never appears + +The booking didn't go through — most often because the surveyor identifier was missing +or invalid, or the deal isn't on the Abri project. Failures land with the tech team, not +in HubSpot, so you won't see an error message. If **Client booking reference** is still +empty well after you set the survey date, flag it to the tech team rather than re-editing +fields. From d6226f8232741a9460471022a0cde5603a7b2f43 Mon Sep 17 00:00:00 2001 From: Daniel Roth Date: Mon, 20 Jul 2026 08:54:12 +0000 Subject: [PATCH 14/14] add docs for Ops --- docs/abri-hubspot-ops-guide.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/abri-hubspot-ops-guide.md b/docs/abri-hubspot-ops-guide.md index 783a125d0..d307854fb 100644 --- a/docs/abri-hubspot-ops-guide.md +++ b/docs/abri-hubspot-ops-guide.md @@ -26,9 +26,9 @@ counts. | When you… | Abri's system… | How you know it worked | |---|---|---| +| Set **Expected commencement date** for the first time | Sends us the tenancy details for the property | Tenant contact records appear in HubSpot, linked to the deal | | Set **Confirmed survey date** for the first time | Creates the survey booking (a "job") against the property, assigned to the surveyor on the deal | Abri's job number appears in **Client booking reference** on the deal | | Change **Confirmed survey date**, **Confirmed survey time** or **Third-party surveyor identifier** on a deal that already has a booking | Updates the existing appointment — new date/time and/or reassigns it to the new surveyor | The deal keeps the same Client booking reference | -| Set **Expected commencement date** for the first time | Sends us the tenancy details for the property | Tenant contact records appear in HubSpot, linked to the deal | | Record a **3rd attempt** (Number of attempts reaches 3) **and** set **Outcome** to an unsuccessful value (see below) | Cancels the booking as abandoned | — (this only fires once per deal) | **Unsuccessful outcomes** that count towards abandonment — the wording must match exactly: @@ -45,10 +45,10 @@ system. | HubSpot deal field | What it feeds | |---|---| +| **Expected commencement date** | Setting it the first time triggers the tenant-details fetch | | **Confirmed survey date** | The appointment date sent to Abri | | **Confirmed survey time** | The appointment slot: `morning` → AM, `afternoon` → PM, a clock time like `09:30` → AM/PM by whether it's before midday, left blank → all day | | **Third-party surveyor identifier** | Which surveyor the booking is assigned to in Abri's system. **Required** — a booking or appointment change cannot be sent without it, and it must be one of Abri's valid surveyor codes. Changing it on a deal that already has a booking reassigns that booking to the new surveyor | -| **Expected commencement date** | Setting it the first time triggers the tenant-details fetch | | **Number of attempts** + **Outcome** | Together they trigger abandonment (3+ attempts and an unsuccessful outcome) | | **Client booking reference** | Abri's job number, written back **by the system** after the booking is created | | **Deal name** | Used as the job description Abri's staff see | @@ -70,12 +70,12 @@ It is written automatically with Abri's job number and is how the system finds t booking when you later change or abandon it. If you overwrite or clear it, appointment changes and abandonments will stop working for that deal. -### The surveyor identifier must be a valid Abri code +### The surveyor identifier (Third-Party Surveyor Identifier) must be a valid Abri code Free-typed or misspelt values will be rejected by Abri's system and the booking won't be made. The tech team holds the current list of valid surveyor codes from Abri — use a -value from that list. (Longer term this field will be set automatically from the deal -owner; until then it's set by hand.) +value from that list. Soon this field will be set automatically from the deal +owner. ### Outcome wording is exact