"""Commercial target and revenue forecast for ClientFlow. The service deliberately separates: - realised value for the selected target metric and month; - committed backlog not yet realised; - probabilistic open pipeline; - value already realised outside the selected period. It is a management forecast, not accounting recognition or cash-flow advice. """ from __future__ import annotations from calendar import monthrange from datetime import date, datetime, timedelta, timezone from typing import Any, Dict from sqlalchemy import text from app.db import engine DEFAULT_STAGE_PROBABILITIES: dict[str, float] = { "NEW_LEAD": 0.10, "INFO_REQUESTED": 0.14, "INFO_SENT": 0.18, "QUOTE_REQUESTED": 0.24, "QUOTE_SENT": 0.40, "PROFORMA_REQUESTED": 0.48, "PROFORMA_SENT": 0.58, "INVOICE_REQUESTED": 0.62, "INVOICE_SENT": 0.70, "WAITING_PAYMENT": 0.76, "PAYMENT_CONFIRMED": 0.95, "ODOO_ORDER_CREATED": 0.97, "IN_PRODUCTION": 0.98, "ORDER_PREPARATION": 0.98, "READY_TO_SHIP": 0.99, "INVOICED": 0.99, "SHIPMENT_CREATED": 0.995, "SHIPPED": 0.995, "TRACKING_SENT": 0.995, "DELIVERED": 1.0, "WON": 1.0, "REVIEW": 0.08, } STAGE_EXPECTED_DAYS: dict[str, int] = { "PAYMENT_CONFIRMED": 7, "ODOO_ORDER_CREATED": 14, "IN_PRODUCTION": 21, "ORDER_PREPARATION": 14, "READY_TO_SHIP": 7, "INVOICED": 7, "SHIPMENT_CREATED": 7, "SHIPPED": 7, "TRACKING_SENT": 7, "DELIVERED": 3, "WAITING_PAYMENT": 21, "INVOICE_REQUESTED": 21, "INVOICE_SENT": 21, "PROFORMA_REQUESTED": 30, "PROFORMA_SENT": 30, "QUOTE_SENT": 45, "QUOTE_REQUESTED": 60, "INFO_SENT": 75, "INFO_REQUESTED": 90, "NEW_LEAD": 90, "REVIEW": 120, } COMMITTED_STAGES = { "PAYMENT_CONFIRMED", "ODOO_ORDER_CREATED", "IN_PRODUCTION", "ORDER_PREPARATION", "READY_TO_SHIP", "INVOICED", "SHIPMENT_CREATED", "SHIPPED", "TRACKING_SENT", "DELIVERED", "WON", } ADVANCED_VALUE_STAGES = { "QUOTE_SENT", "PROFORMA_REQUESTED", "PROFORMA_SENT", "INVOICE_REQUESTED", "INVOICE_SENT", "WAITING_PAYMENT", *COMMITTED_STAGES, } TARGET_METRICS = { "invoiced": "Faturação emitida", "cash_received": "Pagamentos recebidos", "won_sales": "Vendas ganhas", } _SCHEMA_READY = False def _float(value: Any, default: float = 0.0) -> float: try: return float(value or 0) except (TypeError, ValueError): return default def ensure_revenue_forecast_schema() -> None: global _SCHEMA_READY if _SCHEMA_READY: return with engine.begin() as conn: conn.execute(text(""" CREATE TABLE IF NOT EXISTS sales_targets ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), month_start DATE NOT NULL, metric TEXT NOT NULL DEFAULT 'invoiced', target_amount NUMERIC(14,2) NOT NULL DEFAULT 0, currency TEXT NOT NULL DEFAULT 'EUR', updated_by TEXT NOT NULL DEFAULT 'operator', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE(month_start, metric) ) """)) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_sales_targets_month ON sales_targets(month_start, metric)")) _SCHEMA_READY = True def _normalise_metric(metric: str | None) -> str: key = str(metric or "invoiced").strip().lower() return key if key in TARGET_METRICS else "invoiced" def _parse_month(value: str | date | datetime | None, *, now: datetime | None = None) -> date: now = now or datetime.now(timezone.utc) if isinstance(value, datetime): return value.date().replace(day=1) if isinstance(value, date): return value.replace(day=1) raw = str(value or "").strip() if raw: try: return datetime.strptime(raw[:7], "%Y-%m").date().replace(day=1) except ValueError: pass return now.date().replace(day=1) def month_bounds(value: str | date | datetime | None = None, *, now: datetime | None = None) -> tuple[date, date]: start = _parse_month(value, now=now) return start, date(start.year, start.month, monthrange(start.year, start.month)[1]) def get_sales_target(*, month: str | date | datetime | None = None, metric: str = "invoiced") -> dict[str, Any]: ensure_revenue_forecast_schema() month_start, _ = month_bounds(month) metric = _normalise_metric(metric) with engine.begin() as conn: row = conn.execute(text(""" SELECT month_start, metric, target_amount, currency, updated_by, updated_at FROM sales_targets WHERE month_start = :month_start AND metric = :metric """), {"month_start": month_start, "metric": metric}).mappings().first() if not row: return { "month_start": month_start.isoformat(), "metric": metric, "metric_label": TARGET_METRICS[metric], "target_amount": 0.0, "currency": "EUR", "configured": False, } result = dict(row) result["month_start"] = str(result.get("month_start")) result["target_amount"] = round(_float(result.get("target_amount")), 2) result["metric_label"] = TARGET_METRICS[metric] result["configured"] = result["target_amount"] > 0 return result def set_sales_target( *, month: str | date | datetime, metric: str, target_amount: float, currency: str = "EUR", updated_by: str = "operator", ) -> dict[str, Any]: ensure_revenue_forecast_schema() month_start, _ = month_bounds(month) metric = _normalise_metric(metric) amount = max(0.0, round(_float(target_amount), 2)) with engine.begin() as conn: conn.execute(text(""" INSERT INTO sales_targets(month_start, metric, target_amount, currency, updated_by) VALUES (:month_start, :metric, :target_amount, :currency, :updated_by) ON CONFLICT (month_start, metric) DO UPDATE SET target_amount = EXCLUDED.target_amount, currency = EXCLUDED.currency, updated_by = EXCLUDED.updated_by, updated_at = now() """), { "month_start": month_start, "metric": metric, "target_amount": amount, "currency": str(currency or "EUR").upper()[:3], "updated_by": str(updated_by or "operator")[:120], }) return get_sales_target(month=month_start, metric=metric) def stage_probability(stage: str, historical: dict[str, dict[str, Any]] | None = None) -> tuple[float, str]: stage_key = str(stage or "NEW_LEAD").strip().upper() default = DEFAULT_STAGE_PROBABILITIES.get(stage_key, 0.15) sample = (historical or {}).get(stage_key) or {} resolved = int(sample.get("resolved") or 0) rate = _float(sample.get("win_rate"), default) if resolved >= 8: # Bayesian-style shrinkage avoids replacing the prior with a noisy sample. prior_weight = 8 blended = (rate * resolved + default * prior_weight) / (resolved + prior_weight) return max(0.02, min(blended, 1.0)), "historical_blended" return default, "stage_default" def activity_factor(*, updated_at: datetime | None, overdue_tasks: int = 0, completed_recent: int = 0, has_conflict: bool = False) -> float: now = datetime.now(timezone.utc) if updated_at is None: recency = 0.65 else: if updated_at.tzinfo is None: updated_at = updated_at.replace(tzinfo=timezone.utc) age_days = max((now - updated_at).total_seconds() / 86400.0, 0.0) if age_days <= 7: recency = 1.0 elif age_days <= 14: recency = 0.92 elif age_days <= 30: recency = 0.78 elif age_days <= 60: recency = 0.62 else: recency = 0.48 factor = recency if overdue_tasks: factor *= max(0.65, 1.0 - min(int(overdue_tasks), 4) * 0.08) # Small positive cap: routine task completion must not inflate sales probability. if completed_recent: factor *= min(1.03, 1.0 + min(int(completed_recent), 3) * 0.01) if has_conflict: factor *= 0.60 return round(max(0.20, min(factor, 1.03)), 4) def forecast_bucket(expected_date: datetime, *, now: datetime | None = None) -> str: now = now or datetime.now(timezone.utc) if expected_date.tzinfo is None: expected_date = expected_date.replace(tzinfo=timezone.utc) days = (expected_date - now).days if days <= 30: return "0_30" if days <= 60: return "31_60" if days <= 90: return "61_90" return "90_plus" def management_status(*, target: float, forecast_total: float, quality_score: float, recoverable_total: float = 0.0) -> dict[str, str]: target = max(_float(target), 0.0) forecast_total = max(_float(forecast_total), 0.0) recoverable_total = max(_float(recoverable_total), 0.0) quality_score = max(0.0, min(_float(quality_score), 1.0)) if target <= 0: return {"code": "no_target", "label": "Meta não configurada", "tone": "gray", "message": "Define a meta mensal para calcular o desvio e o cumprimento previsto."} ratio = forecast_total / target if ratio >= 1.0 and quality_score >= 0.60: return {"code": "supported", "label": "Meta suportada", "tone": "green", "message": "O realizado, comprometido e pipeline provável suportam a meta atual."} if ratio >= 1.0: return {"code": "supported_low_confidence", "label": "Meta suportada com baixa confiança", "tone": "orange", "message": "O valor suporta a meta, mas a cobertura ou qualidade dos dados é insuficiente para tratar a previsão como segura."} if forecast_total + recoverable_total >= target and recoverable_total > 0: return {"code": "recoverable", "label": "Meta recuperável", "tone": "orange", "message": "A previsão base está abaixo da meta, mas pagamentos e conversões já existentes podem cobrir o desvio se forem acelerados neste mês."} if ratio >= 0.85: return {"code": "at_risk", "label": "Meta em risco moderado", "tone": "orange", "message": "Acelera oportunidades existentes, pagamentos e valorização antes de aumentar a prospeção."} return {"code": "not_supported", "label": "Meta sem cobertura suficiente", "tone": "red", "message": "Mesmo acelerando o pipeline recuperável conhecido, continua a faltar valor; reforça conversão e novo pipeline."} def _historical_stage_rates(conn: Any) -> dict[str, dict[str, Any]]: rows = conn.execute(text(""" WITH resolved AS ( SELECT id, CASE WHEN upper(stage) IN ('WON','DELIVERED') THEN 1 ELSE 0 END AS won FROM opportunities WHERE lower(status) IN ('closed','won','lost','no_interest') OR upper(stage) IN ('WON','LOST','NO_INTEREST','DELIVERED') ), visits AS ( SELECT DISTINCT e.opportunity_id, upper(e.to_stage) AS stage FROM opportunity_events e WHERE e.to_stage IS NOT NULL AND btrim(e.to_stage) <> '' ) SELECT v.stage, COUNT(*)::int AS resolved, SUM(r.won)::int AS won, AVG(r.won::numeric)::float AS win_rate FROM visits v JOIN resolved r ON r.id = v.opportunity_id GROUP BY v.stage """)).mappings().all() return { str(row.get("stage") or "").upper(): { "resolved": int(row.get("resolved") or 0), "won": int(row.get("won") or 0), "win_rate": _float(row.get("win_rate")), } for row in rows } def _realised_for_period(conn: Any, *, metric: str, period_start: date, period_end: date) -> dict[str, Any]: if metric == "cash_received": rows = conn.execute(text(""" WITH latest_doc AS ( SELECT DISTINCT ON (opportunity_id) opportunity_id, COALESCE(total_amount, amount, 0) AS amount FROM commercial_documents WHERE opportunity_id IS NOT NULL AND COALESCE(is_active, TRUE) = TRUE ORDER BY opportunity_id, CASE WHEN document_kind = 'invoice' THEN 0 ELSE 1 END, COALESCE(document_date, created_at::date) DESC, created_at DESC ) SELECT ol.opportunity_id::text, COALESCE(CASE WHEN COALESCE(ol.payload->>'amount','') ~ '^[0-9]+([.,][0-9]+)?$' THEN replace(ol.payload->>'amount', ',', '.')::numeric END, ld.amount, o.value_amount, 0) AS amount, COALESCE(ol.updated_at, ol.created_at) AS realised_at, COALESCE(ol.external_name, 'Pagamento confirmado') AS reference FROM operation_links ol JOIN opportunities o ON o.id = ol.opportunity_id LEFT JOIN latest_doc ld ON ld.opportunity_id = ol.opportunity_id WHERE ol.system = 'clientflow' AND ol.external_type = 'payment' AND lower(ol.status) = 'confirmed' AND COALESCE(ol.updated_at, ol.created_at)::date BETWEEN :period_start AND :period_end """), {"period_start": period_start, "period_end": period_end}).mappings().all() elif metric == "won_sales": rows = conn.execute(text(""" SELECT o.id::text AS opportunity_id, COALESCE(o.value_amount, ld.amount, 0) AS amount, o.updated_at AS realised_at, COALESCE(o.customer_name, o.title, 'Venda ganha') AS reference FROM opportunities o LEFT JOIN LATERAL ( SELECT COALESCE(total_amount, amount, 0) AS amount FROM commercial_documents d WHERE d.opportunity_id = o.id AND COALESCE(d.is_active, TRUE) = TRUE ORDER BY CASE WHEN d.document_kind = 'invoice' THEN 0 ELSE 1 END, COALESCE(d.document_date, d.created_at::date) DESC LIMIT 1 ) ld ON TRUE WHERE (upper(o.stage) IN ('WON','DELIVERED') OR lower(o.status) IN ('won','closed')) AND o.updated_at::date BETWEEN :period_start AND :period_end """), {"period_start": period_start, "period_end": period_end}).mappings().all() else: rows = conn.execute(text(""" SELECT d.opportunity_id::text, COALESCE(d.total_amount, d.amount, 0) AS amount, COALESCE(d.document_date::timestamp, d.created_at) AS realised_at, COALESCE(d.document_number, d.external_id, 'Fatura') AS reference FROM commercial_documents d WHERE lower(d.document_kind) = 'invoice' AND COALESCE(d.is_active, TRUE) = TRUE AND COALESCE(d.role, 'current') IN ('current','accepted') AND COALESCE(d.document_date, d.created_at::date) BETWEEN :period_start AND :period_end """), {"period_start": period_start, "period_end": period_end}).mappings().all() items = [dict(r) for r in rows] return { "amount": round(sum(_float(r.get("amount")) for r in items), 2), "count": len(items), "opportunity_ids": {str(r.get("opportunity_id")) for r in items if r.get("opportunity_id")}, "items": items, } def _already_realised_ids(conn: Any, *, metric: str) -> set[str]: if metric == "cash_received": sql = """ SELECT DISTINCT opportunity_id::text FROM operation_links WHERE system = 'clientflow' AND external_type = 'payment' AND lower(status) = 'confirmed' """ elif metric == "won_sales": sql = """ SELECT DISTINCT id::text FROM opportunities WHERE upper(stage) IN ('WON','DELIVERED') OR lower(status) IN ('won','closed') """ else: sql = """ SELECT DISTINCT opportunity_id::text FROM commercial_documents WHERE opportunity_id IS NOT NULL AND lower(document_kind) = 'invoice' AND COALESCE(is_active, TRUE) = TRUE AND COALESCE(role, 'current') IN ('current','accepted') """ return {str(r[0]) for r in conn.execute(text(sql)).all() if r[0]} def _stage_summary(items: list[dict[str, Any]]) -> list[dict[str, Any]]: grouped: dict[str, dict[str, Any]] = {} for item in items: stage = str(item.get("stage") or "NEW_LEAD") row = grouped.setdefault(stage, {"stage": stage, "count": 0, "valued": 0, "gross": 0.0, "weighted": 0.0}) row["count"] += 1 if _float(item.get("amount")) > 0: row["valued"] += 1 row["gross"] += _float(item.get("amount")) row["weighted"] += _float(item.get("weighted_amount")) result = list(grouped.values()) for row in result: row["gross"] = round(row["gross"], 2) row["weighted"] = round(row["weighted"], 2) return sorted(result, key=lambda r: (r["weighted"], r["gross"]), reverse=True) def get_revenue_forecast(*, limit: int = 1000, month: str | None = None, metric: str = "invoiced") -> Dict[str, Any]: """Return management target, month and 30/60/90-day pipeline forecasts.""" ensure_revenue_forecast_schema() limit = max(1, min(int(limit or 1000), 5000)) metric = _normalise_metric(metric) now = datetime.now(timezone.utc) period_start, period_end = month_bounds(month, now=now) target = get_sales_target(month=period_start, metric=metric) with engine.begin() as conn: historical = _historical_stage_rates(conn) realised = _realised_for_period(conn, metric=metric, period_start=period_start, period_end=period_end) already_realised_ids = _already_realised_ids(conn, metric=metric) rows = conn.execute(text(""" WITH latest_doc AS ( SELECT DISTINCT ON (opportunity_id) opportunity_id, total_amount, document_number, document_kind, document_date FROM commercial_documents WHERE COALESCE(is_active, TRUE) = TRUE AND COALESCE(role, 'current') IN ('current','accepted','historical','history') ORDER BY opportunity_id, CASE WHEN COALESCE(is_primary, FALSE) THEN 0 ELSE 1 END, CASE document_kind WHEN 'invoice' THEN 1 WHEN 'quotation' THEN 2 WHEN 'proforma' THEN 3 ELSE 4 END, COALESCE(document_date, created_at::date) DESC, created_at DESC ), item_totals AS ( SELECT opportunity_id, SUM(COALESCE(total_price, 0)) AS total FROM opportunity_items GROUP BY opportunity_id ), task_stats AS ( SELECT opportunity_id, COUNT(*) FILTER (WHERE status = 'pending' AND due_at IS NOT NULL AND due_at < now())::int AS overdue_tasks, COUNT(*) FILTER (WHERE status IN ('done','completed') AND COALESCE(done_at, updated_at) >= now() - interval '14 days')::int AS completed_recent FROM tasks WHERE opportunity_id IS NOT NULL GROUP BY opportunity_id ), payment_flags AS ( SELECT opportunity_id, BOOL_OR(system = 'clientflow' AND external_type = 'payment' AND lower(status) = 'confirmed') AS payment_confirmed FROM operation_links GROUP BY opportunity_id ) SELECT o.id::text, o.title, o.customer_name, o.stage, o.status, o.value_amount, o.currency, o.updated_at, o.metadata, ld.total_amount AS document_amount, ld.document_number, ld.document_kind, ld.document_date, it.total AS item_amount, COALESCE(ts.overdue_tasks, 0) AS overdue_tasks, COALESCE(ts.completed_recent, 0) AS completed_recent, COALESCE(pf.payment_confirmed, FALSE) AS payment_confirmed FROM opportunities o LEFT JOIN latest_doc ld ON ld.opportunity_id = o.id LEFT JOIN item_totals it ON it.opportunity_id = o.id LEFT JOIN task_stats ts ON ts.opportunity_id = o.id LEFT JOIN payment_flags pf ON pf.opportunity_id = o.id WHERE lower(o.status) = 'open' AND upper(o.stage) NOT IN ('LOST','NO_INTEREST','ARCHIVED') AND NOT COALESCE(lower(o.metadata->>'exclude_from_funnel') IN ('true','1','yes','sim'), FALSE) ORDER BY o.updated_at DESC LIMIT :limit """), {"limit": limit}).mappings().all() buckets = { "0_30": {"label": "Até 30 dias", "gross": 0.0, "weighted": 0.0, "count": 0, "valued": 0}, "31_60": {"label": "31–60 dias", "gross": 0.0, "weighted": 0.0, "count": 0, "valued": 0}, "61_90": {"label": "61–90 dias", "gross": 0.0, "weighted": 0.0, "count": 0, "valued": 0}, "90_plus": {"label": "Mais de 90 dias", "gross": 0.0, "weighted": 0.0, "count": 0, "valued": 0}, } items: list[dict[str, Any]] = [] valued = stale = conflicts = 0 for row in rows: data = dict(row) metadata = data.get("metadata") if isinstance(data.get("metadata"), dict) else {} document_amount = _float(data.get("document_amount")) item_amount = _float(data.get("item_amount")) opportunity_amount = _float(data.get("value_amount")) if document_amount > 0: amount, value_source = document_amount, "document" elif item_amount > 0: amount, value_source = item_amount, "items" else: amount, value_source = opportunity_amount, "opportunity" if amount > 0: valued += 1 stage = str(data.get("stage") or "NEW_LEAD").upper() probability, probability_source = stage_probability(stage, historical) has_conflict = bool(metadata.get("identity_conflict") or metadata.get("fiscal_conflict") or metadata.get("has_nif_conflict")) conflicts += int(has_conflict) factor = activity_factor( updated_at=data.get("updated_at"), overdue_tasks=int(data.get("overdue_tasks") or 0), completed_recent=int(data.get("completed_recent") or 0), has_conflict=has_conflict, ) effective_probability = max(0.0, min(probability * factor, 1.0)) weighted = round(amount * effective_probability, 2) days = STAGE_EXPECTED_DAYS.get(stage, 90) expected_date = now + timedelta(days=days) bucket_key = forecast_bucket(expected_date, now=now) bucket = buckets[bucket_key] bucket["gross"] = round(bucket["gross"] + amount, 2) bucket["weighted"] = round(bucket["weighted"] + weighted, 2) bucket["count"] += 1 bucket["valued"] += int(amount > 0) updated_at = data.get("updated_at") if updated_at and updated_at.tzinfo is None: updated_at = updated_at.replace(tzinfo=timezone.utc) is_stale = bool(updated_at and (now - updated_at).days > 30) stale += int(is_stale) opp_id = str(data.get("id") or "") realised_in_period = opp_id in realised["opportunity_ids"] already_realised = opp_id in already_realised_ids if realised_in_period: forecast_class = "realised" elif already_realised: forecast_class = "realised_other_period" elif stage in COMMITTED_STAGES: forecast_class = "committed" else: forecast_class = "probable" items.append({ "id": opp_id, "title": data.get("title"), "customer_name": data.get("customer_name"), "stage": stage, "amount": round(amount, 2), "currency": data.get("currency") or "EUR", "value_source": value_source, "document_number": data.get("document_number"), "document_kind": data.get("document_kind"), "document_date": str(data.get("document_date") or ""), "payment_confirmed": bool(data.get("payment_confirmed")), "probability": round(probability, 4), "probability_source": probability_source, "probability_sample": historical.get(stage) or {}, "activity_factor": factor, "effective_probability": round(effective_probability, 4), "weighted_amount": weighted, "expected_date": expected_date.isoformat(), "bucket": bucket_key, "forecast_class": forecast_class, "overdue_tasks": int(data.get("overdue_tasks") or 0), "completed_recent": int(data.get("completed_recent") or 0), "has_conflict": has_conflict, "is_stale": is_stale, "updated_at": str(data.get("updated_at") or ""), }) total = len(items) gross = round(sum(x["amount"] for x in items), 2) weighted = round(sum(x["weighted_amount"] for x in items), 2) value_coverage = round(valued / total, 4) if total else 0.0 quality_score = round(max(0.0, min(1.0, value_coverage * 0.65 + (1 - stale / total if total else 0) * 0.20 + (1 - conflicts / total if total else 0) * 0.15)), 4) month_end_dt = datetime.combine(period_end, datetime.max.time(), tzinfo=timezone.utc) next_30_end = now + timedelta(days=30) def eligible_future(item: dict[str, Any]) -> bool: return item["forecast_class"] in {"committed", "probable"} and item["amount"] > 0 month_items = [i for i in items if eligible_future(i) and datetime.fromisoformat(i["expected_date"]) <= month_end_dt] next_30_items = [i for i in items if eligible_future(i) and datetime.fromisoformat(i["expected_date"]) <= next_30_end] def horizon_summary(scope_items: list[dict[str, Any]], *, include_realised: bool) -> dict[str, Any]: committed_items = [i for i in scope_items if i["forecast_class"] == "committed"] probable_items = [i for i in scope_items if i["forecast_class"] == "probable"] committed_amount = round(sum(i["amount"] for i in committed_items), 2) probable_weighted = round(sum(i["weighted_amount"] for i in probable_items), 2) probable_gross = round(sum(i["amount"] for i in probable_items), 2) realised_amount = realised["amount"] if include_realised else 0.0 return { "realised": realised_amount, "committed": committed_amount, "probable": probable_weighted, "probable_gross": probable_gross, "forecast_total": round(realised_amount + committed_amount + probable_weighted, 2), "future_total": round(committed_amount + probable_weighted, 2), "committed_count": len(committed_items), "probable_count": len(probable_items), "count": len(scope_items), } month_horizon = horizon_summary(month_items, include_realised=True) next_30_horizon = horizon_summary(next_30_items, include_realised=False) target_amount = _float(target.get("target_amount")) forecast_total = month_horizon["forecast_total"] gap = round(max(target_amount - forecast_total, 0.0), 2) surplus = round(max(forecast_total - target_amount, 0.0), 2) attainment = round(forecast_total / target_amount, 4) if target_amount > 0 else 0.0 # Existing WAITING_PAYMENT pipeline expected after month-end can sometimes # be accelerated into the selected month. It remains separate from the base # forecast so the dashboard does not overstate certainty. month_item_ids = {i["id"] for i in month_items} recovery_items = [ i for i in items if i["forecast_class"] == "probable" and i["stage"] == "WAITING_PAYMENT" and i["amount"] > 0 and i["id"] not in month_item_ids ] recoverable_weighted = round(sum(i["weighted_amount"] for i in recovery_items), 2) recoverable_gross = round(sum(i["amount"] for i in recovery_items), 2) accelerated_total = round(forecast_total + recoverable_weighted, 2) maximum_known_total = round(forecast_total + recoverable_gross, 2) residual_gap = round(max(target_amount - accelerated_total, 0.0), 2) accelerated_attainment = round(accelerated_total / target_amount, 4) if target_amount > 0 else 0.0 status = management_status( target=target_amount, forecast_total=forecast_total, quality_score=quality_score, recoverable_total=recoverable_weighted, ) probable_valued = [i for i in items if i["forecast_class"] == "probable" and i["amount"] > 0] avg_conversion = ( sum(i["effective_probability"] for i in probable_valued) / len(probable_valued) if probable_valued else 0.35 ) avg_conversion = max(0.15, min(avg_conversion, 0.75)) new_pipeline_required = round(residual_gap / avg_conversion, 2) if residual_gap > 0 else 0.0 average_value = round(sum(i["amount"] for i in probable_valued) / len(probable_valued), 2) if probable_valued else 0.0 new_opportunities_required = int(-(-new_pipeline_required // average_value)) if new_pipeline_required > 0 and average_value > 0 else 0 conservative = round(realised["amount"] + month_horizon["committed"] * 0.90 + month_horizon["probable"] * 0.75, 2) probable = forecast_total optimistic = accelerated_total future_items = [i for i in items if i["forecast_class"] in {"committed", "probable"}] valued_future = [i for i in future_items if i["amount"] > 0] top4 = sum(i["weighted_amount"] if i["forecast_class"] == "probable" else i["amount"] for i in sorted(valued_future, key=lambda x: (x["weighted_amount"], x["amount"]), reverse=True)[:4]) future_expected_total = sum(i["weighted_amount"] if i["forecast_class"] == "probable" else i["amount"] for i in valued_future) concentration = round(top4 / future_expected_total, 4) if future_expected_total else 0.0 waiting_payment = [i for i in future_items if i["stage"] == "WAITING_PAYMENT" and i["amount"] > 0] operational_blocked = [i for i in future_items if i["forecast_class"] == "committed" and i["amount"] > 0] zero_value_advanced = [i for i in future_items if i["stage"] in ADVANCED_VALUE_STAGES and i["amount"] <= 0] overdue_items = [i for i in future_items if i["overdue_tasks"] > 0] diagnostics: list[dict[str, Any]] = [] if total - valued: diagnostics.append({"severity": "high" if value_coverage < 0.5 else "medium", "label": f"{total - valued} oportunidades sem valor", "detail": f"Cobertura monetária de {value_coverage * 100:.0f}%."}) if waiting_payment: diagnostics.append({"severity": "medium", "label": f"{len(waiting_payment)} pagamentos pendentes", "detail": f"Valor conhecido: {sum(i['amount'] for i in waiting_payment):.2f} EUR."}) if overdue_items: diagnostics.append({"severity": "medium", "label": f"{len(overdue_items)} oportunidades com tasks vencidas", "detail": "Podem atrasar conversão ou execução."}) if concentration >= 0.60: diagnostics.append({"severity": "medium", "label": "Previsão concentrada", "detail": f"As 4 maiores oportunidades representam {concentration * 100:.0f}% do valor esperado futuro."}) if gap > 0 and recoverable_weighted > 0: diagnostics.append({"severity": "medium", "label": f"Meta recuperável: {recoverable_weighted:.2f} EUR ponderados", "detail": f"Acelera {len(recovery_items)} pagamento(s) existente(s). Desvio residual após recuperação: {residual_gap:.2f} EUR."}) if residual_gap > 0: diagnostics.append({"severity": "high", "label": f"Desvio residual de {residual_gap:.2f} EUR", "detail": f"Novo pipeline estimado necessário: {new_pipeline_required:.2f} EUR."}) actions: list[dict[str, Any]] = [] for item in sorted(operational_blocked, key=lambda x: x["amount"], reverse=True)[:5]: actions.append({**item, "action_group": "Executar receita comprometida", "recommended_action": "Concluir a próxima ação operacional", "impact_amount": item["amount"], "priority": 1}) for item in sorted(waiting_payment, key=lambda x: x["amount"], reverse=True)[:5]: actions.append({**item, "action_group": "Acelerar pagamento", "recommended_action": "Contactar e confirmar data de pagamento", "impact_amount": item["weighted_amount"], "priority": 2}) for item in sorted(overdue_items, key=lambda x: (x["weighted_amount"], x["amount"]), reverse=True)[:5]: actions.append({**item, "action_group": "Recuperar atraso", "recommended_action": "Resolver task vencida", "impact_amount": item["weighted_amount"], "priority": 3}) for item in sorted(zero_value_advanced, key=lambda x: x["updated_at"], reverse=True)[:5]: actions.append({**item, "action_group": "Valorizar oportunidade", "recommended_action": "Associar documento ou definir valor", "impact_amount": 0.0, "priority": 4}) deduped_actions: list[dict[str, Any]] = [] seen: set[str] = set() for action in sorted(actions, key=lambda x: (x["priority"], -_float(x["impact_amount"]))): if action["id"] in seen: continue seen.add(action["id"]) deduped_actions.append(action) items.sort(key=lambda x: (x["weighted_amount"], x["amount"]), reverse=True) return { "generated_at": now.isoformat(), "model": "sales_target_management_forecast_v2", "scope": "management_forecast_not_accounting_or_cashflow", "period": {"month_start": period_start.isoformat(), "month_end": period_end.isoformat(), "next_30_end": next_30_end.date().isoformat()}, "target": target, "management": { "metric": metric, "metric_label": TARGET_METRICS[metric], "month": month_horizon, "next_30_days": next_30_horizon, "target_amount": round(target_amount, 2), "gap": gap, "surplus": surplus, "attainment": attainment, "status": status, "recoverable": { "weighted": recoverable_weighted, "gross": recoverable_gross, "count": len(recovery_items), "accelerated_total": accelerated_total, "maximum_known_total": maximum_known_total, "residual_gap": residual_gap, "accelerated_attainment": accelerated_attainment, }, "scenarios": {"conservative": conservative, "probable": probable, "optimistic": optimistic, "maximum_known": maximum_known_total}, "new_pipeline_conversion": round(avg_conversion, 4), "new_pipeline_required": new_pipeline_required, "average_opportunity_value": average_value, "new_opportunities_required": new_opportunities_required, "concentration_top4": concentration, }, "summary": { "opportunities": total, "gross_pipeline": gross, "weighted_pipeline": weighted, "valued_opportunities": valued, "unvalued_opportunities": total - valued, "value_coverage": value_coverage, "stale_opportunities": stale, "identity_conflicts": conflicts, "quality_score": quality_score, "realised_amount": realised["amount"], "realised_count": realised["count"], }, "buckets": buckets, "historical_stage_rates": historical, "stage_summary": _stage_summary(items), "diagnostics": diagnostics, "priority_actions": deduped_actions[:10], "realised_items": realised["items"], "items": items, }