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>
164 lines
6.2 KiB
Python
164 lines
6.2 KiB
Python
"""Dispatches the flows named in a trigger message through the orchestrator.
|
|
|
|
The scraper decides which flows fire; this is a dumb dispatcher. Flows run
|
|
in a fixed, retry-safe order — amend, then log, then tenant sync, then
|
|
abandon — so that redelivery re-runs idempotent flows before non-idempotent
|
|
ones: amend is naturally idempotent, log is guarded by the job-number check,
|
|
and tenant contact creation (not idempotent) runs before abandon so its
|
|
failure can never cause a duplicate job log, and a log failure can never
|
|
duplicate contacts. Abandon runs last so a cancellation can never interfere
|
|
with a log, amend or tenant sync in the same batch.
|
|
"""
|
|
|
|
from typing import Dict, Protocol
|
|
|
|
from applications.abri.abri_trigger_request import AbriTriggerRequest
|
|
from domain.abri.models import AbriRequestRejected, PlaceRef
|
|
from orchestration.abri_orchestrator import (
|
|
AbandonJobOrchestrationResult,
|
|
AmendJobOrchestrationResult,
|
|
AppointmentChange,
|
|
ConfirmedSurveyBooking,
|
|
DealAbandonment,
|
|
DealDatabaseGateway,
|
|
LogJobOrchestrationResult,
|
|
LogJobWriteBackError,
|
|
TenantDataSyncResult,
|
|
)
|
|
from utilities.aws_lambda.task_handler import NonRetriableTaskError
|
|
|
|
|
|
class AbriFlows(Protocol):
|
|
"""The per-flow surface of the AbriOrchestrator that dispatch drives."""
|
|
|
|
def amend_job(self, change: AppointmentChange) -> AmendJobOrchestrationResult: ...
|
|
|
|
def log_job(self, booking: ConfirmedSurveyBooking) -> LogJobOrchestrationResult: ...
|
|
|
|
def sync_tenant_data(
|
|
self, place_ref: PlaceRef, deal_id: str
|
|
) -> TenantDataSyncResult: ...
|
|
|
|
def abandon_job(
|
|
self, abandonment: DealAbandonment
|
|
) -> AbandonJobOrchestrationResult: ...
|
|
|
|
|
|
class AbriFlowRejectedError(Exception):
|
|
"""An OpenHousing rejection, surfaced as a failure so the task fails.
|
|
|
|
Retrying a permanent rejection is wasteful but harmless — every flow is
|
|
safe to re-run — and redelivery eventually parks the message in the
|
|
dead-letter queue, which is the ops inbox.
|
|
"""
|
|
|
|
def __init__(self, flow: str, rejection: AbriRequestRejected) -> None:
|
|
self.flow = flow
|
|
self.rejection = rejection
|
|
super().__init__(
|
|
f"{flow} rejected by OpenHousing: {rejection.code}: {rejection.message}"
|
|
)
|
|
|
|
|
|
def dispatch_abri_flows(
|
|
request: AbriTriggerRequest,
|
|
flows: AbriFlows,
|
|
deal_database: DealDatabaseGateway,
|
|
) -> Dict[str, str]:
|
|
"""Run the fired flows in the fixed order; returns a PII-free summary."""
|
|
summary: Dict[str, str] = {}
|
|
|
|
if "amend_job" in request.flows:
|
|
summary["amend_job"] = _run_amend(request, flows)
|
|
if "log_job" in request.flows:
|
|
summary["log_job"] = _run_log(request, flows, deal_database)
|
|
if "sync_tenant_data" in request.flows:
|
|
summary["sync_tenant_data"] = _run_tenant_sync(request, flows)
|
|
if "abandon_job" in request.flows:
|
|
summary["abandon_job"] = _run_abandon(request, flows)
|
|
|
|
return summary
|
|
|
|
|
|
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")
|
|
outcome = flows.amend_job(
|
|
AppointmentChange(
|
|
deal_id=request.hubspot_deal_id,
|
|
confirmed_survey_date=request.confirmed_survey_date,
|
|
confirmed_survey_time=request.confirmed_survey_time,
|
|
)
|
|
)
|
|
if isinstance(outcome, AbriRequestRejected):
|
|
raise AbriFlowRejectedError(flow="amend_job", rejection=outcome)
|
|
return (
|
|
f"appointment amended to {outcome.appointment_date} "
|
|
f"{outcome.appointment_time} (job {outcome.job_no})"
|
|
)
|
|
|
|
|
|
def _run_log(
|
|
request: AbriTriggerRequest,
|
|
flows: AbriFlows,
|
|
deal_database: DealDatabaseGateway,
|
|
) -> str:
|
|
existing_job_no = deal_database.job_no_for_deal(request.hubspot_deal_id)
|
|
if existing_job_no is not None:
|
|
return f"skipped: job {existing_job_no} already logged"
|
|
|
|
if (
|
|
request.place_ref is None
|
|
or request.deal_name is None
|
|
or request.confirmed_survey_date is None
|
|
):
|
|
raise ValueError("log_job fired without place_ref/deal_name/survey date")
|
|
try:
|
|
outcome = flows.log_job(
|
|
ConfirmedSurveyBooking(
|
|
deal_id=request.hubspot_deal_id,
|
|
place_ref=PlaceRef(request.place_ref),
|
|
deal_name=request.deal_name,
|
|
confirmed_survey_date=request.confirmed_survey_date,
|
|
confirmed_survey_time=request.confirmed_survey_time,
|
|
)
|
|
)
|
|
except LogJobWriteBackError as error:
|
|
# The job exists in OpenHousing; a redelivery would log a duplicate.
|
|
# The orphaned job_no stays visible in the failure record.
|
|
raise NonRetriableTaskError(str(error)) from error
|
|
if isinstance(outcome, AbriRequestRejected):
|
|
raise AbriFlowRejectedError(flow="log_job", rejection=outcome)
|
|
return f"job {outcome.job_no} logged"
|
|
|
|
|
|
def _run_abandon(request: AbriTriggerRequest, flows: AbriFlows) -> str:
|
|
# Runs last so a cancellation can never interfere with a log, amend or
|
|
# tenant sync in the same batch. A missing job_no raises the retriable
|
|
# not-yet-recorded error, which propagates so the task fails and redelivers.
|
|
outcome = flows.abandon_job(
|
|
DealAbandonment(
|
|
deal_id=request.hubspot_deal_id,
|
|
outcome=request.outcome,
|
|
confirmed_survey_date=request.confirmed_survey_date,
|
|
last_submission_date=request.last_submission_date,
|
|
)
|
|
)
|
|
if isinstance(outcome, AbriRequestRejected):
|
|
raise AbriFlowRejectedError(flow="abandon_job", rejection=outcome)
|
|
return f"job {outcome.job_no} abandoned"
|
|
|
|
|
|
def _run_tenant_sync(request: AbriTriggerRequest, flows: AbriFlows) -> str:
|
|
if request.place_ref is None:
|
|
raise ValueError("sync_tenant_data fired without a place_ref")
|
|
outcome = flows.sync_tenant_data(
|
|
place_ref=PlaceRef(request.place_ref),
|
|
deal_id=request.hubspot_deal_id,
|
|
)
|
|
if isinstance(outcome, AbriRequestRejected):
|
|
raise AbriFlowRejectedError(flow="sync_tenant_data", rejection=outcome)
|
|
return (
|
|
f"{len(outcome.contact_ids)} contacts created "
|
|
f"({outcome.vulnerable_contact_count} vulnerable)"
|
|
)
|