Files
clientflow_backend/app/followup_service.py

1205 lines
48 KiB
Python

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