Model/applications/abri/dispatch.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

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)"
)