Model/orchestration/abri_orchestrator.py
Daniel Roth 84d0e1e0d5 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>
2026-07-08 08:51:39 +00:00

347 lines
12 KiB
Python

from dataclasses import dataclass
from datetime import date
from typing import Dict, List, Optional, Protocol, Tuple, Union
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,
AbriRequestRejected,
AmendJobRequest,
AppointmentAmended,
JobAbandoned,
LogJobRequest,
PlaceRef,
Tenant,
)
from domain.abri.slots import slot_for_confirmed_time
from infrastructure.abri.abri_client import AbriClient
from infrastructure.hubspot.deal_contacts_client import HubspotDealContactsClient
from infrastructure.hubspot.deal_properties_client import HubspotDealPropertiesClient
from infrastructure.hubspot.errors import HubspotRequestError
# The HubSpot deal property that carries the OpenHousing job number; the
# property ID is fixed externally, the domain and database call it job_no.
JOB_NO_DEAL_PROPERTY = "client_booking_reference"
def _vulnerability_description(tenant: Tenant) -> Optional[str]:
"""The tenant's distinct vulnerabilities, verbatim and newline-joined.
Case-insensitively distinct within the tenant, keeping the first-seen
casing and document order.
"""
distinct: List[str] = []
seen: set[str] = set()
for vulnerability in tenant.vulnerabilities:
if vulnerability.lower() in seen:
continue
seen.add(vulnerability.lower())
distinct.append(vulnerability)
return "\n".join(distinct) if distinct else None
def _contact_properties(tenant: Tenant) -> Dict[str, str]:
candidates: Dict[str, Optional[str]] = {
"firstname": tenant.forenames,
"lastname": tenant.surname,
"mobilephone": tenant.mobile,
"phone": tenant.telephone,
"secondary_phone_number": tenant.secondary_number,
"vulnerability_description": _vulnerability_description(tenant),
}
properties = {name: value for name, value in candidates.items() if value}
# Always written, so "not vulnerable" is distinguishable from "not synced".
properties["is_vulnerable"] = "true" if tenant.vulnerabilities else "false"
return properties
@dataclass(frozen=True)
class TenantDataSyncSummary:
"""PII-free record of what a sync run did: safe to log and persist."""
tenancy_reference: str
contact_ids: Tuple[str, ...]
vulnerable_contact_count: int
TenantDataSyncResult = Union[TenantDataSyncSummary, AbriRequestRejected]
class TenantDataSyncError(Exception):
"""A sync that stopped at the first failed HubSpot write.
Carries a PII-free progress report so the operator knows the deal's
half-state before cleaning up and re-running; contacts created but not
associated are orphaned in HubSpot and need manual cleanup.
"""
def __init__(
self,
message: str,
deal_id: str,
tenant_index: int,
contact_ids_created: Tuple[str, ...],
contact_ids_associated: Tuple[str, ...],
) -> None:
self.deal_id = deal_id
self.tenant_index = tenant_index
self.contact_ids_created = contact_ids_created
self.contact_ids_associated = contact_ids_associated
super().__init__(
f"{message} (deal: {deal_id}, tenant index: {tenant_index}, "
f"contacts created: {list(contact_ids_created)}, "
f"associated: {list(contact_ids_associated)})"
)
@property
def orphaned_contact_ids(self) -> Tuple[str, ...]:
return tuple(
contact_id
for contact_id in self.contact_ids_created
if contact_id not in self.contact_ids_associated
)
@dataclass(frozen=True)
class ConfirmedSurveyBooking:
"""The deal fields a confirmed survey booking contributes to a LogJob."""
deal_id: str
place_ref: PlaceRef
deal_name: str
confirmed_survey_date: date
confirmed_survey_time: Optional[str]
@dataclass(frozen=True)
class LogJobSummary:
"""PII-free record of a logged job: safe to log and persist."""
deal_id: str
job_no: str
LogJobOrchestrationResult = Union[LogJobSummary, AbriRequestRejected]
class DealDatabaseGateway(Protocol):
def record_job_no(self, deal_id: str, job_no: str) -> None: ...
def job_no_for_deal(self, deal_id: str) -> Optional[str]: ...
@dataclass(frozen=True)
class AppointmentChange:
"""The deal fields a changed survey booking contributes to an AmendJob."""
deal_id: str
confirmed_survey_date: date
confirmed_survey_time: Optional[str]
AmendJobOrchestrationResult = Union[AppointmentAmended, AbriRequestRejected]
@dataclass(frozen=True)
class DealAbandonment:
"""The deal fields a crossed-into-abandoned deal contributes to a cancel."""
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.
Retriable: the message should be redelivered until the log flow's
write-back lands (or it dead-letters), so a rapid log-then-amend
sequence eventually converges.
"""
def __init__(self, deal_id: str) -> None:
self.deal_id = deal_id
super().__init__(
f"no job_no recorded yet for deal {deal_id}; cannot amend its appointment"
)
class LogJobWriteBackError(Exception):
"""A job logged in OpenHousing whose job_no could not be written back.
Carries the orphaned job_no so an operator can paste it into HubSpot
manually; the run must never retry log_job, which would log a duplicate
job in OpenHousing.
"""
def __init__(self, message: str, deal_id: str, job_no: str) -> None:
self.deal_id = deal_id
self.job_no = job_no
super().__init__(f"{message} (deal: {deal_id}, orphaned job_no: {job_no})")
def client_ref_for_deal(deal_id: str) -> str:
"""The client_ref sent to OpenHousing: the HubSpot deal ID, for now.
UNCONFIRMED with Abri: whether client_ref must be unique per job (a deal
re-logging after a cancellation would repeat the bare deal ID) is an open
question on issue #1478. Swap the derivation here once Abri answers.
"""
return deal_id
class AbriOrchestrator:
"""Runs the Abri OpenHousing flows for a HubSpot deal, one method each.
Tenant data flows straight from Abri to HubSpot; nothing tenant-related
is persisted in Domna's database or logs. For job logging, HubSpot is
written first (it is the source of truth); the database second, so a
failed database write self-heals on the next scrape.
"""
def __init__(
self,
abri_client: AbriClient,
deal_contacts: HubspotDealContactsClient,
deal_properties: HubspotDealPropertiesClient,
deal_database: DealDatabaseGateway,
) -> None:
self._abri = abri_client
self._deal_contacts = deal_contacts
self._deal_properties = deal_properties
self._deal_database = deal_database
def sync_tenant_data(
self, place_ref: PlaceRef, deal_id: str
) -> TenantDataSyncResult:
tenancy = self._abri.get_tenant_data(place_ref)
if isinstance(tenancy, AbriRequestRejected):
return tenancy
contact_ids: List[str] = []
associated_ids: List[str] = []
for tenant_index, tenant in enumerate(tenancy.tenants):
try:
contact_id = self._deal_contacts.create_contact(
_contact_properties(tenant)
)
contact_ids.append(contact_id)
self._deal_contacts.associate_contact_to_deal(
deal_id=deal_id, contact_id=contact_id
)
associated_ids.append(contact_id)
except HubspotRequestError as error:
# Fail fast at the first failed write; `error` is already
# scrubbed, so chaining it keeps tracebacks PII-free.
raise TenantDataSyncError(
message=str(error),
deal_id=deal_id,
tenant_index=tenant_index,
contact_ids_created=tuple(contact_ids),
contact_ids_associated=tuple(associated_ids),
) from error
return TenantDataSyncSummary(
tenancy_reference=tenancy.tenancy_reference,
contact_ids=tuple(contact_ids),
vulnerable_contact_count=sum(
1 for tenant in tenancy.tenants if tenant.vulnerabilities
),
)
def amend_job(self, change: AppointmentChange) -> AmendJobOrchestrationResult:
job_no = self._deal_database.job_no_for_deal(change.deal_id)
if job_no is None:
raise JobNoNotYetRecordedError(deal_id=change.deal_id)
return self._abri.amend_job(
AmendJobRequest(
job_no=job_no,
appointment_date=change.confirmed_survey_date,
appointment_time=slot_for_confirmed_time(
change.confirmed_survey_time
),
)
)
def abandon_job(
self, abandonment: DealAbandonment
) -> AbandonJobOrchestrationResult:
job_no = self._deal_database.job_no_for_deal(abandonment.deal_id)
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=date_abandoned,
)
)
def log_job(self, booking: ConfirmedSurveyBooking) -> LogJobOrchestrationResult:
descriptions = build_job_descriptions(booking.deal_name)
outcome = self._abri.log_job(
LogJobRequest(
client_ref=client_ref_for_deal(booking.deal_id),
place_ref=booking.place_ref,
appointment_date=booking.confirmed_survey_date,
appointment_time=slot_for_confirmed_time(
booking.confirmed_survey_time
),
short_description=descriptions.short_description,
long_description=descriptions.long_description,
)
)
if isinstance(outcome, AbriRequestRejected):
return outcome
try:
self._deal_properties.write_deal_property(
deal_id=booking.deal_id,
property_name=JOB_NO_DEAL_PROPERTY,
value=outcome.job_no,
)
except HubspotRequestError as error:
# The job now exists in OpenHousing; never re-run log_job (it
# would log a duplicate). `error` is already scrubbed, so
# chaining it keeps tracebacks free of the response body.
raise LogJobWriteBackError(
message=str(error),
deal_id=booking.deal_id,
job_no=outcome.job_no,
) from error
self._deal_database.record_job_no(
deal_id=booking.deal_id, job_no=outcome.job_no
)
return LogJobSummary(deal_id=booking.deal_id, job_no=outcome.job_no)