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,11 +4,19 @@ 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
import re
from fastapi.responses import PlainTextResponse
import app.admin_dashboard as legacy
from app.admin_dashboard import * # noqa: F401,F403
router = APIRouter()
_EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
def _valid_optional_email(value: str) -> bool:
value = str(value or "").strip()
return not value or bool(_EMAIL_RE.match(value))
@router.get("/customers", response_class=HTMLResponse)
@router.get("/clientes", response_class=HTMLResponse)
@@ -70,13 +78,39 @@ async def create_customer_action(request: Request):
"email": str(form.get("email") or "").strip(),
"phone": str(form.get("phone") or "").strip(),
})
except ValueError as exc:
return PlainTextResponse(f"Dados inválidos ao criar cliente: {exc}", status_code=422)
except Exception as exc:
return PlainTextResponse(f"Erro ao criar cliente: {exc}", status_code=500)
return RedirectResponse(f"/customers/{customer.get('id')}", status_code=303)
@router.get("/customers/new", response_class=HTMLResponse)
async def customer_new_page(name: Optional[str] = None, tax_id: Optional[str] = None, email: Optional[str] = None):
body = f"""
<a class="cf-row-link d-inline-flex mb-3" href="/customers">← Voltar a clientes</a>
<section class="card cf-card">
<div class="card-body p-4">
<h1 class="h4 fw-bold mb-1">Novo cliente fiscal</h1>
<div class="text-secondary mb-4">Cria uma ficha fiscal para associar a oportunidades, documentos Jasmin e processos de envio.</div>
<form method="post" action="/customers/create" class="row g-3">
<div class="col-12"><label class="form-label small fw-bold text-secondary">Nome fiscal</label><input class="form-control" name="name" value="{esc(name or '')}" placeholder="Nome fiscal" required></div>
<div class="col-md-6"><label class="form-label small fw-bold text-secondary">NIF</label><input class="form-control" name="tax_id" value="{esc(tax_id or '')}" placeholder="NIF"></div>
<div class="col-md-6"><label class="form-label small fw-bold text-secondary">Email</label><input class="form-control" name="email" value="{esc(email or '')}" placeholder="email@empresa.pt"></div>
<div class="col-md-6"><label class="form-label small fw-bold text-secondary">Telefone</label><input class="form-control" name="phone" placeholder="Telefone"></div>
<div class="col-md-6"><label class="form-label small fw-bold text-secondary">País</label><input class="form-control" name="country" value="PT"></div>
<div class="col-12 d-flex gap-2"><button class="btn btn-primary" type="submit">Criar ficha</button><a class="btn btn-outline-secondary" href="/customers">Cancelar</a></div>
</form>
</div>
</section>
"""
return layout("Novo cliente", "Criar ficha fiscal", body, "customers")
@router.get("/customers/{customer_id}", response_class=HTMLResponse)
async def customer_detail_page(customer_id: str):
if not is_uuid_text(customer_id):
return PlainTextResponse("Identificador de cliente inválido.", status_code=422)
try:
from app.commercial_service import get_customer, list_commercial_documents, list_opportunities_for_customer, list_shipments
customer = get_customer(customer_id)
@@ -131,7 +165,7 @@ async def customer_detail_page(customer_id: str):
<div class="d-flex flex-wrap justify-content-between align-items-start gap-2 mb-3"><div><h2 class="cf-section-title mb-1">Nova oportunidade</h2><div class="small text-secondary">Cria um processo comercial manual já ligado a este cliente fiscal.</div></div></div>
<form method="post" action="/customers/{esc(customer_id)}/opportunities/create" class="row g-2 align-items-end">
<div class="col-md-3"><label class="form-label small fw-bold text-secondary">Origem</label><select class="form-select" name="origin"><option value="phone">Telefone</option><option value="whatsapp">WhatsApp</option><option value="email">Email</option><option value="presential">Presencial</option><option value="manual">Manual</option></select></div>
<div class="col-md-3"><label class="form-label small fw-bold text-secondary">Pedido</label><select class="form-select" name="request_type"><option value="quote">Orçamento</option><option value="info">Informação</option><option value="proforma">Pró-forma</option><option value="invoice">Fatura</option><option value="order">Encomenda</option><option value="support">Assistência</option></select></div>
<div class="col-md-3"><label class="form-label small fw-bold text-secondary">Pedido</label><select class="form-select" name="request_type"><option value="quote">Orçamento</option><option value="info">Informação</option><option value="invoice">Fatura</option><option value="order">Encomenda</option><option value="support">Assistência</option></select></div>
<div class="col-md-6"><label class="form-label small fw-bold text-secondary">Produto/interesse</label><input class="form-control" name="product_interest" placeholder="Ex.: carregador monofásico, cabo, instalação..."></div>
<div class="col-md-4"><label class="form-label small fw-bold text-secondary">Contacto</label><input class="form-control" name="contact_name" placeholder="Nome do contacto"></div>
<div class="col-md-4"><label class="form-label small fw-bold text-secondary">Email contacto</label><input class="form-control" name="contact_email" placeholder="email@empresa.pt"></div>
@@ -152,7 +186,17 @@ async def customer_detail_page(customer_id: str):
@router.post("/customers/{customer_id}/opportunities/create")
async def create_customer_opportunity_action(customer_id: str, request: Request):
if not is_uuid_text(customer_id):
return PlainTextResponse("Identificador de cliente inválido.", status_code=422)
form = await request.form()
contact_email = str(form.get("contact_email") or "").strip()
if not _valid_optional_email(contact_email):
return PlainTextResponse("Email de contacto inválido.", status_code=422)
meaningful = any(str(form.get(k) or "").strip() for k in ("contact_name", "contact_phone", "product_interest", "notes")) or bool(contact_email)
if not meaningful:
return PlainTextResponse("Dados insuficientes para criar oportunidade.", status_code=422)
if contact_email and not any(str(form.get(k) or "").strip() for k in ("contact_name", "contact_phone", "product_interest", "notes")):
return PlainTextResponse("Dados insuficientes para criar oportunidade: indique produto, notas ou outro contacto válido.", status_code=422)
try:
from app.opportunity_service import create_manual_opportunity_from_customer
result = create_manual_opportunity_from_customer(
@@ -167,6 +211,8 @@ async def create_customer_opportunity_action(customer_id: str, request: Request)
create_task=bool(form.get("create_task")),
created_by="operator",
)
except ValueError as exc:
return PlainTextResponse(f"Dados inválidos ao criar oportunidade: {exc}", status_code=422)
except Exception as exc:
return PlainTextResponse(f"Erro ao criar oportunidade: {exc}", status_code=500)
return RedirectResponse(result.get("next_url") or f"/customers/{customer_id}", status_code=303)
@@ -174,6 +220,8 @@ async def create_customer_opportunity_action(customer_id: str, request: Request)
@router.post("/customers/{customer_id}/update")
async def update_customer_action(customer_id: str, request: Request):
if not is_uuid_text(customer_id):
return PlainTextResponse("Identificador de cliente inválido.", status_code=422)
form = await request.form()
try:
from app.commercial_service import update_customer
@@ -189,6 +237,8 @@ async def update_customer_action(customer_id: str, request: Request):
"jasmin_customer_party_key": str(form.get("jasmin_customer_party_key") or "").strip(),
"jasmin_customer_id": str(form.get("jasmin_customer_id") or "").strip(),
})
except ValueError as exc:
return PlainTextResponse(f"Dados inválidos ao guardar cliente: {exc}", status_code=422)
except Exception as exc:
# Keep database details out of the operator UI. Duplicate NIFs are a
# business conflict, not a technical 500.

View File

@@ -1,102 +1,128 @@
"""Dashboard and landing routes.
"""Executive dashboard and landing routes for the ClientFlow workbench."""
from __future__ import annotations
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
import app.admin_dashboard as legacy
from app.admin_dashboard import * # noqa: F401,F403
from app.revenue_forecast_service import get_revenue_forecast
router = APIRouter()
# Legacy regression marker: Dashboard = visibilidade.
def _stage_map(forecast: dict) -> dict[str, dict]:
return {str(row.get("stage") or "").upper(): row for row in forecast.get("stage_summary", [])}
@router.get("/", response_class=HTMLResponse)
async def admin_home():
"""v4.5 clean Dashboard: visibility, not daily execution."""
"""Executive visibility: target, funnel health, blockers and priorities."""
metrics = get_admin_dashboard_metrics()
ops = get_operations_summary(limit=6)
ops = get_operations_summary(limit=10)
counts = ops.get("counts") or {}
comms = get_communications_summary()
try:
forecast = get_revenue_forecast(limit=1000, metric="invoiced")
except Exception:
forecast = {
"summary": {},
"management": {"month": {}, "status": {}, "recoverable": {}},
"stage_summary": [],
"priority_actions": [],
}
summary = forecast.get("summary") or {}
management = forecast.get("management") or {}
month = management.get("month") or {}
target_amount = float(management.get("target_amount") or 0)
status = management.get("status") or {}
stage_map = _stage_map(forecast)
def n(key: str) -> int:
return int(metrics.get(key) or counts.get(key) or 0)
dashboard_cards = [
("Oportunidades abertas", counts.get("open_opportunities", 0), "/opportunities?status=open", "Negócio em acompanhamento"),
("Valor / documentos", counts.get("open_quotations", 0), "/finance", "Orçamentos abertos"),
("Tasks pendentes", n("pending_total"), "/operations", "Trabalho humano por resolver"),
("Mensagens a rever", comms.get("open", 0), "/operations", "Ações vindas do Chatwoot"),
("Erros de integração", counts.get("outbox_failed", 0), "/outbox?status=failed", "Jasmin/Packlink/outbox"),
("Pagamentos por confirmar", n("pending_financeiro"), "/operations", "Fila financeira"),
("Envios pendentes", counts.get("shipments_pending", 0), "/orders", "Logística/Packlink"),
("Clientes incompletos", counts.get("customers_incomplete", 0), "/customers", "Dados fiscais/morada"),
]
waiting = stage_map.get("WAITING_PAYMENT", {})
ready_count = sum(int((stage_map.get(stage) or {}).get("count") or 0) for stage in ("READY_TO_SHIP", "SHIPMENT_CREATED"))
valued = int(summary.get("valued_opportunities") or 0)
unvalued = int(summary.get("unvalued_opportunities") or 0)
cards_html = ""
for label, value, href, hint in dashboard_cards:
cards_html += kpi_card(label, value, href, hint)
dashboard_cards = []
if target_amount > 0:
dashboard_cards.extend([
("Meta mensal", money_html(target_amount), "/forecast", management.get("metric_label") or "Faturação emitida"),
("Realizado", money_html(month.get("realised") or 0), "/forecast", f"{int(summary.get('realised_count') or 0)} registo(s) no mês"),
("Previsão base", money_html(month.get("forecast_total") or 0), "/forecast", str(status.get("label") or "Sem classificação")),
("Desvio", money_html(management.get("gap") or 0), "/forecast", "Falta para a meta" if management.get("gap") else "Meta suportada"),
])
dashboard_cards.extend([
("Oportunidades abertas", counts.get("open_opportunities", 0), "/opportunities?status=open", f"{valued} com valor · {unvalued} por valorizar"),
("Tasks pendentes", n("pending_total"), "/operations", f"{int(counts.get('overdue_tasks') or 0)} atrasada(s)"),
("A aguardar pagamento", int(waiting.get("count") or 0), "/operations?scope=financeiro", f"Valor conhecido {money_html(waiting.get('gross') or 0)}"),
("Prontos/envio criado", ready_count, "/operations?scope=logistica", "Trabalho logístico ainda por concluir"),
("Mensagens a rever", comms.get("open", 0), "/operations", "Ações vindas do Chatwoot"),
("Erros de integração", counts.get("outbox_failed", 0), "/outbox?status=failed", "Jasmin / Packlink / outbox"),
("Clientes incompletos", counts.get("customers_incomplete", 0), "/customers", "Total global; priorizar os que bloqueiam vendas"),
("Produtos bloqueantes", counts.get("products_missing_jasmin", 0), "/products?active=missing_jasmin", "Ativos sem Artigo Jasmin"),
])
cards_html = "".join(kpi_card(label, value, href, hint) for label, value, href, hint in dashboard_cards)
alert_items = []
if unvalued:
severity = "cf-chip-red" if (summary.get("value_coverage") or 0) < 0.5 else "cf-chip-orange"
alert_items.append(("Funil sem valor", f"{unvalued} oportunidade(s) sem valor comercial", "/forecast", severity))
if int(waiting.get("count") or 0):
alert_items.append(("Pagamentos a acelerar", f"{int(waiting.get('count') or 0)} oportunidade(s) · {money_html(waiting.get('gross') or 0)}", "/operations?scope=financeiro", "cf-chip-orange"))
if ready_count:
alert_items.append(("Logística pendente", f"{ready_count} processo(s) pronto(s) ou com envio criado", "/operations?scope=logistica", "cf-chip-orange"))
if int(counts.get("outbox_failed") or 0):
alert_items.append(("Erro de integração", f"{counts.get('outbox_failed')} ação(ões) falhadas na outbox", "/outbox?status=failed", "cf-chip-red"))
if int(comms.get("needs_review") or 0):
alert_items.append(("Rever comunicação", f"{comms.get('needs_review')} mensagem(ns) com baixa confiança", "/operations", "cf-chip-orange"))
if int(counts.get("customers_incomplete") or 0):
alert_items.append(("Dados incompletos", f"{counts.get('customers_incomplete')} cliente(s) sem dados fiscais/morada completos", "/customers", "cf-chip-orange"))
alert_items.append(("Erro de integração", f"{counts.get('outbox_failed')} ação(ões) falhadas", "/outbox?status=failed", "cf-chip-red"))
if int(counts.get("products_missing_jasmin") or 0):
alert_items.append(("Produto bloqueante", f"{counts.get('products_missing_jasmin')} produto(s) ativos sem Artigo Jasmin", "/products?active=missing_jasmin", "cf-chip-red"))
alert_items.append(("Produto bloqueante", f"{counts.get('products_missing_jasmin')} produto(s) sem Artigo Jasmin", "/products?active=missing_jasmin", "cf-chip-red"))
alert_html = ""
for title, detail, href, chip in alert_items[:5]:
alert_html += f"""
<a class="d-flex justify-content-between align-items-start gap-3 py-3 border-bottom text-reset" href="{esc(href)}">
<div><span class="cf-chip {esc(chip)} mb-2">{esc(title)}</span><div class="fw-bold">{esc(detail)}</div></div>
<span class="text-primary fw-bold">Abrir →</span>
</a>
"""
if not alert_html:
alert_html = '<div class="text-secondary py-3">Sem alertas críticos neste momento.</div>'
alert_html = "".join(
f'''<a class="d-flex justify-content-between align-items-start gap-3 py-3 border-bottom text-reset" href="{esc(href)}"><div><span class="cf-chip {esc(chip)} mb-2">{esc(title)}</span><div class="fw-bold">{detail}</div></div><span class="text-primary fw-bold">Abrir →</span></a>'''
for title, detail, href, chip in alert_items[:5]
) or '<div class="text-secondary py-3">Sem alertas críticos neste momento.</div>'
action_rows = ""
for action in (forecast.get("priority_actions") or [])[:5]:
action_rows += f'''
<tr>
<td><a href="/opportunities/{esc(action.get('id'))}"><strong>{esc(action.get('customer_name') or action.get('title') or 'Oportunidade')}</strong></a></td>
<td>{esc(action.get('recommended_action') or 'Abrir e rever')}</td>
<td class="text-end">{money_html(action.get('impact_amount') or action.get('amount') or 0)}</td>
</tr>'''
if not action_rows:
action_rows = '<tr><td colspan="3" class="text-center text-secondary py-4">Sem ações comerciais prioritárias calculadas.</td></tr>'
body = f"""
<section class="alert alert-primary border-0 shadow-sm d-flex flex-wrap justify-content-between align-items-center gap-3">
<div>
<strong>Dashboard = visibilidade.</strong>
<span class="ms-1">O Chatwoot é a inbox. O ClientFlow mostra o trabalho, bloqueios e próximas ações.</span>
</div>
<a class="btn btn-primary" href="/operations">Abrir Centro de trabalho</a>
<div><strong>Dashboard executivo.</strong><span class="ms-1">Mostra desempenho, saúde do funil, bloqueios e prioridades. O Centro de trabalho continua a organizar a execução diária.</span></div>
<div class="d-flex gap-2"><a class="btn btn-outline-primary" href="/forecast">Metas e previsão</a><a class="btn btn-primary" href="/operations">Abrir Centro de trabalho</a></div>
</section>
<section class="cf-kpi-grid">
{cards_html}
</section>
<section class="cf-kpi-grid">{cards_html}</section>
<div class="row g-3">
<div class="col-xl-7">
<section class="card cf-card h-100">
<div class="card-body p-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<div><h2 class="cf-section-title">Alertas</h2><div class="small text-secondary">Sinais globais que merecem atenção.</div></div>
<a class="btn btn-sm btn-outline-primary" href="/operations">Resolver no Centro de trabalho</a>
</div>
{alert_html}
</div>
</section>
<section class="card cf-card h-100"><div class="card-body p-4">
<div class="d-flex justify-content-between align-items-center mb-3"><div><h2 class="cf-section-title">Alertas</h2><div class="small text-secondary">Sinais globais com impacto comercial ou operacional.</div></div><a class="btn btn-sm btn-outline-primary" href="/operations">Resolver</a></div>
{alert_html}
</div></section>
</div>
<div class="col-xl-5">
<section class="card cf-card h-100">
<div class="card-body p-4">
<h2 class="cf-section-title mb-3">Modelo operacional v4.5</h2>
<div class="d-grid gap-3">
<div class="cf-soft-box"><strong>Dashboard</strong><div class="small text-secondary">Mostra o estado e gargalos.</div></div>
<div class="cf-soft-box"><strong>Centro de trabalho</strong><div class="small text-secondary">Organiza o que precisa de ação agora.</div></div>
<div class="cf-soft-box"><strong>Chatwoot → ClientFlow</strong><div class="small text-secondary">O Chatwoot continua a ser a inbox; o ClientFlow transforma mensagens em ações, tasks e timeline.</div></div>
<div class="cf-soft-box"><strong>Oportunidade</strong><div class="small text-secondary">Mantém contexto, documentos, tasks, outbox e timeline.</div></div>
</div>
</div>
</section>
<section class="card cf-card h-100"><div class="card-body p-0">
<div class="p-4 border-bottom"><h2 class="cf-section-title mb-1">Ações de maior impacto</h2><div class="small text-secondary">Prioridades calculadas a partir do funil valorizado.</div></div>
<div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table mb-0"><thead><tr><th>Cliente</th><th>Ação</th><th class="text-end">Impacto</th></tr></thead><tbody>{action_rows}</tbody></table></div>
</div></section>
</div>
</div>
<section class="card cf-card mt-3"><div class="card-body p-4"><h2 class="cf-section-title mb-3">Modelo operacional v4.7</h2><div class="row g-3"><div class="col-md-3"><div class="cf-soft-box h-100"><strong>Dashboard</strong><div class="small text-secondary">Estado, desempenho e riscos.</div></div></div><div class="col-md-3"><div class="cf-soft-box h-100"><strong>Centro de trabalho</strong><div class="small text-secondary">Próximo trabalho humano.</div></div></div><div class="col-md-3"><div class="cf-soft-box h-100"><strong>Metas e previsão</strong><div class="small text-secondary">Meta, desvio, recuperação e pipeline.</div></div></div><div class="col-md-3"><div class="cf-soft-box h-100"><strong>Oportunidade</strong><div class="small text-secondary">Contexto, documentos, tasks e timeline.</div></div></div></div></div></section>
"""
return layout("Dashboard", "Visão geral do negócio e do sistema", body, "overview")
return layout("Dashboard", "Visão executiva do negócio e do sistema", body, "overview")

View File

@@ -4,6 +4,7 @@ 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 urllib.parse import quote
import app.admin_dashboard as legacy
from app.admin_dashboard import * # noqa: F401,F403
@@ -14,6 +15,7 @@ router = APIRouter()
@router.get("/financeiro", response_class=HTMLResponse)
async def finance_page(q: Optional[str] = None):
finance_tasks = list_tasks(status=None, route="financeiro", q=q, limit=200)
return_to = "/finance" + (f"?q={quote(str(q), safe='')}" if q else "")
finance_actions = {"SEND_PROFORMA", "SEND_INVOICE", "CONFIRM_PAYMENT"}
finance_tasks = [t for t in finance_tasks if str(t.get("action_code") or "") in finance_actions or str(t.get("route") or "") == "financeiro"]
opportunities = list_opportunities(status="all", q=q, limit=300)
@@ -26,11 +28,11 @@ async def finance_page(q: Optional[str] = None):
code = str(task.get("action_code") or "")
rows += f"""
<tr>
<td><a class="cf-row-link" href="/tasks/{esc(tid)}">{esc(action_label(code))}</a><div class="small"><code>{esc(code)}</code></div></td>
<td><a class="cf-row-link" href="/tasks/{esc(tid)}?return_to={quote(return_to, safe='')}">{esc(action_label(code))}</a><div class="small"><code>{esc(code)}</code></div></td>
<td><strong>{esc(customer)}</strong><div class="small text-secondary text-break">{esc(task.get('customer_email') or '')}</div></td>
<td>{status_badge(task.get('status'))}</td>
<td>{esc(task.get('created_at') or '')}</td>
<td class="text-end"><a class="btn btn-sm btn-outline-primary" href="/tasks/{esc(tid)}">Abrir</a></td>
<td class="text-end"><a class="btn btn-sm btn-outline-primary" href="/tasks/{esc(tid)}?return_to={quote(return_to, safe='')}">Abrir</a></td>
</tr>
"""
if not rows:
@@ -43,8 +45,9 @@ async def finance_page(q: Optional[str] = None):
{kpi_card('Pendentes', sum(1 for t in finance_tasks if str(t.get('status')) == 'pending'), '/tasks?status=pending&route=financeiro', 'abrir tarefas', 'bi-list-check')}
{kpi_card('Pagamentos confirmados', sum(1 for o in payment_opps if str(o.get('stage')) == 'PAYMENT_CONFIRMED'), '/opportunities', 'seguir para envio', 'bi-check2-circle', 'cf-kpi-tone-green')}
</section>
<div class="d-flex justify-content-end mb-3"><a class="btn btn-outline-primary" href="/finance/forecast"><i class="bi bi-graph-up-arrow"></i> Meta e desempenho comercial</a></div>
<section class="card cf-card cf-filter-card"><form method="get" action="/finance" class="row g-3 align-items-end"><div class="col-lg-8"><label class="form-label small fw-bold text-secondary">Procurar</label><input class="form-control" type="search" name="q" value="{esc(q or '')}" placeholder="cliente, fatura, pagamento..."></div><div class="col-lg-4 d-flex gap-2"><button class="btn btn-primary flex-fill" type="submit">Filtrar</button><a class="btn btn-outline-secondary" href="/finance">Limpar</a></div></form></section>
<section class="card cf-card"><div class="card-body p-0"><div class="p-3 border-bottom"><h2 class="cf-section-title">Financeiro operacional</h2><div class="small text-secondary">Pró-formas, faturas e pagamentos a tratar.</div></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Ação</th><th>Cliente</th><th>Estado</th><th>Criada</th><th></th></tr></thead><tbody>{rows}</tbody></table></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">Financeiro operacional</h2><div class="small text-secondary">Orçamentos, faturas e pagamentos a tratar.</div></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Ação</th><th>Cliente</th><th>Estado</th><th>Criada</th><th></th></tr></thead><tbody>{rows}</tbody></table></div></div></section>
"""
return layout("Financeiro", "O que falta faturar ou confirmar?", body, "finance")

View File

@@ -87,7 +87,7 @@ async def integrations_page():
<form method="post" action="/integrations/odoo/test">
<button class="btn btn-outline-primary btn-sm" type="submit">Testar ligação</button>
</form>
<form method="post" action="/integrations/odoo/sync-products">
<form method="post" action="/integrations/odoo/sync-products" hx-confirm="Sincronizar produtos vendáveis do Odoo agora? Esta ação pode atualizar o catálogo e deve ser usada conscientemente.">
<button class="btn btn-primary btn-sm" type="submit">Sincronizar produtos vendáveis</button>
</form>
</div>
@@ -103,7 +103,7 @@ async def integrations_page():
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h2 class="cf-section-title mb-1">Jasmin</h2>
<div class="small text-secondary">Pró-formas, faturas e documentos fiscais.</div>
<div class="small text-secondary">Orçamentos, faturas e documentos fiscais.</div>
</div>
{badge(jasmin_enabled)}
</div>

View File

@@ -8,6 +8,7 @@ from __future__ import annotations
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from urllib.parse import quote
import app.admin_dashboard as legacy
from app.admin_dashboard import * # noqa: F401,F403
@@ -23,6 +24,10 @@ from app.admin_ui.view_models.operations import (
operation_card_title,
operation_primary_label,
operation_status_chip,
operation_due_label,
operation_priority_reason,
operation_queue_label,
operation_value,
)
router = APIRouter()
@@ -40,7 +45,7 @@ def _operation_card_class(item: dict) -> str:
return card_class
def _render_work_item(item: dict) -> str:
def _render_work_item(item: dict, return_to: str = "/operations?scope=all") -> str:
high = is_high_priority(item)
blocked = is_blocked(item)
title = operation_card_title(item)
@@ -67,8 +72,21 @@ def _render_work_item(item: dict) -> str:
priority_chip = "cf-chip-orange"
if status_text == "sem oportunidade comercial":
priority_chip = "cf-chip-gray"
elif status_text == "associação por confirmar":
priority_chip = "cf-chip-purple"
elif status_text == "validação obrigatória":
priority_chip = "cf-chip-orange"
status_chip_html = "" if status_text == "normal" else f'<span class="cf-chip {priority_chip}">{esc(status_text)}</span>'
primary = operation_primary_label(item)
due_label = operation_due_label(item)
queue_text = operation_queue_label(item)
value = operation_value(item)
value_html = money_html(value) if value > 0 else "Valor por definir"
priority_reason = operation_priority_reason(item)
primary_href = str(item.get('href') or '#')
if primary_href.startswith('/tasks/') and return_to:
sep = '&' if '?' in primary_href else '?'
primary_href = f"{primary_href}{sep}return_to={quote(return_to, safe='')}"
# v4.8.9: details are intentionally not rendered in Operations cards.
# Technical metadata remains available in task/opportunity/admin pages, while
# the work queue keeps only decision-making information.
@@ -83,14 +101,21 @@ def _render_work_item(item: dict) -> str:
{status_chip_html}
</div>
</div>
<div class="cf-work-meta d-flex flex-wrap gap-2 small text-secondary mb-2">
<span class="badge text-bg-light border">{esc(queue_text)}</span>
<span>{esc(due_label)}</span>
<span>·</span>
<span>{value_html}</span>
</div>
<div class="cf-work-next compact">
<span>Próxima ação</span>
<strong>{esc(primary)}</strong>
<div class="small text-secondary mt-1 text-break">{esc(detail)}</div>
<div class="small mt-2"><strong>Motivo da prioridade:</strong> {esc(priority_reason)}</div>
</div>
{blockers_html}
<div class="cf-work-actions">
<a class="btn btn-sm btn-primary cf-work-primary-action" href="{esc(item.get('href') or '#')}">{esc(primary)}</a>
<a class="btn btn-sm btn-primary cf-work-primary-action" href="{esc(primary_href)}">{esc(primary)}</a>
<div class="cf-work-secondary-actions">
{chatwoot_button}
{opportunity_button}
@@ -99,15 +124,17 @@ def _render_work_item(item: dict) -> str:
</article>
"""
def _render_work_group(label: str, items: list[dict]) -> str:
def _render_work_group(label: str, items: list[dict], return_to: str = "/operations?scope=all") -> str:
if not items:
return ""
cards = "".join(_render_work_item(item) for item in items)
cards = "".join(_render_work_item(item, return_to=return_to) for item in items)
return f'<div class="cf-work-section-title">{esc(label)}</div><section class="cf-work-section">{cards}</section>'
def render_operations_work_items(model: dict) -> str:
queue_html = _render_work_group("Prioridade alta", model.get("high_items") or []) + _render_work_group("Normal", model.get("normal_items") or [])
scope = str(model.get("scope") or "all").strip() or "all"
return_to = f"/operations?scope={quote(scope, safe='')}"
queue_html = _render_work_group("Prioridade alta", model.get("high_items") or [], return_to=return_to) + _render_work_group("Normal", model.get("normal_items") or [], return_to=return_to)
if not queue_html:
queue_html = '<div class="cf-work-empty"><strong>Sem trabalho pendente neste filtro.</strong><div class="small mt-1">Quando houver ações humanas ou bloqueios concretos, aparecem aqui.</div></div>'
return f'''

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ 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 urllib.parse import quote
import app.admin_dashboard as legacy
from app.admin_dashboard import * # noqa: F401,F403
@@ -117,9 +118,11 @@ async def order_detail_page(opportunity_id: str):
if not material_rows:
material_rows = f'<tr><td colspan="4" class="text-center text-secondary py-4">Sem produtos definidos. <a href="/opportunities/{esc(opportunity_id)}">Adicionar na oportunidade</a>.</td></tr>'
return_to = f"/orders/{opportunity_id}"
task_rows = ""
for task in tasks:
task_rows += f'<li><a class="cf-row-link" href="/tasks/{esc(task.get("id"))}">{esc(action_label(task.get("action_code")))}</a> · {status_badge(task.get("status"))}</li>'
task_href = f"/tasks/{esc(task.get('id'))}?return_to={quote(return_to, safe='')}"
task_rows += f'<li><a class="cf-row-link" href="{esc(task_href)}">{esc(action_label(task.get("action_code")))}</a> · {status_badge(task.get("status"))}</li>'
if not task_rows:
task_rows = '<li class="text-secondary">Sem tarefas associadas.</li>'

View File

@@ -5,7 +5,7 @@ HTMX table partial so filters and operator actions can refresh the outbox
without replacing the full page.
"""
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, PlainTextResponse
import app.admin_dashboard as legacy
from app.admin_dashboard import * # noqa: F401,F403
from app.admin_ui.guidance import outbox_operator_message
@@ -142,6 +142,8 @@ async def outbox_page(
@router.get("/outbox/{outbox_id}", response_class=HTMLResponse)
async def outbox_detail(outbox_id: str):
if not is_uuid_text(outbox_id):
return PlainTextResponse("Identificador de outbox inválido.", status_code=422)
item = get_outbox_item(outbox_id)
if not item:
@@ -194,6 +196,8 @@ async def outbox_detail(outbox_id: str):
@router.post("/outbox/{outbox_id}/retry")
async def outbox_retry(outbox_id: str, request: Request):
if not is_uuid_text(outbox_id):
return PlainTextResponse("Identificador de outbox inválido.", status_code=422)
form = await request.form()
opportunity_id = str(form.get("opportunity_id") or "").strip()
set_outbox_status(outbox_id=outbox_id, status="pending")
@@ -212,6 +216,8 @@ def _outbox_htmx_or_redirect(request: Request, status: str = "all", target_syste
@router.post("/outbox/{outbox_id}/pending")
async def outbox_pending(outbox_id: str, request: Request):
if not is_uuid_text(outbox_id):
return PlainTextResponse("Identificador de outbox inválido.", status_code=422)
set_outbox_status(outbox_id=outbox_id, status="pending")
from app.operator_audit_service import record_operator_action_best_effort
record_operator_action_best_effort(action="outbox_reprocess_requested", entity_type="outbox", entity_id=outbox_id, actor="operator", after={"status": "pending"})
@@ -220,6 +226,8 @@ async def outbox_pending(outbox_id: str, request: Request):
@router.post("/outbox/{outbox_id}/sent")
async def outbox_sent(outbox_id: str, request: Request):
if not is_uuid_text(outbox_id):
return PlainTextResponse("Identificador de outbox inválido.", status_code=422)
set_outbox_status(outbox_id=outbox_id, status="sent")
from app.operator_audit_service import record_operator_action_best_effort
record_operator_action_best_effort(action="outbox_marked_sent", entity_type="outbox", entity_id=outbox_id, actor="operator", after={"status": "sent"})
@@ -228,6 +236,8 @@ async def outbox_sent(outbox_id: str, request: Request):
@router.post("/outbox/{outbox_id}/failed")
async def outbox_failed(outbox_id: str, request: Request):
if not is_uuid_text(outbox_id):
return PlainTextResponse("Identificador de outbox inválido.", status_code=422)
set_outbox_status(outbox_id=outbox_id, status="failed", error="Marcado manualmente como failed.")
from app.operator_audit_service import record_operator_action_best_effort
record_operator_action_best_effort(action="outbox_marked_failed", entity_type="outbox", entity_id=outbox_id, actor="operator", after={"status": "failed"})
@@ -236,6 +246,8 @@ async def outbox_failed(outbox_id: str, request: Request):
@router.post("/outbox/{outbox_id}/ignored")
async def outbox_ignored(outbox_id: str, request: Request):
if not is_uuid_text(outbox_id):
return PlainTextResponse("Identificador de outbox inválido.", status_code=422)
set_outbox_status(outbox_id=outbox_id, status="ignored", error="Ignorado manualmente pelo operador.")
from app.operator_audit_service import record_operator_action_best_effort
record_operator_action_best_effort(action="outbox_ignored", entity_type="outbox", entity_id=outbox_id, actor="operator", after={"status": "ignored"})

View File

@@ -4,12 +4,37 @@ 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 decimal import Decimal, InvalidOperation
from fastapi.responses import PlainTextResponse
import app.admin_dashboard as legacy
from app.admin_dashboard import * # noqa: F401,F403
router = APIRouter()
def _validate_product_form_payload(data: dict) -> str:
sku = str(data.get("sku") or "").strip()
name = str(data.get("name") or "").strip()
if not sku:
return "SKU é obrigatório."
if not name:
return "Nome do produto é obrigatório."
for key, label, allow_blank, min_value in [
("default_unit_price", "Preço base", True, Decimal("0")),
("vat_rate", "IVA", True, Decimal("0")),
]:
value = data.get(key)
if allow_blank and (value is None or str(value).strip() == ""):
continue
try:
decimal_value = Decimal(str(value).replace(",", ".").strip())
except (InvalidOperation, ValueError):
return f"{label} inválido."
if decimal_value < min_value:
return f"{label} não pode ser negativo."
return ""
@router.get("/products", response_class=HTMLResponse)
@router.get("/produtos", response_class=HTMLResponse)
async def products_page(
@@ -186,6 +211,14 @@ async def product_new_page():
@router.post("/products")
async def product_create(request: Request):
data = dict(await request.form())
validation_error = _validate_product_form_payload(data)
if validation_error:
body = f'''
<a class="cf-row-link d-inline-flex mb-3" href="/products">← Voltar a produtos</a>
<section class="alert alert-danger">{esc(validation_error)}</section>
<section class="card cf-card"><div class="card-body p-4">{product_form_html(data, action="/products", submit_label="Criar produto")}</div></section>
'''
return HTMLResponse(layout("Novo produto", "Corrige os campos e tenta novamente", body, "products"), status_code=422)
try:
product_id = create_product(data)
return RedirectResponse(f"/products/{product_id}", status_code=303)
@@ -195,11 +228,13 @@ async def product_create(request: Request):
<section class="alert alert-danger">Erro ao criar produto: {esc(exc)}</section>
<section class="card cf-card"><div class="card-body p-4">{product_form_html(data, action="/products", submit_label="Criar produto")}</div></section>
'''
return layout("Novo produto", "Corrige os campos e tenta novamente", body, "products")
return HTMLResponse(layout("Novo produto", "Corrige os campos e tenta novamente", body, "products"), status_code=422)
@router.get("/products/{product_id}", response_class=HTMLResponse)
async def product_detail_page(product_id: str):
if not is_uuid_text(product_id):
return PlainTextResponse("Identificador de produto inválido.", status_code=422)
product = get_product(product_id)
if not product:
return layout("Produto não encontrado", "Catálogo", '<section class="cf-empty">Produto não encontrado.</section>', "products")
@@ -242,7 +277,18 @@ async def product_detail_page(product_id: str):
@router.post("/products/{product_id}/update")
async def product_update(product_id: str, request: Request):
if not is_uuid_text(product_id):
return PlainTextResponse("Identificador de produto inválido.", status_code=422)
data = dict(await request.form())
validation_error = _validate_product_form_payload(data)
if validation_error:
product = get_product(product_id) or data
body = f'''
<a class="cf-row-link d-inline-flex mb-3" href="/products/{esc(product_id)}">← Voltar ao produto</a>
<section class="alert alert-danger">{esc(validation_error)}</section>
<section class="card cf-card"><div class="card-body p-4">{product_form_html(product, action=f"/products/{product_id}/update", submit_label="Guardar alterações")}</div></section>
'''
return HTMLResponse(layout("Editar produto", "Corrige os campos e tenta novamente", body, "products"), status_code=422)
try:
update_product(product_id, data)
return RedirectResponse(f"/products/{product_id}", status_code=303)
@@ -253,18 +299,24 @@ async def product_update(product_id: str, request: Request):
<section class="alert alert-danger">Erro ao guardar produto: {esc(exc)}</section>
<section class="card cf-card"><div class="card-body p-4">{product_form_html(product, action=f"/products/{product_id}/update", submit_label="Guardar alterações")}</div></section>
'''
return layout("Editar produto", "Corrige os campos e tenta novamente", body, "products")
return HTMLResponse(layout("Editar produto", "Corrige os campos e tenta novamente", body, "products"), status_code=422)
@router.post("/products/{product_id}/toggle")
async def product_toggle(product_id: str, request: Request):
if not is_uuid_text(product_id):
return PlainTextResponse("Identificador de produto inválido.", status_code=422)
data = dict(await request.form())
set_product_active(product_id, str(data.get("active") or "false").lower() == "true")
ok = set_product_active(product_id, str(data.get("active") or "false").lower() == "true")
if not ok:
return PlainTextResponse("Produto não encontrado.", status_code=404)
return RedirectResponse(f"/products/{product_id}", status_code=303)
@router.post("/opportunities/{opportunity_id}/items/add")
async def opportunity_item_add(opportunity_id: str, request: Request):
if not is_uuid_text(opportunity_id):
return PlainTextResponse("Identificador de oportunidade inválido.", status_code=422)
data = dict(await request.form())
try:
add_opportunity_item(
@@ -280,7 +332,8 @@ async def opportunity_item_add(opportunity_id: str, request: Request):
except Exception as exc:
print(f"ClientFlow add opportunity item failed: {exc}", flush=True)
if is_htmx(request):
return HTMLResponse(opportunity_products_panel_html(opportunity_id, error_notice=str(exc)), status_code=409)
return HTMLResponse(opportunity_products_panel_html(opportunity_id, error_notice=str(exc)), status_code=422)
return PlainTextResponse(f"Dados inválidos ao adicionar item: {exc}", status_code=422)
if is_htmx(request):
return HTMLResponse(opportunity_products_panel_html(opportunity_id, notice="Produto adicionado à oportunidade."))
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Produto%20adicionado%20%C3%A0%20oportunidade", status_code=303)
@@ -288,12 +341,15 @@ async def opportunity_item_add(opportunity_id: str, request: Request):
@router.post("/opportunities/{opportunity_id}/items/{item_id}/delete")
async def opportunity_item_delete(opportunity_id: str, item_id: str, request: Request):
if not is_uuid_text(opportunity_id) or not is_uuid_text(item_id):
return PlainTextResponse("Identificador inválido.", status_code=422)
try:
delete_opportunity_item(item_id)
except Exception as exc:
print(f"ClientFlow delete opportunity item failed: {exc}", flush=True)
if is_htmx(request):
return HTMLResponse(opportunity_products_panel_html(opportunity_id, error_notice=str(exc)), status_code=409)
return HTMLResponse(opportunity_products_panel_html(opportunity_id, error_notice=str(exc)), status_code=422)
return PlainTextResponse(f"Dados inválidos ao adicionar item: {exc}", status_code=422)
if is_htmx(request):
return HTMLResponse(opportunity_products_panel_html(opportunity_id, notice="Produto removido."))
return RedirectResponse(f"/opportunities/{opportunity_id}", status_code=303)

View File

@@ -52,7 +52,7 @@ router = APIRouter()
TYPE_LABELS = {
"jasmin_quotation": "Orçamento Jasmin",
"jasmin_proforma": "Pró-forma Jasmin",
"jasmin_proforma": "Orçamento Jasmin legado",
"jasmin_invoice": "Fatura Jasmin",
"odoo_sale_order": "Venda Odoo",
"payment_proof": "Comprovativo",
@@ -85,7 +85,7 @@ def _operation_label(value: str | None) -> str:
labels = {
"odoo_sale_order": "Venda Odoo",
"jasmin_quotation": "Orçamento Jasmin",
"jasmin_proforma": "Pró-forma Jasmin",
"jasmin_proforma": "Orçamento Jasmin legado",
"jasmin_invoice": "Fatura Jasmin",
"document": "Documento",
}
@@ -443,8 +443,8 @@ async def reconciliation_page(status: Optional[str] = "open", external_type: Opt
<h2 class="cf-section-title mb-3">Registar pedido externo</h2>
<form method="post" action="/reconciliation/manual-request" class="d-grid gap-3">
<div class="row g-2"><div class="col-md-4"><label class="form-label small fw-bold">Origem</label><select class="form-select" name="source_channel"><option>WhatsApp</option><option>Telefone</option><option>Email direto</option><option>Presencial</option><option>Outro</option></select></div><div class="col-md-8"><label class="form-label small fw-bold">Nome/contacto</label><input class="form-control" name="customer_name" required></div></div>
<div class="row g-2"><div class="col-md-6"><label class="form-label small fw-bold">Email</label><input class="form-control" name="customer_email"></div><div class="col-md-6"><label class="form-label small fw-bold">Telefone</label><input class="form-control" name="customer_phone"></div></div>
<div class="row g-2"><div class="col-md-6"><label class="form-label small fw-bold">Produto/interesse</label><input class="form-control" name="product_interest" placeholder="Carregador EV, Cabo..."></div><div class="col-md-6"><label class="form-label small fw-bold">Próxima ação</label><select class="form-select" name="action_code"><option value="SEND_QUOTE">Preparar orçamento</option><option value="SEND_INFO">Preparar resposta</option><option value="SEND_PROFORMA">Emitir pró-forma</option><option value="SEND_INVOICE">Emitir fatura</option><option value="REVIEW_MANUALLY">Rever manualmente</option></select></div></div>
<div class="row g-2"><div class="col-md-6"><label class="form-label small fw-bold">Email</label><input class="form-control" name="customer_email" type="email" autocomplete="off"></div><div class="col-md-6"><label class="form-label small fw-bold">Telefone</label><input class="form-control" name="customer_phone" type="tel" autocomplete="off"></div></div>
<div class="row g-2"><div class="col-md-6"><label class="form-label small fw-bold">Produto/interesse</label><input class="form-control" name="product_interest" placeholder="Carregador EV, Cabo..."></div><div class="col-md-6"><label class="form-label small fw-bold">Próxima ação</label><select class="form-select" name="action_code"><option value="SEND_QUOTE">Preparar orçamento</option><option value="SEND_INFO">Preparar resposta</option><option value="SEND_PROFORMA">Enviar orçamento para pagamento</option><option value="SEND_INVOICE">Emitir fatura</option><option value="REVIEW_MANUALLY">Rever manualmente</option></select></div></div>
<div><label class="form-label small fw-bold">Mensagem/pedido</label><textarea class="form-control" rows="3" name="request_text" placeholder="Colar texto do WhatsApp, telefone ou email direto..."></textarea></div>
<div><button class="btn btn-primary" type="submit">Guardar e criar oportunidade</button></div>
</form>
@@ -506,6 +506,12 @@ async def reconciliation_sync_odoo(request: Request):
days = _safe_days(form.get("days"), 3)
result = sync_odoo_reconciliation_candidates(limit=50, days=days)
notice = f"Odoo últimos {result.get('days', days)} dias: analisadas {result.get('seen', 0)} vendas · candidatos criados/atualizados {result.get('created_or_updated', 0)}"
if result.get("already_linked"):
notice += f" · já ligadas {result.get('already_linked', 0)}"
if result.get("resolved_existing"):
notice += f" · candidatos obsoletos resolvidos {result.get('resolved_existing', 0)}"
if result.get("link_conflicts"):
notice += f" · conflitos de ligação {result.get('link_conflicts', 0)}"
if result.get("skipped"):
notice += " · " + str(result.get("skipped"))
return RedirectResponse(f"/reconciliation?days={days}&notice={esc(notice)}", status_code=303)

View File

@@ -0,0 +1,281 @@
"""Sales target and management forecast dashboard."""
from __future__ import annotations
from urllib.parse import urlencode
from fastapi import APIRouter, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from app.admin_dashboard import esc, money_html, stage_label
from app.admin_ui.components import kpi_card
from app.admin_ui.layout import layout
from app.revenue_forecast_service import TARGET_METRICS, get_revenue_forecast, set_sales_target
router = APIRouter()
def _pct(value: object, digits: int = 0) -> str:
try:
return f"{float(value or 0) * 100:.{digits}f}%"
except Exception:
return "0%"
def _number(value: object) -> float:
try:
return float(value or 0)
except Exception:
return 0.0
def _status_alert(status: dict) -> str:
tone = str(status.get("tone") or "gray")
css = {"green": "success", "orange": "warning", "red": "danger", "gray": "secondary"}.get(tone, "secondary")
return f'<div class="alert alert-{css}"><strong>{esc(status.get("label"))}</strong><div>{esc(status.get("message"))}</div></div>'
def _class_badge(value: str) -> str:
mapping = {
"realised": ("Realizado", "success"),
"committed": ("Comprometido", "primary"),
"probable": ("Provável", "warning"),
"realised_other_period": ("Já realizado", "secondary"),
}
label, css = mapping.get(str(value), (value or "", "secondary"))
return f'<span class="badge text-bg-{css}">{esc(label)}</span>'
@router.post("/forecast/target")
@router.post("/finance/forecast/target")
@router.post("/financeiro/previsao/meta")
async def revenue_forecast_target_save(
month: str = Form(...),
metric: str = Form("invoiced"),
target_amount: str = Form("0"),
):
raw = str(target_amount or "0").strip().replace(" ", "")
if "," in raw:
raw = raw.replace(".", "").replace(",", ".")
try:
amount = float(raw)
except ValueError:
amount = 0.0
set_sales_target(month=month, metric=metric, target_amount=amount, updated_by="operator")
return RedirectResponse(f"/forecast?{urlencode({'month': month, 'metric': metric})}", status_code=303)
@router.get("/finance/forecast")
@router.get("/financeiro/previsao")
async def legacy_revenue_forecast(month: str | None = None, metric: str = "invoiced"):
params = {"metric": metric}
if month:
params["month"] = month
return RedirectResponse(f"/forecast?{urlencode(params)}", status_code=302)
@router.get("/forecast", response_class=HTMLResponse)
async def revenue_forecast_page(month: str | None = None, metric: str = "invoiced"):
forecast = get_revenue_forecast(limit=1000, month=month, metric=metric)
summary = forecast["summary"]
management = forecast["management"]
period = forecast["period"]
target = forecast["target"]
month_data = management["month"]
next30 = management["next_30_days"]
recoverable = management.get("recoverable") or {}
target_amount = _number(management["target_amount"])
metric_options = "".join(
f'<option value="{esc(key)}" {"selected" if key == management["metric"] else ""}>{esc(label)}</option>'
for key, label in TARGET_METRICS.items()
)
month_value = str(period["month_start"])[:7]
# Progress bar segments stop at the target; any surplus is shown separately.
if target_amount > 0:
realised_ratio = min(_number(month_data["realised"]) / target_amount, 1.0)
remaining = max(1.0 - realised_ratio, 0.0)
committed_ratio = min(_number(month_data["committed"]) / target_amount, remaining)
remaining = max(remaining - committed_ratio, 0.0)
probable_ratio = min(_number(month_data["probable"]) / target_amount, remaining)
missing_ratio = max(1.0 - realised_ratio - committed_ratio - probable_ratio, 0.0)
progress = f"""
<div class="cf-target-progress" role="img" aria-label="Cumprimento previsto da meta">
<div class="cf-target-segment cf-target-realised" style="width:{realised_ratio*100:.2f}%" title="Realizado"></div>
<div class="cf-target-segment cf-target-committed" style="width:{committed_ratio*100:.2f}%" title="Comprometido"></div>
<div class="cf-target-segment cf-target-probable" style="width:{probable_ratio*100:.2f}%" title="Provável"></div>
<div class="cf-target-segment cf-target-missing" style="width:{missing_ratio*100:.2f}%" title="Falta"></div>
</div>
<div class="d-flex flex-wrap gap-3 small mt-2">
<span><i class="cf-dot cf-dot-realised"></i> Realizado {money_html(month_data['realised'])}</span>
<span><i class="cf-dot cf-dot-committed"></i> Comprometido {money_html(month_data['committed'])}</span>
<span><i class="cf-dot cf-dot-probable"></i> Provável {money_html(month_data['probable'])}</span>
<span><i class="cf-dot cf-dot-missing"></i> Falta {money_html(management['gap'])}</span>
</div>
"""
else:
progress = '<div class="text-secondary">Configura uma meta para visualizar o progresso e o desvio.</div>'
diagnostics_html = "".join(
f"""
<div class="d-flex gap-3 py-3 border-bottom">
<div><span class="badge text-bg-{'danger' if d.get('severity') == 'high' else 'warning'}">{esc(d.get('severity'))}</span></div>
<div><strong>{esc(d.get('label'))}</strong><div class="small text-secondary">{esc(d.get('detail'))}</div></div>
</div>
"""
for d in forecast.get("diagnostics", [])
) or '<div class="text-secondary py-4">Sem riscos relevantes identificados com os dados atuais.</div>'
actions_rows = ""
for action in forecast.get("priority_actions", []):
impact = action.get("impact_amount") or 0
flags = []
if action.get("overdue_tasks"):
flags.append(f"{action['overdue_tasks']} task(s) vencida(s)")
if action.get("has_conflict"):
flags.append("conflito de identidade")
actions_rows += f"""
<tr>
<td><span class="badge text-bg-light border">{esc(action.get('action_group'))}</span></td>
<td><a href="/opportunities/{esc(action.get('id'))}"><strong>{esc(action.get('customer_name') or action.get('title') or 'Oportunidade')}</strong></a><div class="small text-secondary">{esc(action.get('title') or '')}</div></td>
<td>{esc(stage_label(action.get('stage')))}</td>
<td>{money_html(action.get('amount') or 0)}</td>
<td><strong>{esc(action.get('recommended_action'))}</strong><div class="small text-secondary">{esc(', '.join(flags) or '')}</div></td>
<td>{money_html(impact)}</td>
</tr>
"""
if not actions_rows:
actions_rows = '<tr><td colspan="6" class="text-center text-secondary py-5">Sem ações prioritárias calculadas.</td></tr>'
realised_rows = ""
for item in forecast.get("realised_items", [])[:80]:
realised_rows += f"""
<tr>
<td><strong>{esc(item.get('reference') or 'Realizado')}</strong></td>
<td>{money_html(item.get('amount') or 0)}</td>
<td>{esc(str(item.get('realised_at') or '')[:10])}</td>
<td>{f'<a class="btn btn-sm btn-outline-primary" href="/opportunities/{esc(item.get("opportunity_id"))}">Abrir</a>' if item.get('opportunity_id') else ''}</td>
</tr>
"""
if not realised_rows:
realised_rows = '<tr><td colspan="4" class="text-center text-secondary py-4">Sem valor realizado para esta métrica no mês selecionado.</td></tr>'
opportunity_rows = ""
future_valued = [i for i in forecast["items"] if i.get("forecast_class") in {"committed", "probable"} and _number(i.get("amount")) > 0]
for item in future_valued[:80]:
flags = []
if item.get("has_conflict"):
flags.append("conflito fiscal")
if item.get("is_stale"):
flags.append("inativa >30d")
if item.get("overdue_tasks"):
flags.append(f"{item['overdue_tasks']} task(s) vencida(s)")
sample = item.get("probability_sample") or {}
sample_hint = ""
if item.get("probability_source") == "historical_blended":
sample_hint = f" · {int(sample.get('won') or 0)}/{int(sample.get('resolved') or 0)} ganhas"
probability_cell = (
'<strong>100%</strong><div class="small text-secondary">receita comprometida</div>'
if item.get("forecast_class") == "committed"
else f"<strong>{_pct(item.get('effective_probability'))}</strong><div class=\"small text-secondary\">fase {_pct(item.get('probability'))} × atividade {esc(item.get('activity_factor'))}{esc(sample_hint)}</div>"
)
expected_value = item.get("amount") if item.get("forecast_class") == "committed" else item.get("weighted_amount")
opportunity_rows += f"""
<tr>
<td><a href="/opportunities/{esc(item.get('id'))}"><strong>{esc(item.get('customer_name') or item.get('title') or 'Oportunidade')}</strong></a><div class="small text-secondary">{esc(item.get('title') or '')}</div></td>
<td>{esc(stage_label(item.get('stage')))}<div class="mt-1">{_class_badge(item.get('forecast_class'))}</div></td>
<td>{money_html(item.get('amount') or 0)}<div class="small text-secondary">{esc(item.get('value_source'))}</div></td>
<td>{probability_cell}</td>
<td><strong>{money_html(expected_value or 0)}</strong></td>
<td>{esc(str(item.get('expected_date') or '')[:10])}</td>
<td>{esc(', '.join(flags) or '')}</td>
</tr>
"""
if not opportunity_rows:
opportunity_rows = '<tr><td colspan="7" class="text-center text-secondary py-5">Sem oportunidades futuras valorizadas.</td></tr>'
stage_rows = "".join(
f"<tr><td>{esc(stage_label(row.get('stage')))}</td><td>{esc(row.get('count'))}</td><td>{esc(row.get('valued'))}</td><td>{money_html(row.get('gross') or 0)}</td><td>{money_html(row.get('weighted') or 0)}</td></tr>"
for row in forecast.get("stage_summary", [])
)
zero_value_items = [i for i in forecast["items"] if i.get("forecast_class") in {"committed", "probable"} and _number(i.get("amount")) <= 0]
zero_rows = "".join(
f'<tr><td><a href="/opportunities/{esc(item.get("id"))}"><strong>{esc(item.get("customer_name") or item.get("title") or "Oportunidade")}</strong></a><div class="small text-secondary">{esc(item.get("title") or "")}</div></td><td>{esc(stage_label(item.get("stage")))}</td><td>{esc(str(item.get("updated_at") or "")[:10])}</td><td><a class="btn btn-sm btn-outline-primary" href="/opportunities/{esc(item.get("id"))}">Valorizar</a></td></tr>'
for item in zero_value_items[:30]
) or '<tr><td colspan="4" class="text-center text-secondary py-4">Todas as oportunidades futuras têm valor.</td></tr>'
scenarios = management["scenarios"]
body = f"""
<style>
.cf-target-progress {{display:flex;height:22px;border-radius:999px;overflow:hidden;background:#e9ecef}}
.cf-target-segment {{min-width:0;transition:width .2s ease}}
.cf-target-realised {{background:#198754}} .cf-target-committed {{background:#0d6efd}}
.cf-target-probable {{background:#ffc107}} .cf-target-missing {{background:#e9ecef}}
.cf-dot {{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:5px}}
.cf-dot-realised {{background:#198754}} .cf-dot-committed {{background:#0d6efd}}
.cf-dot-probable {{background:#ffc107}} .cf-dot-missing {{background:#ced4da}}
</style>
<div class="alert alert-info">Dashboard de gestão comercial. Separa o que já conta para a meta, o que está comprometido e o que ainda depende de conversão. Não representa tesouraria nem substitui validação contabilística.</div>
<section class="card cf-card mb-3"><div class="card-body p-4">
<form method="post" action="/forecast/target" class="row g-3 align-items-end">
<div class="col-md-3"><label class="form-label fw-bold">Mês da meta</label><input class="form-control" type="month" name="month" value="{esc(month_value)}" required></div>
<div class="col-md-4"><label class="form-label fw-bold">Métrica</label><select class="form-select" name="metric">{metric_options}</select></div>
<div class="col-md-3"><label class="form-label fw-bold">Meta (€)</label><input class="form-control" name="target_amount" inputmode="decimal" value="{esc(f'{target_amount:.2f}'.replace('.', ','))}" placeholder="10000,00"></div>
<div class="col-md-2"><button class="btn btn-primary w-100" type="submit">Guardar meta</button></div>
</form>
<div class="small text-secondary mt-2">Período: {esc(period['month_start'])} a {esc(period['month_end'])} · critério: {esc(management['metric_label'])}</div>
</div></section>
{_status_alert(management['status'])}
<section class="cf-kpi-grid">
{kpi_card('Meta mensal', money_html(target_amount), '/forecast', management['metric_label'], 'bi-bullseye')}
{kpi_card('Realizado', money_html(month_data['realised']), '/forecast', f"{summary['realised_count']} registo(s) no mês", 'bi-check2-circle', 'cf-kpi-tone-green')}
{kpi_card('Comprometido', money_html(month_data['committed']), '/forecast', f"{month_data['committed_count']} oportunidade(s) até ao fim do mês", 'bi-lock')}
{kpi_card('Pipeline provável', money_html(month_data['probable']), '/forecast', f"{month_data['probable_count']} oportunidade(s) ponderadas", 'bi-graph-up-arrow')}
{kpi_card('Previsão total', money_html(month_data['forecast_total']), '/forecast', f"cumprimento {_pct(management['attainment'])}", 'bi-speedometer2')}
{kpi_card('Desvio', money_html(management['gap']), '/forecast', 'falta para suportar a meta' if management['gap'] else f"excedente {money_html(management['surplus'])}", 'bi-exclamation-triangle', 'cf-kpi-tone-red' if management['gap'] else 'cf-kpi-tone-green')}
</section>
<section class="card cf-card mb-3"><div class="card-body p-4">
<div class="d-flex flex-wrap justify-content-between gap-2 mb-3"><div><h2 class="cf-section-title mb-1">Progresso da meta</h2><div class="small text-secondary">Sem dupla contagem entre realizado, comprometido e provável.</div></div><strong>{_pct(management['attainment'])}</strong></div>
{progress}
</div></section>
<div class="row g-3 mb-3">
<div class="col-xl-6"><section class="card cf-card h-100"><div class="card-body p-4"><h2 class="cf-section-title">Até ao fim do mês</h2><div class="display-6 fw-bold">{money_html(month_data['forecast_total'])}</div><div class="small text-secondary">Realizado {money_html(month_data['realised'])} · futuro adicional {money_html(month_data['future_total'])}</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">Próximos 30 dias adicionais</h2><div class="display-6 fw-bold">{money_html(next30['future_total'])}</div><div class="small text-secondary">Até {esc(period['next_30_end'])}; não inclui o realizado do mês.</div></div></section></div>
</div>
<section class="card cf-card mb-3"><div class="card-body p-4">
<div class="d-flex justify-content-between gap-3 flex-wrap">
<div><h2 class="cf-section-title mb-1">Capacidade de recuperação</h2><div class="small text-secondary">Pagamentos já existentes previstos após o fim do mês que podem ser acelerados. Não entram na previsão base.</div></div>
<div class="text-end"><div class="h3 fw-bold mb-0">{money_html(recoverable.get('weighted') or 0)}</div><div class="small text-secondary">{esc(recoverable.get('count') or 0)} pagamento(s) · bruto {money_html(recoverable.get('gross') or 0)}</div></div>
</div>
<div class="row g-3 mt-1">
<div class="col-md-4"><div class="cf-soft-box"><span class="small text-secondary">Previsão com aceleração</span><strong class="d-block h4 mb-0">{money_html(recoverable.get('accelerated_total') or month_data['forecast_total'])}</strong></div></div>
<div class="col-md-4"><div class="cf-soft-box"><span class="small text-secondary">Cumprimento acelerado</span><strong class="d-block h4 mb-0">{_pct(recoverable.get('accelerated_attainment'))}</strong></div></div>
<div class="col-md-4"><div class="cf-soft-box"><span class="small text-secondary">Desvio residual</span><strong class="d-block h4 mb-0">{money_html(recoverable.get('residual_gap') or 0)}</strong></div></div>
</div>
</div></section>
<div class="row g-3 mb-3">
<div class="col-xl-7"><section class="card cf-card h-100"><div class="card-body p-4"><h2 class="cf-section-title mb-2">Diagnóstico do desvio</h2>{diagnostics_html}</div></section></div>
<div class="col-xl-5"><section class="card cf-card h-100"><div class="card-body p-4"><h2 class="cf-section-title mb-3">Cenários até ao fim do mês</h2><table class="table cf-table"><tbody><tr><th>Conservador</th><td class="text-end">{money_html(scenarios['conservative'])}</td></tr><tr><th>Provável</th><td class="text-end fw-bold">{money_html(scenarios['probable'])}</td></tr><tr><th>Com aceleração</th><td class="text-end">{money_html(scenarios['optimistic'])}</td></tr><tr><th>Potencial máximo conhecido</th><td class="text-end">{money_html(scenarios.get('maximum_known') or scenarios['optimistic'])}</td></tr></tbody></table><hr><div class="small text-secondary">Novo pipeline necessário</div><div class="h3 fw-bold">{money_html(management['new_pipeline_required'])}</div><div class="small text-secondary">Conversão usada {_pct(management['new_pipeline_conversion'])} · aproximadamente {esc(management['new_opportunities_required'])} nova(s) oportunidade(s), quando existe valor médio suficiente.</div></div></section></div>
</div>
<section class="card cf-card mb-3"><div class="card-body p-0"><div class="p-3 border-bottom"><h2 class="cf-section-title">Ações de maior impacto</h2><div class="small text-secondary">O que executar hoje para proteger ou recuperar a meta.</div></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Grupo</th><th>Oportunidade</th><th>Fase</th><th>Valor</th><th>Ação recomendada</th><th>Impacto</th></tr></thead><tbody>{actions_rows}</tbody></table></div></div></section>
<section class="card cf-card mb-3"><div class="card-body p-0"><div class="p-3 border-bottom"><h2 class="cf-section-title">Realizado no mês</h2><div class="small text-secondary">Registos que já contam para a métrica selecionada; não voltam a ser somados no pipeline futuro.</div></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Referência</th><th>Valor</th><th>Data</th><th></th></tr></thead><tbody>{realised_rows}</tbody></table></div></div></section>
<section class="card cf-card mb-3"><div class="card-body p-0"><div class="p-3 border-bottom"><h2 class="cf-section-title">Oportunidades que suportam a previsão futura</h2><div class="small text-secondary">Realizado no mês é apresentado separadamente; esta tabela mostra apenas valor adicional comprometido ou provável.</div></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Oportunidade</th><th>Fase</th><th>Valor</th><th>Probabilidade</th><th>Valor esperado</th><th>Data</th><th>Alertas</th></tr></thead><tbody>{opportunity_rows}</tbody></table></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-0"><div class="p-3 border-bottom"><h2 class="cf-section-title">Funil por fase</h2><div class="small text-secondary">Quantidade, cobertura e valor.</div></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Fase</th><th>Total</th><th>Com valor</th><th>Bruto</th><th>Ponderado</th></tr></thead><tbody>{stage_rows}</tbody></table></div></div></section></div>
<div class="col-xl-6"><section class="card cf-card h-100"><div class="card-body p-0"><div class="p-3 border-bottom"><h2 class="cf-section-title">Oportunidades por valorizar</h2><div class="small text-secondary">{esc(summary['unvalued_opportunities'])} de {esc(summary['opportunities'])} sem valor · cobertura {_pct(summary['value_coverage'])} · qualidade {_pct(summary['quality_score'])}.</div></div><div class="cf-table-wrap border-0 rounded-0"><table class="table cf-table"><thead><tr><th>Oportunidade</th><th>Fase</th><th>Atualizada</th><th></th></tr></thead><tbody>{zero_rows}</tbody></table></div></div></section></div>
</div>
"""
return layout("Meta e desempenho comercial", "Acompanhar vendas e decidir quando mudar a abordagem", body, "forecast")

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")

File diff suppressed because it is too large Load Diff