"""Settings and system health routes.
Moved from app.admin_dashboard in v4.7.2. The handlers still reuse
legacy helpers to keep this refactor behavior-preserving.
"""
from fastapi import APIRouter
from sqlalchemy import text
from app.db import engine
import app.admin_dashboard as legacy
from app.admin_dashboard import * # noqa: F401,F403
router = APIRouter()
@router.get("/system", response_class=HTMLResponse)
async def system_health_page():
metrics = get_system_health_metrics()
backup = latest_backup_info()
timers_raw = shell_output(["systemctl", "list-timers", "--all", "--no-pager"])
clientflow_timers = "\n".join(
line for line in timers_raw.splitlines()
if "clientflow" in line.lower()
)
services = [
"clientflow-api.service",
"clientflow-outbox.timer",
"clientflow-backup.timer",
"clientflow-followups.timer",
]
service_rows = ""
for service in services:
active = shell_output(["systemctl", "is-active", service])
enabled = shell_output(["systemctl", "is-enabled", service])
ok = active in ["active", "inactive"] if service.endswith(".service") else active == "active"
if service == "clientflow-api.service":
ok = active == "active"
service_rows += f"""
{esc(service)} |
{status_pill(ok, active)} |
{esc(enabled)} |
"""
def n(key):
return metrics.get(key) or 0
db_ok = True
outbox_ok = n("outbox_failed") == 0
webhook_ok = n("webhooks_errors_24h") == 0
backup_ok = bool(backup.get("exists"))
body = f"""
← Voltar à visão geral
·
Sistema
Kanban
API
{status_pill(True, "OK")}
Base de dados
{status_pill(db_ok, "OK" if db_ok else "ERRO")}
Backups
{status_pill(backup_ok, "OK" if backup_ok else "FALTA")}
Webhooks erros 24h
{n("webhooks_errors_24h")}
Tasks pendentes{n("tasks_pending")}
Tasks 24h{n("tasks_24h")}
Webhooks 24h{n("webhooks_24h")}
Outbox falhada{n("outbox_failed")}
Estado dos serviços/timers
| Serviço |
Active |
Enabled |
{service_rows}
Último backup
| Existe | {status_pill(backup_ok, "Sim" if backup_ok else "Não")} |
| Ficheiro | {esc(backup.get("file"))} |
| Tamanho | {esc(backup.get("size"))} |
| Modificado | {esc(backup.get("mtime"))} |
Timers ClientFlow
{esc(clientflow_timers or "Sem timers ClientFlow encontrados.")}
Resumo técnico
| Total tasks | {n("tasks_total")} |
| Tasks done | {n("tasks_done")} |
| Tasks failed | {n("tasks_failed")} |
| Webhooks ignorados 24h | {n("webhooks_ignored_24h")} |
| Outbox pending | {n("outbox_pending")} |
| Outbox sent | {n("outbox_sent")} |
"""
return layout("Sistema", "Estado técnico do ClientFlow e integrações.", body, "system")
@router.get("/settings", response_class=HTMLResponse)
@router.get("/configuracoes", response_class=HTMLResponse)
async def settings_page():
return RedirectResponse("/settings/workflow", status_code=303)
@router.get("/system/health", response_class=HTMLResponse)
async def system_health_operational_page():
health = get_system_health_summary()
settings_info = health.get("settings") or {}
timers = health.get("timers") or {}
outbox = health.get("outbox") or {}
documents = health.get("documents") or {}
operational_metrics = health.get("operational_metrics") or {}
def m(key: str) -> int:
try:
return int(operational_metrics.get(key) or 0)
except Exception:
return 0
timer_rows = ""
for name, item in timers.items():
ok = bool(item.get("ok"))
chip = "cf-chip-green" if ok else "cf-chip-orange"
timer_rows += f"""
| {esc(name)} | {esc(item.get('unit') or '—')} | {esc(item.get('active_state') or 'unknown')} |
"""
outbox_rows = ""
for target, by_status in outbox.items():
cells = "".join(f'{esc(status)}: {esc(total)}' for status, total in by_status.items())
outbox_rows += f"| {esc(target)} | {cells} |
"
if not outbox_rows:
outbox_rows = '| Sem dados de outbox. |
'
doc_rows = ""
for kind, by_status in documents.items():
cells = "".join(f'{esc(status)}: {esc(total)}' for status, total in by_status.items())
doc_rows += f"| {esc(kind)} | {cells} |
"
if not doc_rows:
doc_rows = '| Sem documentos. |
'
db_ok = bool((health.get("database") or {}).get("ok"))
critical_count = (0 if db_ok else 1) + m("outbox_processing_stale") + m("outbox_stale") + m("outbox_blocked_or_failed") + m("chatwoot_incoming_pending")
warning_count = m("ambiguous_opportunity_tasks") + m("open_opportunities_without_fiscal_customer") + m("active_incomplete_fiscal_customers") + m("products_missing_external_code")
if critical_count:
production_state = "Crítico"
production_class = "critical"
production_hint = "corrigir bloqueios antes de automatizar"
elif warning_count:
production_state = "Atenção"
production_class = "warn"
production_hint = "existem validações operacionais pendentes"
else:
production_state = "OK"
production_class = "ok"
production_hint = "sem bloqueios críticos visíveis"
body = f"""
Estado para operação
{production_state}{production_hint}
Críticos: {esc(critical_count)}Atenção: {esc(warning_count)}
{kpi_card('Estado geral', esc(health.get('status')), None, 'OK' if db_ok else 'ver base de dados', 'bi-heart-pulse', 'cf-kpi-tone-green' if db_ok else 'cf-kpi-tone-red')}
{kpi_card('Jasmin', 'ativo' if settings_info.get('jasmin_enabled') else 'inativo', None, f"{esc(settings_info.get('jasmin_company_key') or '—')} · {esc(settings_info.get('jasmin_quotation_serie') or '—')}", 'bi-receipt')}
{kpi_card('Packlink', 'ativo' if settings_info.get('packlink_enabled') else 'inativo', None, f"serviço {esc(settings_info.get('packlink_default_service_id') or '—')}", 'bi-truck')}
{kpi_card('Ambiente', esc(settings_info.get('env') or '—'), None, esc(settings_info.get('app_name') or 'ClientFlow'), 'bi-sliders')}
{kpi_card('Tasks pendentes', esc(m('tasks_pending')), '/operations', f"done 24h: {esc(m('tasks_done_24h'))}", 'bi-list-task')}
{kpi_card('Ambíguas', esc(m('ambiguous_opportunity_tasks')), '/operations?scope=ambiguas', 'associação por confirmar', 'bi-person-exclamation', 'cf-kpi-tone-orange' if m('ambiguous_opportunity_tasks') else 'cf-kpi-tone-green')}
{kpi_card('Outbox stale', esc(m('outbox_stale') or m('outbox_processing_stale')), '/outbox?status=stale', f"modo: {esc(settings_info.get('outbox_stale_recovery_mode') or 'manual_only')}", 'bi-hourglass-split', 'cf-kpi-tone-red' if (m('outbox_stale') or m('outbox_processing_stale')) else 'cf-kpi-tone-green')}
{kpi_card('Ações operador 24h', esc(m('operator_actions_24h')), '/events', 'auditoria operacional', 'bi-shield-check')}
{kpi_card('Oportunidades sem cliente fiscal', esc(m('open_opportunities_without_fiscal_customer')), '/operations?scope=ambiguas', 'associação por confirmar', 'bi-person-vcard', 'cf-kpi-tone-orange' if m('open_opportunities_without_fiscal_customer') else 'cf-kpi-tone-green')}
{kpi_card('Clientes fiscais incompletos', esc(m('active_incomplete_fiscal_customers')), '/customers', 'em oportunidades ativas', 'bi-exclamation-diamond', 'cf-kpi-tone-orange' if m('active_incomplete_fiscal_customers') else 'cf-kpi-tone-green')}
{kpi_card('Produtos sem código externo', esc(m('products_missing_external_code')), '/products', 'podem bloquear Jasmin', 'bi-box-seam', 'cf-kpi-tone-orange' if m('products_missing_external_code') else 'cf-kpi-tone-green')}
{kpi_card('Último webhook Chatwoot', esc('—' if not m('seconds_since_last_chatwoot_webhook') else str(m('seconds_since_last_chatwoot_webhook')) + 's'), '/events', f"eventos: {esc(m('chatwoot_events_total'))}", 'bi-chat-dots')}
{kpi_card('Chatwoot inbound pendente', esc(m('chatwoot_incoming_pending')), '/events', f"inbound 24h: {esc(m('chatwoot_incoming_24h'))}", 'bi-inbox', 'cf-kpi-tone-red' if m('chatwoot_incoming_pending') else 'cf-kpi-tone-green')}
Timers systemd
Estado best-effort dos timers da outbox.
| Integração | Unidade | Estado |
{timer_rows}
Outbox por sistema
| Sistema | Estados |
{outbox_rows}
Base de dados
Estado: {'OK' if db_ok else 'Erro'}
{esc((health.get('database') or {}).get('error') or 'Sem erros reportados.')}
"""
return layout("Saúde operacional", "Base de dados, integrações, timers e contadores", body, "system")
@router.get("/settings/workflow", response_class=HTMLResponse)
@router.get("/configuracao/fluxo-operacional", response_class=HTMLResponse)
async def workflow_settings_page():
from app.domain.opportunity_flow import load_company_profile
profile = load_company_profile("blif")
stage_rows = "".join(
f"{esc(item.get('code'))} | {esc(item.get('label'))} |
"
for item in profile.commercial_stages
if isinstance(item, dict)
) or '| Sem fases configuradas. |
'
payment_rows = "".join(f"{esc(k)} | {esc(v)} |
" for k, v in profile.payment_terms.items())
delivery_rows = "".join(f"{esc(k)} | {esc(v)} |
" for k, v in profile.delivery_terms.items())
action_rows = "".join(
f"{esc(code)} | {esc((cfg or {}).get('label') if isinstance(cfg, dict) else cfg)} | {esc((cfg or {}).get('description') if isinstance(cfg, dict) else '')} |
"
for code, cfg in profile.actions.items()
)
right_cards = profile.ui.get("opportunity_cards", {}).get("right", []) if isinstance(profile.ui, dict) else []
left_cards = profile.ui.get("opportunity_cards", {}).get("left", []) if isinstance(profile.ui, dict) else []
body = f'''
← Voltar ao sistema
Fluxo operacional
Configuração controlada do perfil ativo da empresa. As regras críticas continuam protegidas no backend.
{esc(profile.name)}{esc(profile.version)}
Defaults
- Pagamento
- {esc(profile.defaults.get('payment_terms') or '—')}
- Entrega
- {esc(profile.defaults.get('delivery_terms') or '—')}
- Follow-up
- {esc(profile.defaults.get('follow_up_delay_days') or '—')} dias
Cards da oportunidade
Coluna operação
{esc(' → '.join(map(str, right_cards)) or '—')}
Coluna contexto
{esc(' → '.join(map(str, left_cards)) or '—')}
Ações do motor
| Código | Nome | Descrição |
{action_rows}
'''
return layout("Fluxo operacional", "Perfil de empresa e defaults do workflow", body, "settings")
@router.get("/settings/workflow/audit", response_class=HTMLResponse)
@router.get("/admin/workflow/audit", response_class=HTMLResponse)
async def workflow_audit_page():
"""Lightweight coherence audit for workflow/operator UX regressions."""
findings = []
try:
with engine.begin() as conn:
rows = conn.execute(text("""
SELECT
o.id::text,
o.title,
o.stage,
o.customer_name,
o.updated_at,
COUNT(*) FILTER (WHERE t.status = 'pending')::int AS pending_tasks,
COUNT(*) FILTER (WHERE t.status = 'pending' AND t.action_code = 'CONFIRM_PAYMENT')::int AS pending_confirm_payment,
COUNT(*) FILTER (WHERE t.status = 'pending' AND t.action_code = 'SEND_INVOICE')::int AS pending_send_invoice,
COUNT(*) FILTER (WHERE d.document_kind = 'invoice')::int AS invoices,
COUNT(*) FILTER (WHERE d.document_kind = 'quotation')::int AS quotes,
COUNT(*) FILTER (WHERE ol.system = 'clientflow' AND ol.external_type = 'payment' AND ol.status = 'confirmed')::int AS payments_confirmed,
COUNT(*) FILTER (WHERE lower(coalesce(t.note,'')) LIKE '%fatura por emitir%')::int AS stale_invoice_note,
COUNT(*) FILTER (WHERE lower(coalesce(t.note,'')) LIKE '%pró-forma%' OR lower(coalesce(t.action,'')) LIKE '%pró-forma%')::int AS visible_proforma_task
FROM opportunities o
LEFT JOIN tasks t ON t.opportunity_id = o.id
LEFT JOIN commercial_documents d ON d.opportunity_id = o.id
LEFT JOIN operation_links ol ON ol.opportunity_id = o.id
WHERE coalesce(o.status, 'open') <> 'closed'
GROUP BY o.id, o.title, o.stage, o.customer_name, o.updated_at
ORDER BY o.updated_at DESC NULLS LAST
LIMIT 300
""")).mappings().all()
except Exception as exc:
rows = []
findings.append({"severity": "alto", "code": "audit_query_failed", "title": "Auditoria indisponível", "detail": str(exc), "url": "/system"})
try:
from app.jasmin_fiscal_sync_service import audit_jasmin_fiscal_gaps
findings.extend(audit_jasmin_fiscal_gaps(limit=150))
except Exception as exc:
findings.append({"severity": "baixo", "code": "jasmin_fiscal_audit_unavailable", "title": "Auditoria Jasmin fiscal indisponível", "detail": str(exc), "url": "/settings/workflow/audit"})
for row in rows:
url = f"/opportunities/{row.get('id')}"
title = row.get("title") or row.get("customer_name") or row.get("id")
if int(row.get("payments_confirmed") or 0) > 0 and int(row.get("pending_confirm_payment") or 0) > 0:
findings.append({"severity": "alto", "code": "payment_confirmed_but_confirm_task", "title": title, "detail": "Pagamento confirmado mas ainda existe task pendente de confirmar pagamento.", "url": url})
if int(row.get("invoices") or 0) > 0 and int(row.get("stale_invoice_note") or 0) > 0:
findings.append({"severity": "médio", "code": "invoice_exists_but_task_says_to_issue", "title": title, "detail": "Fatura associada mas alguma task ainda diz 'fatura por emitir'.", "url": url})
if int(row.get("visible_proforma_task") or 0) > 0:
findings.append({"severity": "baixo", "code": "legacy_proforma_word_visible", "title": title, "detail": "Texto histórico ainda contém 'pró-forma'; normalizar para orçamento para pagamento.", "url": url})
if int(row.get("payments_confirmed") or 0) > 0 and int(row.get("invoices") or 0) == 0 and str(row.get("stage") or "").upper() not in {"PAYMENT_CONFIRMED", "WAITING_PAYMENT", "REVIEW"}:
findings.append({"severity": "médio", "code": "payment_confirmed_without_invoice", "title": title, "detail": "Pagamento confirmado sem fatura associada; verificar próxima ação.", "url": url})
severity_order = {"alto": 0, "médio": 1, "baixo": 2}
findings.sort(key=lambda f: (severity_order.get(str(f.get("severity")), 9), str(f.get("title") or "")))
rows_html = ""
for f in findings[:200]:
sev = str(f.get("severity") or "baixo")
chip = "cf-chip-red" if sev == "alto" else ("cf-chip-orange" if sev == "médio" else "cf-chip-gray")
rows_html += f"""
| {esc(sev)} |
{esc(f.get('code'))} |
{esc(f.get('title') or 'Oportunidade')} {esc(f.get('detail') or '')} |
"""
if not rows_html:
rows_html = '| Sem incoerências encontradas nos primeiros processos analisados. |
'
body = f'''
← Voltar ao fluxo operacional
Auditor de coerência do fluxo
Deteta sinais de regressão entre pagamentos, faturas, tarefas antigas e textos legados. Esta primeira versão é read-only.
{len(findings)} achado(s)
Achados
Prioriza alto/médio antes de operar novas ações financeiras.
| Severidade | Código | Processo |
{rows_html}
'''
return layout("Auditor de fluxo", "Coerência operacional", body, "settings")