Files
clientflow_backend/app/opportunity_service.py

1926 lines
80 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 couldnt 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