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 _commercial_document_operation_fallbacks(opportunity_id: str) -> Dict[tuple, Dict[str, Any]]: """Infer operation cards from linked commercial_documents when operation_links lag. Reconstructed opportunities often have commercial_documents imported from Jasmin but no matching operation_links. Without this fallback the flow card can show “Fatura ○” although a current invoice is visibly associated. """ try: with engine.begin() as conn: rows = conn.execute(text(""" SELECT document_kind, document_number, external_id, external_url, status, payload, updated_at FROM commercial_documents WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND COALESCE(is_active, TRUE) IS TRUE AND COALESCE(role, 'current') IN ('current','accepted','historical','history') AND document_kind IN ('quotation','quote','proforma','invoice') ORDER BY CASE WHEN COALESCE(role, 'current') IN ('current','accepted') THEN 0 ELSE 1 END, CASE WHEN document_kind = 'invoice' THEN 0 ELSE 1 END, COALESCE(updated_at, now()) DESC LIMIT 10 """), {"opportunity_id": opportunity_id}).mappings().all() except Exception: return {} fallbacks: Dict[tuple, Dict[str, Any]] = {} for row in rows: kind = str(row.get("document_kind") or "").lower() if kind == "invoice": key = ("jasmin", "invoice") status = "issued" elif kind in {"quotation", "quote", "proforma"}: key = ("jasmin", "quotation") status = "created" else: continue if key in fallbacks: continue name = row.get("document_number") or row.get("external_id") or "Documento Jasmin" fallbacks[key] = { "id": "", "opportunity_id": opportunity_id, "system": key[0], "external_type": key[1], "external_id": row.get("external_id") or row.get("document_number") or "", "external_name": name, "external_url": row.get("external_url") or "", "status": status, "payload": row.get("payload") or {"source": "commercial_documents_fallback"}, "last_synced_at": row.get("updated_at"), "created_at": row.get("updated_at"), "updated_at": row.get("updated_at"), } return fallbacks 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} commercial_fallbacks = _commercial_document_operation_fallbacks(opportunity_id) cards = [] for card in OPERATION_CARDS: link = by_key.get((card["system"], card["external_type"])) or commercial_fallbacks.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 "") == "odoo_sale_order": # Manual sales-number registration should close the pending PREPARE_ORDER task # and immediately try to read WH/OUT status from Odoo. This keeps the # opportunity detail coherent after the operator enters S00xxx. try: sale_ref = external_name or external_id or "" with engine.begin() as conn: conn.execute(text(""" UPDATE tasks SET status = 'done', note = COALESCE(note, '') || CAST(:note AS TEXT), updated_at = NOW() WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND action_code = 'PREPARE_ORDER' AND status = 'pending' """), { "opportunity_id": opportunity_id, "note": f"\n\nConcluída automaticamente: venda Odoo {sale_ref} associada." if sale_ref else "\n\nConcluída automaticamente: venda Odoo associada.", }) except Exception as exc: print(f"ClientFlow prepare-order task cleanup failed for {opportunity_id}: {exc}", flush=True) try: from app.odoo_service import sync_opportunity_odoo_status result = dict(row or {}) result["odoo_sync"] = sync_opportunity_odoo_status(opportunity_id) return result except Exception as exc: print(f"ClientFlow Odoo status sync after sale link failed for {opportunity_id}: {exc}", flush=True) 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": steps.append("Envio registado. Por agora, concluir quando fatura enviada, pagamento confirmado e WH/OUT concluído.") if not steps: steps.append("Fluxo sem bloqueios aparentes. Rever tarefas pendentes e próximos contactos.") return steps