Import ClientFlow production v4928.1.5.132.4

This commit is contained in:
plx
2026-07-29 13:11:01 +00:00
parent 6445044ac6
commit 261d342057
405 changed files with 48373 additions and 1401 deletions

View File

@@ -4,6 +4,8 @@ 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
@@ -203,7 +205,7 @@ async def system_health_page():
@router.get("/settings", response_class=HTMLResponse)
@router.get("/configuracoes", response_class=HTMLResponse)
async def settings_page():
return RedirectResponse("/system", status_code=303)
return RedirectResponse("/settings/workflow", status_code=303)
@router.get("/system/health", response_class=HTMLResponse)
@@ -244,7 +246,7 @@ async def system_health_operational_page():
doc_rows = '<tr><td colspan="2" class="text-secondary py-4">Sem documentos.</td></tr>'
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")
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"
@@ -288,6 +290,10 @@ async def system_health_operational_page():
{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')}
</section>
<section class="cf-kpi-grid mt-3">
{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')}
</section>
<div class="row g-3">
<div class="col-xl-6"><section class="card cf-card"><div class="card-body p-0"><div class="p-3 border-bottom"><h2 class="cf-section-title">Timers systemd</h2><div class="small text-secondary">Estado best-effort dos timers da outbox.</div></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Integração</th><th>Unidade</th><th>Estado</th></tr></thead><tbody>{timer_rows}</tbody></table></div></div></section></div>
<div class="col-xl-6"><section class="card cf-card"><div class="card-body p-0"><div class="p-3 border-bottom"><h2 class="cf-section-title">Outbox por sistema</h2></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Sistema</th><th>Estados</th></tr></thead><tbody>{outbox_rows}</tbody></table></div></div></section></div>
@@ -298,3 +304,127 @@ async def system_health_operational_page():
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"<tr><td><code>{esc(item.get('code'))}</code></td><td>{esc(item.get('label'))}</td></tr>"
for item in profile.commercial_stages
if isinstance(item, dict)
) or '<tr><td colspan="2" class="text-secondary py-4">Sem fases configuradas.</td></tr>'
payment_rows = "".join(f"<tr><td><code>{esc(k)}</code></td><td>{esc(v)}</td></tr>" for k, v in profile.payment_terms.items())
delivery_rows = "".join(f"<tr><td><code>{esc(k)}</code></td><td>{esc(v)}</td></tr>" for k, v in profile.delivery_terms.items())
action_rows = "".join(
f"<tr><td><code>{esc(code)}</code></td><td>{esc((cfg or {}).get('label') if isinstance(cfg, dict) else cfg)}</td><td class='text-secondary small'>{esc((cfg or {}).get('description') if isinstance(cfg, dict) else '')}</td></tr>"
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'''
<a class="cf-row-link d-inline-flex mb-3" href="/system">← Voltar ao sistema</a>
<section class="card cf-card mb-3"><div class="card-body p-4">
<div class="d-flex flex-wrap justify-content-between gap-3 align-items-start">
<div>
<h1 class="h3 fw-bold mb-2">Fluxo operacional</h1>
<div class="text-secondary">Configuração controlada do perfil ativo da empresa. As regras críticas continuam protegidas no backend.</div><div class="mt-3"><a class="btn btn-outline-primary btn-sm" href="/settings/workflow/audit">Abrir auditor de coerência</a></div>
</div>
<div class="text-end"><span class="cf-chip cf-chip-blue">{esc(profile.name)}</span><div class="small text-secondary mt-2">{esc(profile.version)}</div></div>
</div>
</div></section>
<div class="row g-3">
<div class="col-xl-6"><section class="card cf-card h-100"><div class="card-body p-4"><h2 class="cf-section-title mb-3">Defaults</h2><dl class="row mb-0"><dt class="col-5">Pagamento</dt><dd class="col-7">{esc(profile.defaults.get('payment_terms') or '')}</dd><dt class="col-5">Entrega</dt><dd class="col-7">{esc(profile.defaults.get('delivery_terms') or '')}</dd><dt class="col-5">Follow-up</dt><dd class="col-7">{esc(profile.defaults.get('follow_up_delay_days') or '')} dias</dd></dl></div></section></div>
<div class="col-xl-6"><section class="card cf-card h-100"><div class="card-body p-4"><h2 class="cf-section-title mb-3">Cards da oportunidade</h2><div class="small text-secondary fw-bold">Coluna operação</div><div class="mb-2">{esc(''.join(map(str, right_cards)) or '')}</div><div class="small text-secondary fw-bold">Coluna contexto</div><div>{esc(''.join(map(str, left_cards)) or '')}</div></div></section></div>
<div class="col-xl-6"><section class="card cf-card h-100"><div class="card-body p-4"><h2 class="cf-section-title mb-3">Condições de pagamento</h2><table class="table cf-table"><tbody>{payment_rows}</tbody></table></div></section></div>
<div class="col-xl-6"><section class="card cf-card h-100"><div class="card-body p-4"><h2 class="cf-section-title mb-3">Tipos de entrega</h2><table class="table cf-table"><tbody>{delivery_rows}</tbody></table></div></section></div>
<div class="col-12"><section class="card cf-card"><div class="card-body p-4"><h2 class="cf-section-title mb-3">Fases comerciais</h2><table class="table cf-table"><thead><tr><th>Código</th><th>Nome</th></tr></thead><tbody>{stage_rows}</tbody></table></div></section></div>
<div class="col-12"><section class="card cf-card"><div class="card-body p-4"><h2 class="cf-section-title mb-3">Ações do motor</h2><table class="table cf-table"><thead><tr><th>Código</th><th>Nome</th><th>Descrição</th></tr></thead><tbody>{action_rows}</tbody></table></div></section></div>
</div>
'''
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"""
<tr>
<td><span class="cf-chip {chip}">{esc(sev)}</span></td>
<td><code>{esc(f.get('code'))}</code></td>
<td><a class="cf-row-link" href="{esc(f.get('url') or '#')}">{esc(f.get('title') or 'Oportunidade')}</a><div class="small text-secondary">{esc(f.get('detail') or '')}</div></td>
</tr>
"""
if not rows_html:
rows_html = '<tr><td colspan="3" class="text-secondary py-4">Sem incoerências encontradas nos primeiros processos analisados.</td></tr>'
body = f'''
<a class="cf-row-link d-inline-flex mb-3" href="/settings/workflow">← Voltar ao fluxo operacional</a>
<section class="card cf-card mb-3"><div class="card-body p-4">
<h1 class="h3 fw-bold mb-2">Auditor de coerência do fluxo</h1>
<div class="text-secondary">Deteta sinais de regressão entre pagamentos, faturas, tarefas antigas e textos legados. Esta primeira versão é read-only.</div>
<div class="mt-3"><span class="cf-chip cf-chip-blue">{len(findings)} achado(s)</span></div>
</div></section>
<section class="card cf-card"><div class="card-body p-0">
<div class="p-3 border-bottom"><h2 class="cf-section-title">Achados</h2><div class="small text-secondary">Prioriza alto/médio antes de operar novas ações financeiras.</div></div>
<div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Severidade</th><th>Código</th><th>Processo</th></tr></thead><tbody>{rows_html}</tbody></table></div>
</div></section>
'''
return layout("Auditor de fluxo", "Coerência operacional", body, "settings")