Files
clientflow_backend/operation_service.py

166 lines
13 KiB
Python

import json
import uuid
from typing import Any, Dict, List, Optional
from sqlalchemy import text
from app.db import engine
from app.config import settings
from app.opportunity_service import set_opportunity_stage
from app.workflow_guard import validate_operation_action
_SCHEMA_READY = False
OPERATION_ACTIONS: Dict[str, Dict[str, str]] = {
"jasmin_quotation": {"label": "Registar orçamento Jasmin", "system": "jasmin", "external_type": "quotation", "status": "created", "stage": "QUOTE_SENT", "note": "Orçamento criado/registado no Jasmin."},
"jasmin_proforma": {"label": "Registo legado de pró-forma", "system": "jasmin", "external_type": "proforma", "status": "issued", "stage": "QUOTE_SENT", "note": "Registo legado tratado como orçamento/pedido de pagamento."},
"payment_confirmed": {"label": "Registar pagamento confirmado", "system": "clientflow", "external_type": "payment", "status": "confirmed", "stage": "PAYMENT_CONFIRMED", "note": "Pagamento confirmado pelo operador."},
"odoo_sale_order": {"label": "Registar venda Odoo", "system": "odoo", "external_type": "sale_order", "status": "created", "stage": "ODOO_ORDER_CREATED", "note": "Venda/encomenda registada no Odoo."},
"odoo_production": {"label": "Registar produção Odoo", "system": "odoo", "external_type": "production", "status": "in_progress", "stage": "IN_PRODUCTION", "note": "Produção/preparação em curso no Odoo."},
"odoo_physical_validated": {"label": "Validar encomenda física", "system": "odoo", "external_type": "physical_validation", "status": "validated", "stage": "READY_TO_SHIP", "note": "Encomenda física validada no Odoo."},
"jasmin_invoice": {"label": "Registar fatura Jasmin", "system": "jasmin", "external_type": "invoice", "status": "issued", "stage": "INVOICED", "note": "Fatura emitida/registada no Jasmin."},
"packlink_shipment": {"label": "Registar envio Packlink", "system": "packlink", "external_type": "shipment", "status": "created", "stage": "SHIPMENT_CREATED", "note": "Envio criado/registado no Packlink."},
"tracking_sent": {"label": "Registar tracking enviado", "system": "clientflow", "external_type": "tracking", "status": "sent", "stage": "TRACKING_SENT", "note": "Tracking enviado ao cliente via Chatwoot/email."},
"delivered": {"label": "Registar entrega concluída", "system": "packlink", "external_type": "delivery", "status": "delivered", "stage": "WON", "note": "Processo entregue/concluído."},
}
OPERATION_CARDS: List[Dict[str, str]] = [
{"key": "payment", "label": "Pagamento", "system": "clientflow", "external_type": "payment", "empty": "Por confirmar"},
{"key": "odoo_sale_order", "label": "Venda Odoo", "system": "odoo", "external_type": "sale_order", "empty": "Não criada"},
{"key": "odoo_physical_status", "label": "Estado físico Odoo", "system": "odoo", "external_type": "physical_status", "empty": "Não sincronizado"},
{"key": "odoo_production", "label": "Produção Odoo", "system": "odoo", "external_type": "production", "empty": "Não iniciada"},
{"key": "physical_validation", "label": "Encomenda física", "system": "odoo", "external_type": "physical_validation", "empty": "A validar"},
{"key": "jasmin_quotation", "label": "Orçamento Jasmin", "system": "jasmin", "external_type": "quotation", "empty": "Não criado"},
{"key": "jasmin_invoice", "label": "Fatura Jasmin", "system": "jasmin", "external_type": "invoice", "empty": "Não emitida"},
{"key": "packlink_shipment", "label": "Envio Packlink", "system": "packlink", "external_type": "shipment", "empty": "Não criado"},
]
STATUS_LABELS = {
"not_created": "Não criado", "pending": "Pendente", "issued": "Emitida", "confirmed": "Confirmado", "created": "Criado", "converted": "Convertido", "superseded": "Substituído", "in_progress": "Em curso", "validated": "Validado", "sent": "Enviado", "delivered": "Entregue", "failed": "Falhou", "ready_to_ship": "Pronta para despacho", "waiting_stock": "A aguardar stock", "in_production": "Em produção", "shipped": "Expedida", "order_created": "Venda criada", "quote_only": "Cotação", "not_found": "Não encontrada", "no_order": "Sem venda", "unknown": "Desconhecido", "cancelled": "Cancelada",
}
def _json(value: Any) -> str:
return json.dumps(value or {}, ensure_ascii=False, default=str)
def ensure_operation_schema() -> None:
global _SCHEMA_READY
if _SCHEMA_READY:
return
with engine.begin() as conn:
conn.execute(text("""
CREATE TABLE IF NOT EXISTS operation_links (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
opportunity_id UUID NOT NULL REFERENCES opportunities(id) ON DELETE CASCADE,
system TEXT NOT NULL,
external_type TEXT NOT NULL,
external_id TEXT,
external_name TEXT,
external_url TEXT,
status TEXT NOT NULL DEFAULT 'pending',
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
last_synced_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""))
conn.execute(text("""CREATE UNIQUE INDEX IF NOT EXISTS ux_operation_links_key ON operation_links(opportunity_id, system, external_type)"""))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_operation_links_opportunity ON operation_links(opportunity_id)"))
conn.execute(text("CREATE INDEX IF NOT EXISTS idx_operation_links_system ON operation_links(system, external_type, status)"))
_SCHEMA_READY = True
def integration_settings_summary() -> Dict[str, Dict[str, Any]]:
return {
"odoo": {"enabled": bool(settings.odoo_enabled), "label": "Odoo", "public_url": settings.odoo_public_url or settings.odoo_base_url, "role": "Vendas, stock, produção e validação física"},
"jasmin": {"enabled": bool(settings.jasmin_enabled), "label": "Jasmin", "public_url": settings.jasmin_public_url or settings.jasmin_base_url, "role": "Orçamentos, faturas e documentos fiscais"},
"packlink": {"enabled": bool(settings.packlink_enabled), "label": "Packlink PRO", "public_url": settings.packlink_public_url or settings.packlink_base_url, "role": "Recolhas, envios, etiquetas e tracking"},
"chatwoot": {"enabled": bool(settings.chatwoot_base_url), "label": "Chatwoot", "public_url": settings.chatwoot_public_url or settings.chatwoot_base_url, "role": "Comunicação com cliente"},
}
def get_operation_links(opportunity_id: str) -> List[Dict[str, Any]]:
ensure_operation_schema()
with engine.begin() as conn:
rows = conn.execute(text("""
SELECT id::text, opportunity_id::text, system, external_type, external_id, external_name, external_url, status, payload, last_synced_at, created_at, updated_at
FROM operation_links
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
ORDER BY system, external_type
"""), {"opportunity_id": opportunity_id}).mappings().all()
return [dict(r) for r in rows]
def get_operation_snapshot(opportunity_id: str) -> Dict[str, Any]:
links = get_operation_links(opportunity_id)
by_key = {(x["system"], x["external_type"]): x for x in links}
cards = []
for card in OPERATION_CARDS:
link = by_key.get((card["system"], card["external_type"]))
if link:
status = link.get("status") or "pending"
cards.append({**card, **link, "status_label": STATUS_LABELS.get(status, status)})
else:
cards.append({**card, "status": "not_created", "status_label": card["empty"], "external_name": "", "external_url": ""})
return {"cards": cards, "links": links, "settings": integration_settings_summary(), "actions": OPERATION_ACTIONS}
def register_operation_action(opportunity_id: str, action_key: str, *, external_id: str = "", external_name: str = "", external_url: str = "", note: str = "", payload: Optional[Dict[str, Any]] = None, created_by: str = "operator") -> Dict[str, Any]:
ensure_operation_schema()
action = OPERATION_ACTIONS.get(str(action_key or ""))
if not action:
raise ValueError(f"Unsupported operation action: {action_key}")
validate_operation_action(opportunity_id, action_key)
system = action["system"]
external_type = action["external_type"]
status = action["status"]
stage = action["stage"]
note = note or action.get("note") or action.get("label") or action_key
external_name = external_name or action.get("label") or external_type
with engine.begin() as conn:
row = conn.execute(text("""
INSERT INTO operation_links (opportunity_id, system, external_type, external_id, external_name, external_url, status, payload, last_synced_at, updated_at)
VALUES (CAST(:opportunity_id AS UUID), CAST(:system AS TEXT), CAST(:external_type AS TEXT), NULLIF(CAST(:external_id AS TEXT), ''), NULLIF(CAST(:external_name AS TEXT), ''), NULLIF(CAST(:external_url AS TEXT), ''), CAST(:status AS TEXT), CAST(:payload AS JSONB), now(), now())
ON CONFLICT (opportunity_id, system, external_type)
DO UPDATE SET external_id = COALESCE(EXCLUDED.external_id, operation_links.external_id), external_name = COALESCE(EXCLUDED.external_name, operation_links.external_name), external_url = COALESCE(EXCLUDED.external_url, operation_links.external_url), status = EXCLUDED.status, payload = operation_links.payload || EXCLUDED.payload, last_synced_at = now(), updated_at = now()
RETURNING id::text, system, external_type, status
"""), {"opportunity_id": opportunity_id, "system": system, "external_type": external_type, "external_id": external_id or "", "external_name": external_name or "", "external_url": external_url or "", "status": status, "payload": _json({"action_key": action_key, "note": note, **(payload or {})})}).mappings().first()
conn.execute(text("""
INSERT INTO opportunity_events (id, opportunity_id, event_type, from_stage, to_stage, note, payload, created_by)
SELECT CAST(:id AS UUID), id, 'operation_action_registered', stage, CAST(:stage AS TEXT), CAST(:note AS TEXT), CAST(:payload AS JSONB), CAST(:created_by AS TEXT)
FROM opportunities WHERE id = CAST(:opportunity_id AS UUID)
"""), {"id": str(uuid.uuid4()), "opportunity_id": opportunity_id, "stage": stage, "note": note, "payload": _json({"action_key": action_key, "system": system, "external_type": external_type, "status": status}), "created_by": created_by})
set_opportunity_stage(opportunity_id, stage, note=note, created_by=created_by)
if str(action_key or "") == "payment_confirmed":
try:
from app.task_service import mark_obsolete_payment_followup_tasks
closed = mark_obsolete_payment_followup_tasks(
opportunity_id,
actor=created_by or "operator",
reason="Pagamento confirmado registado; fechar follow-ups/confirmações de pagamento pendentes.",
)
result = dict(row or {})
result["obsolete_tasks_closed"] = closed
return result
except Exception as exc:
print(f"ClientFlow obsolete payment task cleanup failed for {opportunity_id}: {exc}", flush=True)
return dict(row or {})
def operation_next_steps(snapshot: Dict[str, Any], stage: str) -> List[str]:
stage = str(stage or "")
status_by = {c["key"]: c.get("status") for c in snapshot.get("cards", [])}
steps: List[str] = []
if stage in {"QUOTE_SENT", "WAITING_PAYMENT"} and status_by.get("payment") != "confirmed":
steps.append("Confirmar pagamento com base no orçamento antes de emitir fatura e preparar envio.")
if stage in {"PAYMENT_CONFIRMED"} and status_by.get("jasmin_invoice") == "not_created":
steps.append("Emitir/registar fatura no Jasmin depois do pagamento confirmado.")
if stage in {"PAYMENT_CONFIRMED", "INVOICED"} and status_by.get("odoo_sale_order") == "not_created":
steps.append("Criar venda/encomenda no Odoo com os dados validados no ClientFlow.")
if stage in {"ODOO_ORDER_CREATED", "IN_PRODUCTION"} and status_by.get("physical_validation") != "validated":
steps.append("Aguardar produção/preparação e validar a encomenda física no Odoo.")
if stage in {"READY_TO_SHIP", "INVOICED"} and status_by.get("jasmin_invoice") != "issued":
steps.append("Emitir fatura no Jasmin antes do envio, salvo exceção operacional.")
if stage in {"READY_TO_SHIP", "INVOICED"} and status_by.get("packlink_shipment") != "created":
steps.append("Criar envio/recolha no Packlink quando a encomenda estiver pronta.")
if stage == "SHIPMENT_CREATED" and status_by.get("tracking") != "sent":
steps.append("Enviar tracking ao cliente pelo Chatwoot/email.")
if not steps:
steps.append("Fluxo sem bloqueios aparentes. Rever tarefas pendentes e próximos contactos.")
return steps