mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
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>
347 lines
12 KiB
Python
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)
|