Files
clientflow_backend/app/opportunity_service.py
2026-06-09 22:55:58 +01:00

1138 lines
44 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
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 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",
"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