1138 lines
44 KiB
Python
1138 lines
44 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
|
||
|
||
|
||
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": "Venda Odoo criada",
|
||
"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",
|
||
}
|
||
|
||
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,
|
||
}
|
||
|
||
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"]),
|
||
]
|
||
|
||
# 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",
|
||
"CREATE_SHIPMENT": "SHIPMENT_CREATED",
|
||
"MARK_NO_INTEREST": "NO_INTEREST",
|
||
}
|
||
|
||
|
||
_SCHEMA_READY = False
|
||
|
||
|
||
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("CREATE INDEX IF NOT EXISTS idx_opportunities_stage ON opportunities(stage)"))
|
||
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 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 _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"}:
|
||
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,
|
||
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 _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") -> None:
|
||
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
|
||
|
||
# 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
|
||
|
||
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):
|
||
mark_task_opportunity_link_ambiguous(
|
||
task_id,
|
||
reason="multiple_recent_open_opportunities_for_chatwoot_contact",
|
||
created_by=created_by,
|
||
)
|
||
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")
|
||
new_stage = _advance_stage(old_stage, suggested_stage)
|
||
conn.execute(text("""
|
||
UPDATE opportunities
|
||
SET
|
||
title = COALESCE(NULLIF(title, ''), :title),
|
||
stage = :stage,
|
||
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,
|
||
"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, 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()), 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})
|
||
|
||
# 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 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()
|
||
suggested_stage = STAGE_ON_TASK_DONE.get(action_code)
|
||
if not suggested_stage:
|
||
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 = "closed" if new_stage in {"WON", "LOST", "NO_INTEREST"} 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"}),
|
||
})
|
||
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,
|
||
})
|
||
|
||
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
|
||
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
|
||
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,
|
||
conversation_id,
|
||
contact_id,
|
||
done_at,
|
||
done_by
|
||
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 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 FROM opportunities WHERE id = CAST(:opportunity_id AS UUID)
|
||
"""), {"opportunity_id": opportunity_id}).mappings().first()
|
||
if not current:
|
||
return False
|
||
old_stage = current.get("stage")
|
||
status = "closed" if stage in {"WON", "LOST", "NO_INTEREST"} 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) = 'closed' THEN COALESCE(closed_at, now()) ELSE NULL END,
|
||
updated_at = now(),
|
||
metadata = COALESCE(metadata, '{}'::jsonb) || jsonb_build_object('manual_stage_changed_at', now(), 'manual_stage_changed_by', CAST(:created_by AS TEXT))
|
||
WHERE id = CAST(:opportunity_id AS UUID)
|
||
"""), {"opportunity_id": opportunity_id, "stage": stage, "status": status, "created_by": 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,
|
||
})
|
||
return True
|