1926 lines
80 KiB
Python
1926 lines
80 KiB
Python
import json
|
||
import re
|
||
import uuid
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
from sqlalchemy import text
|
||
|
||
from app.db import engine
|
||
from app.work_center_action_policy import reconstructed_review_metadata_patch
|
||
from app.company_opportunity_linking import (
|
||
find_document_owner_opportunity_for_task,
|
||
find_unique_company_domain_opportunity_for_task,
|
||
manual_link_candidates_for_task,
|
||
mark_task_ambiguous_with_candidates,
|
||
)
|
||
|
||
|
||
OPPORTUNITY_STAGE_LABELS: Dict[str, str] = {
|
||
"NEW_LEAD": "Novo pedido",
|
||
"INFO_REQUESTED": "Informação solicitada",
|
||
"INFO_SENT": "Informação enviada",
|
||
"QUOTE_REQUESTED": "Orçamento solicitado",
|
||
"QUOTE_SENT": "Orçamento enviado",
|
||
"PROFORMA_REQUESTED": "Pró-forma solicitada",
|
||
"PROFORMA_SENT": "Pró-forma enviada",
|
||
"INVOICE_REQUESTED": "Fatura solicitada",
|
||
"INVOICE_SENT": "Fatura enviada",
|
||
"WAITING_PAYMENT": "A aguardar pagamento",
|
||
"PAYMENT_CONFIRMED": "Pagamento confirmado",
|
||
"ODOO_ORDER_CREATED": "Encomenda em preparação",
|
||
"IN_PRODUCTION": "Em produção",
|
||
"READY_TO_SHIP": "Pronto para envio",
|
||
"INVOICED": "Faturado",
|
||
"SHIPMENT_CREATED": "Envio criado",
|
||
"TRACKING_SENT": "Tracking enviado",
|
||
"DELIVERED": "Entregue",
|
||
"ORDER_PREPARATION": "Em preparação",
|
||
"SHIPPED": "Enviado",
|
||
"WON": "Concluído",
|
||
"LOST": "Perdido",
|
||
"NO_INTEREST": "Sem interesse",
|
||
"REVIEW": "Rever",
|
||
"ARCHIVED": "Arquivada",
|
||
}
|
||
|
||
OPPORTUNITY_STAGE_RANK: Dict[str, int] = {
|
||
"NEW_LEAD": 10,
|
||
"INFO_REQUESTED": 20,
|
||
"INFO_SENT": 30,
|
||
"QUOTE_REQUESTED": 40,
|
||
"QUOTE_SENT": 50,
|
||
"PROFORMA_REQUESTED": 55,
|
||
"PROFORMA_SENT": 60,
|
||
"INVOICE_REQUESTED": 62,
|
||
"INVOICE_SENT": 65,
|
||
"WAITING_PAYMENT": 70,
|
||
"PAYMENT_CONFIRMED": 80,
|
||
"ODOO_ORDER_CREATED": 85,
|
||
"IN_PRODUCTION": 88,
|
||
"ORDER_PREPARATION": 90,
|
||
"READY_TO_SHIP": 92,
|
||
"INVOICED": 94,
|
||
"SHIPMENT_CREATED": 98,
|
||
"SHIPPED": 100,
|
||
"TRACKING_SENT": 102,
|
||
"DELIVERED": 108,
|
||
"WON": 110,
|
||
"LOST": 120,
|
||
"NO_INTEREST": 118,
|
||
"REVIEW": 5,
|
||
"ARCHIVED": 130,
|
||
}
|
||
|
||
OPPORTUNITY_BOARD_COLUMNS: List[Tuple[str, str, List[str]]] = [
|
||
("requests", "Pedidos", ["NEW_LEAD", "INFO_REQUESTED", "QUOTE_REQUESTED", "PROFORMA_REQUESTED", "INVOICE_REQUESTED", "REVIEW"]),
|
||
("sent", "Info / orçamento", ["INFO_SENT", "QUOTE_SENT", "PROFORMA_SENT", "INVOICE_SENT"]),
|
||
("payment", "Pagamento", ["WAITING_PAYMENT", "PAYMENT_CONFIRMED"]),
|
||
("operations", "Operação / envio", ["PAYMENT_CONFIRMED", "ODOO_ORDER_CREATED", "IN_PRODUCTION", "ORDER_PREPARATION", "READY_TO_SHIP", "INVOICED", "SHIPMENT_CREATED", "SHIPPED", "TRACKING_SENT", "DELIVERED"]),
|
||
("closed", "Fechadas", ["WON", "LOST", "NO_INTEREST"]),
|
||
("archived", "Arquivadas", ["ARCHIVED"]),
|
||
]
|
||
|
||
# Actions that may update an existing commercial opportunity. Creation is more
|
||
# restrictive and is controlled by can_create_new_opportunity_for_task().
|
||
OPPORTUNITY_RELEVANT_ACTION_CODES = {
|
||
"SEND_INFO",
|
||
"SEND_QUOTE",
|
||
"SEND_PROFORMA",
|
||
"SEND_INVOICE",
|
||
"CONFIRM_PAYMENT",
|
||
"PREPARE_ORDER",
|
||
"CREATE_SHIPMENT",
|
||
"MARK_NO_INTEREST",
|
||
}
|
||
|
||
# Actions that are allowed to create a brand-new opportunity when there is no
|
||
# strong/unique existing match. Keep this list conservative: Operations can still
|
||
# hold a task without creating a commercial process.
|
||
OPPORTUNITY_CREATE_ACTION_CODES = {
|
||
"SEND_QUOTE",
|
||
"SEND_PROFORMA",
|
||
"SEND_INVOICE",
|
||
"CONFIRM_PAYMENT",
|
||
"PREPARE_ORDER",
|
||
"CREATE_SHIPMENT",
|
||
}
|
||
|
||
NEVER_CREATE_OPPORTUNITY_ACTION_CODES = {
|
||
"REVIEW_MANUALLY",
|
||
"REMOVE_FROM_LIST",
|
||
"IGNORE_SPAM",
|
||
"NO_ACTION",
|
||
"IGNORE_BOUNCE",
|
||
"SUPPORT",
|
||
"MARK_NO_INTEREST",
|
||
}
|
||
|
||
SYSTEM_SENDER_PATTERNS = (
|
||
"mail delivery subsystem",
|
||
"mailer-daemon",
|
||
"postmaster",
|
||
"delivery status notification",
|
||
"microsoft exchange",
|
||
"office 365",
|
||
)
|
||
|
||
SYSTEM_SUBJECT_PATTERNS = (
|
||
"returned mail",
|
||
"undelivered mail",
|
||
"delivery status notification",
|
||
"failure notice",
|
||
"mail delivery failed",
|
||
"undeliverable",
|
||
"delivery has failed",
|
||
"non-delivery report",
|
||
"non delivery report",
|
||
"your message couldn't be delivered",
|
||
"your message couldn’t be delivered",
|
||
"recipient wasn't found",
|
||
"recipient was not found",
|
||
"unknown to address",
|
||
"remote server returned",
|
||
"550 5.1.1",
|
||
"5.1.10",
|
||
"wasn't found at",
|
||
"was not found at",
|
||
"office 365",
|
||
"microsoft exchange",
|
||
"não entregue",
|
||
)
|
||
|
||
COMMERCIAL_INTENT_TERMS = (
|
||
"orçamento", "orcamento", "cotação", "cotacao", "proposta", "preço", "preco",
|
||
"comprar", "encomendar", "encomenda", "fatura", "factura", "pró-forma",
|
||
"proforma", "pagamento", "comprovativo", "disponibilidade", "quero avançar",
|
||
"pretendo avançar", "adjudicar", "pedido de cotação", "pedido de orçamento",
|
||
)
|
||
|
||
STAGE_ON_TASK_CREATED = {
|
||
"SEND_INFO": "INFO_REQUESTED",
|
||
"SEND_QUOTE": "QUOTE_REQUESTED",
|
||
"SEND_PROFORMA": "PROFORMA_REQUESTED",
|
||
"SEND_INVOICE": "INVOICE_REQUESTED",
|
||
"CONFIRM_PAYMENT": "WAITING_PAYMENT",
|
||
"PREPARE_ORDER": "ORDER_PREPARATION",
|
||
"CREATE_SHIPMENT": "READY_TO_SHIP",
|
||
"MARK_NO_INTEREST": "REVIEW",
|
||
}
|
||
|
||
STAGE_ON_TASK_DONE = {
|
||
"SEND_INFO": "INFO_SENT",
|
||
"SEND_QUOTE": "QUOTE_SENT",
|
||
"SEND_PROFORMA": "WAITING_PAYMENT",
|
||
"SEND_INVOICE": "WAITING_PAYMENT",
|
||
"CONFIRM_PAYMENT": "PAYMENT_CONFIRMED",
|
||
"PREPARE_ORDER": "ORDER_PREPARATION",
|
||
"VALIDATE_PHYSICAL_ORDER": "READY_TO_SHIP",
|
||
"CREATE_SHIPMENT": "SHIPMENT_CREATED",
|
||
"MARK_NO_INTEREST": "NO_INTEREST",
|
||
}
|
||
|
||
|
||
_SCHEMA_READY = False
|
||
|
||
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",
|
||
}
|
||
TERMINAL_OPPORTUNITY_STAGES = {"WON", "LOST", "NO_INTEREST", "DELIVERED", "ARCHIVED"}
|
||
|
||
OPPORTUNITY_LIFECYCLE_STATES = {
|
||
"active": "Ativa",
|
||
"awaiting_customer": "A aguardar cliente",
|
||
"follow_up_due": "Follow-up vencido",
|
||
"recovery": "Recuperação",
|
||
"nurture": "Acompanhamento futuro",
|
||
}
|
||
LOSS_REASON_LABELS = {
|
||
"no_response": "Sem resposta após sequência completa",
|
||
"price": "Preço",
|
||
"competitor": "Escolheu concorrente",
|
||
"no_budget": "Sem orçamento",
|
||
"project_cancelled": "Projeto cancelado",
|
||
"future_timing": "Timing futuro",
|
||
"no_interest": "Sem interesse",
|
||
"invalid_contact": "Contacto inválido",
|
||
"duplicate": "Duplicado",
|
||
"spam": "Spam",
|
||
"product_mismatch": "Produto inadequado",
|
||
"other": "Outro",
|
||
}
|
||
|
||
|
||
def _json(value: Any) -> str:
|
||
return json.dumps(value or {}, ensure_ascii=False, default=str)
|
||
|
||
|
||
def _uuid(value: Optional[str]) -> Optional[str]:
|
||
value = str(value or "").strip()
|
||
return value or None
|
||
|
||
|
||
def ensure_opportunity_schema() -> None:
|
||
"""Cria a primeira versão local do pipeline ClientFlow.
|
||
|
||
É intencionalmente aditiva: não altera nem remove dados antigos.
|
||
"""
|
||
global _SCHEMA_READY
|
||
if _SCHEMA_READY:
|
||
return
|
||
|
||
with engine.begin() as conn:
|
||
conn.execute(text("""
|
||
CREATE TABLE IF NOT EXISTS opportunities (
|
||
id UUID PRIMARY KEY,
|
||
title TEXT NOT NULL,
|
||
stage TEXT NOT NULL DEFAULT 'NEW_LEAD',
|
||
status TEXT NOT NULL DEFAULT 'open',
|
||
contact_id TEXT,
|
||
customer_id TEXT,
|
||
conversation_id TEXT,
|
||
customer_name TEXT,
|
||
customer_email TEXT,
|
||
customer_phone TEXT,
|
||
product_interest TEXT,
|
||
value_amount NUMERIC(12,2),
|
||
currency TEXT NOT NULL DEFAULT 'EUR',
|
||
source_system TEXT NOT NULL DEFAULT 'clientflow',
|
||
source_event_id TEXT,
|
||
last_action_code TEXT,
|
||
last_task_id UUID,
|
||
last_message_at TIMESTAMPTZ,
|
||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
closed_at TIMESTAMPTZ
|
||
)
|
||
"""))
|
||
conn.execute(text("""
|
||
CREATE TABLE IF NOT EXISTS opportunity_events (
|
||
id UUID PRIMARY KEY,
|
||
opportunity_id UUID NOT NULL REFERENCES opportunities(id) ON DELETE CASCADE,
|
||
event_type TEXT NOT NULL,
|
||
task_id UUID,
|
||
action_code TEXT,
|
||
from_stage TEXT,
|
||
to_stage TEXT,
|
||
note TEXT,
|
||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||
created_by TEXT NOT NULL DEFAULT 'system',
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
)
|
||
"""))
|
||
conn.execute(text("ALTER TABLE tasks ADD COLUMN IF NOT EXISTS opportunity_id UUID"))
|
||
conn.execute(text("ALTER TABLE opportunities ADD COLUMN IF NOT EXISTS lifecycle_state TEXT NOT NULL DEFAULT 'active'"))
|
||
conn.execute(text("ALTER TABLE opportunities ADD COLUMN IF NOT EXISTS last_customer_activity_at TIMESTAMPTZ"))
|
||
conn.execute(text("ALTER TABLE opportunities ADD COLUMN IF NOT EXISTS last_operator_activity_at TIMESTAMPTZ"))
|
||
conn.execute(text("ALTER TABLE opportunities ADD COLUMN IF NOT EXISTS last_outbound_sent_at TIMESTAMPTZ"))
|
||
conn.execute(text("ALTER TABLE opportunities ADD COLUMN IF NOT EXISTS last_delivery_checked_at TIMESTAMPTZ"))
|
||
conn.execute(text("ALTER TABLE opportunities ADD COLUMN IF NOT EXISTS last_delivery_status TEXT"))
|
||
conn.execute(text("ALTER TABLE opportunities ADD COLUMN IF NOT EXISTS last_commercial_activity_at TIMESTAMPTZ"))
|
||
conn.execute(text("ALTER TABLE opportunities ADD COLUMN IF NOT EXISTS next_follow_up_at TIMESTAMPTZ"))
|
||
conn.execute(text("ALTER TABLE opportunities ADD COLUMN IF NOT EXISTS follow_up_attempts INTEGER NOT NULL DEFAULT 0"))
|
||
conn.execute(text("ALTER TABLE opportunities ADD COLUMN IF NOT EXISTS nurture_until TIMESTAMPTZ"))
|
||
conn.execute(text("ALTER TABLE opportunities ADD COLUMN IF NOT EXISTS lost_reason TEXT"))
|
||
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_opportunities_stage ON opportunities(stage)"))
|
||
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_opportunities_lifecycle ON opportunities(status, lifecycle_state, next_follow_up_at)"))
|
||
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_opportunities_contact ON opportunities(contact_id)"))
|
||
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_opportunities_conversation ON opportunities(conversation_id)"))
|
||
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_opportunity_events_opportunity ON opportunity_events(opportunity_id, created_at DESC)"))
|
||
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_tasks_opportunity_id ON tasks(opportunity_id)"))
|
||
|
||
_SCHEMA_READY = True
|
||
|
||
|
||
def lifecycle_label(state: Optional[str]) -> str:
|
||
key = str(state or "active").strip().lower() or "active"
|
||
return OPPORTUNITY_LIFECYCLE_STATES.get(key, key.replace("_", " ").title())
|
||
|
||
|
||
def set_opportunity_lifecycle(
|
||
opportunity_id: str,
|
||
state: str,
|
||
*,
|
||
nurture_until: Optional[str] = None,
|
||
reason: str = "",
|
||
created_by: str = "operator",
|
||
) -> bool:
|
||
"""Update the operational lifecycle without changing the commercial stage.
|
||
|
||
Recovery and nurture remain open opportunities but are excluded from the
|
||
active-day-to-day queue unless their review date is due.
|
||
"""
|
||
ensure_opportunity_schema()
|
||
state = str(state or "active").strip().lower() or "active"
|
||
if state not in OPPORTUNITY_LIFECYCLE_STATES:
|
||
raise ValueError(f"Unsupported lifecycle state: {state}")
|
||
with engine.begin() as conn:
|
||
current = conn.execute(text("""
|
||
SELECT id::text, lifecycle_state, stage, status
|
||
FROM opportunities
|
||
WHERE id = CAST(:opportunity_id AS UUID)
|
||
LIMIT 1
|
||
"""), {"opportunity_id": opportunity_id}).mappings().first()
|
||
if not current:
|
||
return False
|
||
conn.execute(text("""
|
||
UPDATE opportunities
|
||
SET lifecycle_state = :state,
|
||
nurture_until = CASE
|
||
WHEN :state = 'nurture' THEN CAST(NULLIF(:nurture_until, '') AS TIMESTAMPTZ)
|
||
ELSE NULL
|
||
END,
|
||
next_follow_up_at = CASE
|
||
WHEN :state = 'nurture' THEN CAST(NULLIF(:nurture_until, '') AS TIMESTAMPTZ)
|
||
WHEN :state = 'active' THEN NULL
|
||
ELSE next_follow_up_at
|
||
END,
|
||
updated_at = now(),
|
||
metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata_patch AS JSONB)
|
||
WHERE id = CAST(:opportunity_id AS UUID)
|
||
"""), {
|
||
"opportunity_id": opportunity_id,
|
||
"state": state,
|
||
"nurture_until": str(nurture_until or ""),
|
||
"metadata_patch": _json({
|
||
"lifecycle_reason": reason,
|
||
"lifecycle_changed_by": created_by,
|
||
"lifecycle_changed_at": "now",
|
||
}),
|
||
})
|
||
conn.execute(text("""
|
||
INSERT INTO opportunity_events (
|
||
id, opportunity_id, event_type, from_stage, to_stage, note, payload, created_by
|
||
) VALUES (
|
||
CAST(:id AS UUID), CAST(:opportunity_id AS UUID), 'lifecycle_changed',
|
||
CAST(:from_state AS TEXT), CAST(:to_state AS TEXT), CAST(:note AS TEXT),
|
||
CAST(:payload AS JSONB), CAST(:created_by AS TEXT)
|
||
)
|
||
"""), {
|
||
"id": str(uuid.uuid4()),
|
||
"opportunity_id": opportunity_id,
|
||
"from_state": str(current.get("lifecycle_state") or "active"),
|
||
"to_state": state,
|
||
"note": reason or f"Estado operacional alterado para {lifecycle_label(state)}.",
|
||
"payload": _json({"nurture_until": nurture_until or None}),
|
||
"created_by": created_by,
|
||
})
|
||
return True
|
||
|
||
|
||
def mark_opportunity_lost(
|
||
opportunity_id: str,
|
||
*,
|
||
reason_code: str,
|
||
note: str = "",
|
||
created_by: str = "operator",
|
||
) -> bool:
|
||
"""Close an opportunity as LOST with an auditable mandatory reason."""
|
||
reason_code = str(reason_code or "").strip().lower()
|
||
if reason_code not in LOSS_REASON_LABELS:
|
||
raise ValueError("Motivo de perda obrigatório ou inválido.")
|
||
if reason_code == "future_timing":
|
||
raise ValueError("Timing futuro deve usar acompanhamento futuro (nurture), não perdida.")
|
||
changed = set_opportunity_stage(
|
||
opportunity_id,
|
||
"LOST",
|
||
note=note or LOSS_REASON_LABELS[reason_code],
|
||
created_by=created_by,
|
||
)
|
||
if not changed:
|
||
return False
|
||
with engine.begin() as conn:
|
||
conn.execute(text("""
|
||
UPDATE opportunities
|
||
SET lost_reason = :reason_code,
|
||
lifecycle_state = 'active',
|
||
next_follow_up_at = NULL,
|
||
nurture_until = NULL,
|
||
metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:patch AS JSONB),
|
||
updated_at = now()
|
||
WHERE id = CAST(:opportunity_id AS UUID)
|
||
"""), {
|
||
"opportunity_id": opportunity_id,
|
||
"reason_code": reason_code,
|
||
"patch": _json({
|
||
"lost_reason": reason_code,
|
||
"lost_reason_label": LOSS_REASON_LABELS[reason_code],
|
||
"lost_note": note,
|
||
"lost_by": created_by,
|
||
}),
|
||
})
|
||
return True
|
||
|
||
|
||
def stage_label(stage: Optional[str]) -> str:
|
||
return OPPORTUNITY_STAGE_LABELS.get(str(stage or ""), str(stage or "—"))
|
||
|
||
|
||
def _stage_rank(stage: Optional[str]) -> int:
|
||
return OPPORTUNITY_STAGE_RANK.get(str(stage or ""), 0)
|
||
|
||
|
||
def _is_terminal_stage(stage: Optional[str]) -> bool:
|
||
return str(stage or "").strip().upper() in TERMINAL_OPPORTUNITY_STAGES
|
||
|
||
|
||
def _is_dangerous_stage_regression(old_stage: str, new_stage: str) -> bool:
|
||
old_stage = str(old_stage or "").strip().upper()
|
||
new_stage = str(new_stage or "").strip().upper()
|
||
if not old_stage or old_stage == new_stage:
|
||
return False
|
||
# WON/LOST/NO_INTEREST representam decisão terminal. Reabrir exige ação explícita,
|
||
# não uma simples mudança de fase para orçamento/info.
|
||
if _is_terminal_stage(old_stage) and not _is_terminal_stage(new_stage):
|
||
return True
|
||
# Regressões profundas do funil operacional para etapas comerciais também são arriscadas.
|
||
if _stage_rank(old_stage) >= _stage_rank("PAYMENT_CONFIRMED") and _stage_rank(new_stage) < _stage_rank("PAYMENT_CONFIRMED"):
|
||
return True
|
||
return False
|
||
|
||
|
||
def close_pending_tasks_for_terminal_stage(conn, opportunity_id: str, *, stage: str, actor: str) -> int:
|
||
"""Fecha ruído operacional/comercial incompatível com WON/LOST/NO_INTEREST."""
|
||
terminal = str(stage or "").strip().upper()
|
||
if terminal not in TERMINAL_OPPORTUNITY_STAGES:
|
||
return 0
|
||
rows = conn.execute(text("""
|
||
UPDATE tasks
|
||
SET status = CASE WHEN :stage IN ('LOST','ARCHIVED') THEN 'skipped' ELSE 'done' END,
|
||
done_at = now(),
|
||
done_by = :actor,
|
||
updated_at = now(),
|
||
metadata = COALESCE(metadata, '{}'::jsonb)
|
||
|| jsonb_build_object('auto_closed_by_opportunity_stage', :stage, 'auto_closed_at', now())
|
||
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
||
AND status = 'pending'
|
||
AND (
|
||
action_code IN (
|
||
'SEND_INFO','SEND_QUOTE','SEND_PROFORMA','SEND_INVOICE',
|
||
'CONFIRM_PAYMENT','PREPARE_ORDER','CREATE_SHIPMENT','MARK_NO_INTEREST',
|
||
'CONFIRM_DELIVERY','RECOVER_OPPORTUNITY','REVIEW_NURTURE'
|
||
)
|
||
OR action_code LIKE 'FOLLOW_UP_%'
|
||
)
|
||
RETURNING id::text
|
||
"""), {"opportunity_id": opportunity_id, "stage": terminal, "actor": actor}).mappings().all()
|
||
return len(rows)
|
||
|
||
|
||
def _advance_stage(current: Optional[str], suggested: Optional[str]) -> str:
|
||
current = str(current or "NEW_LEAD")
|
||
suggested = str(suggested or current)
|
||
if current in {"WON", "LOST", "NO_INTEREST", "ARCHIVED"}:
|
||
return current
|
||
if _stage_rank(suggested) >= _stage_rank(current):
|
||
return suggested
|
||
return current
|
||
|
||
|
||
def _detect_product_interest(text_value: str) -> str:
|
||
text_value = str(text_value or "").lower()
|
||
patterns = [
|
||
("Carregador EV", ["carregador", "wallbox", "wall box", "ev charger", "carregamento"]),
|
||
("Cabo", ["cabo", "type 2", "tipo 2"]),
|
||
("Instalação", ["instalação", "instalacao", "instalar"]),
|
||
]
|
||
for label, terms in patterns:
|
||
if any(term in text_value for term in terms):
|
||
return label
|
||
return ""
|
||
|
||
|
||
|
||
|
||
def _normalized_text(*values: Any) -> str:
|
||
return " ".join(str(value or "") for value in values).casefold()
|
||
|
||
|
||
def is_system_or_bounce_task(task: Dict[str, Any]) -> bool:
|
||
"""Detect automatic e-mail system messages that should not become sales opportunities."""
|
||
sender = _normalized_text(task.get("customer_name"), task.get("customer_email"))
|
||
subject = _normalized_text(task.get("message_subject"), task.get("request_text"), task.get("note"))
|
||
if any(pattern in sender for pattern in SYSTEM_SENDER_PATTERNS):
|
||
return True
|
||
return any(pattern in subject for pattern in SYSTEM_SUBJECT_PATTERNS)
|
||
|
||
|
||
def has_commercial_intent(task: Dict[str, Any]) -> bool:
|
||
text_value = _normalized_text(task.get("message_subject"), task.get("request_text"), task.get("note"), task.get("action"))
|
||
return any(term in text_value for term in COMMERCIAL_INTENT_TERMS)
|
||
|
||
|
||
def _mark_task_no_opportunity(task_id: str, *, reason: str, created_by: str = "system") -> None:
|
||
sql = text("""
|
||
UPDATE tasks
|
||
SET metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata AS JSONB),
|
||
updated_at = now()
|
||
WHERE id = CAST(:task_id AS UUID)
|
||
""")
|
||
with engine.begin() as conn:
|
||
conn.execute(sql, {
|
||
"task_id": task_id,
|
||
"metadata": _json({
|
||
"no_opportunity_reason": reason,
|
||
"opportunity_creation_status": "skipped",
|
||
"opportunity_creation_checked_by": created_by,
|
||
}),
|
||
})
|
||
|
||
|
||
def can_create_new_opportunity_for_task(task: Dict[str, Any], action_code: str) -> tuple[bool, str]:
|
||
"""Return whether this task is allowed to create a new opportunity.
|
||
|
||
v4.8.3 guardrail: not every inbound Chatwoot message is a commercial
|
||
opportunity. Bounces, spam/system messages, unsubscribe requests and failed
|
||
classifications should stay as tasks/review items without polluting the
|
||
opportunity board.
|
||
"""
|
||
action_code = str(action_code or "").upper()
|
||
if is_system_or_bounce_task(task):
|
||
return False, "system_or_bounce_message"
|
||
if action_code in NEVER_CREATE_OPPORTUNITY_ACTION_CODES:
|
||
return False, "action_code_never_creates_opportunity"
|
||
if action_code == "SEND_INFO" and not has_commercial_intent(task):
|
||
return False, "send_info_without_clear_commercial_intent"
|
||
if action_code == "SEND_INFO":
|
||
return True, "send_info_with_commercial_intent"
|
||
if action_code in OPPORTUNITY_CREATE_ACTION_CODES:
|
||
return True, "commercial_action_code"
|
||
return False, "action_code_not_commercial"
|
||
|
||
def _compact(value: Any, limit: int = 180) -> str:
|
||
value = re.sub(r"\s+", " ", str(value or "")).strip()
|
||
if len(value) > limit:
|
||
return value[: limit - 1].rstrip() + "…"
|
||
return value
|
||
|
||
|
||
def get_task_context(task_id: str) -> Optional[Dict[str, Any]]:
|
||
ensure_opportunity_schema()
|
||
sql = text("""
|
||
SELECT
|
||
t.id::text,
|
||
t.action_run_id::text,
|
||
t.message_id::text,
|
||
t.raw_event_id::text,
|
||
t.opportunity_id::text,
|
||
t.conversation_id,
|
||
t.contact_id,
|
||
t.customer_id,
|
||
t.action_code,
|
||
t.route,
|
||
t.action,
|
||
t.note,
|
||
t.status,
|
||
t.source_system,
|
||
t.source_event_id,
|
||
COALESCE(t.metadata, '{}'::jsonb) AS metadata,
|
||
t.created_at,
|
||
t.updated_at,
|
||
COALESCE(
|
||
NULLIF(re.payload->'sender'->>'name', ''),
|
||
NULLIF(re.payload->'conversation'->'meta'->'sender'->>'name', ''),
|
||
NULLIF(t.customer_id, ''),
|
||
NULLIF(t.contact_id, '')
|
||
) AS customer_name,
|
||
COALESCE(
|
||
NULLIF(re.payload->'sender'->>'email', ''),
|
||
NULLIF(re.payload->'conversation'->'meta'->'sender'->>'email', ''),
|
||
NULLIF(re.payload->'conversation'->'contact_inbox'->>'source_id', '')
|
||
) AS customer_email,
|
||
COALESCE(
|
||
NULLIF(re.payload->'sender'->>'phone_number', ''),
|
||
NULLIF(re.payload->'conversation'->'meta'->'sender'->>'phone_number', '')
|
||
) AS customer_phone,
|
||
COALESCE(
|
||
NULLIF(re.payload->'conversation'->'additional_attributes'->>'mail_subject', ''),
|
||
NULLIF(re.payload->'content_attributes'->'email'->>'subject', ''),
|
||
NULLIF(re.payload->'conversation'->'messages'->0->'content_attributes'->'email'->>'subject', '')
|
||
) AS message_subject,
|
||
COALESCE(
|
||
NULLIF(m.clean_body, ''),
|
||
NULLIF(m.raw_body, ''),
|
||
NULLIF(re.payload->>'content', '')
|
||
) AS request_text
|
||
FROM tasks t
|
||
LEFT JOIN messages m ON m.id = t.message_id
|
||
LEFT JOIN raw_events re ON re.id = t.raw_event_id
|
||
WHERE t.id = CAST(:task_id AS UUID)
|
||
LIMIT 1
|
||
""")
|
||
with engine.begin() as conn:
|
||
row = conn.execute(sql, {"task_id": task_id}).mappings().first()
|
||
return dict(row) if row else None
|
||
|
||
|
||
CONTACT_MATCH_RECENT_DAYS = 45
|
||
|
||
|
||
def _find_open_opportunity_by_conversation(conversation_id: object) -> Optional[Dict[str, Any]]:
|
||
conversation_id = str(conversation_id or "").strip()
|
||
if not conversation_id:
|
||
return None
|
||
|
||
sql = text("""
|
||
SELECT *
|
||
FROM opportunities
|
||
WHERE status = 'open'
|
||
AND conversation_id = :conversation_id
|
||
ORDER BY updated_at DESC
|
||
LIMIT 1
|
||
""")
|
||
with engine.begin() as conn:
|
||
row = conn.execute(sql, {"conversation_id": conversation_id}).mappings().first()
|
||
if not row:
|
||
return None
|
||
result = dict(row)
|
||
result["_link_match_reason"] = "conversation_id"
|
||
return result
|
||
|
||
|
||
def _find_reopenable_opportunity_by_conversation(conversation_id: object) -> Optional[Dict[str, Any]]:
|
||
"""Return an exact-conversation LOST/NO_INTEREST opportunity for safe reopening."""
|
||
conversation_id = str(conversation_id or "").strip()
|
||
if not conversation_id:
|
||
return None
|
||
sql = text("""
|
||
SELECT *
|
||
FROM opportunities
|
||
WHERE status = 'closed'
|
||
AND stage IN ('LOST', 'NO_INTEREST')
|
||
AND conversation_id = :conversation_id
|
||
ORDER BY closed_at DESC NULLS LAST, updated_at DESC
|
||
LIMIT 1
|
||
""")
|
||
with engine.begin() as conn:
|
||
row = conn.execute(sql, {"conversation_id": conversation_id}).mappings().first()
|
||
if not row:
|
||
return None
|
||
result = dict(row)
|
||
result["_link_match_reason"] = "closed_conversation_reopened"
|
||
result["_reopen_from_customer_reply"] = True
|
||
return result
|
||
|
||
|
||
def _open_opportunities_for_contact(contact_id: object, *, limit: int = 3) -> List[Dict[str, Any]]:
|
||
contact_id = str(contact_id or "").strip()
|
||
if not contact_id:
|
||
return []
|
||
|
||
sql = text("""
|
||
SELECT *
|
||
FROM opportunities
|
||
WHERE status = 'open'
|
||
AND contact_id = :contact_id
|
||
AND updated_at >= now() - make_interval(days => :recent_days)
|
||
ORDER BY updated_at DESC
|
||
LIMIT :limit
|
||
""")
|
||
with engine.begin() as conn:
|
||
rows = conn.execute(sql, {
|
||
"contact_id": contact_id,
|
||
"recent_days": CONTACT_MATCH_RECENT_DAYS,
|
||
"limit": int(limit),
|
||
}).mappings().all()
|
||
return [dict(row) for row in rows]
|
||
|
||
|
||
def has_ambiguous_opportunity_match(task: Dict[str, Any]) -> bool:
|
||
"""Return True when a Chatwoot contact maps to more than one recent open opportunity.
|
||
|
||
conversation_id is the only strong automatic match. contact_id is weak because
|
||
it represents a Chatwoot person/contact, while fiscal customer can be a company.
|
||
"""
|
||
if _find_open_opportunity_by_conversation(task.get("conversation_id")):
|
||
return False
|
||
return len(_open_opportunities_for_contact(task.get("contact_id"), limit=2)) > 1
|
||
|
||
|
||
def mark_task_opportunity_link_ambiguous(
|
||
task_id: str,
|
||
*,
|
||
reason: str,
|
||
created_by: str = "system",
|
||
candidates: Optional[List[Dict[str, Any]]] = None,
|
||
) -> None:
|
||
"""Route a task to manual opportunity association with candidate context."""
|
||
if candidates:
|
||
mark_task_ambiguous_with_candidates(task_id, reason=reason, candidates=candidates, created_by=created_by)
|
||
return
|
||
sql = text("""
|
||
UPDATE tasks
|
||
SET
|
||
route = CASE WHEN status = 'pending' THEN 'rever' ELSE route END,
|
||
metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata AS JSONB),
|
||
updated_at = now()
|
||
WHERE id = CAST(:task_id AS UUID)
|
||
""")
|
||
with engine.begin() as conn:
|
||
conn.execute(sql, {
|
||
"task_id": task_id,
|
||
"metadata": _json({
|
||
"opportunity_linking_status": "ambiguous",
|
||
"opportunity_linking_reason": reason,
|
||
"opportunity_linking_checked_by": created_by,
|
||
}),
|
||
})
|
||
|
||
|
||
def find_open_opportunity_for_task(task: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||
ensure_opportunity_schema()
|
||
|
||
# Strong match: the exact Chatwoot conversation already belongs to an open opportunity.
|
||
by_conversation = _find_open_opportunity_by_conversation(task.get("conversation_id"))
|
||
if by_conversation:
|
||
return by_conversation
|
||
|
||
# A new customer reply on the exact same conversation safely reopens only
|
||
# opportunities previously closed as LOST/NO_INTEREST. Delivered/won sales
|
||
# remain closed so a later purchase becomes a new process.
|
||
reopenable = _find_reopenable_opportunity_by_conversation(task.get("conversation_id"))
|
||
if reopenable:
|
||
return reopenable
|
||
|
||
# v4928.1.5.91: cross-conversation B2B linking. A finance department may
|
||
# send a payment/comprovativo in a new Chatwoot conversation. If the subject
|
||
# or body references an existing Jasmin document, the document owner is the
|
||
# strongest process identity and must win over the new conversation id.
|
||
by_document_reference = find_document_owner_opportunity_for_task(task)
|
||
if by_document_reference:
|
||
return by_document_reference
|
||
|
||
# Weak match: contact_id may be a person whose fiscal customer is a company.
|
||
# Only auto-link when there is exactly one recent open opportunity for that contact.
|
||
contact_matches = _open_opportunities_for_contact(task.get("contact_id"), limit=2)
|
||
if len(contact_matches) == 1:
|
||
result = dict(contact_matches[0])
|
||
result["_link_match_reason"] = "contact_id_unique_recent"
|
||
return result
|
||
|
||
# Business-domain match is only safe when unique. It handles the common B2B
|
||
# pattern: compras@empresa.pt asks, financeiro@empresa.pt pays. Multiple
|
||
# opportunities for the same domain remain manual/reconciliation cases.
|
||
by_unique_company_domain = find_unique_company_domain_opportunity_for_task(task)
|
||
if by_unique_company_domain:
|
||
return by_unique_company_domain
|
||
|
||
return None
|
||
|
||
|
||
def _build_title(task: Dict[str, Any]) -> str:
|
||
subject = _compact(task.get("message_subject"), 90)
|
||
customer = _compact(task.get("customer_name") or task.get("customer_email") or task.get("contact_id"), 70)
|
||
product = _detect_product_interest(" ".join([
|
||
str(task.get("message_subject") or ""),
|
||
str(task.get("request_text") or ""),
|
||
str(task.get("note") or ""),
|
||
]))
|
||
if subject:
|
||
return subject
|
||
if customer and product:
|
||
return f"{customer} · {product}"
|
||
if customer:
|
||
return f"Oportunidade · {customer}"
|
||
return "Nova oportunidade"
|
||
|
||
|
||
def upsert_opportunity_for_task(
|
||
task_id: str,
|
||
*,
|
||
trigger: str = "task_created",
|
||
created_by: str = "system",
|
||
) -> Optional[str]:
|
||
ensure_opportunity_schema()
|
||
task = get_task_context(task_id)
|
||
if not task:
|
||
return None
|
||
|
||
action_code = str(task.get("action_code") or "").upper()
|
||
if action_code not in OPPORTUNITY_RELEVANT_ACTION_CODES:
|
||
_mark_task_no_opportunity(task_id, reason="action_code_not_opportunity_relevant", created_by=created_by)
|
||
return None
|
||
|
||
if is_system_or_bounce_task(task):
|
||
_mark_task_no_opportunity(task_id, reason="system_or_bounce_message", created_by=created_by)
|
||
return None
|
||
|
||
suggested_stage = STAGE_ON_TASK_CREATED.get(action_code, "NEW_LEAD")
|
||
existing = find_open_opportunity_for_task(task)
|
||
if not existing and has_ambiguous_opportunity_match(task):
|
||
candidates = manual_link_candidates_for_task(task)
|
||
mark_task_opportunity_link_ambiguous(
|
||
task_id,
|
||
reason="multiple_recent_open_opportunities_for_chatwoot_contact",
|
||
created_by=created_by,
|
||
candidates=candidates,
|
||
)
|
||
return None
|
||
if not existing:
|
||
manual_candidates = manual_link_candidates_for_task(task)
|
||
if len(manual_candidates) > 1:
|
||
mark_task_opportunity_link_ambiguous(
|
||
task_id,
|
||
reason="multiple_company_domain_or_document_candidates",
|
||
created_by=created_by,
|
||
candidates=manual_candidates,
|
||
)
|
||
return None
|
||
if action_code == "MARK_NO_INTEREST" and not existing:
|
||
_mark_task_no_opportunity(task_id, reason="mark_no_interest_without_existing_opportunity", created_by=created_by)
|
||
return None
|
||
if not existing:
|
||
can_create, skip_reason = can_create_new_opportunity_for_task(task, action_code)
|
||
if not can_create:
|
||
_mark_task_no_opportunity(task_id, reason=skip_reason, created_by=created_by)
|
||
return None
|
||
product_interest = _detect_product_interest(" ".join([
|
||
str(task.get("message_subject") or ""),
|
||
str(task.get("request_text") or ""),
|
||
str(task.get("note") or ""),
|
||
]))
|
||
|
||
with engine.begin() as conn:
|
||
if existing:
|
||
opportunity_id = str(existing["id"])
|
||
old_stage = str(existing.get("stage") or "NEW_LEAD")
|
||
reopening = bool(existing.get("_reopen_from_customer_reply"))
|
||
new_stage = suggested_stage if reopening else _advance_stage(old_stage, suggested_stage)
|
||
conn.execute(text("""
|
||
UPDATE opportunities
|
||
SET
|
||
title = COALESCE(NULLIF(title, ''), :title),
|
||
stage = :stage,
|
||
status = 'open',
|
||
closed_at = CASE WHEN :reopening THEN NULL ELSE closed_at END,
|
||
lifecycle_state = 'active',
|
||
next_follow_up_at = NULL,
|
||
nurture_until = NULL,
|
||
follow_up_attempts = 0,
|
||
last_customer_activity_at = COALESCE(CAST(:last_message_at AS TIMESTAMPTZ), now()),
|
||
last_commercial_activity_at = COALESCE(CAST(:last_message_at AS TIMESTAMPTZ), now()),
|
||
contact_id = COALESCE(NULLIF(contact_id, ''), :contact_id),
|
||
customer_id = COALESCE(NULLIF(customer_id, ''), :customer_id),
|
||
conversation_id = COALESCE(NULLIF(conversation_id, ''), :conversation_id),
|
||
customer_name = COALESCE(NULLIF(:customer_name, ''), customer_name),
|
||
customer_email = COALESCE(NULLIF(:customer_email, ''), customer_email),
|
||
customer_phone = COALESCE(NULLIF(:customer_phone, ''), customer_phone),
|
||
product_interest = COALESCE(NULLIF(:product_interest, ''), product_interest),
|
||
last_action_code = :action_code,
|
||
last_task_id = CAST(:task_id AS UUID),
|
||
last_message_at = COALESCE(:last_message_at, now()),
|
||
updated_at = now(),
|
||
metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata AS JSONB)
|
||
WHERE id = CAST(:opportunity_id AS UUID)
|
||
"""), {
|
||
"opportunity_id": opportunity_id,
|
||
"title": _build_title(task),
|
||
"stage": new_stage,
|
||
"reopening": reopening,
|
||
"contact_id": task.get("contact_id"),
|
||
"customer_id": task.get("customer_id"),
|
||
"conversation_id": task.get("conversation_id"),
|
||
"customer_name": task.get("customer_name") or "",
|
||
"customer_email": task.get("customer_email") or "",
|
||
"customer_phone": task.get("customer_phone") or "",
|
||
"product_interest": product_interest or "",
|
||
"action_code": action_code,
|
||
"task_id": task_id,
|
||
"last_message_at": task.get("created_at"),
|
||
"metadata": _json({"last_trigger": trigger}),
|
||
})
|
||
event_id = str(uuid.uuid4())
|
||
conn.execute(text("""
|
||
INSERT INTO opportunity_events (
|
||
id, opportunity_id, event_type, task_id, action_code,
|
||
from_stage, to_stage, note, payload, created_by
|
||
) VALUES (
|
||
CAST(:id AS UUID), CAST(:opportunity_id AS UUID), :event_type,
|
||
CAST(:task_id AS UUID), :action_code,
|
||
:from_stage, :to_stage, :note, CAST(:payload AS JSONB), :created_by
|
||
)
|
||
"""), {
|
||
"id": event_id,
|
||
"opportunity_id": opportunity_id,
|
||
"event_type": trigger,
|
||
"task_id": task_id,
|
||
"action_code": action_code,
|
||
"from_stage": old_stage,
|
||
"to_stage": new_stage,
|
||
"note": task.get("note") or task.get("action") or "",
|
||
"payload": _json({"task_status": task.get("status")}),
|
||
"created_by": created_by,
|
||
})
|
||
else:
|
||
opportunity_id = str(uuid.uuid4())
|
||
new_stage = suggested_stage
|
||
conn.execute(text("""
|
||
INSERT INTO opportunities (
|
||
id, title, stage, status, contact_id, customer_id, conversation_id,
|
||
customer_name, customer_email, customer_phone, product_interest,
|
||
source_system, source_event_id, last_action_code, last_task_id,
|
||
last_message_at, lifecycle_state, last_customer_activity_at,
|
||
last_commercial_activity_at, metadata
|
||
) VALUES (
|
||
CAST(:id AS UUID), :title, :stage, 'open', :contact_id, :customer_id, :conversation_id,
|
||
:customer_name, :customer_email, :customer_phone, :product_interest,
|
||
:source_system, :source_event_id, :action_code, CAST(:task_id AS UUID),
|
||
COALESCE(:last_message_at, now()), 'active', COALESCE(:last_message_at, now()),
|
||
COALESCE(:last_message_at, now()), CAST(:metadata AS JSONB)
|
||
)
|
||
"""), {
|
||
"id": opportunity_id,
|
||
"title": _build_title(task),
|
||
"stage": new_stage,
|
||
"contact_id": task.get("contact_id"),
|
||
"customer_id": task.get("customer_id"),
|
||
"conversation_id": task.get("conversation_id"),
|
||
"customer_name": task.get("customer_name"),
|
||
"customer_email": task.get("customer_email"),
|
||
"customer_phone": task.get("customer_phone"),
|
||
"product_interest": product_interest,
|
||
"source_system": task.get("source_system") or "clientflow",
|
||
"source_event_id": task.get("source_event_id"),
|
||
"action_code": action_code,
|
||
"task_id": task_id,
|
||
"last_message_at": task.get("created_at"),
|
||
"metadata": _json({"created_from_task_id": task_id, "last_trigger": trigger}),
|
||
})
|
||
conn.execute(text("""
|
||
INSERT INTO opportunity_events (
|
||
id, opportunity_id, event_type, task_id, action_code,
|
||
from_stage, to_stage, note, payload, created_by
|
||
) VALUES (
|
||
CAST(:id AS UUID), CAST(:opportunity_id AS UUID), :event_type,
|
||
CAST(:task_id AS UUID), :action_code,
|
||
NULL, :to_stage, :note, CAST(:payload AS JSONB), :created_by
|
||
)
|
||
"""), {
|
||
"id": str(uuid.uuid4()),
|
||
"opportunity_id": opportunity_id,
|
||
"event_type": trigger,
|
||
"task_id": task_id,
|
||
"action_code": action_code,
|
||
"to_stage": new_stage,
|
||
"note": task.get("note") or task.get("action") or "",
|
||
"payload": _json({"task_status": task.get("status")}),
|
||
"created_by": created_by,
|
||
})
|
||
|
||
conn.execute(text("""
|
||
UPDATE tasks
|
||
SET opportunity_id = CAST(:opportunity_id AS UUID),
|
||
metadata = COALESCE(metadata, '{}'::jsonb) || jsonb_build_object('opportunity_id', :opportunity_id),
|
||
updated_at = now()
|
||
WHERE id = CAST(:task_id AS UUID)
|
||
"""), {"opportunity_id": opportunity_id, "task_id": task_id})
|
||
|
||
# v4928.1.5.17: incoming customer activity closes open semi-automatic
|
||
# follow-up tasks for this opportunity. The new inbound task remains open
|
||
# for the operator; only stale reminders are cleared.
|
||
try:
|
||
from app.followup_service import close_pending_followups_for_opportunity
|
||
|
||
close_pending_followups_for_opportunity(
|
||
opportunity_id=opportunity_id,
|
||
reason=f"customer_activity:{action_code}",
|
||
except_task_id=task_id,
|
||
created_by="system",
|
||
)
|
||
with engine.begin() as conn:
|
||
conn.execute(text("""
|
||
UPDATE opportunities
|
||
SET lifecycle_state = 'active',
|
||
next_follow_up_at = NULL,
|
||
nurture_until = NULL,
|
||
follow_up_attempts = 0,
|
||
last_customer_activity_at = COALESCE(CAST(:activity_at AS TIMESTAMPTZ), now()),
|
||
last_commercial_activity_at = COALESCE(CAST(:activity_at AS TIMESTAMPTZ), now()),
|
||
updated_at = now(),
|
||
metadata = COALESCE(metadata, '{}'::jsonb) || jsonb_build_object(
|
||
'last_customer_activity_source', CAST(:source AS TEXT),
|
||
'reactivated_by_customer', TRUE
|
||
)
|
||
WHERE id = CAST(:opportunity_id AS UUID)
|
||
"""), {
|
||
"opportunity_id": opportunity_id,
|
||
"activity_at": task.get("created_at"),
|
||
"source": action_code,
|
||
})
|
||
except Exception:
|
||
pass
|
||
|
||
# v4.9.25: newly created/updated opportunities should immediately try to
|
||
# get a fiscal customer suggestion. This is best-effort and never blocks
|
||
# task processing or opportunity creation.
|
||
try:
|
||
from app.fiscal_enrichment_service import enrich_opportunity
|
||
enrich_opportunity(opportunity_id, apply_safe=True)
|
||
except Exception:
|
||
pass
|
||
|
||
return opportunity_id
|
||
|
||
|
||
def close_pending_followups_for_opportunity(
|
||
conn,
|
||
opportunity_id: str,
|
||
*,
|
||
reason: str = "opportunity_terminal_stage",
|
||
actor: str = "system",
|
||
) -> int:
|
||
"""Close pending follow-up tasks when an opportunity reaches a terminal stage.
|
||
|
||
Follow-ups are reminders to progress an open process. Keeping them pending
|
||
after WON/LOST/NO_INTEREST/DELIVERED makes the workbench contradictory: the
|
||
opportunity appears closed but still requires action.
|
||
"""
|
||
result = conn.execute(text("""
|
||
UPDATE tasks
|
||
SET status = 'skipped',
|
||
done_at = COALESCE(done_at, now()),
|
||
done_by = CAST(:actor AS TEXT),
|
||
updated_at = now(),
|
||
metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata_patch AS JSONB)
|
||
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
||
AND status = 'pending'
|
||
AND action_code IN (
|
||
'FOLLOW_UP_QUOTE',
|
||
'FOLLOW_UP_PROFORMA',
|
||
'FOLLOW_UP_PAYMENT',
|
||
'FOLLOW_UP_CUSTOMER_REVIEW',
|
||
'FOLLOW_UP_GENERIC'
|
||
)
|
||
"""), {
|
||
"opportunity_id": opportunity_id,
|
||
"actor": actor,
|
||
"metadata_patch": _json({
|
||
"auto_closed_follow_up": True,
|
||
"auto_closed_reason": reason,
|
||
"auto_closed_by": actor,
|
||
}),
|
||
})
|
||
return int(getattr(result, "rowcount", 0) or 0)
|
||
|
||
|
||
def advance_opportunity_after_task_done(
|
||
task_id: str,
|
||
*,
|
||
event_type: Optional[str] = None,
|
||
payload: Optional[Dict[str, Any]] = None,
|
||
created_by: str = "operator",
|
||
) -> Optional[str]:
|
||
ensure_opportunity_schema()
|
||
task = get_task_context(task_id)
|
||
if not task:
|
||
return None
|
||
|
||
opportunity_id = task.get("opportunity_id") or upsert_opportunity_for_task(
|
||
task_id,
|
||
trigger="task_completed_link_created",
|
||
created_by=created_by,
|
||
)
|
||
if not opportunity_id:
|
||
return None
|
||
|
||
action_code = str(task.get("action_code") or "").upper()
|
||
|
||
if action_code == "REVIEW_RECONSTRUCTED_PROCESS":
|
||
task_metadata = task.get("metadata") if isinstance(task.get("metadata"), dict) else {}
|
||
blocked_action_code = str(task_metadata.get("blocked_action_code") or "").strip().upper()
|
||
patch = reconstructed_review_metadata_patch(
|
||
"validated",
|
||
actor=created_by,
|
||
reason=str((payload or {}).get("done_note") or "Processo reconstruído validado pelo operador."),
|
||
blocked_action_code=blocked_action_code,
|
||
)
|
||
with engine.begin() as conn:
|
||
current = conn.execute(text("""
|
||
SELECT stage FROM opportunities
|
||
WHERE id = CAST(:opportunity_id AS UUID)
|
||
FOR UPDATE
|
||
"""), {"opportunity_id": opportunity_id}).mappings().first()
|
||
if not current:
|
||
return str(opportunity_id)
|
||
conn.execute(text("""
|
||
UPDATE opportunities
|
||
SET metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata AS JSONB),
|
||
last_action_code = COALESCE(NULLIF(:blocked_action_code, ''), last_action_code),
|
||
updated_at = now()
|
||
WHERE id = CAST(:opportunity_id AS UUID)
|
||
"""), {
|
||
"opportunity_id": opportunity_id,
|
||
"blocked_action_code": blocked_action_code,
|
||
"metadata": _json(patch),
|
||
})
|
||
conn.execute(text("""
|
||
INSERT INTO opportunity_events (
|
||
id, opportunity_id, event_type, task_id, action_code,
|
||
from_stage, to_stage, note, payload, created_by
|
||
) VALUES (
|
||
CAST(:id AS UUID), CAST(:opportunity_id AS UUID),
|
||
'reconstructed_process_review_validated', CAST(:task_id AS UUID),
|
||
'REVIEW_RECONSTRUCTED_PROCESS', CAST(:stage AS TEXT), CAST(:stage AS TEXT),
|
||
CAST(:note AS TEXT), CAST(:payload AS JSONB), CAST(:created_by AS TEXT)
|
||
)
|
||
"""), {
|
||
"id": str(uuid.uuid4()),
|
||
"opportunity_id": opportunity_id,
|
||
"task_id": task_id,
|
||
"stage": str(current.get("stage") or ""),
|
||
"note": str((payload or {}).get("done_note") or "Processo reconstruído validado pelo operador."),
|
||
"payload": _json({"blocked_action_code": blocked_action_code, **patch}),
|
||
"created_by": created_by,
|
||
})
|
||
try:
|
||
from app.opportunity_next_action_service import get_opportunity_next_action
|
||
from app.opportunity_action_task_materializer import ensure_pending_task_for_next_action
|
||
|
||
post_review_decision = get_opportunity_next_action(str(opportunity_id))
|
||
post_review_result = ensure_pending_task_for_next_action(
|
||
str(opportunity_id),
|
||
post_review_decision,
|
||
source="reconstructed_review_completion",
|
||
actor=created_by,
|
||
reactivate_skipped=True,
|
||
)
|
||
with engine.begin() as conn:
|
||
conn.execute(text("""
|
||
INSERT INTO opportunity_events (
|
||
id, opportunity_id, event_type, task_id, action_code,
|
||
from_stage, to_stage, note, payload, created_by
|
||
) VALUES (
|
||
CAST(:id AS UUID), CAST(:opportunity_id AS UUID),
|
||
'post_review_task_materialization', CAST(:task_id AS UUID),
|
||
CAST(:action_code AS TEXT), CAST(:stage AS TEXT), CAST(:stage AS TEXT),
|
||
CAST(:note AS TEXT), CAST(:payload AS JSONB), CAST(:created_by AS TEXT)
|
||
)
|
||
"""), {
|
||
"id": str(uuid.uuid4()),
|
||
"opportunity_id": opportunity_id,
|
||
"task_id": task_id,
|
||
"action_code": str(post_review_decision.get("action_code") or ""),
|
||
"stage": str(current.get("stage") or ""),
|
||
"note": str(post_review_result.get("reason") or "post_review_materialization"),
|
||
"payload": _json({
|
||
"decision": post_review_decision,
|
||
"materialization": post_review_result,
|
||
"version": "v4928.1.5.132.2",
|
||
}),
|
||
"created_by": created_by,
|
||
})
|
||
except Exception as exc:
|
||
print(f"ClientFlow post-review task materialization failed for opportunity {opportunity_id}: {exc}", flush=True)
|
||
return str(opportunity_id)
|
||
|
||
if action_code == "VALIDATE_PHYSICAL_ORDER":
|
||
from app.operation_service import register_operation_action
|
||
|
||
register_operation_action(
|
||
str(opportunity_id),
|
||
"odoo_physical_validated",
|
||
note=str((payload or {}).get("done_note") or "Encomenda física validada pelo operador."),
|
||
payload={"task_id": task_id, "source": "VALIDATE_PHYSICAL_ORDER"},
|
||
created_by=created_by,
|
||
)
|
||
|
||
suggested_stage = STAGE_ON_TASK_DONE.get(action_code)
|
||
if not suggested_stage:
|
||
# Follow-up tasks do not necessarily move the commercial stage, but
|
||
# completing one should still evaluate the cascade and schedule the
|
||
# next follow-up only if the opportunity has not advanced.
|
||
try:
|
||
from app.followup_service import schedule_follow_up_after_task_done
|
||
|
||
schedule_follow_up_after_task_done(
|
||
task_id=task_id,
|
||
action_code=action_code,
|
||
opportunity_id=str(opportunity_id),
|
||
created_by="system",
|
||
)
|
||
except Exception as exc:
|
||
print(f"ClientFlow follow-up cascade failed for opportunity {opportunity_id}: {exc}", flush=True)
|
||
return str(opportunity_id)
|
||
|
||
with engine.begin() as conn:
|
||
current = conn.execute(text("""
|
||
SELECT stage, status FROM opportunities WHERE id = CAST(:opportunity_id AS UUID)
|
||
"""), {"opportunity_id": opportunity_id}).mappings().first()
|
||
if not current:
|
||
return str(opportunity_id)
|
||
old_stage = str(current.get("stage") or "NEW_LEAD")
|
||
new_stage = _advance_stage(old_stage, suggested_stage)
|
||
status = "archived" if new_stage == "ARCHIVED" else ("closed" if new_stage in {"WON", "LOST", "NO_INTEREST", "DELIVERED"} else "open")
|
||
conn.execute(text("""
|
||
UPDATE opportunities
|
||
SET
|
||
stage = :stage,
|
||
status = :status,
|
||
closed_at = CASE WHEN CAST(:status AS TEXT) = 'closed' THEN COALESCE(closed_at, now()) ELSE closed_at END,
|
||
last_action_code = :action_code,
|
||
last_task_id = CAST(:task_id AS UUID),
|
||
updated_at = now(),
|
||
metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata AS JSONB)
|
||
WHERE id = CAST(:opportunity_id AS UUID)
|
||
"""), {
|
||
"opportunity_id": opportunity_id,
|
||
"stage": new_stage,
|
||
"status": status,
|
||
"action_code": action_code,
|
||
"task_id": task_id,
|
||
"metadata": _json({"last_done_event_type": event_type or "task_done"}),
|
||
})
|
||
if status == "closed":
|
||
close_pending_followups_for_opportunity(
|
||
conn,
|
||
str(opportunity_id),
|
||
reason=f"task_done_stage_{new_stage}",
|
||
actor=created_by,
|
||
)
|
||
|
||
conn.execute(text("""
|
||
INSERT INTO opportunity_events (
|
||
id, opportunity_id, event_type, task_id, action_code,
|
||
from_stage, to_stage, note, payload, created_by
|
||
) VALUES (
|
||
CAST(:id AS UUID), CAST(:opportunity_id AS UUID), CAST(:event_type AS TEXT),
|
||
CAST(:task_id AS UUID), CAST(:action_code AS TEXT),
|
||
CAST(:from_stage AS TEXT), CAST(:to_stage AS TEXT), CAST(:note AS TEXT), CAST(:payload AS JSONB), CAST(:created_by AS TEXT)
|
||
)
|
||
"""), {
|
||
"id": str(uuid.uuid4()),
|
||
"opportunity_id": opportunity_id,
|
||
"event_type": event_type or "task_done",
|
||
"task_id": task_id,
|
||
"action_code": action_code,
|
||
"from_stage": old_stage,
|
||
"to_stage": new_stage,
|
||
"note": f"Tarefa concluída: {task.get('action') or action_code}",
|
||
"payload": _json(payload or {}),
|
||
"created_by": created_by,
|
||
})
|
||
|
||
try:
|
||
if status == "closed":
|
||
from app.followup_service import cancel_pending_followups_for_opportunity
|
||
|
||
cancel_pending_followups_for_opportunity(
|
||
opportunity_id=str(opportunity_id),
|
||
reason=f"opportunity_closed:{new_stage}",
|
||
created_by="system",
|
||
)
|
||
else:
|
||
from app.followup_service import (
|
||
align_pending_followups_for_opportunity,
|
||
schedule_follow_up_after_task_done,
|
||
)
|
||
|
||
align_pending_followups_for_opportunity(
|
||
opportunity_id=str(opportunity_id),
|
||
created_by="system",
|
||
)
|
||
schedule_follow_up_after_task_done(
|
||
task_id=task_id,
|
||
action_code=action_code,
|
||
opportunity_id=str(opportunity_id),
|
||
created_by="system",
|
||
)
|
||
except Exception as exc:
|
||
print(f"ClientFlow follow-up scheduling failed for opportunity {opportunity_id}: {exc}", flush=True)
|
||
|
||
if action_code == "VALIDATE_PHYSICAL_ORDER":
|
||
try:
|
||
from app.opportunity_next_action_service import get_opportunity_next_action
|
||
from app.opportunity_action_task_materializer import ensure_pending_task_for_next_action
|
||
|
||
ensure_pending_task_for_next_action(
|
||
str(opportunity_id),
|
||
get_opportunity_next_action(str(opportunity_id)),
|
||
source="physical_validation_completion",
|
||
actor=created_by,
|
||
reactivate_skipped=True,
|
||
)
|
||
except Exception as exc:
|
||
print(f"ClientFlow shipment task materialization failed for opportunity {opportunity_id}: {exc}", flush=True)
|
||
|
||
return str(opportunity_id)
|
||
|
||
|
||
MANUAL_REQUEST_TYPES = {
|
||
"quote": ("SEND_QUOTE", "QUOTE_REQUESTED", "Orçamento"),
|
||
"info": ("SEND_INFO", "INFO_REQUESTED", "Informação"),
|
||
"proforma": ("SEND_PROFORMA", "PROFORMA_REQUESTED", "Pró-forma"),
|
||
"invoice": ("SEND_INVOICE", "INVOICE_REQUESTED", "Fatura"),
|
||
"order": ("SEND_QUOTE", "QUOTE_REQUESTED", "Encomenda"),
|
||
"support": ("SUPPORT", "REVIEW", "Assistência"),
|
||
"manual": ("SEND_QUOTE", "NEW_LEAD", "Pedido manual"),
|
||
}
|
||
|
||
MANUAL_ORIGIN_LABELS = {
|
||
"phone": "telefone",
|
||
"manual": "manual",
|
||
"email": "email",
|
||
"whatsapp": "WhatsApp",
|
||
"presential": "presencial",
|
||
"reconciliation": "reconciliação",
|
||
}
|
||
|
||
|
||
def create_manual_opportunity_from_customer(
|
||
customer_id: str,
|
||
*,
|
||
origin: str = "phone",
|
||
request_type: str = "quote",
|
||
contact_name: str = "",
|
||
contact_email: str = "",
|
||
contact_phone: str = "",
|
||
product_interest: str = "",
|
||
notes: str = "",
|
||
create_task: bool = True,
|
||
created_by: str = "operator",
|
||
) -> Dict[str, Any]:
|
||
"""Create a fiscal-customer-first opportunity from the customer page.
|
||
|
||
This covers manual/phone/WhatsApp/presential requests where no Chatwoot
|
||
conversation exists yet. The opportunity is created already linked to the
|
||
fiscal customer, and can optionally create the initial human task.
|
||
"""
|
||
from app.action_catalog import get_action_config
|
||
from app.commercial_service import get_customer, ensure_commercial_schema
|
||
from app.db import ensure_core_schema
|
||
|
||
ensure_core_schema()
|
||
ensure_opportunity_schema()
|
||
ensure_commercial_schema()
|
||
|
||
customer = get_customer(customer_id)
|
||
if not customer:
|
||
raise ValueError("Cliente fiscal não encontrado")
|
||
|
||
origin_key = str(origin or "phone").strip().lower() or "phone"
|
||
request_key = str(request_type or "quote").strip().lower() or "quote"
|
||
action_code, stage, request_label = MANUAL_REQUEST_TYPES.get(request_key, MANUAL_REQUEST_TYPES["quote"])
|
||
origin_label = MANUAL_ORIGIN_LABELS.get(origin_key, origin_key)
|
||
|
||
contact_name = str(contact_name or "").strip()
|
||
contact_email = str(contact_email or "").strip()
|
||
contact_phone = str(contact_phone or "").strip()
|
||
product_interest = str(product_interest or "").strip()
|
||
notes = str(notes or "").strip()
|
||
|
||
fiscal_name = str(customer.get("name") or "Cliente").strip()
|
||
title_bits = [request_label, fiscal_name]
|
||
if product_interest:
|
||
title_bits.append(product_interest[:80])
|
||
title = " — ".join(bit for bit in title_bits if bit)
|
||
|
||
config = get_action_config(action_code)
|
||
metadata = {
|
||
"created_manually": True,
|
||
"created_from_customer_page": True,
|
||
"origin": origin_key,
|
||
"origin_label": origin_label,
|
||
"request_type": request_key,
|
||
"notes": notes,
|
||
"customer_snapshot": {
|
||
"id": str(customer.get("id") or customer_id),
|
||
"name": customer.get("name"),
|
||
"tax_id": customer.get("tax_id"),
|
||
"email": customer.get("email"),
|
||
"phone": customer.get("phone"),
|
||
"street_name": customer.get("street_name"),
|
||
"postal_zone": customer.get("postal_zone"),
|
||
"city_name": customer.get("city_name"),
|
||
"country": customer.get("country"),
|
||
},
|
||
}
|
||
|
||
opportunity_id = str(uuid.uuid4())
|
||
task_id: Optional[str] = None
|
||
with engine.begin() as conn:
|
||
conn.execute(text("""
|
||
INSERT INTO opportunities (
|
||
id, title, stage, status, local_customer_id,
|
||
customer_name, customer_email, customer_phone, product_interest,
|
||
source_system, source_event_id, last_action_code, metadata
|
||
) VALUES (
|
||
CAST(:id AS UUID), :title, :stage, 'open', CAST(:customer_id AS UUID),
|
||
:customer_name, :customer_email, :customer_phone, :product_interest,
|
||
'clientflow_manual', :source_event_id, :action_code, CAST(:metadata AS JSONB)
|
||
)
|
||
"""), {
|
||
"id": opportunity_id,
|
||
"title": title,
|
||
"stage": stage,
|
||
"customer_id": customer_id,
|
||
"customer_name": contact_name or fiscal_name,
|
||
"customer_email": contact_email or customer.get("email"),
|
||
"customer_phone": contact_phone or customer.get("phone"),
|
||
"product_interest": product_interest,
|
||
"source_event_id": f"manual:{opportunity_id}",
|
||
"action_code": action_code,
|
||
"metadata": _json(metadata),
|
||
})
|
||
|
||
conn.execute(text("""
|
||
INSERT INTO opportunity_events (
|
||
id, opportunity_id, event_type, action_code, from_stage, to_stage,
|
||
note, payload, created_by
|
||
) VALUES (
|
||
gen_random_uuid(), CAST(:opportunity_id AS UUID), 'manual_opportunity_created',
|
||
:action_code, NULL, :stage, :note, CAST(:payload AS JSONB), :created_by
|
||
)
|
||
"""), {
|
||
"opportunity_id": opportunity_id,
|
||
"action_code": action_code,
|
||
"stage": stage,
|
||
"note": notes or f"Oportunidade criada manualmente a partir do cliente fiscal por {origin_label}.",
|
||
"payload": _json(metadata),
|
||
"created_by": created_by,
|
||
})
|
||
|
||
if create_task and action_code:
|
||
task_row = conn.execute(text("""
|
||
INSERT INTO tasks (
|
||
opportunity_id, customer_id, action_code, route, action, note,
|
||
action_required, safe_to_post, status, source_system, source_event_id,
|
||
idempotency_key, metadata
|
||
) VALUES (
|
||
CAST(:opportunity_id AS UUID), :customer_id, :action_code, :route, :action, :note,
|
||
:action_required, :safe_to_post, 'pending', 'clientflow_manual', :source_event_id,
|
||
:idempotency_key, CAST(:metadata AS JSONB)
|
||
)
|
||
RETURNING id::text
|
||
"""), {
|
||
"opportunity_id": opportunity_id,
|
||
"customer_id": str(customer_id),
|
||
"action_code": action_code,
|
||
"route": config.get("route") or "vendas",
|
||
"action": config.get("action") or action_code,
|
||
"note": notes or f"Pedido criado manualmente por {origin_label}. Preparar próxima ação.",
|
||
"action_required": bool(config.get("action_required", True)),
|
||
"safe_to_post": bool(config.get("safe_to_post", False)),
|
||
"source_event_id": f"manual:{opportunity_id}",
|
||
"idempotency_key": f"manual-opportunity:{opportunity_id}:initial-task",
|
||
"metadata": _json({
|
||
"created_from_manual_opportunity": True,
|
||
"opportunity_id": opportunity_id,
|
||
"origin": origin_key,
|
||
"request_type": request_key,
|
||
"customer_snapshot": metadata["customer_snapshot"],
|
||
}),
|
||
}).mappings().first()
|
||
task_id = str(task_row["id"]) if task_row else None
|
||
if task_id:
|
||
conn.execute(text("""
|
||
UPDATE opportunities
|
||
SET last_task_id = CAST(:task_id AS UUID), updated_at = now()
|
||
WHERE id = CAST(:opportunity_id AS UUID)
|
||
"""), {"opportunity_id": opportunity_id, "task_id": task_id})
|
||
conn.execute(text("""
|
||
INSERT INTO task_events (task_id, event_type, payload, created_by)
|
||
VALUES (CAST(:task_id AS UUID), 'task_created', CAST(:payload AS JSONB), :created_by)
|
||
"""), {
|
||
"task_id": task_id,
|
||
"payload": _json({"source": "manual_opportunity", "opportunity_id": opportunity_id}),
|
||
"created_by": created_by,
|
||
})
|
||
|
||
return {
|
||
"ok": True,
|
||
"opportunity_id": opportunity_id,
|
||
"task_id": task_id,
|
||
"customer_id": str(customer_id),
|
||
"stage": stage,
|
||
"action_code": action_code,
|
||
"next_url": f"/opportunities/{opportunity_id}",
|
||
}
|
||
|
||
def list_opportunities(
|
||
*,
|
||
stage: Optional[str] = None,
|
||
q: Optional[str] = None,
|
||
status: Optional[str] = None,
|
||
limit: int = 300,
|
||
) -> List[Dict[str, Any]]:
|
||
ensure_opportunity_schema()
|
||
# A oportunidade mantém dois conceitos diferentes:
|
||
# - o.customer_*: contacto/origem captado da conversa/tarefa;
|
||
# - o.local_customer_id -> customers: ficha fiscal usada em Jasmin/documentos.
|
||
# A board deve mostrar ambos quando divergem, para evitar abrir uma oportunidade
|
||
# que parece ser de um contacto mas emite documentos para outro cliente fiscal.
|
||
filters = []
|
||
params: Dict[str, Any] = {"limit": int(limit)}
|
||
if stage and stage != "all":
|
||
filters.append("o.stage = :stage")
|
||
params["stage"] = stage
|
||
if status and status != "all":
|
||
filters.append("o.status = :status")
|
||
params["status"] = status
|
||
if q:
|
||
filters.append("""
|
||
(
|
||
o.title ILIKE :q OR o.customer_name ILIKE :q OR o.customer_email ILIKE :q
|
||
OR o.contact_id ILIKE :q OR o.conversation_id ILIKE :q OR o.product_interest ILIKE :q
|
||
OR c.name ILIKE :q OR c.email ILIKE :q OR c.tax_id ILIKE :q
|
||
)
|
||
""")
|
||
params["q"] = f"%{str(q).strip()}%"
|
||
where_sql = "WHERE " + " AND ".join(filters) if filters else ""
|
||
sql = text(f"""
|
||
SELECT
|
||
o.*,
|
||
c.id::text AS linked_customer_id,
|
||
c.name AS linked_customer_name,
|
||
c.email AS linked_customer_email,
|
||
c.tax_id AS linked_customer_tax_id,
|
||
c.street_name AS linked_customer_street_name,
|
||
c.postal_zone AS linked_customer_postal_zone,
|
||
c.city_name AS linked_customer_city_name,
|
||
c.phone AS linked_customer_phone,
|
||
(SELECT count(*) FROM tasks t WHERE t.opportunity_id = o.id) AS task_count,
|
||
(SELECT count(*) FROM tasks t WHERE t.opportunity_id = o.id AND t.status = 'pending') AS pending_task_count,
|
||
(
|
||
SELECT t.id::text FROM tasks t
|
||
WHERE t.opportunity_id = o.id AND t.status = 'pending'
|
||
AND NOT (t.action_code LIKE 'FOLLOW_UP_%' AND t.due_at IS NOT NULL AND t.due_at > now())
|
||
ORDER BY
|
||
CASE WHEN t.action_code IN ('ASSOCIATE_OPPORTUNITY','REVIEW_ASSOCIATION','LINK_DOCUMENT','REVIEW_RECONSTRUCTED_PROCESS') THEN 0 ELSE 1 END,
|
||
t.due_at NULLS LAST, t.created_at
|
||
LIMIT 1
|
||
) AS pending_primary_task_id,
|
||
(
|
||
SELECT t.action_code FROM tasks t
|
||
WHERE t.opportunity_id = o.id AND t.status = 'pending'
|
||
AND NOT (t.action_code LIKE 'FOLLOW_UP_%' AND t.due_at IS NOT NULL AND t.due_at > now())
|
||
ORDER BY
|
||
CASE WHEN t.action_code IN ('ASSOCIATE_OPPORTUNITY','REVIEW_ASSOCIATION','LINK_DOCUMENT','REVIEW_RECONSTRUCTED_PROCESS') THEN 0 ELSE 1 END,
|
||
t.due_at NULLS LAST, t.created_at
|
||
LIMIT 1
|
||
) AS pending_primary_action_code,
|
||
(
|
||
SELECT t.action FROM tasks t
|
||
WHERE t.opportunity_id = o.id AND t.status = 'pending'
|
||
AND NOT (t.action_code LIKE 'FOLLOW_UP_%' AND t.due_at IS NOT NULL AND t.due_at > now())
|
||
ORDER BY
|
||
CASE WHEN t.action_code IN ('ASSOCIATE_OPPORTUNITY','REVIEW_ASSOCIATION','LINK_DOCUMENT','REVIEW_RECONSTRUCTED_PROCESS') THEN 0 ELSE 1 END,
|
||
t.due_at NULLS LAST, t.created_at
|
||
LIMIT 1
|
||
) AS pending_primary_action,
|
||
(
|
||
SELECT t.action_code FROM tasks t
|
||
WHERE t.opportunity_id = o.id AND t.status = 'pending'
|
||
AND (t.action_code LIKE 'FOLLOW_UP_%' OR t.action_code IN ('CONFIRM_DELIVERY','RECOVER_OPPORTUNITY','REVIEW_NURTURE'))
|
||
ORDER BY t.due_at NULLS LAST, t.created_at DESC LIMIT 1
|
||
) AS pending_follow_up_action_code,
|
||
(
|
||
SELECT t.action FROM tasks t
|
||
WHERE t.opportunity_id = o.id AND t.status = 'pending'
|
||
AND (t.action_code LIKE 'FOLLOW_UP_%' OR t.action_code IN ('CONFIRM_DELIVERY','RECOVER_OPPORTUNITY','REVIEW_NURTURE'))
|
||
ORDER BY t.due_at NULLS LAST, t.created_at DESC LIMIT 1
|
||
) AS pending_follow_up_action
|
||
FROM opportunities o
|
||
LEFT JOIN customers c ON c.id = o.local_customer_id
|
||
{where_sql}
|
||
ORDER BY
|
||
CASE o.status WHEN 'open' THEN 0 ELSE 1 END,
|
||
o.updated_at DESC
|
||
LIMIT :limit
|
||
""")
|
||
with engine.begin() as conn:
|
||
rows = conn.execute(sql, params).mappings().all()
|
||
return [dict(row) for row in rows]
|
||
|
||
|
||
def get_opportunity(opportunity_id: str) -> Optional[Dict[str, Any]]:
|
||
ensure_opportunity_schema()
|
||
sql = text("""
|
||
SELECT
|
||
o.*,
|
||
c.id::text AS linked_customer_id,
|
||
c.name AS linked_customer_name,
|
||
c.email AS linked_customer_email,
|
||
c.tax_id AS linked_customer_tax_id,
|
||
c.street_name AS linked_customer_street_name,
|
||
c.postal_zone AS linked_customer_postal_zone,
|
||
c.city_name AS linked_customer_city_name,
|
||
c.phone AS linked_customer_phone,
|
||
(SELECT count(*) FROM tasks t WHERE t.opportunity_id = o.id) AS task_count,
|
||
(SELECT count(*) FROM tasks t WHERE t.opportunity_id = o.id AND t.status = 'pending') AS pending_task_count,
|
||
(
|
||
SELECT t.id::text FROM tasks t
|
||
WHERE t.opportunity_id = o.id AND t.status = 'pending'
|
||
AND NOT (t.action_code LIKE 'FOLLOW_UP_%' AND t.due_at IS NOT NULL AND t.due_at > now())
|
||
ORDER BY
|
||
CASE WHEN t.action_code IN ('ASSOCIATE_OPPORTUNITY','REVIEW_ASSOCIATION','LINK_DOCUMENT','REVIEW_RECONSTRUCTED_PROCESS') THEN 0 ELSE 1 END,
|
||
t.due_at NULLS LAST, t.created_at
|
||
LIMIT 1
|
||
) AS pending_primary_task_id,
|
||
(
|
||
SELECT t.action_code FROM tasks t
|
||
WHERE t.opportunity_id = o.id AND t.status = 'pending'
|
||
AND NOT (t.action_code LIKE 'FOLLOW_UP_%' AND t.due_at IS NOT NULL AND t.due_at > now())
|
||
ORDER BY
|
||
CASE WHEN t.action_code IN ('ASSOCIATE_OPPORTUNITY','REVIEW_ASSOCIATION','LINK_DOCUMENT','REVIEW_RECONSTRUCTED_PROCESS') THEN 0 ELSE 1 END,
|
||
t.due_at NULLS LAST, t.created_at
|
||
LIMIT 1
|
||
) AS pending_primary_action_code,
|
||
(
|
||
SELECT t.action FROM tasks t
|
||
WHERE t.opportunity_id = o.id AND t.status = 'pending'
|
||
AND NOT (t.action_code LIKE 'FOLLOW_UP_%' AND t.due_at IS NOT NULL AND t.due_at > now())
|
||
ORDER BY
|
||
CASE WHEN t.action_code IN ('ASSOCIATE_OPPORTUNITY','REVIEW_ASSOCIATION','LINK_DOCUMENT','REVIEW_RECONSTRUCTED_PROCESS') THEN 0 ELSE 1 END,
|
||
t.due_at NULLS LAST, t.created_at
|
||
LIMIT 1
|
||
) AS pending_primary_action,
|
||
(
|
||
SELECT t.action_code FROM tasks t
|
||
WHERE t.opportunity_id = o.id AND t.status = 'pending'
|
||
AND (t.action_code LIKE 'FOLLOW_UP_%' OR t.action_code IN ('CONFIRM_DELIVERY','RECOVER_OPPORTUNITY','REVIEW_NURTURE'))
|
||
ORDER BY t.due_at NULLS LAST, t.created_at DESC LIMIT 1
|
||
) AS pending_follow_up_action_code,
|
||
(
|
||
SELECT t.action FROM tasks t
|
||
WHERE t.opportunity_id = o.id AND t.status = 'pending'
|
||
AND (t.action_code LIKE 'FOLLOW_UP_%' OR t.action_code IN ('CONFIRM_DELIVERY','RECOVER_OPPORTUNITY','REVIEW_NURTURE'))
|
||
ORDER BY t.due_at NULLS LAST, t.created_at DESC LIMIT 1
|
||
) AS pending_follow_up_action
|
||
FROM opportunities o
|
||
LEFT JOIN customers c ON c.id = o.local_customer_id
|
||
WHERE o.id = CAST(:opportunity_id AS UUID)
|
||
LIMIT 1
|
||
""")
|
||
with engine.begin() as conn:
|
||
row = conn.execute(sql, {"opportunity_id": opportunity_id}).mappings().first()
|
||
return dict(row) if row else None
|
||
|
||
|
||
def list_opportunity_tasks(opportunity_id: str, *, limit: int = 100) -> List[Dict[str, Any]]:
|
||
ensure_opportunity_schema()
|
||
sql = text("""
|
||
SELECT
|
||
id::text,
|
||
created_at,
|
||
updated_at,
|
||
action_code,
|
||
route,
|
||
action,
|
||
note,
|
||
status,
|
||
priority,
|
||
due_at,
|
||
conversation_id,
|
||
contact_id,
|
||
done_at,
|
||
done_by,
|
||
metadata
|
||
FROM tasks
|
||
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
||
ORDER BY created_at DESC
|
||
LIMIT :limit
|
||
""")
|
||
with engine.begin() as conn:
|
||
rows = conn.execute(sql, {"opportunity_id": opportunity_id, "limit": int(limit)}).mappings().all()
|
||
return [dict(row) for row in rows]
|
||
|
||
|
||
def list_opportunity_events(opportunity_id: str, *, limit: int = 100) -> List[Dict[str, Any]]:
|
||
ensure_opportunity_schema()
|
||
sql = text("""
|
||
SELECT
|
||
id::text,
|
||
opportunity_id::text,
|
||
event_type,
|
||
task_id::text,
|
||
action_code,
|
||
from_stage,
|
||
to_stage,
|
||
note,
|
||
payload,
|
||
created_by,
|
||
created_at
|
||
FROM opportunity_events
|
||
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
||
ORDER BY created_at DESC
|
||
LIMIT :limit
|
||
""")
|
||
with engine.begin() as conn:
|
||
rows = conn.execute(sql, {"opportunity_id": opportunity_id, "limit": int(limit)}).mappings().all()
|
||
return [dict(row) for row in rows]
|
||
|
||
|
||
|
||
|
||
def archive_spam_opportunity_if_safe(
|
||
opportunity_id: str,
|
||
*,
|
||
reason: str = "spam",
|
||
actor: str = "system",
|
||
source_task_id: Optional[str] = None,
|
||
) -> Dict[str, Any]:
|
||
"""Archive a spam/false-positive opportunity without counting it as LOST.
|
||
|
||
This is intentionally conservative: it refuses to archive opportunities that
|
||
already have Jasmin/Odoo evidence. Spam must disappear from open boards and
|
||
funnel statistics, but the raw task/communication history remains auditable.
|
||
"""
|
||
ensure_opportunity_schema()
|
||
opportunity_id = str(opportunity_id or "").strip()
|
||
if not opportunity_id:
|
||
return {"ok": False, "reason": "missing_opportunity_id"}
|
||
|
||
metadata_patch = {
|
||
"archived_reason": reason or "spam",
|
||
"archived_by": actor,
|
||
"archived_via": "archive_spam_opportunity_if_safe",
|
||
"exclude_from_funnel": True,
|
||
}
|
||
if source_task_id:
|
||
metadata_patch["archived_source_task_id"] = str(source_task_id)
|
||
|
||
with engine.begin() as conn:
|
||
current = conn.execute(text("""
|
||
SELECT id::text, stage, status, metadata
|
||
FROM opportunities
|
||
WHERE id = CAST(:opportunity_id AS UUID)
|
||
LIMIT 1
|
||
"""), {"opportunity_id": opportunity_id}).mappings().first()
|
||
if not current:
|
||
return {"ok": False, "reason": "opportunity_not_found"}
|
||
|
||
if str(current.get("status") or "").lower() == "archived" or str(current.get("stage") or "").upper() == "ARCHIVED":
|
||
return {"ok": True, "status": "already_archived", "opportunity_id": opportunity_id}
|
||
|
||
counts = conn.execute(text("""
|
||
SELECT
|
||
(SELECT COUNT(*) FROM commercial_documents
|
||
WHERE opportunity_id = CAST(:opportunity_id AS UUID)) AS docs,
|
||
(SELECT COUNT(*) FROM reconciliation_items
|
||
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
||
AND source_system IN ('jasmin','odoo')) AS linked_external,
|
||
(SELECT COUNT(*) FROM operation_links
|
||
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
||
AND system IN ('jasmin','odoo','packlink')) AS operation_links,
|
||
(SELECT COUNT(*) FROM tasks
|
||
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
||
AND status = 'pending') AS pending_tasks
|
||
"""), {"opportunity_id": opportunity_id}).mappings().first()
|
||
docs = int(counts.get("docs") or 0)
|
||
linked_external = int(counts.get("linked_external") or 0)
|
||
operation_links = int(counts.get("operation_links") or 0)
|
||
if docs or linked_external or operation_links:
|
||
return {
|
||
"ok": False,
|
||
"reason": "has_commercial_or_external_evidence",
|
||
"docs": docs,
|
||
"linked_external": linked_external,
|
||
"operation_links": operation_links,
|
||
}
|
||
|
||
ignored_tasks = conn.execute(text("""
|
||
UPDATE tasks
|
||
SET status = CASE WHEN status = 'pending' THEN 'ignored' ELSE status END,
|
||
done_at = CASE WHEN status = 'pending' THEN COALESCE(done_at, now()) ELSE done_at END,
|
||
done_by = CASE WHEN status = 'pending' THEN CAST(:actor AS TEXT) ELSE done_by END,
|
||
note = COALESCE(note, '') || E'\n\nArquivada: oportunidade classificada como spam/falso positivo.',
|
||
metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:task_metadata AS JSONB),
|
||
updated_at = now()
|
||
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
||
AND status = 'pending'
|
||
RETURNING id::text
|
||
"""), {
|
||
"opportunity_id": opportunity_id,
|
||
"actor": actor,
|
||
"task_metadata": _json({
|
||
"auto_ignored_by_spam_archive": True,
|
||
"archive_reason": reason or "spam",
|
||
"archive_actor": actor,
|
||
}),
|
||
}).mappings().all()
|
||
|
||
conn.execute(text("""
|
||
UPDATE opportunities
|
||
SET stage = 'ARCHIVED',
|
||
status = 'archived',
|
||
closed_at = COALESCE(closed_at, now()),
|
||
metadata = COALESCE(metadata, '{}'::jsonb)
|
||
|| CAST(:metadata AS JSONB)
|
||
|| jsonb_build_object('archived_at', now()),
|
||
updated_at = now()
|
||
WHERE id = CAST(:opportunity_id AS UUID)
|
||
"""), {
|
||
"opportunity_id": opportunity_id,
|
||
"metadata": _json(metadata_patch),
|
||
})
|
||
|
||
conn.execute(text("""
|
||
INSERT INTO opportunity_events (
|
||
id, opportunity_id, event_type, task_id, from_stage, to_stage,
|
||
note, payload, created_by
|
||
) VALUES (
|
||
CAST(:id AS UUID), CAST(:opportunity_id AS UUID), 'opportunity_archived',
|
||
CASE WHEN :source_task_id = '' THEN NULL ELSE CAST(:source_task_id AS UUID) END,
|
||
CAST(:from_stage AS TEXT), 'ARCHIVED', CAST(:note AS TEXT), CAST(:payload AS JSONB), CAST(:actor AS TEXT)
|
||
)
|
||
"""), {
|
||
"id": str(uuid.uuid4()),
|
||
"opportunity_id": opportunity_id,
|
||
"source_task_id": str(source_task_id or ""),
|
||
"from_stage": str(current.get("stage") or ""),
|
||
"note": reason or "Arquivada como spam/falso positivo.",
|
||
"payload": _json({
|
||
"reason": reason or "spam",
|
||
"excluded_from_funnel": True,
|
||
"ignored_task_ids": [row["id"] for row in ignored_tasks],
|
||
}),
|
||
"actor": actor,
|
||
})
|
||
|
||
return {
|
||
"ok": True,
|
||
"status": "archived",
|
||
"opportunity_id": opportunity_id,
|
||
"ignored_tasks": len(ignored_tasks),
|
||
}
|
||
|
||
def set_opportunity_stage(
|
||
opportunity_id: str,
|
||
stage: str,
|
||
*,
|
||
note: str = "",
|
||
created_by: str = "operator",
|
||
) -> bool:
|
||
ensure_opportunity_schema()
|
||
stage = str(stage or "").strip().upper()
|
||
if stage not in OPPORTUNITY_STAGE_LABELS:
|
||
raise ValueError(f"Unsupported opportunity stage: {stage}")
|
||
|
||
with engine.begin() as conn:
|
||
current = conn.execute(text("""
|
||
SELECT stage, status FROM opportunities WHERE id = CAST(:opportunity_id AS UUID)
|
||
"""), {"opportunity_id": opportunity_id}).mappings().first()
|
||
if not current:
|
||
return False
|
||
old_stage = str(current.get("stage") or "NEW_LEAD")
|
||
if _is_dangerous_stage_regression(old_stage, stage):
|
||
raise ValueError(
|
||
f"Transição perigosa bloqueada: {old_stage} -> {stage}. "
|
||
"Reabre a oportunidade explicitamente antes de regressar a fases comerciais."
|
||
)
|
||
status = "archived" if stage == "ARCHIVED" else ("closed" if stage in {"WON", "LOST", "NO_INTEREST", "DELIVERED"} else "open")
|
||
conn.execute(text("""
|
||
UPDATE opportunities
|
||
SET stage = CAST(:stage AS TEXT),
|
||
status = CAST(:status AS TEXT),
|
||
closed_at = CASE WHEN CAST(:status AS TEXT) IN ('closed','archived') THEN COALESCE(closed_at, now()) ELSE NULL END,
|
||
lifecycle_state = CASE WHEN CAST(:status AS TEXT) IN ('closed','archived') THEN 'active' ELSE lifecycle_state END,
|
||
next_follow_up_at = CASE WHEN CAST(:status AS TEXT) IN ('closed','archived') THEN NULL ELSE next_follow_up_at END,
|
||
nurture_until = CASE WHEN CAST(:status AS TEXT) IN ('closed','archived') THEN NULL ELSE nurture_until END,
|
||
updated_at = now(),
|
||
metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata_patch AS JSONB)
|
||
WHERE id = CAST(:opportunity_id AS UUID)
|
||
"""), {
|
||
"opportunity_id": opportunity_id,
|
||
"stage": stage,
|
||
"status": status,
|
||
"created_by": created_by,
|
||
"metadata_patch": _json({"manual_stage_changed_at": "now", "manual_stage_changed_by": created_by}),
|
||
})
|
||
if status in {"closed", "archived"}:
|
||
close_pending_tasks_for_terminal_stage(conn, opportunity_id, stage=stage, actor=created_by)
|
||
conn.execute(text("""
|
||
INSERT INTO opportunity_events (
|
||
id, opportunity_id, event_type, from_stage, to_stage, note, payload, created_by
|
||
) VALUES (
|
||
CAST(:id AS UUID), CAST(:opportunity_id AS UUID), 'manual_stage_changed',
|
||
CAST(:from_stage AS TEXT), CAST(:to_stage AS TEXT), CAST(:note AS TEXT), '{}'::jsonb, CAST(:created_by AS TEXT)
|
||
)
|
||
"""), {
|
||
"id": str(uuid.uuid4()),
|
||
"opportunity_id": opportunity_id,
|
||
"from_stage": old_stage,
|
||
"to_stage": stage,
|
||
"note": note or f"Estado alterado manualmente para {stage_label(stage)}.",
|
||
"created_by": created_by,
|
||
})
|
||
if status == "closed":
|
||
try:
|
||
from app.followup_service import cancel_pending_followups_for_opportunity
|
||
|
||
cancel_pending_followups_for_opportunity(
|
||
opportunity_id=opportunity_id,
|
||
reason=f"manual_stage_closed:{stage}",
|
||
created_by=created_by,
|
||
)
|
||
except Exception:
|
||
pass
|
||
return True
|