Files
clientflow_backend/app/admin_ui/pages/revenue_forecast.py

282 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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")