"""Proposal engine (§9): create booking requests, route delivery through the tenant's scheduling provider, drive state transitions and client SMS. States: pending → resolved_by_owner | confirmed | rejected | expired The same transition functions are used by email action links, the dashboard, and the partner webhook — semantics are identical everywhere (§B.3). """ from __future__ import annotations import logging import uuid from datetime import UTC, datetime, timedelta from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from gogo.config import get_settings from gogo.domain import BookingRequestData, BookingStatus, DeliveryResult, Slot from gogo.i18n import fmt_date, fmt_slot, fmt_time from gogo.models import BookingRequest, Service, Tenant, utcnow from gogo.scheduling.base import get_provider from gogo.sms import send_sms from gogo.sms.templates import render_sms log = logging.getLogger("gogo.proposals") class TransitionError(Exception): """Invalid state transition (e.g. confirming an already-rejected request).""" async def create_booking_request( session: AsyncSession, tenant: Tenant, *, source: str, client_name: str, client_phone: str, service_id: str | None = None, service_name_raw: str = "", slots: list[Slot] | None = None, time_preference_text: str = "", home_visit: bool = False, address: str | None = None, summary: str = "", call_id: uuid.UUID | None = None, chat_session_id: uuid.UUID | None = None, ) -> tuple[BookingRequest, DeliveryResult]: """Create a pending request and deliver it via the tenant's provider.""" slots = (slots or [])[:3] # hard cap: at most 3 offered slots (§6.2) now = utcnow() req = BookingRequest( tenant_id=tenant.id, source=source, client_name=client_name.strip(), client_phone=client_phone.strip(), service_id=uuid.UUID(service_id) if service_id else None, service_name_raw=service_name_raw, slots=[s.model_dump(mode="json") for s in slots], time_preference_text=time_preference_text, home_visit=home_visit, address=address, summary=summary, call_id=call_id, chat_session_id=chat_session_id, status=BookingStatus.pending.value, expires_at=now + timedelta(hours=tenant.proposal_ttl_hours or 24), ) session.add(req) await session.flush() # assign req.id data = BookingRequestData( gogo_request_id=str(req.id), tenant_id=str(tenant.id), created_at=now, source=source, client_name=req.client_name, client_phone=req.client_phone, service_id=service_id, service_name_raw=service_name_raw, requested_slots=slots, time_preference_text=time_preference_text, home_visit=home_visit, address=address, summary=summary, transcript_url=f"{get_settings().base_url}/t/{req.id}", ) provider = await get_provider(session, tenant) result = await provider.deliver_request(data) if not result.ok: log.error("delivery failed for request %s: %s", req.id, result.detail) # Optional "request received" SMS to the client (§9.1) if tenant.sms_request_received and req.client_phone: await send_sms( session, tenant.id, req.client_phone, render_sms(tenant, "request_received"), kind="received", ) return req, result # -- transitions ------------------------------------------------------------- async def resolve_request( session: AsyncSession, tenant: Tenant, req: BookingRequest, note: str = "" ) -> None: """Owner contacted the client directly — primary flow. Gogo sends NO SMS (§9.2).""" _require_pending(req, allow_same=BookingStatus.resolved_by_owner) if req.status != BookingStatus.pending.value: return # idempotent replay req.status = BookingStatus.resolved_by_owner.value req.resolved_at = utcnow() req.resolution_note = note async def confirm_request( session: AsyncSession, tenant: Tenant, req: BookingRequest, slot: Slot, *, recheck: bool = True, ) -> bool: """Owner confirms a slot → confirmation SMS to client. Returns False (no transition) if recheck finds the slot busy — the caller should show a warning and let the owner decide (force with recheck=False). """ _require_pending(req, allow_same=BookingStatus.confirmed) if req.status == BookingStatus.confirmed.value: return True # idempotent replay if recheck: provider = await get_provider(session, tenant) checker = getattr(provider, "is_slot_free", None) if checker is not None: try: free = await checker( str(req.service_id) if req.service_id else None, slot ) except Exception: # noqa: BLE001 — recheck is best-effort log.exception("free/busy recheck failed for %s", req.id) free = True if not free: return False req.status = BookingStatus.confirmed.value req.confirmed_slot = slot.model_dump(mode="json") req.resolved_at = utcnow() service_name = await _service_name(session, req) day_name = fmt_slot(slot.start, tenant.timezone).split(",")[0] body = render_sms( tenant, "confirmation", usluga=service_name, dan=day_name, datum=fmt_date(slot.start, tenant.timezone), vrijeme=fmt_time(slot.start, tenant.timezone), ) if req.client_phone: await send_sms(session, tenant.id, req.client_phone, body, kind="confirmation") return True async def reject_request( session: AsyncSession, tenant: Tenant, req: BookingRequest, custom_sms: str | None = None ) -> None: """Reject → rejection SMS to client (template, editable before send §9.2).""" _require_pending(req, allow_same=BookingStatus.rejected) if req.status == BookingStatus.rejected.value: return # idempotent replay req.status = BookingStatus.rejected.value req.resolved_at = utcnow() body = custom_sms or render_sms(tenant, "rejection") if req.client_phone: await send_sms(session, tenant.id, req.client_phone, body, kind="rejection") async def expire_request(session: AsyncSession, tenant: Tenant, req: BookingRequest) -> None: """TTL passed with no owner action → apology SMS to client (§9.2).""" if req.status != BookingStatus.pending.value: return req.status = BookingStatus.expired.value req.resolved_at = utcnow() if req.client_phone: await send_sms( session, tenant.id, req.client_phone, render_sms(tenant, "expiry"), kind="expiry" ) def _require_pending(req: BookingRequest, allow_same: BookingStatus) -> None: if req.status not in (BookingStatus.pending.value, allow_same.value): raise TransitionError( f"request {req.id} is {req.status}, cannot transition to {allow_same.value}" ) async def _service_name(session: AsyncSession, req: BookingRequest) -> str: if req.service_id: service = ( await session.execute(select(Service).where(Service.id == req.service_id)) ).scalar_one_or_none() if service: return service.name return req.service_name_raw or "termin" # -- background jobs --------------------------------------------------------- async def process_expirations(session: AsyncSession) -> int: """Expire overdue pending requests; send owner reminders at 50% TTL. Returns count expired.""" from gogo.proposals.email import send_reminder_email now = datetime.now(UTC) pending = ( ( await session.execute( select(BookingRequest).where( BookingRequest.status == BookingStatus.pending.value ) ) ) .scalars() .all() ) expired = 0 for req in pending: tenant = ( await session.execute(select(Tenant).where(Tenant.id == req.tenant_id)) ).scalar_one() expires_at = req.expires_at if expires_at is None: continue if expires_at.tzinfo is None: expires_at = expires_at.replace(tzinfo=UTC) if expires_at <= now: await expire_request(session, tenant, req) expired += 1 else: ttl = timedelta(hours=tenant.proposal_ttl_hours or 24) if not req.reminder_sent and expires_at - now <= ttl / 2: await send_reminder_email(session, tenant, req) req.reminder_sent = True return expired