Release v4928.1.4.2 stable
This commit is contained in:
322
app/communication_service.py
Normal file
322
app/communication_service.py
Normal file
@@ -0,0 +1,322 @@
|
||||
"""Communication/inbox-classification helpers for ClientFlow v4.5.
|
||||
|
||||
A communication is the durable record of an inbound/outbound email, Chatwoot
|
||||
message or other customer message. Tasks and outbox items are the actions that
|
||||
come from it; this module deliberately keeps the original communication separate
|
||||
from human work and automation work.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db import engine
|
||||
|
||||
|
||||
COMMUNICATION_STATUSES = {
|
||||
"new",
|
||||
"classified",
|
||||
"needs_review",
|
||||
"linked",
|
||||
"task_created",
|
||||
"done",
|
||||
"ignored",
|
||||
}
|
||||
|
||||
|
||||
ACTION_CLASSIFICATION_MAP: dict[str, tuple[str, str, str]] = {
|
||||
"pedido_orcamento": ("Comercial", "Criar/validar oportunidade", "cf-chip-blue"),
|
||||
"pedido_informacao": ("Comercial", "Responder pedido de informação", "cf-chip-blue"),
|
||||
"aceitacao_orcamento": ("Financeiro", "Converter em fatura/pró-forma", "cf-chip-green"),
|
||||
"comprovativo_pagamento": ("Financeiro", "Confirmar pagamento", "cf-chip-green"),
|
||||
"pedido_fatura": ("Financeiro", "Emitir/enviar fatura", "cf-chip-green"),
|
||||
"dados_fiscais": ("Financeiro", "Atualizar dados fiscais", "cf-chip-green"),
|
||||
"pedido_tracking": ("Operações", "Verificar envio/tracking", "cf-chip-purple"),
|
||||
"reclamacao": ("Suporte", "Responder reclamação", "cf-chip-orange"),
|
||||
"pedido_remocao_lista": ("Marketing", "Remover contacto da lista", "cf-chip-gray"),
|
||||
}
|
||||
|
||||
|
||||
def ensure_communication_schema() -> None:
|
||||
"""Create/upgrade v4.5 communications and timeline tables.
|
||||
|
||||
The statements are additive to be safe on field deployments.
|
||||
"""
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("CREATE EXTENSION IF NOT EXISTS pgcrypto"))
|
||||
|
||||
conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS communications (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
source_system TEXT NOT NULL DEFAULT 'email',
|
||||
source_message_id TEXT,
|
||||
thread_id TEXT,
|
||||
conversation_id TEXT,
|
||||
contact_id TEXT,
|
||||
direction TEXT NOT NULL DEFAULT 'inbound',
|
||||
sender_name TEXT,
|
||||
sender_email TEXT,
|
||||
recipient TEXT,
|
||||
subject TEXT,
|
||||
body TEXT,
|
||||
classification TEXT,
|
||||
confidence NUMERIC(4,3),
|
||||
status TEXT NOT NULL DEFAULT 'new',
|
||||
customer_id UUID,
|
||||
opportunity_id UUID,
|
||||
task_id UUID,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
"""))
|
||||
|
||||
for statement in [
|
||||
"ALTER TABLE communications ADD COLUMN IF NOT EXISTS source_system TEXT NOT NULL DEFAULT 'email'",
|
||||
"ALTER TABLE communications ADD COLUMN IF NOT EXISTS source_message_id TEXT",
|
||||
"ALTER TABLE communications ADD COLUMN IF NOT EXISTS thread_id TEXT",
|
||||
"ALTER TABLE communications ADD COLUMN IF NOT EXISTS conversation_id TEXT",
|
||||
"ALTER TABLE communications ADD COLUMN IF NOT EXISTS contact_id TEXT",
|
||||
"ALTER TABLE communications ADD COLUMN IF NOT EXISTS direction TEXT NOT NULL DEFAULT 'inbound'",
|
||||
"ALTER TABLE communications ADD COLUMN IF NOT EXISTS sender_name TEXT",
|
||||
"ALTER TABLE communications ADD COLUMN IF NOT EXISTS sender_email TEXT",
|
||||
"ALTER TABLE communications ADD COLUMN IF NOT EXISTS recipient TEXT",
|
||||
"ALTER TABLE communications ADD COLUMN IF NOT EXISTS subject TEXT",
|
||||
"ALTER TABLE communications ADD COLUMN IF NOT EXISTS body TEXT",
|
||||
"ALTER TABLE communications ADD COLUMN IF NOT EXISTS classification TEXT",
|
||||
"ALTER TABLE communications ADD COLUMN IF NOT EXISTS confidence NUMERIC(4,3)",
|
||||
"ALTER TABLE communications ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'new'",
|
||||
"ALTER TABLE communications ADD COLUMN IF NOT EXISTS customer_id UUID",
|
||||
"ALTER TABLE communications ADD COLUMN IF NOT EXISTS opportunity_id UUID",
|
||||
"ALTER TABLE communications ADD COLUMN IF NOT EXISTS task_id UUID",
|
||||
"ALTER TABLE communications ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'::jsonb",
|
||||
"ALTER TABLE communications ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT now()",
|
||||
"ALTER TABLE communications ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now()",
|
||||
]:
|
||||
conn.execute(text(statement))
|
||||
|
||||
conn.execute(text("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ux_communications_source_message
|
||||
ON communications(source_system, source_message_id)
|
||||
WHERE source_message_id IS NOT NULL
|
||||
"""))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_communications_created ON communications(created_at DESC)"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_communications_status ON communications(status)"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_communications_classification ON communications(classification)"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_communications_sender_email ON communications(sender_email)"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_communications_customer ON communications(customer_id)"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_communications_opportunity ON communications(opportunity_id)"))
|
||||
|
||||
# v4.5 task context columns. Existing code still uses action/route; these
|
||||
# columns let the UI connect a task back to the communication/document/etc.
|
||||
for statement in [
|
||||
"ALTER TABLE tasks ADD COLUMN IF NOT EXISTS communication_id UUID",
|
||||
"ALTER TABLE tasks ADD COLUMN IF NOT EXISTS document_id UUID",
|
||||
"ALTER TABLE tasks ADD COLUMN IF NOT EXISTS shipment_id UUID",
|
||||
"ALTER TABLE tasks ADD COLUMN IF NOT EXISTS outbox_id UUID",
|
||||
"ALTER TABLE tasks ADD COLUMN IF NOT EXISTS priority TEXT NOT NULL DEFAULT 'normal'",
|
||||
"ALTER TABLE tasks ADD COLUMN IF NOT EXISTS assigned_to TEXT",
|
||||
]:
|
||||
conn.execute(text(statement))
|
||||
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_tasks_communication ON tasks(communication_id)"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_tasks_document ON tasks(document_id)"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_tasks_shipment ON tasks(shipment_id)"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_tasks_outbox ON tasks(outbox_id)"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority)"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_tasks_due_status ON tasks(status, due_at)"))
|
||||
|
||||
conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS timeline_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
opportunity_id UUID,
|
||||
customer_id UUID,
|
||||
event_type TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
source TEXT NOT NULL DEFAULT 'clientflow',
|
||||
related_type TEXT,
|
||||
related_id TEXT,
|
||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_by TEXT NOT NULL DEFAULT 'system',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
"""))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_timeline_opportunity ON timeline_events(opportunity_id, created_at DESC)"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_timeline_customer ON timeline_events(customer_id, created_at DESC)"))
|
||||
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_timeline_related ON timeline_events(related_type, related_id)"))
|
||||
|
||||
|
||||
def _row_dict(row: Any) -> Dict[str, Any]:
|
||||
return dict(row) if row is not None else {}
|
||||
|
||||
|
||||
def normalize_status(status: Optional[str]) -> Optional[str]:
|
||||
if not status:
|
||||
return None
|
||||
status = status.strip().lower()
|
||||
return status if status in COMMUNICATION_STATUSES else None
|
||||
|
||||
|
||||
def list_communications(
|
||||
*,
|
||||
status: Optional[str] = None,
|
||||
classification: Optional[str] = None,
|
||||
q: Optional[str] = None,
|
||||
opportunity_id: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""List classified inbox items with optional filters."""
|
||||
filters = []
|
||||
params: Dict[str, Any] = {"limit": int(limit)}
|
||||
if normalize_status(status):
|
||||
filters.append("c.status = :status")
|
||||
params["status"] = normalize_status(status)
|
||||
if classification:
|
||||
filters.append("c.classification = :classification")
|
||||
params["classification"] = classification
|
||||
if opportunity_id:
|
||||
filters.append("c.opportunity_id = CAST(:opportunity_id AS UUID)")
|
||||
params["opportunity_id"] = opportunity_id
|
||||
if q:
|
||||
filters.append("(c.sender_email ILIKE :q OR c.sender_name ILIKE :q OR c.subject ILIKE :q OR c.body ILIKE :q OR c.classification ILIKE :q)")
|
||||
params["q"] = f"%{q}%"
|
||||
where = "WHERE " + " AND ".join(filters) if filters else ""
|
||||
|
||||
with engine.begin() as conn:
|
||||
rows = conn.execute(text(f"""
|
||||
SELECT c.id::text, c.source_system, c.source_message_id, c.thread_id,
|
||||
c.conversation_id, c.contact_id, c.direction, c.sender_name,
|
||||
c.sender_email, c.recipient, c.subject, c.body, c.classification,
|
||||
c.confidence, c.status, c.customer_id::text, c.opportunity_id::text,
|
||||
c.task_id::text, c.metadata, c.created_at, c.updated_at,
|
||||
cu.name AS customer_name, o.title AS opportunity_title
|
||||
FROM communications c
|
||||
LEFT JOIN customers cu ON cu.id = c.customer_id
|
||||
LEFT JOIN opportunities o ON o.id = c.opportunity_id
|
||||
{where}
|
||||
ORDER BY c.created_at DESC
|
||||
LIMIT :limit
|
||||
"""), params).mappings().all()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def list_communications_for_opportunity(opportunity_id: str, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
return list_communications(opportunity_id=opportunity_id, limit=limit)
|
||||
|
||||
|
||||
def get_communication(communication_id: str) -> Optional[Dict[str, Any]]:
|
||||
with engine.begin() as conn:
|
||||
row = conn.execute(text("""
|
||||
SELECT c.id::text, c.source_system, c.source_message_id, c.thread_id,
|
||||
c.conversation_id, c.contact_id, c.direction, c.sender_name,
|
||||
c.sender_email, c.recipient, c.subject, c.body, c.classification,
|
||||
c.confidence, c.status, c.customer_id::text, c.opportunity_id::text,
|
||||
c.task_id::text, c.metadata, c.created_at, c.updated_at,
|
||||
cu.name AS customer_name, o.title AS opportunity_title
|
||||
FROM communications c
|
||||
LEFT JOIN customers cu ON cu.id = c.customer_id
|
||||
LEFT JOIN opportunities o ON o.id = c.opportunity_id
|
||||
WHERE c.id = CAST(:id AS UUID)
|
||||
"""), {"id": communication_id}).mappings().first()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def get_communications_summary() -> Dict[str, int]:
|
||||
with engine.begin() as conn:
|
||||
row = conn.execute(text("""
|
||||
SELECT
|
||||
COUNT(*)::int AS total,
|
||||
COUNT(*) FILTER (WHERE status IN ('new','classified','needs_review'))::int AS open,
|
||||
COUNT(*) FILTER (WHERE status = 'needs_review')::int AS needs_review,
|
||||
COUNT(*) FILTER (WHERE customer_id IS NULL AND direction = 'inbound')::int AS without_customer,
|
||||
COUNT(*) FILTER (WHERE opportunity_id IS NULL AND direction = 'inbound')::int AS without_opportunity,
|
||||
COUNT(*) FILTER (WHERE task_id IS NOT NULL)::int AS with_task,
|
||||
COUNT(*) FILTER (WHERE created_at >= now() - interval '24 hours')::int AS last_24h
|
||||
FROM communications
|
||||
""")).mappings().first()
|
||||
return {k: int(v or 0) for k, v in dict(row or {}).items()}
|
||||
|
||||
|
||||
def set_communication_status(communication_id: str, status: str) -> None:
|
||||
status = normalize_status(status)
|
||||
if not status:
|
||||
raise ValueError("Estado de comunicação inválido.")
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("""
|
||||
UPDATE communications
|
||||
SET status = :status, updated_at = now()
|
||||
WHERE id = CAST(:id AS UUID)
|
||||
"""), {"id": communication_id, "status": status})
|
||||
|
||||
|
||||
def link_communication_to_customer(communication_id: str, customer_id: Optional[str]) -> None:
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("""
|
||||
UPDATE communications
|
||||
SET customer_id = CASE WHEN :customer_id = '' THEN NULL ELSE CAST(:customer_id AS UUID) END,
|
||||
status = CASE WHEN status IN ('new','classified','needs_review') THEN 'linked' ELSE status END,
|
||||
updated_at = now()
|
||||
WHERE id = CAST(:id AS UUID)
|
||||
"""), {"id": communication_id, "customer_id": customer_id or ""})
|
||||
|
||||
|
||||
def link_communication_to_opportunity(communication_id: str, opportunity_id: Optional[str]) -> None:
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("""
|
||||
UPDATE communications
|
||||
SET opportunity_id = CASE WHEN :opportunity_id = '' THEN NULL ELSE CAST(:opportunity_id AS UUID) END,
|
||||
status = CASE WHEN status IN ('new','classified','needs_review') THEN 'linked' ELSE status END,
|
||||
updated_at = now()
|
||||
WHERE id = CAST(:id AS UUID)
|
||||
"""), {"id": communication_id, "opportunity_id": opportunity_id or ""})
|
||||
|
||||
|
||||
def create_timeline_event(
|
||||
*,
|
||||
opportunity_id: Optional[str] = None,
|
||||
customer_id: Optional[str] = None,
|
||||
event_type: str,
|
||||
title: str,
|
||||
description: str = "",
|
||||
source: str = "clientflow",
|
||||
related_type: Optional[str] = None,
|
||||
related_id: Optional[str] = None,
|
||||
payload: Optional[Dict[str, Any]] = None,
|
||||
created_by: str = "system",
|
||||
) -> Optional[str]:
|
||||
if not opportunity_id and not customer_id:
|
||||
return None
|
||||
with engine.begin() as conn:
|
||||
row = conn.execute(text("""
|
||||
INSERT INTO timeline_events (
|
||||
opportunity_id, customer_id, event_type, title, description,
|
||||
source, related_type, related_id, payload, created_by
|
||||
) VALUES (
|
||||
CASE WHEN :opportunity_id = '' THEN NULL ELSE CAST(:opportunity_id AS UUID) END,
|
||||
CASE WHEN :customer_id = '' THEN NULL ELSE CAST(:customer_id AS UUID) END,
|
||||
:event_type, :title, :description, :source, :related_type,
|
||||
:related_id, CAST(:payload AS JSONB), :created_by
|
||||
)
|
||||
RETURNING id::text
|
||||
"""), {
|
||||
"opportunity_id": opportunity_id or "",
|
||||
"customer_id": customer_id or "",
|
||||
"event_type": event_type,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"source": source,
|
||||
"related_type": related_type,
|
||||
"related_id": related_id,
|
||||
"payload": json.dumps(payload or {}, ensure_ascii=False),
|
||||
"created_by": created_by,
|
||||
}).first()
|
||||
return str(row[0]) if row else None
|
||||
|
||||
|
||||
def classification_action(classification: Optional[str]) -> Dict[str, str]:
|
||||
key = (classification or "").strip().lower()
|
||||
queue, action, chip = ACTION_CLASSIFICATION_MAP.get(key, ("Revisão", "Rever classificação", "cf-chip-orange"))
|
||||
return {"queue": queue, "action": action, "chip": chip}
|
||||
Reference in New Issue
Block a user