282 lines
20 KiB
Python
282 lines
20 KiB
Python
"""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")
|