Intermédiaire

Endpoints d'agrégation

Les endpoints d'agrégation ne retournent pas des lignes individuelles, mais des données calculées : totaux, moyennes, comptages, regroupements.

CRUD vs Agrégation

AspectEndpoint CRUDEndpoint d'agrégation
RetourneDes lignes individuellesDes valeurs calculées
ExempleGET /api/v1/ventes (liste de ventes)GET /api/v1/ventes/stats (totaux, moyennes)
Volume de réponsePeut être très grand (pagination nécessaire)Généralement compact
SQL typiqueSELECT * FROM ventesSELECT SUM(montant), COUNT(*) FROM ventes GROUP BY region
UsageAfficher un tableau de donnéesAlimenter un dashboard, un graphique

Cas d'usage typiques

Types d'agrégation courants :

  • Totaux : chiffre d'affaires total, nombre de ventes
  • Moyennes : panier moyen, montant moyen par transaction
  • Comptages : nombre de clients actifs, nombre de commandes par jour
  • Regroupements : ventes par région, CA mensuel, répartition par catégorie
  • Évolutions : tendance mensuelle, comparaison année N vs N-1

Pensez dashboard

Chaque graphique d'un tableau de bord correspond à un endpoint d'agrégation. Un graphique en barres "CA par région" consomme un endpoint /ventes/par-region. Une courbe "évolution mensuelle" consomme /ventes/evolution-mensuelle.

Design des URI

Les endpoints d'agrégation sont des sous-ressources de la collection principale :

GET /api/v1/ventes/stats                   # Statistiques globales
GET /api/v1/ventes/par-region              # Regroupement par région
GET /api/v1/ventes/par-categorie           # Regroupement par catégorie
GET /api/v1/ventes/evolution-mensuelle     # Série temporelle
GET /api/v1/ventes/top-produits            # Classement

Les mêmes filtres que l'endpoint principal peuvent s'appliquer :

GET /api/v1/ventes/par-region?annee=2024
GET /api/v1/ventes/evolution-mensuelle?region=IDF

Implémentation avec SQLite

Statistiques globales

python
@ventes_bp.route('/stats', methods=['GET'])
def statistiques():
    """Statistiques globales des ventes."""
    region = request.args.get('region')
    annee = request.args.get('annee', type=int)

    stats = repository.get_stats(region=region, annee=annee)
    return jsonify(stats)
python
# Dans le Repository
def get_stats(self, region=None, annee=None):
    """Retourne les statistiques agrégées."""
    conn = self._get_connection()

    query = """
        SELECT
            COUNT(*) as nombre_ventes,
            COALESCE(SUM(montant), 0) as chiffre_affaires_total,
            COALESCE(AVG(montant), 0) as montant_moyen,
            COALESCE(MIN(montant), 0) as montant_min,
            COALESCE(MAX(montant), 0) as montant_max
        FROM ventes
        WHERE 1=1
    """
    params = []

    if region:
        query += " AND region = ?"
        params.append(region)
    if annee:
        query += " AND strftime('%Y', date) = ?"
        params.append(str(annee))

    result = conn.execute(query, params).fetchone()
    conn.close()

    return {
        "nombre_ventes": result['nombre_ventes'],
        "chiffre_affaires_total": round(result['chiffre_affaires_total'], 2),
        "montant_moyen": round(result['montant_moyen'], 2),
        "montant_min": result['montant_min'],
        "montant_max": result['montant_max'],
    }

COALESCE

COALESCE(valeur, 0) retourne 0 si la valeur est NULL (par exemple si la table est vide). Sans cela, la réponse JSON contiendrait null au lieu de 0, ce qui peut casser les graphiques côté client.

json
{
  "nombre_ventes": 15420,
  "chiffre_affaires_total": 2845600.50,
  "montant_moyen": 184.54,
  "montant_min": 5.99,
  "montant_max": 12500.00
}

Regroupement par dimension (GROUP BY)

On regroupe les données selon une dimension (région, catégorie, mois) et on calcule des métriques pour chaque groupe.

python
@ventes_bp.route('/par-region', methods=['GET'])
def ventes_par_region():
    """Ventes agrégées par région."""
    annee = request.args.get('annee', type=int)

    result = repository.get_par_region(annee=annee)
    return jsonify({"data": result})
python
# Dans le Repository
def get_par_region(self, annee=None):
    """Ventes groupées par région."""
    conn = self._get_connection()

    query = """
        SELECT
            region,
            COUNT(*) as nombre_ventes,
            SUM(montant) as chiffre_affaires,
            AVG(montant) as panier_moyen
        FROM ventes
        WHERE 1=1
    """
    params = []

    if annee:
        query += " AND strftime('%Y', date) = ?"
        params.append(str(annee))

    query += " GROUP BY region ORDER BY chiffre_affaires DESC"

    rows = conn.execute(query, params).fetchall()
    conn.close()

    return [
        {
            "region": row['region'],
            "nombre_ventes": row['nombre_ventes'],
            "chiffre_affaires": round(row['chiffre_affaires'], 2),
            "panier_moyen": round(row['panier_moyen'], 2),
        }
        for row in rows
    ]
json
{
  "data": [
    {"region": "IDF", "nombre_ventes": 5200, "chiffre_affaires": 1250000.00, "panier_moyen": 240.38},
    {"region": "PACA", "nombre_ventes": 3100, "chiffre_affaires": 680000.00, "panier_moyen": 219.35},
    {"region": "ARA", "nombre_ventes": 2800, "chiffre_affaires": 520000.00, "panier_moyen": 185.71}
  ]
}

Évolution temporelle (série chronologique)

Les séries chronologiques alimentent les courbes d'évolution.

python
@ventes_bp.route('/evolution-mensuelle', methods=['GET'])
def evolution_mensuelle():
    """Évolution mensuelle du chiffre d'affaires."""
    region = request.args.get('region')
    annee = request.args.get('annee', type=int)

    result = repository.get_evolution_mensuelle(region=region, annee=annee)
    return jsonify({"data": result})
python
# Dans le Repository
def get_evolution_mensuelle(self, region=None, annee=None):
    """Évolution mensuelle des ventes."""
    conn = self._get_connection()

    query = """
        SELECT
            strftime('%Y-%m', date) as mois,
            COUNT(*) as nombre_ventes,
            SUM(montant) as chiffre_affaires
        FROM ventes
        WHERE 1=1
    """
    params = []

    if region:
        query += " AND region = ?"
        params.append(region)
    if annee:
        query += " AND strftime('%Y', date) = ?"
        params.append(str(annee))

    query += " GROUP BY mois ORDER BY mois ASC"

    rows = conn.execute(query, params).fetchall()
    conn.close()

    return [
        {
            "mois": row['mois'],
            "nombre_ventes": row['nombre_ventes'],
            "chiffre_affaires": round(row['chiffre_affaires'], 2),
        }
        for row in rows
    ]
json
{
  "data": [
    {"mois": "2024-01", "nombre_ventes": 1200, "chiffre_affaires": 215000.00},
    {"mois": "2024-02", "nombre_ventes": 1350, "chiffre_affaires": 243000.00},
    {"mois": "2024-03", "nombre_ventes": 1580, "chiffre_affaires": 298000.00}
  ]
}

Alternative : pandas pour des agrégations complexes

Pour des calculs plus complexes (percentiles, corrélations, pivots), pandas peut compléter SQL :

python
import pandas as pd
import sqlite3

def get_analyse_croisee(self, annee=None):
    """Analyse croisée région × catégorie avec pandas."""
    conn = sqlite3.connect(self.db_path)

    query = "SELECT region, categorie, montant FROM ventes"
    params = []
    if annee:
        query += " WHERE strftime('%Y', date) = ?"
        params.append(str(annee))

    # Charger dans un DataFrame
    df = pd.read_sql_query(query, conn, params=params)
    conn.close()

    # Tableau croisé dynamique
    pivot = df.pivot_table(
        values='montant',
        index='region',
        columns='categorie',
        aggfunc=['sum', 'count'],
        fill_value=0
    )

    # Convertir en format JSON-friendly
    result = []
    for region in pivot.index:
        entry = {"region": region}
        for categorie in df['categorie'].unique():
            entry[f"{categorie}_ca"] = round(float(pivot.loc[region, ('sum', 'montant', categorie)]), 2)
            entry[f"{categorie}_nb"] = int(pivot.loc[region, ('count', 'montant', categorie)])
        result.append(entry)

    return result

pandas dans une API

pandas dans une API convient pour des volumes modérés (quelques dizaines de milliers de lignes). Pour de très grands volumes, les requêtes SQL directes consomment moins de mémoire.

Structure des réponses d'agrégation

Les réponses d'agrégation n'ont généralement pas besoin de pagination (le résultat est compact). Structure recommandée :

json
{
  "data": [
    {"region": "IDF", "chiffre_affaires": 1250000.00},
    {"region": "PACA", "chiffre_affaires": 680000.00}
  ]
}

Pour les statistiques globales (un seul objet), pas besoin de tableau :

json
{
  "nombre_ventes": 15420,
  "chiffre_affaires_total": 2845600.50,
  "montant_moyen": 184.54
}

Quelle fonction SQL permet de calculer la somme des montants regroupés par région ?

Pourquoi utiliser COALESCE dans les requêtes d'agrégation ?

À retenir

Points clés

  • Les endpoints d'agrégation retournent des données calculées (SUM, COUNT, AVG, GROUP BY)
  • Ils sont conçus comme des sous-ressources : /ventes/stats, /ventes/par-region
  • Les mêmes filtres que l'endpoint principal s'appliquent (région, année, etc.)
  • Utilisez COALESCE pour éviter les valeurs NULL dans les réponses
  • pandas peut compléter SQL pour des agrégations complexes (pivots, percentiles)
  • Les réponses d'agrégation sont généralement compactes et n'ont pas besoin de pagination
  • Chaque graphique d'un dashboard correspond à un endpoint d'agrégation