Skip to content

Como Fazer: API de Relatórios Agregados

Referência FT: FT245 (NENE2-FT/agglog) — API de Relatórios Agregados

Demonstra uma API de relatórios agregados multidimensional onde uma única tabela de pedidos é segmentada em totais de resumo, detalhamento diário, distribuição de status e itens principais — tudo com filtragem opcional por intervalo de datas, COALESCE para agregações seguras com zero e COUNT(CASE WHEN...) para contagens condicionais sem subconsultas.


Rotas

MétodoCaminhoDescrição
POST/ordersRegistrar um pedido
GET/reports/summaryTotal de pedidos, receita, valor médio, contagem de concluídos
GET/reports/dailyReceita e contagem de pedidos por dia
GET/reports/by-statusContagem de pedidos e receita agrupados por status
GET/reports/top-itemsPrincipais itens por receita (limitado, ordenado)

Schema

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 é restrito por um CHECK no nível do banco de dados como proteção adicional. amount é armazenado como inteiro (menor unidade de moeda). created_at é uma string ISO — comparações de datas usam ordenação de string no formato YYYY-MM-DD, que é consistente lexicograficamente com a ordem cronológica.


Agregação de resumo: COALESCE + COUNT(CASE WHEN ...)

O endpoint de resumo retorna várias métricas agregadas em uma única consulta:

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) — retorna 0 em vez de NULL quando a tabela não tem linhas correspondentes. SUM() e AVG() retornam NULL em conjuntos vazios; COALESCE converte isso para um zero seguro.

COUNT(CASE WHEN status = 'completed' THEN 1 END) — conta apenas linhas onde status = 'completed', sem uma subconsulta ou segunda passagem. CASE WHEN retorna NULL para linhas não correspondentes; COUNT ignora NULL, então apenas os pedidos concluídos são contados.

Isso é equivalente a um COUNT filtrado, mas executa em uma única varredura, tornando-o mais eficiente do que consultas separadas para cada status.


Detalhamento diário: substr() para truncamento de data

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) extrai os primeiros 10 caracteres (YYYY-MM-DD) da string ISO datetime, agrupando todos os eventos no mesmo dia do calendário. Esta é uma alternativa ao strftime('%Y-%m-%d', created_at) do SQLite para strings de timestamp no formato ISO 8601 com um prefixo fixo.

GROUP BY date usa o alias — o SQLite suporta aliasing em GROUP BY (ao contrário de alguns outros bancos de dados que exigem repetir a expressão).


Distribuição por status: 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 coloca o status mais comum primeiro. O conjunto de resultados tem no máximo tantas linhas quantos valores de status distintos existirem (quatro neste schema).


Principais itens: classificados por receita com 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 parametrizado seleciona os top N itens por receita total. O parâmetro limit do caminho é limitado no lado do servidor:

php
private const int MAX_LIMIT = 100;

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

min(..., MAX_LIMIT) previne que os clientes solicitem mais de 100 itens. Nota: is_numeric($q['limit']) é usado aqui (em vez de is_int) porque os valores de query string são sempre strings — is_int sempre falharia em entradas de query string.


Cláusula WHERE dinâmica com dateFilter()

Todas as consultas de agregação compartilham um helper dateFilter() que anexa condições apenas quando um limite de data é fornecido:

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];
}

Quando from e to são ambos null, $where é '' — a tabela inteira é varrida. O chamador incorpora {$where} na string SQL antes da consulta ser executada. Os valores reais ainda são parametrizados (?) — apenas a palavra-chave WHERE é interpolada.


Validação de datas: round-trip com createFromFormat()

Aceitar from e to como strings YYYY-MM-DD requer validar que a data é bem formada e semanticamente válida (ex.: 2026-02-30 é rejeitada):

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;
}

Validação em dois passos:

  1. preg_match — rejeita formato não correspondente rapidamente sem overhead de objeto de data.
  2. createFromFormat + round-trip format() — detecta datas semanticamente inválidas como 2026-02-30 (que o PHP transbordaría para 2026-03-02 se validado apenas por regex).

A direção do intervalo também é validada:

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

A comparação de strings funciona corretamente aqui porque ambas as datas são YYYY-MM-DD — um formato onde a ordem lexicográfica é igual à ordem cronológica.


Built-ins do NENE2 usados

Built-inPropósito
ValidationException / ValidationError422 estruturado com array errors
JsonResponseFactory::create()Codifica resposta JSON
Constantes RouterPARAMETERS_ATTRIBUTE para params de caminho

Howtos relacionados

Publicado sob a licença MIT.