Skip to content

How-to : API de rapports agrégés ​

Référence FT : FT245 (NENE2-FT/agglog) — API de rapports agrégés

Démontre une API de rapports agrégés multi-dimensionnelle où une seule table de commandes est découpée en totaux récapitulatifs, répartition quotidienne, distribution par statut et articles en tête — le tout avec filtrage optionnel par plage de dates, COALESCE pour des agrégations sûres sur les zéros, et COUNT(CASE WHEN...) pour les décomptes conditionnels sans sous-requêtes.


Routes ​

MéthodeCheminDescription
POST/ordersEnregistrer une commande
GET/reports/summaryTotal commandes, revenu, valeur moyenne, nombre de complétées
GET/reports/dailyRevenu et nombre de commandes par jour
GET/reports/by-statusNombre de commandes et revenu groupés par statut
GET/reports/top-itemsMeilleurs articles par revenu (limités, classés)

Schéma ​

sql
CREATE TABLE IF NOT EXISTS orders (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    customer_id TEXT    NOT NULL,
    item_name   TEXT    NOT NULL,
    amount      INTEGER NOT NULL,
    status      TEXT    NOT NULL DEFAULT 'pending'
                    CHECK(status IN ('pending', 'completed', 'refunded', 'cancelled')),
    created_at  TEXT    NOT NULL
);

status est contraint par un CHECK au niveau DB comme filet de sécurité. amount est stocké comme entier (plus petite unité monétaire). created_at est une chaîne ISO — les comparaisons de dates utilisent l'ordre des chaînes au format YYYY-MM-DD, qui est lexicographiquement cohérent avec l'ordre chronologique.


Agrégation de résumé : COALESCE + COUNT(CASE WHEN ...) ​

L'endpoint de résumé retourne plusieurs métriques agrégées dans une seule requête :

php
$row = $this->db->fetchOne(
    "SELECT COUNT(*) AS total_orders,
            COALESCE(SUM(amount), 0) AS total_revenue,
            COALESCE(AVG(amount), 0) AS avg_order_value,
            COUNT(CASE WHEN status = 'completed' THEN 1 END) AS completed_orders
     FROM orders {$where}",
    $params,
);

COALESCE(SUM(amount), 0) — retourne 0 au lieu de NULL quand la table n'a pas de lignes correspondantes. SUM() et AVG() retournent NULL sur des ensembles vides ; COALESCE convertit cela en un zéro sûr.

COUNT(CASE WHEN status = 'completed' THEN 1 END) — compte uniquement les lignes où status = 'completed', sans sous-requête ni second passage. CASE WHEN retourne NULL pour les lignes non correspondantes ; COUNT ignore les NULL, donc seules les commandes complétées sont comptées.

C'est équivalent à un COUNT filtré mais s'exécute en un seul scan, le rendant plus efficace que des requêtes séparées pour chaque statut.


Répartition quotidienne : substr() pour la troncature de date ​

php
$rows = $this->db->fetchAll(
    "SELECT substr(created_at, 1, 10) AS date,
            COUNT(*) AS order_count,
            SUM(amount) AS revenue
     FROM orders {$where}
     GROUP BY date
     ORDER BY date ASC",
    $params,
);

substr(created_at, 1, 10) extrait les 10 premiers caractères (YYYY-MM-DD) de la chaîne datetime ISO, regroupant tous les événements du même jour calendaire. C'est une alternative à strftime('%Y-%m-%d', created_at) de SQLite pour les chaînes de timestamp au format ISO 8601 avec un préfixe fixe.

GROUP BY date utilise l'alias — SQLite supporte les alias dans GROUP BY (contrairement à certaines autres bases de données qui nécessitent de répéter l'expression).


Distribution par statut : GROUP BY status ORDER BY count DESC ​

php
$rows = $this->db->fetchAll(
    "SELECT status, COUNT(*) AS order_count, SUM(amount) AS revenue
     FROM orders {$where}
     GROUP BY status
     ORDER BY order_count DESC",
    $params,
);

ORDER BY order_count DESC place le statut le plus courant en premier. L'ensemble de résultats a au plus autant de lignes qu'il y a de valeurs de statut distinctes (quatre dans ce schéma).


Meilleurs articles : classés par revenu avec LIMIT ​

php
$rows = $this->db->fetchAll(
    "SELECT item_name, COUNT(*) AS order_count, SUM(amount) AS revenue
     FROM orders {$where}
     GROUP BY item_name
     ORDER BY revenue DESC
     LIMIT ?",
    $params,
);

ORDER BY revenue DESC LIMIT ? — LIMIT paramétré sélectionne les N meilleurs articles par revenu total. Le paramètre de chemin limit est limité côté serveur :

php
private const int MAX_LIMIT = 100;

$limit = min((int) $q['limit'], self::MAX_LIMIT);

min(..., MAX_LIMIT) empêche les clients de demander plus de 100 articles. Note : is_numeric($q['limit']) est utilisé ici (plutôt que is_int) car les valeurs de chaîne de requête sont toujours des chaînes — is_int échouerait toujours sur les entrées de chaîne de requête.


Clause WHERE dynamique avec dateFilter() ​

Toutes les requêtes d'agrégation partagent un helper dateFilter() qui n'ajoute des conditions que lorsqu'une borne de date est fournie :

php
private function dateFilter(?string $from, ?string $to): array
{
    $conditions = [];
    $params     = [];
    if ($from !== null) {
        $conditions[] = 'created_at >= ?';
        $params[]     = $from;
    }
    if ($to !== null) {
        $conditions[] = 'created_at <= ?';
        $params[]     = $to;
    }
    $where = $conditions !== [] ? 'WHERE ' . implode(' AND ', $conditions) : '';
    return [$where, $params];
}

Quand from et to sont tous les deux null, $where est '' — la table entière est scannée. L'appelant intègre {$where} dans la chaîne SQL avant que la requête ne soit exécutée. Les valeurs réelles sont toujours paramétrées (?) — seul le mot-clé WHERE est interpolé.


Validation des dates : aller-retour avec createFromFormat() ​

Accepter from et to comme chaînes YYYY-MM-DD nécessite de valider que la date est à la fois bien formée et sémantiquement valide (ex. 2026-02-30 est rejeté) :

php
private function isValidDate(string $date): bool
{
    if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
        return false;
    }
    $d = \DateTimeImmutable::createFromFormat('Y-m-d', $date);
    return $d !== false && $d->format('Y-m-d') === $date;
}

Validation en deux étapes :

  1. preg_match — rejette rapidement les formats non correspondants sans surcharge d'objet date.
  2. createFromFormat + format() en aller-retour — détecte les dates sémantiquement invalides comme 2026-02-30 (que PHP déborderait vers 2026-03-02 si validé uniquement par regex).

La direction de la plage est également validée :

php
if ($from !== null && $to !== null && $from > $to) {
    $errors[] = new ValidationError('from', 'from must be before or equal to to', 'invalid');
}

La comparaison de chaînes fonctionne correctement ici car les deux dates sont au format YYYY-MM-DD — un format où l'ordre lexicographique est égal à l'ordre chronologique.


Fonctions intégrées NENE2 utilisées ​

IntégréObjectif
ValidationException / ValidationError422 structuré avec tableau errors
JsonResponseFactory::create()Encode la réponse JSON
Constantes RouterPARAMETERS_ATTRIBUTE pour les paramètres de chemin

Howtos associés ​

Publié sous licence MIT.