"""Semi-automatic, cascading follow-up workflow for ClientFlow opportunities. Follow-ups are normal human tasks with ``due_at``: - ClientFlow schedules only the next required follow-up; - the operator sends/handles it manually or semi-automatically; - completing a follow-up creates the next step in the cascade only if the opportunity has not advanced; - no email/Chatwoot/WhatsApp message is sent automatically here. """ from __future__ import annotations import json from datetime import datetime, timedelta, timezone from typing import Any, Dict, Optional from sqlalchemy import text from app.config import settings from app.db import engine FOLLOW_UP_ACTION_CODES = { "FOLLOW_UP_QUOTE", "FOLLOW_UP_PROFORMA", "FOLLOW_UP_PAYMENT", "FOLLOW_UP_CUSTOMER_REVIEW", "FOLLOW_UP_GENERIC", "CONFIRM_DELIVERY", "RECOVER_OPPORTUNITY", "REVIEW_NURTURE", } # Short, stage-aware contact cadences. v4928.1.5.127 starts directly with # commercial follow-up; delivery verification is an explicit manual exception. # Delays are business days counted from the operator action that completed the # prior step. FOLLOW_UP_CADENCES: Dict[str, list[Dict[str, Any]]] = { # v4928.1.5.127: delivery verification is no longer an automatic first # step. The normal sequence starts with the commercial follow-up. The # operator may still create CONFIRM_DELIVERY manually when the history # suggests a real delivery problem. "info": [ { "action_code": "FOLLOW_UP_CUSTOMER_REVIEW", "route": "vendas", "delay_business_days": 2, "action": "Confirmar interesse após informação", "note": "Perguntar se a informação corresponde ao que procura e se pretende orçamento. Não enviar automaticamente.", "purpose": "interest_check", }, { "action_code": "FOLLOW_UP_CUSTOMER_REVIEW", "route": "vendas", "delay_business_days": 4, "action": "Segundo follow-up da informação", "note": "Fazer uma pergunta concreta sobre necessidade, potência, instalação ou prazo. Não enviar automaticamente.", "purpose": "qualification_followup", }, { "action_code": "FOLLOW_UP_CUSTOMER_REVIEW", "route": "vendas", "delay_business_days": 5, "action": "Última tentativa sobre a informação", "note": "Última tentativa antes de enviar a oportunidade para recuperação ou acompanhamento futuro. Não enviar automaticamente.", "purpose": "final_attempt", }, ], "quote": [ { "action_code": "FOLLOW_UP_QUOTE", "route": "vendas", "delay_business_days": 2, "action": "Esclarecer dúvidas do orçamento", "note": "Perguntar se existem dúvidas técnicas, comerciais ou de instalação. Não enviar automaticamente.", "purpose": "objection_check", }, { "action_code": "FOLLOW_UP_QUOTE", "route": "vendas", "delay_business_days": 4, "action": "Follow-up de decisão do orçamento", "note": "Confirmar decisão, prazo e eventual obstáculo para avançar. Não enviar automaticamente.", "purpose": "decision_followup", }, { "action_code": "FOLLOW_UP_QUOTE", "route": "vendas", "delay_business_days": 5, "action": "Última tentativa do orçamento", "note": "Última tentativa comercial antes de recuperação ou acompanhamento futuro. Não enviar automaticamente.", "purpose": "final_attempt", }, ], "payment": [ { "action_code": "FOLLOW_UP_PAYMENT", "route": "financeiro", "delay_business_days": 2, "action": "Pedir data prevista de pagamento", "note": "Perguntar quando prevê efetuar o pagamento e registar a data prometida. Não enviar automaticamente.", "purpose": "payment_date", }, { "action_code": "FOLLOW_UP_PAYMENT", "route": "financeiro", "delay_business_days": 3, "action": "Lembrete de pagamento", "note": "Confirmar se existe alguma pendência administrativa ou financeira. Não enviar automaticamente.", "purpose": "payment_reminder", }, { "action_code": "FOLLOW_UP_PAYMENT", "route": "financeiro", "delay_business_days": 3, "action": "Contacto direto sobre pagamento", "note": "Contactar por canal alternativo quando adequado e rever intenção de compra. Não enviar automaticamente.", "purpose": "direct_contact", }, { "action_code": "FOLLOW_UP_PAYMENT", "route": "financeiro", "delay_business_days": 5, "action": "Rever intenção e suspender reserva", "note": "Última tentativa: confirmar se ainda pretende avançar antes de suspender ou mover para recuperação. Não enviar automaticamente.", "purpose": "final_attempt", }, ], "generic": [ { "action_code": "FOLLOW_UP_GENERIC", "route": "vendas", "delay_business_days": 2, "action": "Fazer follow-up manual", "note": "Novo contacto de seguimento conforme contexto da oportunidade. Não enviar automaticamente.", "purpose": "generic_followup", }, { "action_code": "FOLLOW_UP_GENERIC", "route": "vendas", "delay_business_days": 4, "action": "Segundo follow-up manual", "note": "Segundo contacto, preferencialmente com pergunta concreta ou canal alternativo. Não enviar automaticamente.", "purpose": "generic_followup", }, { "action_code": "FOLLOW_UP_GENERIC", "route": "vendas", "delay_business_days": 5, "action": "Última tentativa manual", "note": "Última tentativa antes de revisão de recuperação. Não enviar automaticamente.", "purpose": "final_attempt", }, ], } FOLLOW_UP_POLICIES: Dict[str, Dict[str, Any]] = { "SEND_INFO": {"family": "info", "reason": "INFO_SENT", "action_code": "FOLLOW_UP_CUSTOMER_REVIEW"}, "SEND_QUOTE": {"family": "quote", "reason": "QUOTE_SENT", "action_code": "FOLLOW_UP_QUOTE"}, "SEND_PROFORMA": {"family": "payment", "reason": "PROFORMA_SENT", "action_code": "FOLLOW_UP_PAYMENT"}, "SEND_INVOICE": {"family": "payment", "reason": "INVOICE_SENT_AFTER_DELIVERY", "action_code": "FOLLOW_UP_PAYMENT"}, } # Kept for compatibility with older callers/tests. The actual next step is # resolved from the completed task metadata and FOLLOW_UP_CADENCES. FOLLOW_UP_CASCADE_BY_ACTION: Dict[str, Dict[str, Any]] = { "CONFIRM_DELIVERY": {"family": "generic"}, "FOLLOW_UP_CUSTOMER_REVIEW": {"family": "info"}, "FOLLOW_UP_QUOTE": {"family": "quote"}, "FOLLOW_UP_PROFORMA": {"family": "payment"}, "FOLLOW_UP_PAYMENT": {"family": "payment"}, "FOLLOW_UP_GENERIC": {"family": "generic"}, } MANUAL_FOLLOW_UP_DEFAULTS: Dict[str, Dict[str, Any]] = { "quote": {"family": "quote", "reason": "QUOTE_SENT"}, "proforma": {"family": "payment", "reason": "PROFORMA_SENT"}, "payment": {"family": "payment", "reason": "PAYMENT_FOLLOWUP"}, "review": {"family": "info", "reason": "INFO_SENT"}, "generic": {"family": "generic", "reason": "GENERIC_FOLLOWUP"}, "delivery": {"family": "generic", "reason": "MANUAL_DELIVERY_CHECK"}, } TERMINAL_STAGE_VALUES = {"WON", "LOST", "NO_INTEREST", "DELIVERED", "ARCHIVED"} TERMINAL_STATUS_VALUES = {"closed", "won", "lost", "no_interest", "archived", "spam"} def _json(value: Any) -> str: return json.dumps(value or {}, ensure_ascii=False, default=str) def _metadata_dict(value: Any) -> Dict[str, Any]: if isinstance(value, dict): return value if not value: return {} try: return json.loads(value) if isinstance(value, str) else dict(value) except Exception: return {} def _cadence_for_family(family: str) -> list[Dict[str, Any]]: key = str(family or "generic").strip().lower() or "generic" return list(FOLLOW_UP_CADENCES.get(key) or FOLLOW_UP_CADENCES["generic"]) def _cadence_step(family: str, stage: int) -> Optional[Dict[str, Any]]: cadence = _cadence_for_family(family) index = max(1, int(stage or 1)) - 1 if index < 0 or index >= len(cadence): return None return dict(cadence[index]) def _family_reason(family: str, fallback: str = "FOLLOW_UP") -> str: return { "info": "INFO_SENT", "quote": "QUOTE_SENT", "payment": "PAYMENT_FOLLOWUP", "generic": "GENERIC_FOLLOWUP", }.get(str(family or "").strip().lower(), fallback) def _business_days_from_now(days: int) -> datetime: """Return an aware datetime after N business days, keeping current time.""" days = max(1, min(int(days or 1), 60)) current = datetime.now(timezone.utc) due = current remaining = days while remaining > 0: due += timedelta(days=1) if due.weekday() < 5: remaining -= 1 return due def is_follow_up_action(action_code: str) -> bool: return str(action_code or "").strip().upper() in FOLLOW_UP_ACTION_CODES def follow_up_suggested_message( *, action_code: str, customer_name: str = "", follow_up_stage: int = 1, follow_up_family: str = "generic", ) -> str: """Return a safe draft text matched to the contact sequence step.""" code = str(action_code or "").strip().upper() stage = max(1, int(follow_up_stage or 1)) family = str(follow_up_family or "generic").strip().lower() or "generic" first_name = str(customer_name or "").strip().split(" ")[0] greeting = f"Olá {first_name}," if first_name and first_name.casefold() not in {"cliente", "desconhecido"} else "Olá," closing = "Obrigado,\nEquipa BLIF" if code == "CONFIRM_DELIVERY": object_label = { "quote": "o orçamento", "payment": "os dados/documento para pagamento", "info": "a informação", }.get(family, "a nossa comunicação") return f"""{greeting} Queria apenas confirmar se recebeu {object_label} que enviámos. Caso não tenha chegado corretamente, reenvio de imediato. {closing}""" if code == "FOLLOW_UP_QUOTE": if stage >= len(_cadence_for_family("quote")): body = "Ainda pretende avançar com esta proposta ou prefere que encerremos o processo por agora?" elif stage >= 3: body = "Conseguiu avaliar a proposta? Há algum ponto de preço, prazo, instalação ou compatibilidade que esteja a bloquear a decisão?" else: body = "Ficou com alguma dúvida técnica ou comercial sobre o orçamento que possamos esclarecer?" return f"""{greeting} {body} {closing}""" if code in {"FOLLOW_UP_PROFORMA", "FOLLOW_UP_PAYMENT"}: if stage >= len(_cadence_for_family("payment")): body = "Ainda pretende avançar com a encomenda? Precisamos de confirmar a intenção antes de manter a reserva/processo ativo." elif stage >= 3: body = "Existe alguma pendência administrativa ou financeira que esteja a impedir o pagamento?" else: body = "Consegue indicar-nos a data prevista para efetuar o pagamento?" return f"""{greeting} {body} {closing}""" if code == "FOLLOW_UP_CUSTOMER_REVIEW": if stage >= len(_cadence_for_family("info")): body = "Ainda pretende avançar com este pedido ou prefere que retomemos o contacto noutra altura?" elif stage >= 3: body = "Para conseguirmos orientar a solução, pode indicar potência pretendida, local de instalação e prazo estimado?" else: body = "A informação enviada corresponde ao que procura? Pretende que preparemos um orçamento?" return f"""{greeting} {body} {closing}""" return f"""{greeting} Ficámos sem resposta relativamente ao processo em aberto. Ainda pretende avançar ou prefere que deixemos este contacto para mais tarde? {closing}""" def _load_opportunity_context(opportunity_id: str) -> Optional[Dict[str, Any]]: with engine.begin() as conn: row = conn.execute(text(""" SELECT id::text, status, stage, customer_id, contact_id, conversation_id, customer_name, customer_email, customer_phone, title, lifecycle_state, last_customer_activity_at, last_operator_activity_at, last_outbound_sent_at, last_delivery_checked_at, last_delivery_status, last_commercial_activity_at, next_follow_up_at, follow_up_attempts, nurture_until, metadata FROM opportunities WHERE id = CAST(:opportunity_id AS UUID) LIMIT 1 """), {"opportunity_id": opportunity_id}).mappings().first() return dict(row) if row else None def _load_task_context(task_id: str) -> Dict[str, Any]: if not task_id: return {} with engine.begin() as conn: row = conn.execute(text(""" SELECT id::text, opportunity_id::text, action_code, action, note, route, status, customer_id, contact_id, conversation_id, source_system, source_event_id, created_at, done_at, metadata FROM tasks WHERE id = CAST(:task_id AS UUID) LIMIT 1 """), {"task_id": task_id}).mappings().first() return dict(row) if row else {} def _doc_counts(conn, opportunity_id: str) -> Dict[str, int]: try: row = conn.execute(text(""" SELECT COUNT(*) FILTER (WHERE document_kind IN ('quotation','proforma'))::int AS quotes, COUNT(*) FILTER (WHERE document_kind = 'invoice')::int AS invoices FROM commercial_documents WHERE opportunity_id = CAST(:opportunity_id AS UUID) """), {"opportunity_id": opportunity_id}).mappings().first() return {"quotes": int((row or {}).get("quotes") or 0), "invoices": int((row or {}).get("invoices") or 0)} except Exception: return {"quotes": 0, "invoices": 0} def _payment_confirmed(conn, opportunity_id: str) -> bool: try: return bool(conn.execute(text(""" SELECT 1 FROM operation_links WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND system = 'clientflow' AND external_type = 'payment' AND status = 'confirmed' LIMIT 1 """), {"opportunity_id": opportunity_id}).scalar()) except Exception: return False def _current_next_action_code(opportunity_id: str) -> str: try: from app.opportunity_next_action_service import get_opportunity_next_action decision = get_opportunity_next_action(opportunity_id) or {} return str(decision.get("action_code") or "").strip().upper() except Exception: return "" def _should_skip_because_advanced(*, opportunity_id: str, family: str, source_action_code: str = "") -> str: """Return a non-empty reason if the opportunity has advanced past this follow-up.""" opportunity = _load_opportunity_context(opportunity_id) if not opportunity: return "opportunity_not_found" if str(opportunity.get("status") or "").strip().lower() in TERMINAL_STATUS_VALUES: return "opportunity_closed" if str(opportunity.get("stage") or "").strip().upper() in TERMINAL_STAGE_VALUES: return "opportunity_terminal_stage" family = str(family or "generic").strip().lower() source_code = str(source_action_code or "").strip().upper() next_code = _current_next_action_code(opportunity_id) stage = str(opportunity.get("stage") or "").strip().upper() with engine.begin() as conn: docs = _doc_counts(conn, opportunity_id) paid = _payment_confirmed(conn, opportunity_id) pending_codes = { str(value or "").strip().upper() for value in conn.execute(text(""" SELECT action_code FROM tasks WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND status = 'pending' """), {"opportunity_id": opportunity_id}).scalars().all() } if family == "info" and (docs["quotes"] > 0 or docs["invoices"] > 0): return "document_already_created" if family == "quote" and (docs["invoices"] > 0 or paid): return "quote_already_advanced" if family == "quote" and stage in { "INVOICE_REQUESTED", "INVOICE_SENT", "WAITING_PAYMENT", "PAYMENT_CONFIRMED", "ODOO_ORDER_CREATED", "IN_PRODUCTION", "ORDER_PREPARATION", "READY_TO_SHIP", "INVOICED", "SHIPMENT_CREATED", "SHIPPED", "TRACKING_SENT", "DELIVERED", "WON", }: return f"quote_followup_obsolete_for_stage:{stage}" if family == "quote" and pending_codes & {"SEND_INVOICE", "CONFIRM_PAYMENT", "PREPARE_ORDER", "VALIDATE_PHYSICAL_ORDER", "CREATE_SHIPMENT"}: return "quote_followup_superseded_by_pending_action" if family == "payment" and paid: return "payment_already_confirmed" # If central decision has moved to a more concrete operational action, do # not create a generic follow-up that competes with it. concrete_next_actions = { "SEND_INVOICE", "CREATE_INVOICE", "SHIP_ORDER", "CLOSE_OPPORTUNITY", "WAIT_PRODUCTION", "WAIT_ODOO", "CREATE_ODOO_ORDER", "LINK_ODOO_ORDER", "RECONCILE_DOCUMENTS", "VALIDATE_FISCAL_CUSTOMER", "REVIEW", } if next_code in concrete_next_actions: return f"central_next_action:{next_code}" # For SEND_INVOICE, schedule payment follow-up only when the central model # still says payment follow-up/payment confirmation is needed. This avoids # creating payment follow-ups in the normal before-shipping flow after a # pre-paid invoice has just been sent. if source_code == "SEND_INVOICE" and next_code not in {"FOLLOW_UP_PAYMENT", "CONFIRM_PAYMENT", ""}: return f"invoice_not_payment_followup:{next_code}" return "" def _has_pending_equivalent(conn, *, opportunity_id: str, family: str, action_code: str, reason: str) -> Optional[Dict[str, Any]]: family = str(family or "").strip().lower() return conn.execute(text(""" SELECT id::text, due_at, action_code FROM tasks WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND status = 'pending' AND ( action_code = CAST(:action_code AS TEXT) OR COALESCE(metadata->>'follow_up_family', '') = CAST(:family AS TEXT) OR COALESCE(metadata->>'follow_up_reason', '') = CAST(:reason AS TEXT) ) ORDER BY due_at NULLS LAST, created_at DESC LIMIT 1 """), { "opportunity_id": opportunity_id, "action_code": action_code, "family": family, "reason": reason, }).mappings().first() def _task_stage_from_metadata(task: Dict[str, Any]) -> int: metadata = _metadata_dict(task.get("metadata")) try: return int(metadata.get("follow_up_stage") or 0) except Exception: return 0 def _root_task_id_for(task: Dict[str, Any], fallback: str = "") -> str: metadata = _metadata_dict(task.get("metadata")) return str(metadata.get("follow_up_root_task_id") or metadata.get("follow_up_trigger_task_id") or fallback or task.get("id") or "") def _record_completed_outbound_activity( *, opportunity_id: str, completed_action_code: str, completed_task: Optional[Dict[str, Any]] = None, next_stage: int = 1, ) -> None: """Record real operator contact separately from technical updated_at changes.""" task = completed_task or {} metadata = _metadata_dict(task.get("metadata")) completed_at = task.get("done_at") or datetime.now(timezone.utc) current_stage = _task_stage_from_metadata(task) is_contact_attempt = str(completed_action_code or "").strip().upper() in FOLLOW_UP_ACTION_CODES attempts = current_stage if is_contact_attempt and current_stage < 90 else max(0, int(next_stage or 1) - 1) with engine.begin() as conn: conn.execute(text(""" UPDATE opportunities SET last_operator_activity_at = COALESCE(CAST(:completed_at AS TIMESTAMPTZ), now()), last_outbound_sent_at = CASE WHEN :action_code IN ('SEND_INFO','SEND_QUOTE','SEND_PROFORMA','SEND_INVOICE','FOLLOW_UP_QUOTE','FOLLOW_UP_PROFORMA','FOLLOW_UP_PAYMENT','FOLLOW_UP_CUSTOMER_REVIEW','FOLLOW_UP_GENERIC') THEN COALESCE(CAST(:completed_at AS TIMESTAMPTZ), now()) ELSE last_outbound_sent_at END, last_delivery_checked_at = CASE WHEN :action_code = 'CONFIRM_DELIVERY' THEN COALESCE(CAST(:completed_at AS TIMESTAMPTZ), now()) ELSE last_delivery_checked_at END, last_delivery_status = CASE WHEN :action_code = 'CONFIRM_DELIVERY' THEN 'checked' WHEN :action_code IN ('SEND_INFO','SEND_QUOTE','SEND_PROFORMA','SEND_INVOICE') THEN 'sent_unverified' ELSE last_delivery_status END, last_commercial_activity_at = COALESCE(CAST(:completed_at AS TIMESTAMPTZ), now()), follow_up_attempts = GREATEST(COALESCE(follow_up_attempts, 0), :attempts), lifecycle_state = 'awaiting_customer', updated_at = now(), metadata = COALESCE(metadata, '{}'::jsonb) || jsonb_build_object( 'last_operator_contact_action', CAST(:action_code AS TEXT), 'last_operator_contact_at', COALESCE(CAST(:completed_at AS TIMESTAMPTZ), now())::text, 'last_completed_follow_up_purpose', CAST(:purpose AS TEXT) ) WHERE id = CAST(:opportunity_id AS UUID) """), { "opportunity_id": opportunity_id, "completed_at": completed_at, "attempts": int(attempts or 0), "action_code": str(completed_action_code or ""), "purpose": str(metadata.get("follow_up_purpose") or ""), }) def create_follow_up_task( *, opportunity_id: str, action_code: str, route: str, action: str, note: str, reason: str, delay_days: int = 3, trigger_task_id: str = "", created_by: str = "system", idempotency_suffix: str = "", follow_up_family: str = "generic", follow_up_stage: int = 1, follow_up_max_stage: int = 3, cascade: bool = True, contact_purpose: str = "", due_at_override: Optional[datetime] = None, allow_confirm_delivery: bool = False, ) -> Dict[str, Any]: """Create one pending follow-up task with due_at. Only the next step is created. Idempotency is per opportunity + family + stage + root trigger, and we also check for an already pending equivalent. """ opportunity_id = str(opportunity_id or "").strip() if not opportunity_id: return {"ok": False, "status": "missing_opportunity_id"} opportunity = _load_opportunity_context(opportunity_id) if not opportunity: return {"ok": False, "status": "opportunity_not_found"} if str(opportunity.get("status") or "").strip().lower() in TERMINAL_STATUS_VALUES: return {"ok": True, "status": "skipped_closed_opportunity", "opportunity_id": opportunity_id} action_code = str(action_code or "FOLLOW_UP_GENERIC").strip().upper() if ( action_code == "CONFIRM_DELIVERY" and not bool(getattr(settings, "confirm_delivery_automation_enabled", False)) and not bool(allow_confirm_delivery) ): return { "ok": True, "status": "skipped_confirm_delivery_automation_disabled", "opportunity_id": opportunity_id, } route = str(route or "vendas").strip().lower() or "vendas" reason = str(reason or "MANUAL").strip().upper() or "MANUAL" family = str(follow_up_family or "generic").strip().lower() or "generic" opportunity_stage = str(opportunity.get("stage") or "").strip().upper() if opportunity_stage == "WAITING_PAYMENT" and action_code in {"FOLLOW_UP_QUOTE", "FOLLOW_UP_PROFORMA"}: action_code = "FOLLOW_UP_PAYMENT" family = "payment" route = "financeiro" action = "Fazer follow-up de pagamento" note = "Confirmar se existe alguma pendência relativamente ao pagamento. Não enviar automaticamente." reason = "PAYMENT_FOLLOWUP" stage = max(1, int(follow_up_stage or 1)) max_stage = max(stage, int(follow_up_max_stage or stage)) trigger = _load_task_context(trigger_task_id) if trigger_task_id else {} root_task_id = _root_task_id_for(trigger, fallback=trigger_task_id) if trigger else str(trigger_task_id or idempotency_suffix or "manual") customer_id = str(opportunity.get("customer_id") or trigger.get("customer_id") or "") contact_id = str(opportunity.get("contact_id") or trigger.get("contact_id") or "") conversation_id = str(opportunity.get("conversation_id") or trigger.get("conversation_id") or "") suggested_message = follow_up_suggested_message( action_code=action_code, customer_name=str(opportunity.get("customer_name") or ""), follow_up_stage=stage, follow_up_family=family, ) due_at = due_at_override or _business_days_from_now(delay_days) idempotency_source = str(root_task_id or idempotency_suffix or datetime.now(timezone.utc).isoformat()) idempotency_key = f"followup:{opportunity_id}:{family}:{stage}:{idempotency_source}" metadata = { "task_type": "follow_up", "follow_up": True, "follow_up_mode": "semi_automatic_cascade", "follow_up_family": family, "follow_up_stage": stage, "follow_up_max_stage": max_stage, "follow_up_reason": reason, "follow_up_trigger_task_id": str(trigger_task_id or ""), "follow_up_root_task_id": str(root_task_id or ""), "opportunity_id": opportunity_id, "suggested_message": suggested_message, "automation_policy": "system_schedules_next_operator_followup_only", "created_by": created_by, "cascade": bool(cascade), "business_days_delay": int(delay_days or 1), "follow_up_purpose": str(contact_purpose or ""), "delivery_confirmation": str(contact_purpose or "") == "delivery_confirmation", } with engine.begin() as conn: existing = _has_pending_equivalent( conn, opportunity_id=opportunity_id, family=family, action_code=action_code, reason=reason, ) if existing: return { "ok": True, "status": "already_pending", "task_id": str(existing.get("id")), "opportunity_id": opportunity_id, } row = conn.execute(text(""" INSERT INTO tasks ( opportunity_id, customer_id, contact_id, conversation_id, action_code, route, action, note, action_required, safe_to_post, status, source_system, source_event_id, idempotency_key, due_at, metadata, priority ) VALUES ( CAST(:opportunity_id AS UUID), NULLIF(:customer_id, ''), NULLIF(:contact_id, ''), NULLIF(:conversation_id, ''), :action_code, :route, :action, :note, TRUE, FALSE, 'pending', 'clientflow_followup', :source_event_id, :idempotency_key, CAST(:due_at AS TIMESTAMPTZ), CAST(:metadata AS JSONB), :priority ) ON CONFLICT (idempotency_key) DO NOTHING RETURNING id::text, due_at """), { "opportunity_id": opportunity_id, "customer_id": customer_id, "contact_id": contact_id, "conversation_id": conversation_id, "action_code": action_code, "route": route, "action": action, "note": note, "source_event_id": f"followup:{opportunity_id}:{family}:{stage}", "idempotency_key": idempotency_key, "due_at": due_at.isoformat(), "metadata": _json(metadata), "priority": "high" if action_code in {"RECOVER_OPPORTUNITY", "REVIEW_NURTURE"} or (bool(cascade) and stage >= max_stage) else "normal", }).mappings().first() if not row: return {"ok": True, "status": "idempotent_noop", "opportunity_id": opportunity_id} task_id = str(row.get("id")) event_payload = { "opportunity_id": opportunity_id, "reason": reason, "family": family, "stage": stage, "max_stage": max_stage, "delay_business_days": int(delay_days or 1), "due_at": due_at.isoformat(), } conn.execute(text(""" INSERT INTO task_events (task_id, event_type, payload, created_by) VALUES (CAST(:task_id AS UUID), 'follow_up_scheduled', CAST(:payload AS JSONB), :created_by) """), { "task_id": task_id, "payload": _json(event_payload), "created_by": created_by, }) conn.execute(text(""" INSERT INTO opportunity_events ( id, opportunity_id, event_type, task_id, action_code, note, payload, created_by ) VALUES ( gen_random_uuid(), CAST(:opportunity_id AS UUID), 'follow_up_scheduled', CAST(:task_id AS UUID), :action_code, :note, CAST(:payload AS JSONB), :created_by ) """), { "opportunity_id": opportunity_id, "task_id": task_id, "action_code": action_code, "note": note, "payload": _json(event_payload), "created_by": created_by, }) lifecycle_state = ( "recovery" if action_code == "RECOVER_OPPORTUNITY" else "nurture" if action_code == "REVIEW_NURTURE" else "awaiting_customer" ) attempts = max_stage if stage >= 90 else max(0, stage - 1) conn.execute(text(""" UPDATE opportunities SET updated_at = now(), lifecycle_state = :lifecycle_state, next_follow_up_at = CAST(:due_at AS TIMESTAMPTZ), follow_up_attempts = GREATEST(COALESCE(follow_up_attempts, 0), :attempts), metadata = COALESCE(metadata, '{}'::jsonb) || jsonb_build_object( 'last_follow_up_task_id', CAST(:task_id AS TEXT), 'last_follow_up_scheduled_at', now()::text, 'last_follow_up_family', CAST(:family AS TEXT), 'last_follow_up_stage', CAST(:stage AS INTEGER), 'last_follow_up_purpose', CAST(:purpose AS TEXT) ) WHERE id = CAST(:opportunity_id AS UUID) """), { "opportunity_id": opportunity_id, "task_id": task_id, "family": family, "stage": stage, "purpose": str(contact_purpose or ""), "lifecycle_state": lifecycle_state, "due_at": due_at.isoformat(), "attempts": attempts, }) return {"ok": True, "status": "created", "task_id": task_id, "opportunity_id": opportunity_id, "stage": stage, "family": family} def _create_recovery_task(*, opportunity_id: str, family: str, trigger_task_id: str, created_by: str) -> Dict[str, Any]: """Move an exhausted contact sequence to a dedicated recovery queue.""" family = str(family or "generic").strip().lower() or "generic" action = "Recuperar oportunidade sem resposta" note = ( "A sequência normal de contactos terminou sem resposta. Fazer uma última revisão: " "tentar canal alternativo, alterar abordagem, colocar em acompanhamento futuro ou marcar como perdida com motivo." ) return create_follow_up_task( opportunity_id=opportunity_id, action_code="RECOVER_OPPORTUNITY", route="financeiro" if family == "payment" else "vendas", action=action, note=note, reason=f"{family.upper()}_CASCADE_EXHAUSTED", delay_days=1, trigger_task_id=trigger_task_id, created_by=created_by, follow_up_family=family, follow_up_stage=99, follow_up_max_stage=99, cascade=False, contact_purpose="recovery_review", ) def schedule_follow_up_after_task_done( *, task_id: str, action_code: str, opportunity_id: str, created_by: str = "system", ) -> Dict[str, Any]: """Schedule the next short-cadence step after a real operator action.""" code = str(action_code or "").strip().upper() task = _load_task_context(task_id) task_metadata = _metadata_dict(task.get("metadata")) if code in FOLLOW_UP_POLICIES: trigger_policy = dict(FOLLOW_UP_POLICIES[code]) family = str(trigger_policy.get("family") or "generic").strip().lower() reason = str(trigger_policy.get("reason") or _family_reason(family)) next_stage = 1 elif code in FOLLOW_UP_CASCADE_BY_ACTION or code in FOLLOW_UP_ACTION_CODES: family = str( task_metadata.get("follow_up_family") or FOLLOW_UP_CASCADE_BY_ACTION.get(code, {}).get("family") or "generic" ).strip().lower() reason = str(task_metadata.get("follow_up_reason") or _family_reason(family)) current_stage = _task_stage_from_metadata(task) if current_stage >= 90 and task_metadata.get("cascade") is not False: return {"ok": True, "status": "recovery_task_completed", "opportunity_id": opportunity_id} next_stage = max(2, current_stage + 1) else: return {"ok": True, "status": "no_policy"} cadence = _cadence_for_family(family) max_stage = len(cadence) if code in FOLLOW_UP_ACTION_CODES and task_metadata.get("cascade") is False: _record_completed_outbound_activity( opportunity_id=opportunity_id, completed_action_code=code, completed_task=task, next_stage=max(1, _task_stage_from_metadata(task)), ) return { "ok": True, "status": "manual_follow_up_completed_without_cascade", "opportunity_id": opportunity_id, } skip_reason = _should_skip_because_advanced( opportunity_id=opportunity_id, family=family, source_action_code=code, ) if skip_reason: return {"ok": True, "status": "skipped_advanced", "reason": skip_reason, "opportunity_id": opportunity_id} _record_completed_outbound_activity( opportunity_id=opportunity_id, completed_action_code=code, completed_task=task, next_stage=next_stage, ) if next_stage > max_stage: return _create_recovery_task( opportunity_id=opportunity_id, family=family, trigger_task_id=str(task_id or ""), created_by=created_by, ) step = _cadence_step(family, next_stage) or {} return create_follow_up_task( opportunity_id=opportunity_id, action_code=str(step.get("action_code") or "FOLLOW_UP_GENERIC"), route=str(step.get("route") or ("financeiro" if family == "payment" else "vendas")), action=str(step.get("action") or "Fazer follow-up"), note=str(step.get("note") or "Novo contacto de seguimento. Não enviar automaticamente."), reason=reason, delay_days=int(step.get("delay_business_days") or 1), trigger_task_id=str(task_id or ""), created_by=created_by, follow_up_family=family, follow_up_stage=next_stage, follow_up_max_stage=max_stage, cascade=True, contact_purpose=str(step.get("purpose") or ""), ) def materialize_initial_follow_up_for_opportunity( *, opportunity_id: str, family: str, due_at: Optional[datetime] = None, reason: str = "BACKFILL", created_by: str = "system", ) -> Dict[str, Any]: """Create the first normal commercial follow-up for an open process. v4928.1.5.127 deliberately skips automatic delivery confirmation. Delivery verification remains available only as an explicit operator action. """ family = str(family or "generic").strip().lower() or "generic" step = _cadence_step(family, 1) or _cadence_step("generic", 1) or {} return create_follow_up_task( opportunity_id=opportunity_id, action_code=str(step.get("action_code") or "FOLLOW_UP_GENERIC"), route=str(step.get("route") or ("financeiro" if family == "payment" else "vendas")), action=str(step.get("action") or "Fazer follow-up"), note=str(step.get("note") or "Fazer novo contacto comercial sem envio automático."), reason=str(reason or _family_reason(family)), delay_days=int(step.get("delay_business_days") or 1), created_by=created_by, idempotency_suffix=f"initial:{family}:{reason}", follow_up_family=family, follow_up_stage=1, follow_up_max_stage=len(_cadence_for_family(family)), cascade=True, contact_purpose=str(step.get("purpose") or "commercial_followup"), due_at_override=due_at, ) def create_manual_follow_up_for_opportunity( *, opportunity_id: str, follow_up_type: str = "generic", delay_days: int = 3, note: str = "", created_by: str = "operator", ) -> Dict[str, Any]: key = str(follow_up_type or "generic").strip().lower() or "generic" if key == "delivery": opportunity = _load_opportunity_context(opportunity_id) or {} stage = str(opportunity.get("stage") or "").strip().upper() if stage in {"PROFORMA_SENT", "INVOICE_SENT", "WAITING_PAYMENT"}: family = "payment" route = "financeiro" action = "Verificar entrega dos dados de pagamento" default_note = ( "Usar apenas quando o histórico sugere um problema real de envio. " "Validar destinatário, documento, bounce/outbox e decidir se é necessário reenviar." ) elif stage == "QUOTE_SENT": family = "quote" route = "vendas" action = "Verificar entrega do orçamento" default_note = ( "Usar apenas quando vários contactos sem resposta sugerem um problema de envio. " "Validar destinatário, anexo, bounce/outbox e decidir se é necessário reenviar." ) else: family = "info" if stage == "INFO_SENT" else "generic" route = "vendas" action = "Verificar entrega da comunicação" default_note = ( "Usar apenas quando o histórico sugere um problema real de envio. " "Validar destinatário, conteúdo, bounce/outbox e decidir se é necessário reenviar." ) suffix = f"manual-delivery:{created_by}:{datetime.now(timezone.utc).isoformat()}" return create_follow_up_task( opportunity_id=opportunity_id, action_code="CONFIRM_DELIVERY", route=route, action=action, note=(str(note or "").strip() or default_note), reason="MANUAL_DELIVERY_CHECK", delay_days=delay_days, trigger_task_id="", created_by=created_by, idempotency_suffix=suffix, follow_up_family=family, follow_up_stage=1, follow_up_max_stage=1, cascade=False, contact_purpose="manual_delivery_check", allow_confirm_delivery=True, ) policy = MANUAL_FOLLOW_UP_DEFAULTS.get(key, MANUAL_FOLLOW_UP_DEFAULTS["generic"]) family = str(policy.get("family") or key).strip().lower() or "generic" step = _cadence_step(family, 1) or _cadence_step("generic", 1) or {} suffix = f"manual:{created_by}:{datetime.now(timezone.utc).isoformat()}" return create_follow_up_task( opportunity_id=opportunity_id, action_code=str(step.get("action_code") or "FOLLOW_UP_GENERIC"), route=str(step.get("route") or "vendas"), action=str(step.get("action") or "Fazer follow-up manual"), note=(str(note or "").strip() or str(step.get("note") or "Novo contacto de seguimento.")), reason=str(policy.get("reason") or _family_reason(family)), delay_days=delay_days, trigger_task_id="", created_by=created_by, idempotency_suffix=suffix, follow_up_family=family, follow_up_stage=1, follow_up_max_stage=len(_cadence_for_family(family)), cascade=True, contact_purpose=str(step.get("purpose") or "manual_followup"), ) def align_pending_followups_for_opportunity( *, opportunity_id: str, created_by: str = "system", ) -> Dict[str, Any]: """Repair pending follow-ups so they match the current commercial stage. This is idempotent and safe to run after every stage transition or as a maintenance backfill. It never sends messages. """ opportunity = _load_opportunity_context(opportunity_id) if not opportunity: return {"ok": False, "status": "opportunity_not_found", "updated": 0, "closed": 0} stage = str(opportunity.get("stage") or "").strip().upper() status = str(opportunity.get("status") or "").strip().lower() if status in TERMINAL_STATUS_VALUES or stage in TERMINAL_STAGE_VALUES: closed = cancel_pending_followups_for_opportunity( opportunity_id=opportunity_id, reason=f"terminal:{stage or status}", created_by=created_by ) return {"ok": True, "status": "terminal", "updated": 0, "closed": closed} with engine.begin() as conn: pending_codes = { str(v or "").strip().upper() for v in conn.execute(text(""" SELECT action_code FROM tasks WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND status = 'pending' """), {"opportunity_id": opportunity_id}).scalars().all() } superseding = pending_codes & {"SEND_INVOICE", "CONFIRM_PAYMENT", "PREPARE_ORDER", "VALIDATE_PHYSICAL_ORDER", "CREATE_SHIPMENT"} closed_rows = [] if superseding: closed_rows = conn.execute(text(""" UPDATE tasks SET status = 'done', done_at = COALESCE(done_at, now()), done_by = :created_by, updated_at = now(), metadata = COALESCE(metadata, '{}'::jsonb) || jsonb_build_object( 'follow_up_closed_reason', 'superseded_by_pending_action', 'superseding_action_codes', CAST(:codes AS JSONB), 'follow_up_closed_at', now() ) WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND status = 'pending' AND action_code IN ('FOLLOW_UP_QUOTE','FOLLOW_UP_PROFORMA') RETURNING id::text """), { "opportunity_id": opportunity_id, "created_by": created_by, "codes": _json(sorted(superseding)), }).mappings().all() updated_rows = [] if stage == "WAITING_PAYMENT" and not superseding: updated_rows = conn.execute(text(""" UPDATE tasks SET action_code = 'FOLLOW_UP_PAYMENT', route = 'financeiro', action = 'Fazer follow-up de pagamento', note = 'Confirmar se existe alguma pendência relativamente ao pagamento. Não enviar automaticamente.', updated_at = now(), metadata = COALESCE(metadata, '{}'::jsonb) || jsonb_build_object( 'follow_up_family', 'payment', 'follow_up_reason', 'PAYMENT_FOLLOWUP', 'aligned_from_action_code', action_code, 'aligned_at', now() ) WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND status = 'pending' AND action_code IN ('FOLLOW_UP_QUOTE','FOLLOW_UP_PROFORMA') RETURNING id::text """), {"opportunity_id": opportunity_id}).mappings().all() return { "ok": True, "status": "aligned", "updated": len(updated_rows), "closed": len(closed_rows), "stage": stage, } def close_pending_followups_for_opportunity( *, opportunity_id: str, reason: str, created_by: str = "system", except_task_id: str = "", ) -> int: """Mark open follow-ups as done when the client replies or the process advances.""" opportunity_id = str(opportunity_id or "").strip() if not opportunity_id: return 0 params = { "opportunity_id": opportunity_id, "reason": str(reason or "").strip() or "follow_up_closed", "created_by": created_by, "except_task_id": str(except_task_id or ""), } with engine.begin() as conn: rows = conn.execute(text(""" UPDATE tasks SET status = 'done', done_at = now(), done_by = :created_by, updated_at = now(), metadata = COALESCE(metadata, '{}'::jsonb) || jsonb_build_object('follow_up_closed_reason', :reason, 'follow_up_closed_at', now()) WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND status = 'pending' AND ( action_code LIKE 'FOLLOW_UP_%' OR COALESCE(metadata->>'task_type', '') = 'follow_up' ) AND (:except_task_id = '' OR id <> CAST(:except_task_id AS UUID)) RETURNING id::text, action_code """), params).mappings().all() for row in rows: conn.execute(text(""" INSERT INTO task_events (task_id, event_type, payload, created_by) VALUES (CAST(:task_id AS UUID), 'follow_up_auto_closed', CAST(:payload AS JSONB), :created_by) """), { "task_id": row["id"], "payload": _json({"opportunity_id": opportunity_id, "reason": reason}), "created_by": created_by, }) return len(rows) def cancel_pending_followups_for_opportunity( *, opportunity_id: str, reason: str, created_by: str = "system", ) -> int: opportunity_id = str(opportunity_id or "").strip() if not opportunity_id: return 0 with engine.begin() as conn: rows = conn.execute(text(""" UPDATE tasks SET status = 'skipped', updated_at = now(), metadata = COALESCE(metadata, '{}'::jsonb) || jsonb_build_object('follow_up_cancelled_reason', :reason, 'follow_up_cancelled_at', now()) WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND status = 'pending' AND ( action_code LIKE 'FOLLOW_UP_%' OR COALESCE(metadata->>'task_type', '') = 'follow_up' ) RETURNING id::text """), { "opportunity_id": opportunity_id, "reason": str(reason or "opportunity_closed"), }).mappings().all() for row in rows: conn.execute(text(""" INSERT INTO task_events (task_id, event_type, payload, created_by) VALUES (CAST(:task_id AS UUID), 'follow_up_cancelled', CAST(:payload AS JSONB), :created_by) """), { "task_id": row["id"], "payload": _json({"opportunity_id": opportunity_id, "reason": reason}), "created_by": created_by, }) return len(rows)