"""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'
{esc(status.get("label"))}
{esc(status.get("message"))}
' 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'{esc(label)}' @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'' 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"""
Realizado {money_html(month_data['realised'])} Comprometido {money_html(month_data['committed'])} Provável {money_html(month_data['probable'])} Falta {money_html(management['gap'])}
""" else: progress = '
Configura uma meta para visualizar o progresso e o desvio.
' diagnostics_html = "".join( f"""
{esc(d.get('severity'))}
{esc(d.get('label'))}
{esc(d.get('detail'))}
""" for d in forecast.get("diagnostics", []) ) or '
Sem riscos relevantes identificados com os dados atuais.
' 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""" {esc(action.get('action_group'))} {esc(action.get('customer_name') or action.get('title') or 'Oportunidade')}
{esc(action.get('title') or '')}
{esc(stage_label(action.get('stage')))} {money_html(action.get('amount') or 0)} {esc(action.get('recommended_action'))}
{esc(', '.join(flags) or '—')}
{money_html(impact)} """ if not actions_rows: actions_rows = 'Sem ações prioritárias calculadas.' realised_rows = "" for item in forecast.get("realised_items", [])[:80]: realised_rows += f""" {esc(item.get('reference') or 'Realizado')} {money_html(item.get('amount') or 0)} {esc(str(item.get('realised_at') or '')[:10])} {f'Abrir' if item.get('opportunity_id') else '—'} """ if not realised_rows: realised_rows = 'Sem valor realizado para esta métrica no mês selecionado.' 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 = ( '100%
receita comprometida
' if item.get("forecast_class") == "committed" else f"{_pct(item.get('effective_probability'))}
fase {_pct(item.get('probability'))} × atividade {esc(item.get('activity_factor'))}{esc(sample_hint)}
" ) expected_value = item.get("amount") if item.get("forecast_class") == "committed" else item.get("weighted_amount") opportunity_rows += f""" {esc(item.get('customer_name') or item.get('title') or 'Oportunidade')}
{esc(item.get('title') or '')}
{esc(stage_label(item.get('stage')))}
{_class_badge(item.get('forecast_class'))}
{money_html(item.get('amount') or 0)}
{esc(item.get('value_source'))}
{probability_cell} {money_html(expected_value or 0)} {esc(str(item.get('expected_date') or '')[:10])} {esc(', '.join(flags) or '—')} """ if not opportunity_rows: opportunity_rows = 'Sem oportunidades futuras valorizadas.' stage_rows = "".join( f"{esc(stage_label(row.get('stage')))}{esc(row.get('count'))}{esc(row.get('valued'))}{money_html(row.get('gross') or 0)}{money_html(row.get('weighted') or 0)}" 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'{esc(item.get("customer_name") or item.get("title") or "Oportunidade")}
{esc(item.get("title") or "")}
{esc(stage_label(item.get("stage")))}{esc(str(item.get("updated_at") or "")[:10])}Valorizar' for item in zero_value_items[:30] ) or 'Todas as oportunidades futuras têm valor.' scenarios = management["scenarios"] body = f"""
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.
Período: {esc(period['month_start'])} a {esc(period['month_end'])} · critério: {esc(management['metric_label'])}
{_status_alert(management['status'])}
{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')}

Progresso da meta

Sem dupla contagem entre realizado, comprometido e provável.
{_pct(management['attainment'])}
{progress}

Até ao fim do mês

{money_html(month_data['forecast_total'])}
Realizado {money_html(month_data['realised'])} · futuro adicional {money_html(month_data['future_total'])}

Próximos 30 dias adicionais

{money_html(next30['future_total'])}
Até {esc(period['next_30_end'])}; não inclui o realizado do mês.

Capacidade de recuperação

Pagamentos já existentes previstos após o fim do mês que podem ser acelerados. Não entram na previsão base.
{money_html(recoverable.get('weighted') or 0)}
{esc(recoverable.get('count') or 0)} pagamento(s) · bruto {money_html(recoverable.get('gross') or 0)}
Previsão com aceleração{money_html(recoverable.get('accelerated_total') or month_data['forecast_total'])}
Cumprimento acelerado{_pct(recoverable.get('accelerated_attainment'))}
Desvio residual{money_html(recoverable.get('residual_gap') or 0)}

Diagnóstico do desvio

{diagnostics_html}

Cenários até ao fim do mês

Conservador{money_html(scenarios['conservative'])}
Provável{money_html(scenarios['probable'])}
Com aceleração{money_html(scenarios['optimistic'])}
Potencial máximo conhecido{money_html(scenarios.get('maximum_known') or scenarios['optimistic'])}

Novo pipeline necessário
{money_html(management['new_pipeline_required'])}
Conversão usada {_pct(management['new_pipeline_conversion'])} · aproximadamente {esc(management['new_opportunities_required'])} nova(s) oportunidade(s), quando existe valor médio suficiente.

Ações de maior impacto

O que executar hoje para proteger ou recuperar a meta.
{actions_rows}
GrupoOportunidadeFaseValorAção recomendadaImpacto

Realizado no mês

Registos que já contam para a métrica selecionada; não voltam a ser somados no pipeline futuro.
{realised_rows}
ReferênciaValorData

Oportunidades que suportam a previsão futura

Realizado no mês é apresentado separadamente; esta tabela mostra apenas valor adicional comprometido ou provável.
{opportunity_rows}
OportunidadeFaseValorProbabilidadeValor esperadoDataAlertas

Funil por fase

Quantidade, cobertura e valor.
{stage_rows}
FaseTotalCom valorBrutoPonderado

Oportunidades por valorizar

{esc(summary['unvalued_opportunities'])} de {esc(summary['opportunities'])} sem valor · cobertura {_pct(summary['value_coverage'])} · qualidade {_pct(summary['quality_score'])}.
{zero_rows}
OportunidadeFaseAtualizada
""" return layout("Meta e desempenho comercial", "Acompanhar vendas e decidir quando mudar a abordagem", body, "forecast")