Skip to content

#74 — revenue-forecast

PLANIFIÉ

Priorité: 🟠 HAUTE · Type: C (Celery) · Conteneur: rgz-beat · Code: app/tasks/forecast.pyDépendances: #4 rgz-db, #19 moteur-facturation


Description

Prévision de revenus trimestriales basée sur l'ARPU moyen (Average Revenue Per User) = 45,000 FCFA/revendeur/mois. Génère 3 scénarios pour la direction commerciale :

  1. Pessimiste (-20%) : Perte 20% revendeurs dans 6 mois
  2. Réaliste : Tendance actuelle linéaire
  3. Optimiste (+30%) : Gain 30% revendeurs dans 6 mois

Utilisé pour :

  • Budgeting : Projections trimestrielles/annuelles
  • Planning : Embauches, infrastructure, couts marketing
  • Negociations : Avec partenaires, investisseurs
  • Reporting : Actionnaires, ARCEP (capacité expansion)

Généré une fois par mois, intégré dans rapports de gestion.

Architecture Interne

Flux de Génération

Mensuellement le 1er du mois à 07:00 UTC:

Celery Beat déclenche rgz.forecast.revenue

Récupérer données du mois précédent:
  1. Compter revendeurs actifs (status=active)
  2. Calculer ARPU total:
     SUM(invoice_total_fcfa) / COUNT(DISTINCT reseller_id) / 1
     (mensuel, pas besoin diviser par 12)
  3. Récupérer historique 12 mois:
     SELECT month, count_resellers, total_revenue
     FROM revenue_monthly WHERE month >= NOW() - 12 months

Analyser tendance:
  - Linear regression: resellers = f(month)
  - Slope indique: gain/perte par mois

Générer 3 scénarios à 6 mois:
  1. Pessimiste: slope * 0.8 (réduit de 20%)
  2. Réaliste:   slope * 1.0 (tendance actuelle)
  3. Optimiste:  slope * 1.3 (augmenté de 30%)

Calculer revenue pour chaque scénario:
  Projected_revendeurs * ARPU * 6 mois

Enregistrer forecast + notifier management

Schéma de Données

sql
-- Table source (créée par #19)
TABLE invoices:
  id UUID PK
  reseller_id UUID FK
  month DATE
  total_fcfa DECIMAL(12,2)
  status CHECK(draft|sent|paid|overdue|cancelled)

-- Table mensuelle revenue (créée par #19 ou #74)
TABLE revenue_monthly:
  id UUID PK
  month DATE UNIQUE
  active_reseller_count INT
  total_revenue_fcfa DECIMAL(12,2)
  arpu_fcfa DECIMAL(10,2) (total / reseller_count)
  average_subscribers_per_reseller DECIMAL(10,1)
  churn_percent DECIMAL(5,2)
  new_resellers INT
  churned_resellers INT
  created_at TIMESTAMP

-- Table forecast (créée par #74)
TABLE revenue_forecast:
  id UUID PK
  forecast_month DATE (mois du forecast, ex: 2026-02)
  forecast_horizon_months INT (6)
  base_month DATE (mois actuel -1, ex: 2026-01)
  base_reseller_count INT (487)
  base_arpu_fcfa DECIMAL(10,2) (45000)

  -- Scénario pessimiste
  pessimistic_reseller_count_6m INT
  pessimistic_revenue_6m_fcfa DECIMAL(12,2)
  pessimistic_churn_rate_percent DECIMAL(5,2)

  -- Scénario réaliste
  realistic_reseller_count_6m INT
  realistic_revenue_6m_fcfa DECIMAL(12,2)
  realistic_growth_rate_percent DECIMAL(5,2)
  realistic_churn_rate_percent DECIMAL(5,2)

  -- Scénario optimiste
  optimistic_reseller_count_6m INT
  optimistic_revenue_6m_fcfa DECIMAL(12,2)
  optimistic_growth_rate_percent DECIMAL(5,2)

  trend_slope DECIMAL(10,4) (revendeurs par mois, linear)
  trend_r_squared DECIMAL(5,4) (qualité regression)

  generated_at TIMESTAMP
  reviewed_by UUID FK (CFO)
  reviewed_at TIMESTAMP

Exemple Forecast

FORECAST REVENUE — Février 2026
Base: Janvier 2026
Horizon: 6 mois (Février — Juillet 2026)

═══════════════════════════════════════════════════════════════

BASE METRICS (Janvier 2026):
  Revendeurs actifs:         487
  ARPU moyen:                45,000 FCFA/mois
  Total revenue Janvier:     21.9M FCFA
  Churn rate (dernier mois):  2.1%
  New resellers (dernier mois): 8

ANALYSE TENDANCE (12 mois):
  Pente (linear regression):  3.2 revendeurs/mois (gain)
  R² (qualité):              0.87 (bon fit)
  Interprétation:            +3 revendeurs/mois, tendance stable

═══════════════════════════════════════════════════════════════

SCÉNARIO 1 — PESSIMISTE (-20% churn)
───────────────────────────────────────────────────────────────
Hypothèse: Churn augmente à 4.2% (doublé), growth stagne

Revendeurs (fin juillet):  487 - (6 * 2 revendeurs/mois * 2)
                          = 487 - 24 = 463

Revenue 6 mois:            463 * 45,000 * (6/12)  [moyenne période]
                          ≈ 103.4M FCFA

ARPU impact:               -5% (clients moins engagés)
Final estimate:            103.4M - 5.2M = 98.2M FCFA

───────────────────────────────────────────────────────────────

SCÉNARIO 2 — RÉALISTE (tendance actuelle)
───────────────────────────────────────────────────────────────
Hypothèse: Pente continue, churn stable 2.1%

Revendeurs (fin juillet):  487 + (6 * 3.2 revendeurs/mois)
                          = 487 + 19 = 506

Revenue 6 mois:            506 * 45,000 * (6/12)
                          ≈ 113.9M FCFA

ARPU stable:               45,000 FCFA constant
Final estimate:            113.9M FCFA

───────────────────────────────────────────────────────────────

SCÉNARIO 3 — OPTIMISTE (+30% growth)
───────────────────────────────────────────────────────────────
Hypothèse: Marketing push réussi, gain 4.2 revendeurs/mois

Revendeurs (fin juillet):  487 + (6 * 3.2 * 1.3)
                          = 487 + 25 = 512

Revenue 6 mois:            512 * 45,000 * (6/12)
                          ≈ 115.2M FCFA

ARPU impact:               +3% (economies of scale)
Final estimate:            115.2M + 3.5M = 118.7M FCFA

═══════════════════════════════════════════════════════════════

SUMMARY FOR MANAGEMENT:
┌─────────────────────────────────────────────────────────┐
│ REVENUE FORECAST 6 MOIS (Fév-Jul 2026)                  │
├─────────────────────────────────────────────────────────┤
│ PESSIMISTE (P10):    98.2M FCFA   (-13.8% vs réaliste) │
│ RÉALISTE (P50):     113.9M FCFA   (base de planning)   │
│ OPTIMISTE (P90):    118.7M FCFA   (+4.2% vs réaliste)  │
│                                                         │
│ Range: 98.2M — 118.7M FCFA (+/-10.2%)                 │
│                                                         │
│ Recommendations:                                        │
│ • Budget réaliste + 5M FCFA contingency (pessimiste)  │
│ • Plan capacité serveurs pour +506 revendeurs         │
│ • Market study si churn > 3% (pessimiste trigger)     │
└─────────────────────────────────────────────────────────┘

───────────────────────────────────────────────────────────
Forecast généré: 2026-02-01 à 07:30 UTC
Par: Celery task rgz.forecast.revenue
Reviewed by: [CFO signature — pending]
Prochaine révision: 2026-03-01

Configuration

env
# Revenue Forecast
FORECAST_HORIZON_MONTHS=6
FORECAST_BASE_ARPU_FCFA=45000          # ARPU consensus
FORECAST_PESSIMISTIC_FACTOR=0.8         # 80% growth rate
FORECAST_OPTIMISTIC_FACTOR=1.3          # 130% growth rate
FORECAST_MIN_HISTORICAL_MONTHS=12       # Min data pour regression

# Thresholds
FORECAST_CHURN_ALERT_PERCENT=3.5       # Alert si churn > 3.5%
FORECAST_GROWTH_ALERT_PERCENT=-1.0     # Alert si growth < -1%

# Output
FORECAST_OUTPUT_DIR=/var/reports/forecasts
FORECAST_NOTIFY_CFO=true
FORECAST_SLACK_CHANNEL=#finance-forecasts

Endpoints API

MéthodeRouteDescriptionRéponse
GET/api/v1/reports/forecast?months=6Récupérer forecast courant
GET/api/v1/reports/forecast/historical?periods=12Historique 12 derniers forecastsList[forecast]
POST/api/v1/reports/forecast/generateGénérer forecast manuellement (admin)202 Accepted
GET/api/v1/reports/forecast/sensitivityAnalyse sensibilité (si ARPU change ±10%?)

Authentification: CFO/Finance only + Admin

Celery Task

ChampValeur
Task namergz.forecast.revenue
ScheduleMonthly 1st of month 07:00 UTC
Queuergz.reports
Timeout300s
Retry2x

Logique esquisse:

python
@app.task(name='rgz.forecast.revenue', bind=True)
def forecast_revenue(self):
    """
    Génère forecast revenue 6 mois (3 scénarios)
    """
    try:
        # 1. Récupérer base metrics (mois précédent)
        last_month = datetime.utcnow().replace(day=1) - timedelta(days=1)
        last_month_start = last_month.replace(day=1)

        # Count active resellers
        active_count = db.query(func.count(Reseller.id)).filter(
            Reseller.status == ResellerStatus.active
        ).scalar()

        # ARPU calculation
        last_month_revenue = db.query(
            func.sum(Invoice.total_fcfa)
        ).filter(
            func.date_trunc('month', Invoice.invoice_date) == last_month_start,
            Invoice.status != InvoiceStatus.cancelled
        ).scalar() or 0

        arpu = last_month_revenue / active_count if active_count > 0 else 45000

        # 2. Récupérer historique 12 mois
        historical = db.query(
            RevenueMonthly.month,
            RevenueMonthly.active_reseller_count
        ).filter(
            RevenueMonthly.month >= datetime.utcnow() - timedelta(days=365)
        ).order_by(RevenueMonthly.month).all()

        if len(historical) < 12:
            logger.warn("Insufficient historical data, skipping forecast")
            return {'status': 'skipped', 'reason': 'insufficient_data'}

        # 3. Linear regression
        months_num = np.arange(len(historical))
        reseller_counts = np.array([h[1] for h in historical])

        coeffs = np.polyfit(months_num, reseller_counts, 1)
        slope = coeffs[0]  # revendeurs per month
        intercept = coeffs[1]

        # Calculate R²
        predicted = np.polyval(coeffs, months_num)
        ss_res = np.sum((reseller_counts - predicted) ** 2)
        ss_tot = np.sum((reseller_counts - np.mean(reseller_counts)) ** 2)
        r_squared = 1 - (ss_res / ss_tot) if ss_tot > 0 else 0

        # 4. Générer 3 scénarios
        scenarios = {}
        for scenario_name, growth_factor in [
            ('pessimistic', 0.8),
            ('realistic', 1.0),
            ('optimistic', 1.3)
        ]:
            adjusted_slope = slope * growth_factor
            revendeurs_6m = active_count + (adjusted_slope * 6)
            revenue_6m = revendeurs_6m * arpu * 0.5  # 6 mois = 0.5 années

            scenarios[scenario_name] = {
                'reseller_count': int(revendeurs_6m),
                'revenue_fcfa': int(revenue_6m),
                'growth_rate': float(adjusted_slope * 12)  # Annualized
            }

        # 5. Enregistrer forecast
        forecast = RevenueForecast(
            forecast_month=datetime.utcnow().replace(day=1),
            forecast_horizon_months=6,
            base_month=last_month_start,
            base_reseller_count=active_count,
            base_arpu_fcfa=int(arpu),
            pessimistic_reseller_count_6m=scenarios['pessimistic']['reseller_count'],
            pessimistic_revenue_6m_fcfa=scenarios['pessimistic']['revenue_fcfa'],
            realistic_reseller_count_6m=scenarios['realistic']['reseller_count'],
            realistic_revenue_6m_fcfa=scenarios['realistic']['revenue_fcfa'],
            optimistic_reseller_count_6m=scenarios['optimistic']['reseller_count'],
            optimistic_revenue_6m_fcfa=scenarios['optimistic']['revenue_fcfa'],
            trend_slope=float(slope),
            trend_r_squared=float(r_squared),
            generated_at=datetime.utcnow()
        )
        db.add(forecast)
        db.commit()

        # 6. Notifier CFO
        send_email.delay(
            to='cfo@rgz.bj',
            subject='Monthly Revenue Forecast Generated',
            template='forecast_notification',
            context={'forecast': forecast, 'scenarios': scenarios}
        )

        return {
            'status': 'success',
            'scenarios': scenarios,
            'r_squared': r_squared
        }

    except Exception as e:
        logger.error(f"Revenue forecast failed: {e}")
        self.retry(exc=e, countdown=600)

Commandes Utiles

bash
# Déclencher forecast manuellement
docker-compose exec rgz-api celery -A app.celery_app call rgz.forecast.revenue

# Récupérer forecast courant
curl -H "Authorization: Bearer {cfo_token}" \
  "http://api-rgz.duckdns.org/api/v1/reports/forecast?months=6" | jq

# Analyse sensibilité (ARPU +/- 10%)
curl -H "Authorization: Bearer {cfo_token}" \
  "http://api-rgz.duckdns.org/api/v1/reports/forecast/sensitivity" | jq

# Historique 12 derniers forecasts
curl -H "Authorization: Bearer {cfo_token}" \
  "http://api-rgz.duckdns.org/api/v1/reports/forecast/historical?periods=12" | jq

# Logs forecast generation
docker-compose logs rgz-beat | grep "rgz.forecast.revenue"

# Vérifier revenue_monthly table (debug)
docker-compose exec rgz-db psql -U $POSTGRES_USER -d $POSTGRES_DB -c \
  "SELECT month, active_reseller_count, total_revenue_fcfa, arpu_fcfa FROM revenue_monthly ORDER BY month DESC LIMIT 12;"

Implémentation TODO

  • [ ] Schéma DB revenue_monthly + revenue_forecast
  • [ ] Tâche Celery rgz.forecast.revenue dans app/tasks/forecast.py
  • [ ] Fonction _linear_regression() (numpy polyfit)
  • [ ] Endpoints API GET/POST /api/v1/reports/forecast*
  • [ ] Intégration #19 moteur-facturation (revenue_monthly calculation)
  • [ ] Analyse sensibilité (si ARPU change ±10%)
  • [ ] Email notification CFO avec 3 scénarios
  • [ ] Tests: données synthétiques 12 mois avec tendance
  • [ ] Dashboard finance: visualisation 3 scénarios
  • [ ] Documentation: ARPU definition, regression method, scenario assumptions

Dernière mise à jour: 2026-02-21

PROJET MOSAÏQUE — 81 outils, 22 conteneurs, 500+ revendeurs WiFi Zone