1205 lines
48 KiB
Python
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)
|