"""Central decision service for opportunity next actions. v4.9.27 keeps this deliberately small and read-only: it does not replace existing task workflow yet, but gives UI/reconciliation a single vocabulary for what the operator should do next. """ from __future__ import annotations from dataclasses import dataclass, asdict from typing import Any, Dict, Optional from sqlalchemy import text from app.db import engine @dataclass class OpportunityNextAction: action_code: str label: str description: str priority: str = "normal" target_url: Optional[str] = None can_execute: bool = True reason_if_blocked: Optional[str] = None document_id: Optional[str] = None document_number: Optional[str] = None def to_dict(self) -> Dict[str, Any]: return asdict(self) def _first_row(conn: Any, sql: str, params: Dict[str, Any]) -> Optional[Dict[str, Any]]: row = conn.execute(text(sql), params).mappings().first() return dict(row) if row else None def get_opportunity_next_action(opportunity_id: str) -> Dict[str, Any]: """Return the recommended operator action for one opportunity. Priority order is intentionally conservative: 1. unresolved pending task; 2. missing fiscal customer; 3. pending reconciliation evidence; 4. Jasmin commercial document state; 5. generic follow-up. """ params = {"opportunity_id": opportunity_id} with engine.begin() as conn: opp = _first_row(conn, """ SELECT id::text, stage, status, title, fiscal_customer_id::text, customer_id::text, metadata FROM opportunities WHERE id = CAST(:opportunity_id AS UUID) """, params) if not opp: return OpportunityNextAction( action_code="NOT_FOUND", label="Oportunidade não encontrada", description="Não foi possível encontrar esta oportunidade.", priority="baixa", can_execute=False, reason_if_blocked="opportunity_not_found", ).to_dict() task = _first_row(conn, """ SELECT id::text, action_code, action, note, priority, route, due_at, created_at FROM tasks WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND status = 'pending' ORDER BY CASE COALESCE(priority, 'normal') WHEN 'alta' THEN 1 WHEN 'normal' THEN 2 WHEN 'baixa' THEN 3 ELSE 4 END, due_at NULLS LAST, created_at DESC LIMIT 1 """, params) if task: return OpportunityNextAction( action_code=str(task.get("action_code") or "OPEN_TASK"), label=str(task.get("action") or task.get("action_code") or "Abrir tarefa pendente"), description=str(task.get("note") or "Abrir a tarefa pendente para continuar o processo."), priority=str(task.get("priority") or "normal"), target_url=f"/tasks/{task.get('id')}", ).to_dict() if not opp.get("fiscal_customer_id"): return OpportunityNextAction( action_code="VALIDATE_FISCAL_CUSTOMER", label="Validar cliente fiscal", description="Antes de emitir documentos oficiais, confirma ou associa o cliente fiscal correto.", priority="alta", target_url=f"/opportunities/{opportunity_id}/fiscal-enrich", ).to_dict() candidate = _first_row(conn, """ SELECT id::text, source_system, external_type, document_number, title, confidence FROM reconciliation_items WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND status = 'open' ORDER BY confidence DESC NULLS LAST, created_at DESC LIMIT 1 """, params) if candidate: doc_ref = candidate.get("document_number") or candidate.get("title") or "documento encontrado" return OpportunityNextAction( action_code="RECONCILE_DOCUMENTS", label="Confirmar documento encontrado", description=f"Existe evidência por decidir: {doc_ref}.", priority="alta", target_url="/reconciliation", document_number=str(candidate.get("document_number") or "") or None, ).to_dict() current_doc = _first_row(conn, """ SELECT id::text, document_kind, document_number, status, total_amount, document_date, role FROM commercial_documents WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND system = 'jasmin' AND COALESCE(is_active, TRUE) = TRUE AND COALESCE(role, 'current') IN ('current', 'accepted') ORDER BY CASE document_kind WHEN 'invoice' THEN 1 WHEN 'proforma' THEN 2 WHEN 'quotation' THEN 3 ELSE 4 END, COALESCE(document_date, created_at::date) DESC, created_at DESC LIMIT 1 """, params) if not current_doc: return OpportunityNextAction( action_code="CREATE_JASMIN_QUOTE", label="Criar orçamento no Jasmin", description="A oportunidade tem cliente fiscal, mas ainda não tem orçamento Jasmin ligado.", priority="normal", target_url=f"/opportunities/{opportunity_id}#documentos", ).to_dict() kind = str(current_doc.get("document_kind") or "") number = current_doc.get("document_number") or "documento" if kind == "quotation": return OpportunityNextAction( action_code="SEND_PROFORMA", label="Avançar a partir do orçamento", description=f"Orçamento atual {number}. Se o cliente aceitou, emitir ou enviar pró-forma.", priority="normal", target_url=f"/opportunities/{opportunity_id}#documentos", document_id=current_doc.get("id"), document_number=number, ).to_dict() payment_confirmed = bool(_first_row(conn, """ SELECT id::text FROM operation_links WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND system = 'clientflow' AND external_type = 'payment' AND status = 'confirmed' LIMIT 1 """, params)) if kind == "proforma": return OpportunityNextAction( action_code="CONFIRM_PAYMENT", label="Confirmar pagamento", description=f"Pró-forma {number} ligada. Confirmar pagamento ou acompanhar o cliente.", priority="alta", target_url=f"/opportunities/{opportunity_id}#tasks", document_id=current_doc.get("id"), document_number=number, ).to_dict() if kind == "invoice": if not payment_confirmed: legacy_note = " Se for um registo antigo/reconstruído, rever pagamento e marcar como concluído quando validado." return OpportunityNextAction( action_code="CONFIRM_PAYMENT", label="Aguardar / confirmar pagamento", description=f"Fatura {number} ligada. Confirmar pagamento ou acompanhar o cliente." + legacy_note, priority="alta", target_url=f"/opportunities/{opportunity_id}#tasks", document_id=current_doc.get("id"), document_number=number, ).to_dict() return OpportunityNextAction( action_code="PREPARE_ORDER", label="Preparar encomenda / envio", description=f"Fatura {number} ligada e pagamento confirmado. Validar preparação, Odoo e envio.", priority="alta", target_url=f"/opportunities/{opportunity_id}#tasks", document_id=current_doc.get("id"), document_number=number, ).to_dict() return OpportunityNextAction( action_code="FOLLOW_UP", label="Acompanhar oportunidade", description="Processo sem bloqueios óbvios. Rever estado e próximo contacto.", priority="baixa", target_url=f"/opportunities/{opportunity_id}", ).to_dict()