from pathlib import Path import os from datetime import datetime, timezone import html import json from uuid import UUID from hmac import compare_digest from typing import Optional from fastapi import APIRouter, Request, Depends, HTTPException from fastapi.responses import HTMLResponse, RedirectResponse, PlainTextResponse, Response from starlette.concurrency import run_in_threadpool from app.admin_queries import list_action_runs, list_business_events from app.integration_outbox_service import get_outbox_item, list_outbox, set_outbox_status from app.config import settings from app.preparation_service import prepare_task as run_task_preparation from app.preparation_view_model import build_preparation_view_model from app.workflow_guard import OperationActionBlocked, get_workflow_action_plan from app.odoo_service import ( test_odoo_connection, sync_odoo_products, get_odoo_product_snapshot, sync_opportunity_odoo_status, ) from app.operation_service import get_operation_snapshot, operation_next_steps, register_operation_action from app.operations_service import get_operations_summary, get_system_health_summary, list_unified_opportunity_timeline from app.communication_service import ( classification_action, create_timeline_event, get_communication, get_communications_summary, link_communication_to_customer, link_communication_to_opportunity, list_communications, list_communications_for_opportunity, set_communication_status, ) from app.task_service import complete_task, complete_task_with_note, get_admin_dashboard_metrics, get_customer_profile, get_system_health_metrics, get_task_detail, get_latest_task_preparation, list_admin_recent_raw_events, list_admin_recent_tasks, list_customer_messages, list_customer_opportunity_mappings, list_customer_task_history, list_customer_tasks, list_tasks, skip_task, reclassify_task from app.opportunity_service import ( OPPORTUNITY_BOARD_COLUMNS, OPPORTUNITY_STAGE_LABELS, get_opportunity, list_opportunities, list_opportunity_events, list_opportunity_tasks, set_opportunity_stage, stage_label, ) from app.product_service import ( add_opportunity_item, create_product, delete_opportunity_item, get_product, list_opportunity_items, list_product_categories, list_products, set_product_active, update_opportunity_item, update_product, ) from app.admin_ui.components import kpi_card from app.admin_ui.layout import layout from app.admin_ui.styles import ADMIN_UI_V451_CSS # ADMIN_UI_CSS moved to app.admin_ui.styles in v4.7. # Route handlers moved to app.admin_ui.pages.* in v4.7.2. def require_admin_access(request: Request) -> None: """Proteção opcional da UI admin. Se CLIENTFLOW_ADMIN_TOKEN estiver vazio, mantém compatibilidade local. Em produção deve ser definido e enviado em X-ClientFlow-Admin-Token, cookie clientflow_admin_token, ou query param admin_token atrás de HTTPS/proxy. """ expected = (settings.clientflow_admin_token or "").strip() if not expected: return received = ( request.headers.get("X-ClientFlow-Admin-Token") or request.cookies.get("clientflow_admin_token") or request.query_params.get("admin_token") or "" ).strip() if not received or not compare_digest(received, expected): raise HTTPException(status_code=401, detail="admin auth required") router = APIRouter(prefix="", tags=["admin"], dependencies=[Depends(require_admin_access)]) def esc(value) -> str: return html.escape(str(value or "")) def chatwoot_conversation_url(conversation_id: object) -> str: conversation_id = str(conversation_id or "").strip() if not conversation_id: return "" public_url = ( getattr(settings, "chatwoot_public_url", "") or getattr(settings, "chatwoot_base_url", "") or "" ).rstrip("/") account_id = str(getattr(settings, "chatwoot_account_id", "") or "").strip() if not public_url or not account_id: return "" return f"{public_url}/app/accounts/{account_id}/conversations/{conversation_id}" def chatwoot_button(conversation_id: object, label: str = "Abrir Chatwoot") -> str: href = chatwoot_conversation_url(conversation_id) if not href: return "" return f' {esc(label)}' def shell_output(cmd: list[str], *, timeout: int = 8) -> str: try: import subprocess result = subprocess.run( cmd, capture_output=True, text=True, timeout=timeout, check=False, ) output = (result.stdout or "") + (result.stderr or "") return output.strip() except Exception as e: return f"erro ao executar {' '.join(cmd)}: {e!r}" def status_pill(ok: bool, label: str) -> str: cls = "pill-ok" if ok else "pill-bad" return f'{esc(label)}' def latest_backup_info() -> dict: backup_dir = Path(os.getenv("CLIENTFLOW_BACKUP_DIR", "./backups/clientflow")) files = sorted( backup_dir.glob("clientflow-*.sql.gz"), key=lambda x: x.stat().st_mtime if x.exists() else 0, reverse=True, ) if not files: return { "exists": False, "file": "", "size": "", "mtime": "", } f = files[0] stat = f.stat() return { "exists": True, "file": str(f), "size": f"{stat.st_size / 1024:.1f} KB", "mtime": datetime.fromtimestamp(stat.st_mtime).isoformat(timespec="seconds"), } def done_note_options_html_for(action_code: str) -> str: done_note_templates = { "SEND_INFO": [ "Informação enviada ao cliente no Chatwoot.", "Cliente informado por email.", "Informação comercial enviada; aguardar resposta.", ], "SEND_QUOTE": [ "Proposta/cotação enviada ao cliente.", "Cotação enviada no Chatwoot.", "Proposta enviada; aguardar confirmação do cliente.", ], "SEND_PROFORMA": [ "Fatura pró-forma emitida/enviada ao cliente.", "Pró-forma enviada; aguardar pagamento/confirmação.", ], "SEND_INVOICE": [ "Fatura enviada ao cliente.", "Cliente informado do envio da fatura.", ], "CONFIRM_PAYMENT": [ "Pagamento confirmado.", "Comprovativo validado; processo segue para operações se aplicável.", ], "SUPPORT": [ "Pedido de suporte respondido ou encaminhado.", "Cliente informado; suporte vai acompanhar o caso.", "Pedido encaminhado para análise.", ], "REMOVE_FROM_LIST": [ "Contacto removido da lista.", "Pedido de remoção tratado.", ], "REVIEW_MANUALLY": [ "Caso revisto manualmente.", "Sem ação automática; tratado manualmente.", ], "MARK_NO_INTEREST": [ "Marcado sem interesse atual.", "Cliente informou que não tem necessidade atual.", "Oportunidade encerrada/sem seguimento comercial por agora.", ], "NO_ACTION": [ "Sem ação necessária.", ], "IGNORE_SPAM": [ "Mensagem ignorada como spam.", ], } default_done_notes = [ "Tarefa concluída.", "Cliente informado no Chatwoot.", "Pedido tratado manualmente.", ] templates = done_note_templates.get(action_code or "", default_done_notes) return "".join( f'' for option in templates ) def suggested_reply_for_task(task: dict) -> str: action_code = task.get("action_code") or "" customer_name = task.get("customer_name") or "" first_name = str(customer_name).strip().split(" ")[0] if customer_name else "" greeting = f"Olá {first_name}," if first_name and first_name.lower() not in ["cliente", "desconhecido"] else "Olá," closing = "Obrigado,\nEquipa BLIF" templates = { "SEND_INFO": f"""{greeting} Obrigado pelo seu contacto. Segue informação sobre os nossos carregadores para veículos elétricos. Podemos ajudar com a escolha do modelo mais adequado, disponibilidade, condições de entrega e instalação. Caso pretenda, envie-nos por favor: - tipo de viatura; - local de instalação; - potência disponível; - se pretende carregador monofásico ou trifásico. {closing}""", "SEND_QUOTE": f"""{greeting} Obrigado pelo seu pedido. Vamos preparar/enviar a proposta para o carregador solicitado, incluindo preço, disponibilidade e condições de entrega. Se ainda não tiver indicado, confirme por favor: - modelo pretendido; - quantidade; - morada/localidade para entrega; - dados para faturação, se desejar avançar. {closing}""", "SEND_PROFORMA": f"""{greeting} Podemos emitir/enviar a fatura pró-forma. Para isso, envie por favor os dados de faturação: - nome/empresa; - NIF; - morada; - email para envio; - produto/quantidade pretendida. {closing}""", "SEND_INVOICE": f"""{greeting} Obrigado pela confirmação. Vamos enviar a fatura conforme solicitado. Caso ainda não tenha enviado os dados de faturação, envie por favor: - nome/empresa; - NIF; - morada; - email. {closing}""", "CONFIRM_PAYMENT": f"""{greeting} Obrigado pelo envio da informação/comprovativo. Vamos confirmar o pagamento e dar seguimento ao processo. Se for aplicável, encaminhamos também a encomenda para preparação/envio. Assim que tivermos atualização, informamos. {closing}""", "SUPPORT": f"""{greeting} Obrigado pelo contacto. Vamos encaminhar o seu pedido para suporte. Para ajudar na análise, envie por favor, se aplicável: - modelo do carregador/equipamento; - descrição do pedido/problema; - fotos/vídeos, se possível; - morada/local de instalação, entrega ou recolha; - contacto telefónico. {closing}""", "REMOVE_FROM_LIST": f"""{greeting} Confirmamos que vamos tratar o pedido de remoção da lista de contactos. {closing}""", "MARK_NO_INTEREST": f"""{greeting} Obrigado pela informação. Ficamos ao dispor caso no futuro venham a integrar veículos elétricos na frota ou necessitem de soluções de carregamento. {closing}""", "REVIEW_MANUALLY": f"""{greeting} Obrigado pela sua mensagem. Vamos analisar o pedido internamente e responder assim que possível. {closing}""", } return templates.get(action_code, f"""{greeting} Obrigado pela sua mensagem. Vamos analisar o pedido e responder assim que possível. {closing}""") ACTION_UI_LABELS = { "SEND_INFO": "Enviar informação", "SEND_QUOTE": "Enviar orçamento", "SEND_PROFORMA": "Enviar pró-forma", "SEND_INVOICE": "Enviar fatura", "CONFIRM_PAYMENT": "Confirmar pagamento", "SUPPORT": "Tratar suporte", "REMOVE_FROM_LIST": "Remover da lista", "MARK_NO_INTEREST": "Marcar sem interesse", "IGNORE_SPAM": "Ignorar spam", "REVIEW_MANUALLY": "Rever manualmente", "NO_ACTION": "Sem ação", } ACTION_HINTS = { "SEND_INFO": "Enviar informação geral e pedir os dados mínimos para recomendar o carregador certo.", "SEND_QUOTE": "Preparar/enviar orçamento com preço, disponibilidade, condições de entrega e dados necessários para avançar.", "SEND_PROFORMA": "Recolher dados de faturação e emitir/enviar a pró-forma.", "SEND_INVOICE": "Confirmar dados de faturação e enviar a fatura solicitada.", "CONFIRM_PAYMENT": "Validar pagamento/comprovativo e encaminhar para preparação/envio se aplicável.", "SUPPORT": "Responder ao pedido e recolher informação técnica mínima para análise.", "REMOVE_FROM_LIST": "Confirmar remoção do contacto de comunicações futuras.", "MARK_NO_INTEREST": "Cliente indicou ausência de interesse atual/necessidade após divulgação; não é o mesmo que oportunidade perdida por preço ou funcionalidades.", "IGNORE_SPAM": "Ignorar a mensagem e não criar seguimento comercial.", "REVIEW_MANUALLY": "Analisar manualmente porque a intenção não ficou suficientemente clara.", "NO_ACTION": "Não é necessária ação operacional.", } ACTION_MISSING_HINTS = { "SEND_INFO": ["potência pretendida", "tipo de instalação", "localidade", "contacto telefónico"], "SEND_QUOTE": ["modelo/produto", "quantidade", "morada/localidade", "dados de faturação se avançar"], "SEND_PROFORMA": ["nome/empresa", "NIF", "morada fiscal", "email de faturação", "produto/quantidade"], "SEND_INVOICE": ["nome/empresa", "NIF", "morada fiscal", "email de faturação"], "CONFIRM_PAYMENT": ["valor recebido", "referência/comprovativo", "morada de entrega", "contacto para entrega"], "SUPPORT": ["modelo", "descrição do problema", "fotos/vídeos", "local de instalação", "telefone"], "MARK_NO_INTEREST": ["motivo", "se é apenas falta de interesse atual", "se deve manter contacto para futuro"], } PIPELINE_STEPS = [ ("NEW_LEAD", "Novo pedido"), ("INFO_SENT", "Info enviada"), ("QUOTE_SENT", "Proposta"), ("PROFORMA_SENT", "Pró-forma"), ("PAYMENT_CONFIRMED", "Pagamento"), ("ODOO_ORDER_CREATED", "Odoo"), ("IN_PRODUCTION", "Produção"), ("READY_TO_SHIP", "Pronto"), ("SHIPMENT_CREATED", "Envio"), ("WON", "Concluído"), ("NO_INTEREST", "Sem interesse"), ] def action_label(code: str) -> str: code = str(code or "").strip().upper() return ACTION_UI_LABELS.get(code, code or "Tarefa") def compact_text(value, limit: int = 120) -> str: text = " ".join(str(value or "").split()) if len(text) > limit: return text[: max(0, limit - 1)].rstrip() + "…" return text def fmt_dt(value) -> str: """Formata datas/timestamps para leitura rápida na UI.""" if not value: return "—" try: if isinstance(value, str): dt = datetime.fromisoformat(value.replace("Z", "+00:00")) else: dt = value if getattr(dt, "tzinfo", None) is None: dt = dt.replace(tzinfo=timezone.utc) return dt.strftime("%Y-%m-%d %H:%M") except Exception: return compact_text(value, 32) def humanize_task_detail(value) -> str: detail = str(value or "").strip() lower = detail.casefold() if "resposta llm inválida" in lower or ("invalid" in lower and "action_code" in lower): return "Classificação da mensagem falhou. Rever no Chatwoot e escolher a ação correta." if "limpo manualmente" in lower or "resolvido manualmente" in lower: return "Item já limpo manualmente. Deve ficar no histórico, não na fila diária." return detail def task_next_action_text(task: dict) -> str: note = compact_text(humanize_task_detail(task.get("note") or task.get("action") or ""), 120) if note: return note code = str(task.get("action_code") or "") return action_label(code) def opportunity_contact_name(opportunity: dict) -> str: return str( opportunity.get("customer_name") or opportunity.get("customer_email") or opportunity.get("contact_id") or "Cliente" ).strip() def opportunity_customer_name(opportunity: dict) -> str: # Preferir a ficha fiscal ligada, porque é ela que será usada para Jasmin, # faturas e envios. O contacto original continua visível como origem. return str( opportunity.get("linked_customer_name") or opportunity.get("customer_name") or opportunity.get("customer_email") or opportunity.get("contact_id") or "Cliente" ).strip() def _norm_customer_text(value: object) -> str: return " ".join(str(value or "").strip().casefold().split()) def _customer_name_tokens(value: object) -> set[str]: text = _norm_customer_text(value) for ch in "-_,.;:/()[]{}+&|\n\t": text = text.replace(ch, " ") legal_suffixes = { "lda", "ltd", "sa", "s", "a", "unipessoal", "sociedade", "limitada", "empresa", "companhia", "pt", "portugal", "the", "and", "e", "de", "da", "do", "das", "dos", "para", "com", "ao", "aos", "as", "os", } tokens: set[str] = set() for token in text.split(): token = token.strip() if len(token) < 3 or token in legal_suffixes or "@" in token: continue tokens.add(token) # Prefixes reduce false positives with short/truncated labels such as # "Riotec elec" vs "Riotec - Electricidade, ...". if len(token) >= 4: tokens.add(token[:4]) return tokens def opportunity_customer_mismatch(opportunity: dict) -> bool: """Nome de contacto ≠ cliente fiscal não é um erro fiável. Ex.: contacto "Bruno Oliveira" pode representar a empresa fiscal "Nortuflex"; "Riotec elec" pode ser abreviação da entidade fiscal. A validação crítica deve focar NIF/morada/documentos, não semelhança de nomes. """ return False def opportunity_customer_context_html(opportunity: dict) -> str: linked = opportunity.get("linked_customer_name") original = opportunity.get("customer_name") or opportunity.get("customer_email") tax_id = opportunity.get("linked_customer_tax_id") linked_email = opportunity.get("linked_customer_email") if linked: html = f'
Cliente fiscal: {esc(linked)}' if tax_id: html += f' · NIF {esc(tax_id)}' html += '
' if opportunity_customer_mismatch(opportunity): html += f'
Contacto original: {esc(original or "—")}
' elif linked_email: html += f'
{esc(linked_email)}
' return html return f'
{esc(original or "Sem cliente fiscal associado")}
' def opportunity_next_action_text(opportunity: dict) -> str: pending_count = int(opportunity.get("pending_task_count") or 0) stage = str(opportunity.get("stage") or "NEW_LEAD") last_action = str(opportunity.get("last_action_code") or "") if pending_count: return "Concluir tarefa pendente" by_stage = { "NEW_LEAD": "Qualificar pedido", "INFO_REQUESTED": "Aguardar dados do cliente", "INFO_SENT": "Confirmar interesse", "QUOTE_REQUESTED": "Preparar proposta", "QUOTE_SENT": "Acompanhar decisão", "PROFORMA_REQUESTED": "Preparar pró-forma", "PROFORMA_SENT": "Aguardar pagamento", "INVOICE_REQUESTED": "Emitir fatura", "INVOICE_SENT": "Aguardar pagamento", "WAITING_PAYMENT": "Confirmar pagamento", "PAYMENT_CONFIRMED": "Preparar encomenda", "ORDER_PREPARATION": "Preparar material/envio", "ODOO_ORDER_CREATED": "Validar estado Odoo", "IN_PRODUCTION": "Acompanhar produção", "READY_TO_SHIP": "Criar envio", "INVOICED": "Criar/validar envio", "SHIPMENT_CREATED": "Enviar tracking", "SHIPPED": "Acompanhar entrega", "TRACKING_SENT": "Acompanhar entrega", "DELIVERED": "Fechar oportunidade", "WON": "Concluída", "LOST": "Perdida", "NO_INTEREST": "Sem interesse", "REVIEW": "Rever manualmente", } if last_action: return by_stage.get(stage, action_label(last_action)) return by_stage.get(stage, "Acompanhar oportunidade") def opportunity_priority_chip(opportunity: dict) -> str: pending = int(opportunity.get("pending_task_count") or 0) stage = str(opportunity.get("stage") or "") if pending: return 'Requer ação' if stage in {"WAITING_PAYMENT", "PAYMENT_CONFIRMED", "ORDER_PREPARATION", "READY_TO_SHIP"}: return 'Prioritária' if stage in {"WON", "LOST", "NO_INTEREST"}: return 'Fechada' return 'Normal' def is_uuid_text(value: object) -> bool: try: UUID(str(value or "")) return True except Exception: return False def metadata_dict(value) -> dict: if isinstance(value, dict): return value if isinstance(value, str) and value.strip(): try: parsed = json.loads(value) return parsed if isinstance(parsed, dict) else {} except Exception: return {} return {} def opportunity_id_from_task(task: dict) -> str: meta = metadata_dict(task.get("metadata")) return str(task.get("opportunity_id") or meta.get("opportunity_id") or "").strip() def customer_display(task: dict) -> str: return str(task.get("customer_name") or task.get("customer_email") or task.get("contact_id") or task.get("customer_id") or "Cliente").strip() def action_recommendation_html(action_code: str) -> str: action_code = str(action_code or "").upper() hint = ACTION_HINTS.get(action_code, "Executar a ação indicada e atualizar o estado da tarefa.") missing = ACTION_MISSING_HINTS.get(action_code, []) missing_html = "" if missing: missing_html = "
Pedir se faltar: " + ", ".join(esc(x) for x in missing) + ".
" return f"""
{esc(hint)}
{missing_html}
""" def stage_progress_html(current_stage: str) -> str: rank = OPPORTUNITY_STAGE_LABELS current = str(current_stage or "NEW_LEAD") stage_order = [x[0] for x in PIPELINE_STEPS] current_index = 0 for idx, stage in enumerate(stage_order): if stage == current: current_index = idx break if stage in {"NEW_LEAD", "INFO_SENT", "QUOTE_SENT", "WAITING_PAYMENT", "ORDER_PREPARATION", "SHIPPED", "WON"}: pass # aproxima estados intermédios para o passo visual mais próximo stage_to_step = { "NEW_LEAD": 0, "INFO_REQUESTED": 0, "INFO_SENT": 1, "QUOTE_REQUESTED": 1, "QUOTE_SENT": 2, "PROFORMA_REQUESTED": 2, "PROFORMA_SENT": 3, "INVOICE_REQUESTED": 2, "INVOICE_SENT": 3, "WAITING_PAYMENT": 3, "PAYMENT_CONFIRMED": 4, "ODOO_ORDER_CREATED": 5, "ORDER_PREPARATION": 5, "IN_PRODUCTION": 6, "READY_TO_SHIP": 7, "INVOICED": 7, "SHIPMENT_CREATED": 8, "SHIPPED": 8, "TRACKING_SENT": 8, "DELIVERED": 9, "WON": 9, "LOST": 9, "NO_INTEREST": 9, "REVIEW": 0, } current_index = stage_to_step.get(current, 0) items = "" for idx, (stage, label) in enumerate(PIPELINE_STEPS): css = "done" if idx < current_index else ("active" if idx == current_index else "") items += f"
{esc(label)}
" return f"
{items}
" def opportunity_quick_actions_html(opportunity_id: str) -> str: return "" def operation_status_badge(status: str) -> str: value = str(status or "not_created").strip() normalized = { "0": "draft", "1": "open", "2": "completed", "3": "closed", "open": "open", "completed": "completed", "complete": "completed", "closed": "closed", "converted": "converted", }.get(value.casefold(), value.casefold()) cls = { "not_created": "cf-chip-gray", "pending": "cf-chip-orange", "processing": "cf-chip-purple", "created": "cf-chip-blue", "open": "cf-chip-blue", "draft": "cf-chip-gray", "completed": "cf-chip-green", "closed": "cf-chip-gray", "converted": "cf-chip-green", "issued": "cf-chip-green", "confirmed": "cf-chip-green", "validated": "cf-chip-green", "in_progress": "cf-chip-orange", "sent": "cf-chip-green", "delivered": "cf-chip-green", "failed": "cf-chip-red", "blocked": "cf-chip-red", "dry_run": "cf-chip-gray", "ignored": "cf-chip-gray", "cancelled": "cf-chip-gray", }.get(normalized, "cf-chip-gray") label = { "not_created": "Não criado", "pending": "Pendente", "processing": "A processar", "created": "Criado", "issued": "Emitida", "open": "Aberto", "draft": "Rascunho", "completed": "Concluído", "closed": "Fechado", "converted": "Convertido", "confirmed": "Confirmado", "validated": "Validado", "in_progress": "Em curso", "sent": "Processado", "delivered": "Entregue", "failed": "Falhou", "blocked": "Bloqueado", "dry_run": "Dry-run", "ignored": "Ignorado", "cancelled": "Cancelado", }.get(normalized, value) return f'{esc(label)}' def commercial_document_display_number(doc: dict | None, *, fallback: str = "sem número") -> str: """Return a human commercial document number without exposing UUIDs. Imported Jasmin documents can temporarily have only a UUID/internal id. That is useful for diagnostics, but confusing and unsafe as a commercial number. """ doc = doc or {} parts = " ".join([ str(doc.get("document_type") or "").strip(), str(doc.get("serie") or "").strip(), str(doc.get("series_number") or "").strip(), ]).strip() for value in (doc.get("document_number"), parts, doc.get("external_name"), doc.get("external_ref")): text_value = str(value or "").strip() if text_value and not is_uuid_text(text_value): return text_value return fallback def should_hide_regressive_quotation_hint(opportunity: dict | None, snapshot: dict | None, next_action: dict | None) -> bool: """Avoid suggesting quote creation in later commercial/fulfilment phases.""" opportunity = opportunity or {} snapshot = snapshot or {} next_action = next_action or {} action_key = str(next_action.get("action_key") or "").strip() label = str(next_action.get("label") or "").strip().casefold() if action_key != "jasmin_quotation" and "criar orçamento" not in label: return False stage = str(opportunity.get("stage") or "").strip().upper() late_stages = { "QUOTE_SENT", "PROFORMA_SENT", "INVOICE_SENT", "WAITING_PAYMENT", "PAYMENT_CONFIRMED", "ORDER_CONFIRMED", "IN_PRODUCTION", "READY_TO_SHIP", "SHIPMENT_CREATED", "SHIPPED", "TRACKING_SENT", "DELIVERED", "WON", "LOST", "NO_INTEREST", } if stage in late_stages: return True document_keys = {"jasmin_quotation", "jasmin_proforma", "jasmin_invoice"} for card in snapshot.get("cards") or []: key = str(card.get("key") or "").strip() status = str(card.get("status") or "").strip().lower() if key in document_keys and status not in {"", "not_created", "failed", "blocked", "cancelled", "ignored"}: return True return False def operation_cockpit_html(opportunity_id: str, opportunity: dict, snapshot: dict) -> str: plan = get_workflow_action_plan(opportunity_id) pending_tasks = int(opportunity.get("pending_task_count") or 0) has_invoice_card = any( str(card.get("key") or "") == "jasmin_invoice" and str(card.get("status") or "").lower() not in {"", "not_created", "failed", "blocked", "cancelled", "ignored"} for card in (snapshot.get("cards") or []) ) next_action = plan.get("next_action") or {} next_kind = str(next_action.get("kind") or "") workflow_label = next_action.get("label") or "Sem ação" workflow_reason = next_action.get("reason") or "" physical_reason = plan.get("physical_reason") or "" physical_next = plan.get("physical_next_action") or "" if pending_tasks > 0: main_label = "Concluir tarefa pendente" main_reason = "Existe uma tarefa ativa nesta oportunidade." if should_hide_regressive_quotation_hint(opportunity, snapshot, next_action): main_extra = "Depois: continuar a partir do documento/fase atual." else: main_extra = f"Depois: {workflow_label}" action_html = 'Ver tarefas' else: main_label = workflow_label main_reason = physical_reason or workflow_reason main_extra = physical_next if physical_next and physical_next != main_reason else "" if should_hide_regressive_quotation_hint(opportunity, snapshot, next_action): main_label = "Rever fluxo atual" main_reason = "A oportunidade já tem documento/fase posterior; não criar novo orçamento neste processo." main_extra = "Continua pela fatura, pagamento, envio ou histórico conforme o caso." next_kind = "review" if next_kind == "operation" and next_action.get("action_key"): action_key = str(next_action.get("action_key") or "") action_html = ( f'
' f'
' f'' f'' f'
' f'
' ) elif next_kind == "sync_odoo": action_html = ( f'
' f'' f'
' ) elif next_kind == "wait": action_html = 'Aguardar' else: action_html = 'Sem ação' def step_visual(status): s = str(status or "").lower() if s in {"confirmed", "issued", "created", "validated", "sent", "delivered", "done", "ready_to_ship"}: return "bg-success text-white", "✓" if s in {"in_progress", "pending", "running", "open", "in_production"}: return "bg-warning text-dark", "…" if s in {"failed", "blocked", "cancelled", "not_found"}: return "bg-danger text-white", "!" return "bg-light text-secondary border", "○" short_labels = { "payment": "Pagamento", "proforma": "Pró-forma", "odoo_sale_order": "Venda", "odoo_production": "Produção", "physical_status": "Odoo", "physical_validation": "Validado", "jasmin_quotation": "Orçamento", "jasmin_invoice": "Fatura", "packlink_shipment": "Envio", "tracking": "Tracking", "delivery": "Entregue", } steps_html = "" for card in snapshot.get("cards", []): badge_class, mark = step_visual(card.get("status")) key = str(card.get("key") or "") label = short_labels.get(key, card.get("label") or "") url = str(card.get("external_url") or "").strip() title = card.get("external_name") or card.get("status_label") or label link_open = "" if url: link_open = ( '' ) steps_html += ( '
' '
' '
' f'{mark}' f'
{esc(label)}
' f'{link_open}' '
' '
' '
' ) if not steps_html: steps_html = '
Sem integrações registadas.
' if has_invoice_card and str(main_label).strip().casefold() == "criar orçamento jasmin": main_label = "Acompanhar fatura" main_reason = "Já existe fatura Jasmin associada; não criar novo orçamento neste processo." main_extra = "Confirma pagamento, envio ou marca como histórico/concluído." action_html = 'Rever processo' reason_html = "" if main_reason: reason_html += f'
{esc(main_reason)}
' if main_extra: reason_html += f'
{esc(main_extra)}
' return ( '
' '
' '
' '
' '
Fluxo operacional
' f'

{esc(main_label)}

' f'{reason_html}' '
' f'
{action_html}
' '
' '
' f'{steps_html}' '
' '
' '
' ) def task_priority_chip(task: dict) -> str: priority = str(task.get("priority") or "").strip().lower() if is_task_overdue(task) or priority == "alta": return 'Alta' if priority == "normal": return 'Normal' if priority == "baixa": return 'Baixa' route = str(task.get("route") or "") if route in {"financeiro", "operacoes"}: return 'Normal' return 'Baixa' def pretty_json(value) -> str: return json.dumps(value or {}, ensure_ascii=False, indent=2, default=str) def status_badge(status: str) -> str: value = str(status or "").strip() or "unknown" label = { "pending": "Pendente", "done": "Concluída", "skipped": "Ignorada", "failed": "Falha", "sent": "Enviada", "ignored": "Ignorada", }.get(value, value) cls = { "pending": "status-pending", "done": "status-done", "skipped": "status-skipped", "failed": "status-failed", "sent": "status-done", "ignored": "status-skipped", }.get(value, "status-skipped") return f'{esc(label)}' def route_badge(route: str) -> str: value = str(route or "").strip() or "rever" label = { "vendas": "COMERCIAL", "financeiro": "FINANCEIRO", "suporte": "SUPORTE", "operacoes": "LOGÍSTICA", "spam": "SPAM", "rever": "REVER", }.get(value, value.upper()) cls = { "vendas": "route-vendas", "financeiro": "route-financeiro", "suporte": "route-suporte", "operacoes": "route-operacoes", "spam": "route-rever", "rever": "route-rever", }.get(value, "route-rever") return f'{esc(label)}' def task_sla_minutes(route: str) -> int: return { "suporte": 120, "vendas": 240, "financeiro": 480, "operacoes": 1440, "rever": 1440, }.get(route or "", 1440) def task_age_minutes(task: dict) -> int: created_at = task.get("created_at") if not created_at: return 0 if isinstance(created_at, str): try: created_at = datetime.fromisoformat(created_at.replace("Z", "+00:00")) except Exception: return 0 if created_at.tzinfo is None: created_at = created_at.replace(tzinfo=timezone.utc) return max(0, int((datetime.now(timezone.utc) - created_at).total_seconds() // 60)) def is_task_overdue(task: dict) -> bool: if task.get("status") != "pending": return False return task_age_minutes(task) > task_sla_minutes(task.get("route")) def is_task_today(task: dict) -> bool: created_at = task.get("created_at") if not created_at: return False if isinstance(created_at, str): try: created_at = datetime.fromisoformat(created_at.replace("Z", "+00:00")) except Exception: return False if created_at.tzinfo is None: created_at = created_at.replace(tzinfo=timezone.utc) now = datetime.now(timezone.utc) return created_at.date() == now.date() def sla_badge_html(task: dict) -> str: if task.get("status") != "pending": return "" age = task_age_minutes(task) sla = task_sla_minutes(task.get("route")) if age > sla: overdue = age - sla if overdue >= 60: label = f"Atrasada {overdue // 60}h" else: label = f"Atrasada {overdue}m" return f'{esc(label)}' remaining = sla - age if remaining >= 60: label = f"SLA {remaining // 60}h" else: label = f"SLA {remaining}m" return f'{esc(label)}' # ADMIN_UI_V451_CSS moved to app.admin_ui.styles in v4.7. # Layout, navigation and KPI card helpers moved to app.admin_ui in v4.7. def is_htmx(request: Request) -> bool: return str(request.headers.get("HX-Request") or "").lower() == "true" @router.get("/ui.css") async def admin_ui_css(): return Response(ADMIN_UI_V451_CSS, media_type="text/css") def opportunity_stage_badge(stage: str) -> str: classes = { "NEW_LEAD": "cf-chip-blue", "INFO_REQUESTED": "cf-chip-blue", "INFO_SENT": "cf-chip-green", "QUOTE_REQUESTED": "cf-chip-orange", "QUOTE_SENT": "cf-chip-green", "PROFORMA_REQUESTED": "cf-chip-orange", "PROFORMA_SENT": "cf-chip-green", "INVOICE_REQUESTED": "cf-chip-orange", "INVOICE_SENT": "cf-chip-green", "WAITING_PAYMENT": "cf-chip-orange", "PAYMENT_CONFIRMED": "cf-chip-green", "ORDER_PREPARATION": "cf-chip-purple", "SHIPPED": "cf-chip-purple", "WON": "cf-chip-green", "LOST": "cf-chip-red", "NO_INTEREST": "cf-chip-gray", "REVIEW": "cf-chip-gray", } return f'{esc(stage_label(stage))}' def _opportunity_board_column_for_stage(stage: str) -> str: stage = str(stage or "") for key, _label, stages in OPPORTUNITY_BOARD_COLUMNS: if stage in stages: return key return "requests" def money_html(value, currency: str = "€") -> str: try: number = float(value or 0) except Exception: number = 0.0 formatted = f"{number:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".") return f"{formatted} {esc(currency)}" def product_status_badge(active) -> str: if active: return 'Ativo' return 'Inativo' def item_status_label(status: str) -> str: labels = { "INTERESTED": "Em análise", "QUOTED": "Orçamentado", "ACCEPTED": "Aceite", "REJECTED": "Rejeitado", "CANCELLED": "Cancelado", "UNAVAILABLE": "Indisponível", } return labels.get(str(status or "").upper(), str(status or "—")) def item_status_badge(status: str) -> str: status = str(status or "").upper() cls = { "INTERESTED": "cf-chip-orange", "QUOTED": "cf-chip-blue", "ACCEPTED": "cf-chip-green", "REJECTED": "cf-chip-red", "CANCELLED": "cf-chip-gray", "UNAVAILABLE": "cf-chip-gray", }.get(status, "cf-chip-gray") return f'{esc(item_status_label(status))}' def product_form_html(product: Optional[dict] = None, *, action: str = "/products", submit_label: str = "Guardar produto") -> str: product = product or {} checked = "checked" if product.get("active", True) else "" return f'''
Usado para criar orçamentos/faturas Jasmin. Pode ser diferente do SKU Odoo.
Cancelar
''' def _outbox_items_for_opportunity(opportunity_id: str, *, target_system: str | None = "jasmin", limit: int = 30) -> list[dict]: try: items = list_outbox(target_system=target_system, limit=300) except Exception: return [] filtered = [] for item in items: payload = item.get("payload") or {} if isinstance(payload, str): try: payload = json.loads(payload) except Exception: payload = {} if str(payload.get("opportunity_id") or "") == str(opportunity_id): item = dict(item) item["payload"] = payload filtered.append(item) if len(filtered) >= limit: break return filtered def opportunity_integrations_panel_html(opportunity_id: str) -> str: items = _outbox_items_for_opportunity(opportunity_id, target_system=None, limit=16) counts = {"pending": 0, "failed": 0, "blocked": 0, "dry_run": 0} for item in items: status = str(item.get("status") or "pending") if status in counts: counts[status] += 1 rows = "" for item in items[:8]: status = str(item.get("status") or "pending") row_cls = f"cf-outbox-row-{status}" err = compact_text(item.get("last_error") or "", 160) actions = "" if status in {"failed", "blocked", "dry_run", "ignored", "cancelled"}: actions = f'''
''' elif status == "pending": actions = 'A aguardar timer' rows += f''' {esc(item.get('target_system'))}
{esc(item.get('action_type'))}
{operation_status_badge(status)} {esc(fmt_dt(item.get('updated_at') or item.get('created_at')))} {esc(err)} {actions} ''' if not rows: rows = 'Sem ações de integração para esta oportunidade.' return f'''

Integrações da oportunidade

Estado operacional das ações Jasmin, Packlink, Chatwoot e Mautic ligadas a esta oportunidade.
Ver outbox
Pendentes{counts['pending']}
Falhadas{counts['failed']}
Bloqueadas{counts['blocked']}
Dry-run{counts['dry_run']}
{rows}
Sistema / AçãoEstadoAtualizadoÚltimo erroAção
''' def opportunity_outbox_panel_html(opportunity_id: str, *, target_system: str = "jasmin") -> str: items = _outbox_items_for_opportunity(opportunity_id, target_system=target_system, limit=12) if not items: return "" rows = "" for item in items: retry = "" if item.get("status") in {"failed", "pending"}: retry = f'''
''' err = esc(item.get("last_error") or "") if err and len(err) > 180: err = err[:180] + "…" rows += f''' {esc(item.get('action_type'))}
{esc(fmt_dt(item.get('created_at')))}
{operation_status_badge(str(item.get('status') or 'pending'))} {err} {retry} ''' return f'''
Ações Jasmin / outbox
Processamento automático ou manual das ações pedidas pelos botões.
Ver outbox
{rows}
AçãoEstadoErro
''' def jasmin_documents_html(opportunity_id: str, *, notice: str = "", error_notice: str = "") -> str: try: from app.commercial_service import list_commercial_documents docs = list_commercial_documents(opportunity_id=opportunity_id, limit=20) except Exception as exc: docs = [] error = str(exc) else: error = "" try: from app.jasmin_backfill_service import find_jasmin_document_candidates_for_opportunity jasmin_candidates = find_jasmin_document_candidates_for_opportunity(opportunity_id, limit=8) except Exception as exc: jasmin_candidates = [] if not error: error = f"Erro ao procurar documentos Jasmin existentes: {exc}" linked_tax_id = "" try: from app.commercial_service import get_customer_for_opportunity, normalize_tax_id linked_customer = get_customer_for_opportunity(opportunity_id) linked_tax_id = normalize_tax_id((linked_customer or {}).get("tax_id")) except Exception: linked_tax_id = "" rows = "" kind_labels = {"quotation": "Orçamento", "proforma": "Pró-forma", "invoice": "Fatura"} for doc in docs: number = commercial_document_display_number(doc, fallback="número por atualizar") amount = doc.get("total_amount") if doc.get("total_amount") is not None else doc.get("amount") kind = kind_labels.get(str(doc.get("document_kind") or ""), doc.get("document_kind") or "Documento") doc_id = str(doc.get("id") or "") actions = f"""
PDF
""" role_label = { "current": "Atual", "accepted": "Aceite", "historical": "Histórico", "cancelled": "Cancelado", "related": "Relacionado", }.get(str(doc.get("role") or "current"), str(doc.get("role") or "current")) role_class = "text-bg-primary" if str(doc.get("role") or "current") in {"current", "accepted"} and doc.get("is_primary") else "text-bg-light" external_id_html = "" if not doc.get("document_number") and doc.get("external_id") and not is_uuid_text(doc.get("external_id")): external_id_html = f"
ref. externa {esc(doc.get('external_id') or '')}
" elif not doc.get("document_number") and doc.get("external_id"): external_id_html = "
ID técnico oculto; usar Atualizar nº.
" rows += ( "" f"{esc(kind)}
v{esc(doc.get('version_number') or '—')}
{esc(role_label)}" f"{esc(number)}{external_id_html}" f"{operation_status_badge(str(doc.get('status') or 'created'))}" f"{money_html(amount or 0)}" f"{esc(fmt_dt(doc.get('created_at')))}" f"{actions}" "" ) if not rows: rows = 'Ainda sem documentos Jasmin nesta oportunidade.' current_jasmin_docs_exist = bool(docs) invoice_source_exists = any( str(doc.get("document_kind") or "") in {"quotation", "proforma"} and str(doc.get("status") or "").lower() not in {"cancelled", "failed"} and str(doc.get("role") or "current") in {"current", "accepted", "related"} for doc in docs ) candidate_rows = "" ignored_rows = "" valid_candidate_count = 0 ignored_candidate_count = 0 hidden_other_customer_count = 0 for item in jasmin_candidates: totals = item.get("totals") if isinstance(item.get("totals"), dict) else {} total_amount = totals.get("total_amount") or item.get("amount") or 0 doc_number = item.get("document_number") or (item.get("external_id") if not is_uuid_text(item.get("external_id")) else None) or "número por atualizar" match_reason = item.get("match_reason") or "match" match_score = item.get("match_score") or "" item_id = str(item.get("id") or "") is_valid = bool(item.get("is_valid_candidate")) candidate_tax = "" try: from app.commercial_service import normalize_tax_id candidate_tax = normalize_tax_id(item.get("customer_tax_id")) except Exception: candidate_tax = str(item.get("customer_tax_id") or "").strip() other_customer = bool(linked_tax_id and candidate_tax and candidate_tax != linked_tax_id) tax_conflict = bool(other_customer) invalid_reason = item.get("invalid_reason") or "" if tax_conflict: # Segurança operacional: um documento Jasmin de NIF diferente nunca deve # aparecer como candidato acionável. Fica apenas em auditoria/revisão. is_valid = False invalid_reason = invalid_reason or "NIF divergente do cliente fiscal validado; rever manualmente." if is_valid: valid_candidate_count += 1 else: ignored_candidate_count += 1 status_label = item.get("jasmin_status_label") or "—" status_badge_html = ( 'NIF divergente' if tax_conflict else ( 'Aberto/válido' if is_valid else 'Ignorado' ) ) if is_valid: if current_jasmin_docs_exist: action_html = f'''
Já existe documento atual. A associação direta fica bloqueada para evitar duplicados.
''' else: action_html = f'''
''' else: action_html = ( '' if tax_conflict else '' ) candidate_customer_meta = ( f'
NIF {esc(item.get("customer_tax_id") or "—")}
' if is_valid else ( '
NIF divergente oculto em auditoria
' if tax_conflict else '
NIF oculto em auditoria
' ) ) row_html = f''' {esc(doc_number)}
{esc(item.get('external_type') or 'jasmin')}
{esc(fmt_dt(item.get('document_date') or item.get('updated_at')))}
{money_html(total_amount or 0)}
{esc(item.get('currency') or 'EUR')}
{esc(item.get('customer_name') or '—')}{candidate_customer_meta} {status_badge_html}
Jasmin: {esc(status_label)} {esc(item.get('jasmin_status_code') or '')}
{esc(invalid_reason)}
{esc(match_reason)} {esc(match_score)}
{esc(item.get('line_count') or 0)} linha(s)
{action_html} ''' if is_valid: candidate_rows += row_html else: ignored_rows += row_html candidates_html = "" if candidate_rows or ignored_rows or hidden_other_customer_count: if candidate_rows: candidates_html += f'''
Candidatos Jasmin acionáveis encontrados.
Valida antes de substituir ou associar, especialmente em processos antigos/reconstruídos.
{candidate_rows}
Documento encontradoValorCliente JasminValidaçãoMatchAção
''' else: candidates_html += '''
Nenhum orçamento/pró-forma aberto elegível encontrado.
Documentos antigos, fechados ou de outro cliente não são apresentados como ação principal.
''' if ignored_rows: candidates_html += f'''
Ver documentos ignorados / auditoria
{ignored_rows}
DocumentoValorCliente JasminValidaçãoMatchAção
''' if hidden_other_customer_count: candidates_html += f'
{hidden_other_customer_count} documento(s) ignorado(s) de outro NIF ocultados da lista principal.
' notice_html = f'
{esc(notice)}
' if notice else '' error_notice_html = f'
Não foi possível pedir a ação.
{esc(error_notice).replace(chr(10), "
")}
' if error_notice else '' error_html = f'
{esc(error)}
' if error else '' outbox_html = opportunity_outbox_panel_html(opportunity_id, target_system="jasmin") refreshed_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") if invoice_source_exists: convert_invoice_button_html = ( f'
' '' '
' ) else: convert_invoice_button_html = '' if current_jasmin_docs_exist: create_quotation_button_html = f'''
''' else: create_quotation_button_html = f'''
''' return f'''

Documentos Jasmin

Antes de criar novo orçamento, valida candidatos Jasmin abertos/mais recentes para evitar duplicados.
Última atualização: {esc(refreshed_at)}. Atualização manual para evitar reconstrução automática da janela.
{create_quotation_button_html} {convert_invoice_button_html}
{notice_html}{error_notice_html}{error_html}
{candidates_html}
{rows}
TipoNúmero/IDEstadoValorCriadoAções
{outbox_html}
''' def opportunity_items_table_html(opportunity_id: str, items: list[dict]) -> str: rows = "" historical_rows = "" for item in items: row_html = f''' {esc(item.get('product_name') or 'Produto')}
SKU/Odoo {esc(item.get('sku') or '—')}
Jasmin {esc(item.get('jasmin_sales_item') or '—')}
{esc(item.get('quantity') or '1')} {money_html(item.get('unit_price'))} {money_html(item.get('discount_amount'))} {money_html(item.get('total_price'))} {item_status_badge(item.get('status'))}
''' if str(item.get('status') or '').upper() in {"DELIVERED", "HISTORICAL"}: historical_rows += row_html else: rows += row_html if not rows: rows = 'Sem produtos atuais nesta oportunidade.' historical_html = "" if historical_rows: historical_html = f'''
Ver linhas históricas / entregues
{historical_rows}
ProdutoQtd.PreçoDesc.TotalEstado
''' return f'''
{rows}
ProdutoQtd.PreçoDesc.TotalEstado
{historical_html} ''' def opportunity_add_item_form_html(opportunity_id: str, products: list[dict]) -> str: options = '' for product in products: product_id = esc(product.get("id")) sku = esc(product.get("sku") or "") jasmin = esc(product.get("jasmin_sales_item") or "") name = esc(product.get("name") or "") options += f'' return f'''
''' def opportunity_products_panel_html(opportunity_id: str, *, notice: str = "", error_notice: str = "") -> str: try: items = list_opportunity_items(opportunity_id) active_products = list_products(active="true", limit=300) except Exception as exc: return f'
Erro ao carregar produtos: {esc(exc)}
' total = sum(float(item.get("total_price") or 0) for item in items if str(item.get("status") or "").upper() not in {"REJECTED", "CANCELLED", "DELIVERED", "HISTORICAL"}) notice_html = f'
{esc(notice)}
' if notice else '' error_html = f'
{esc(error_notice)}
' if error_notice else '' missing = [item for item in items if str(item.get("status") or "").upper() not in {"REJECTED", "CANCELLED"} and not item.get("jasmin_sales_item")] validation_html = "" if missing: lis = "".join(f"
  • {esc(i.get('product_name') or i.get('sku') or 'Produto')} sem Artigo Jasmin.
  • " for i in missing) validation_html = f'
    Atenção: estes produtos bloqueiam o orçamento Jasmin:
    ' return f'''

    Produtos

    Linhas comerciais da oportunidade.
    Total linhas atuais {money_html(total)}

    Adicionar produto

    {notice_html}{error_html}{opportunity_add_item_form_html(opportunity_id, active_products)}
    {opportunity_items_table_html(opportunity_id, items)}
    {validation_html}
    '''