unnaki/reports

ETL-style report generation/export package for Laravel 5 through 13. Generates aggregated reports (by hour/day/week/month) to CSV/Excel and stores them on any filesystem disk.

Maintainers

Package info

code.unnaki.net/jeremy/composer_unnakireports.git

pkg:composer/unnaki/reports

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

dev-main 2026-07-23 17:38 UTC

This package is not auto-updated.

Last update: 2026-08-04 18:13:43 UTC


README

Paquete Composer para generar y exportar reportes agregados (CSV / Excel) a partir de los datos que ya existen en la base de datos de tus proyectos Laravel, agrupados por hora, día, semana o mes, y guardarlos en cualquier disco de almacenamiento (local, public, s3, ...).

Pensado como una pieza de ETL ligero: levantas el composer en cualquier proyecto, defines tus reportes y generas archivos por período con un comando Artisan que tú programas en tu scheduler/cron.

  • ✅ Compatible Laravel 5 → 13 (PHP >= 7.1).
  • ✅ Salida CSV nativo (sin dependencias) y Excel xlsx/xls (vía PhpSpreadsheet, opcional).
  • ✅ Definición híbrida: arrays de configuración (simple) + clases Report (complejo) + puente QueryReport (migración directa).
  • ✅ Agregación por período con SQL específico por driver (MySQL/MariaDB, PostgreSQL, SQLite, SQL Server).
  • ✅ Guarda en cualquier disco con streaming (memoria plana incluso en reportes grandes).

Instalación

composer require unnaki/reports

Para exportar a Excel (xlsx/xls) instala además PhpSpreadsheet (CSV no lo necesita):

composer require phpoffice/phpspreadsheet

Registro del Service Provider

  • Laravel 5.5+ (auto-discovery): nada que hacer, se registra solo.
  • Laravel 5.0 – 5.4: agrégalo manualmente en config/app.php:
'providers' => [
    // ...
    Unnaki\Reports\UnnakiReportsServiceProvider::class,
],

Publicar configuración

php artisan vendor:publish --tag=unnaki-reports-config

Esto crea config/unnaki-reports.php.

Conceptos

PiezaRol
ReportDefine qué datos exportar (config array, clase PHP o QueryReport).
PeriodRango [desde, hasta) + etiqueta, para hour\|day\|week\|month.
WriterConvierte filas en archivo (csv, xlsx, xls).
StorageGuarda el archivo en un disco y nombra por período.
RunnerOrquesta: report → period → filas → writer → storage.

Convención de nombres de archivo

{path}/{key}_{etiquetaPeriodo}_{granularidad}.{ext}

Ejemplos:

reports/ventas/ventas_2026-06_month.xlsx
reports/ventas/ventas_2026-06-26_day.csv
reports/ventas/ventas_2026-W26_week.xlsx
reports/ventas/ventas_2026-06-26_14_hour.csv

Uso

1) Reporte por configuración (caso simple)

En config/unnaki-reports.php:

'reports' => [
    'ventas' => [
        'table'       => 'orders',
        'date_column' => 'created_at',
        'select'      => ['status', 'COUNT(*) as total', 'SUM(amount) as monto'],
        'group_by'    => ['status'],
        'order_by'    => ['status' => 'asc'],
        'headings'    => ['Estado', 'Cantidad', 'Monto'],
        'format'      => 'xlsx',
        'disk'        => 'local',
        'path'        => 'reports/ventas',
    ],
],

Generar:

php artisan reports:generate ventas --period=month --date=2026-06
php artisan reports:generate ventas --period=day                 # día anterior
php artisan reports:generate ventas --period=day --format=csv

2) Reporte por clase (lógica compleja)

// app/Reports/VentasReport.php
namespace App\Reports;

use Illuminate\Support\Facades\DB;
use Unnaki\Reports\Reports\AbstractReport;

class VentasReport extends AbstractReport
{
    protected $format = 'xlsx';
    protected $path   = 'reports/ventas';

    public function headings()
    {
        return ['Estado', 'Cantidad', 'Monto'];
    }

    public function rows($period)
    {
        return DB::table('orders')
            ->whereBetween('created_at', [
                $period->startFormatted(),
                $period->endFormatted(),
            ])
            ->selectRaw('status, COUNT(*) as total, SUM(amount) as monto')
            ->groupBy('status')
            ->orderBy('status')
            ->get();
    }

    public function map($row)
    {
        return [$row->status, (int) $row->total, (float) $row->monto];
    }
}

Regístrala en config:

'reports' => [
    'ventas' => \App\Reports\VentasReport::class,
],

Para reportes de detalle grandes (sin agregación, ej. una fila por venta), AbstractReport trae un helper chunkedRows($query, $chunkSize, $column = 'id') que pagina la consulta por $column en vez de traer todo con get():

public function rows($period)
{
    $query = DB::table('orders')
        ->whereBetween('created_at', [$period->startFormatted(), $period->endFormatted()]);

    return $this->chunkedRows($query, 2000);
}

3) QueryReport — migrar un ReportsService existente (recomendado)

Si ya tenés métodos tipo ReportsService::purchases() con joins, FilterReport y selectRaw, no reescribas nada: pegá tu query dentro de un QueryReport. El $period reemplaza al viejo addRangeDateFilter(), y headings acepta el mismo string ; que usabas en DownloadReport.

// config/unnaki-reports.php
'reports' => [

    'compras' => \Unnaki\Reports\Reports\QueryReport::make(
        function ($period) {
            $filter = new \FilterReport('purchases_headers', []);
            // El período del paquete reemplaza addRangeDateFilter('created_at'):
            $filter->where = "purchases_headers.created_at >= '" . $period->startFormatted() . "'"
                . " AND purchases_headers.created_at < '" . $period->endFormatted() . "'";
            $filter->addSoftDeleteFilter();

            return \DB::connection('replica')->table('purchases_headers')
                ->join('payment_method', 'payment_method.id', '=', 'purchases_headers.payment_method_id')
                ->join('agents', 'agents.id', '=', 'purchases_headers.agent_id')
                ->join('users', 'users.id', '=', 'purchases_headers.user_id')
                ->selectRaw('purchases_headers.id, purchases_headers.deposit_type, purchases_headers.invoice,
                    purchases_headers.stamping, purchases_headers.total, users.username,
                    agents.description as agent, payment_method.description as payment, purchases_headers.created_at')
                ->whereRaw($filter->where);
        },
        // Mismo header ";" que pasabas a DownloadReport:
        'ID;DEPOSITO;FACTURA;TIMBRADO;TOTAL;USUARIO;AGENTE;TIPO_PAGO;FECHA',
        ['format' => 'csv', 'path' => 'reports/compras', 'delimiter' => ';']
    ),

],

Generarlo por período:

php artisan reports:generate compras --period=day            # compras de ayer
php artisan reports:generate compras --period=month --date=2026-06

¿Qué cambia respecto a tu código actual?

Tu método original:

public static function purchases($input)
{
    $filter = new FilterReport('purchases_headers', $input);
    $filter->addRangeDateFilter('created_at');
    $filter->addAgentFilter('agents.view.all');
    $filter->addSoftDeleteFilter();

    $sqlquery = \DB::connection('replica')->table('purchases_headers')
        ->join('payment_method', 'payment_method.id', '=', 'purchases_headers.payment_method_id')
        ->join('agents', 'agents.id', '=', 'purchases_headers.agent_id')
        ->join('users', 'users.id', '=', 'purchases_headers.user_id')
        ->selectRaw('...')
        ->whereRaw($filter->where);

    if (request('download')) {
        $header  = "ID;DEPOSITO;FACTURA;TIMBRADO;TOTAL;USUARIO;AGENTE;TIPO_PAGO;FECHA";
        $service = new DownloadReport();
        $service->download('csv', $sqlquery->get(), $header, 'Compras_');
    }
    return $sqlquery->paginate(40);
}

Equivalencias:

Antes (ReportsService::purchases)Con el paquete
$filter->addRangeDateFilter('created_at') (rango desde request)$period->startFormatted() / endFormatted() (rango del período)
if (request('download')) { DownloadReport->download('csv', ...) }Lo hace el Runner/comando, y además guarda en disco
Header string "ID;DEPOSITO;..."Igual, lo pasás como 2º argumento
Solo bajo demanda desde la web+ Programable por hora/día/semana/mes
->paginate(40) para la vistaSe queda en tu ReportsService (la web no cambia)

El paquete no reemplaza la parte web (paginate(40)). Esa lógica sigue en tu ReportsService. El paquete se encarga de la exportación por período + almacenamiento + scheduler.

Tip (no duplicar la query): extraé el armado de la query a un método estático y reutilizalo tanto en ReportsService::purchases() (para la web) como en el QueryReport (para la exportación programada):

class ReportsService
{
    public static function purchasesQuery($from, $to)
    {
        $where = "purchases_headers.created_at >= '$from' AND purchases_headers.created_at < '$to'";
        // ... + addSoftDeleteFilter, etc.
        return \DB::connection('replica')->table('purchases_headers')
            ->join(/* ... */)
            ->selectRaw('/* ... */')
            ->whereRaw($where);
    }

    public static function purchases($input)
    {
        // web: usa la fecha del request o un rango por defecto
        return self::purchasesQuery($input['from'], $input['to'])->paginate(40);
    }
}
// config
'compras' => \Unnaki\Reports\Reports\QueryReport::make(
    function ($period) {
        return \ReportsService::purchasesQuery($period->startFormatted(), $period->endFormatted());
    },
    'ID;DEPOSITO;FACTURA;TIMBRADO;TOTAL;USUARIO;AGENTE;TIPO_PAGO;FECHA',
    ['format' => 'csv', 'path' => 'reports/compras', 'delimiter' => ';']
),

Opciones de QueryReport::make($resolver, $headings, $options)

ParámetroDescripción
$resolverfunction ($period) { ... } → devuelve un query builder (se le hace get()), Collection, array o Generator.
$headingsArray o string ;-separado (legacy).
$options['delimiter']Separador para parsear el header string (default ;).
$options['map']function ($row) { return [...]; } mapeo de cada fila.
$options['format']csv\|xlsx\|xls.
$options['disk']Disco destino.
$options['path']Carpeta destino.
$options['key']Clave/nombre base del archivo.
$options['chunk_size']Si el resolver devuelve un query builder, pagina por chunk_column en vez de un único get() — memoria plana para reportes de detalle grandes (miles+ de filas).
$options['chunk_column']Columna ascendente y única usada para paginar (default 'id').

4) Desde código

use Unnaki\Reports\ReportRunner;

$result = app(ReportRunner::class)->run('ventas', 'month', '2026-06');
// $result => ['disk' => 'local', 'path' => 'reports/ventas/ventas_2026-06_month.xlsx', ...]

Comandos Artisan

reports:generate

php artisan reports:generate {report?} [opciones]
OpciónDescripción
reportClave del reporte (omitir si usas --all).
--period=hour\|day\|week\|month (default month).
--date=Fecha de referencia: 2026-06, 2026-06-26, 2026-W26, 2026-06-26 14. Default: período anterior completo.
--format=csv\|xlsx\|xls (override).
--disk=Disco destino (override).
--path=Carpeta destino (override).
--currentUsa el período que contiene la fecha/now en vez del anterior.
--allGenera todos los reportes registrados.

Ejemplos:

php artisan reports:generate ventas --period=day            # ventas de ayer
php artisan reports:generate ventas --period=month --current # mes en curso
php artisan reports:generate --all --period=day             # todos, diario

reports:list

php artisan reports:list

Programar por mes / semana / día / hora

En el Kernel de tu app (app/Console/Kernel.php):

protected function schedule(Schedule $schedule)
{
    // Cada hora reporta la hora anterior completa
    $schedule->command('reports:generate compras --period=hour')->hourly();

    // Cada día a la 01:00 reporta el día anterior
    $schedule->command('reports:generate compras --period=day')->dailyAt('01:00');

    // Cada lunes reporta la semana anterior
    $schedule->command('reports:generate compras --period=week')->weeklyOn(1, '02:00');

    // El día 1 de cada mes reporta el mes anterior
    $schedule->command('reports:generate compras --period=month')->monthlyOn(1, '03:00');

    // Todos los reportes registrados, diario
    $schedule->command('reports:generate --all --period=day')->dailyAt('04:00');
}

Recuerda tener el cron de Laravel activo:

* * * * * cd /ruta/proyecto && php artisan schedule:run >> /dev/null 2>&1

En proyectos Laravel 5 sin scheduler puedes invocar el comando directo desde crontab:

0 1 * * * cd /ruta/proyecto && php artisan reports:generate compras --period=day

Opciones de reporte por configuración (array)

ClaveReq.Descripción
tableTabla origen.
connectionConexión de BD (null = default). Ej: 'replica'.
date_columnColumna para filtrar al rango del período.
selectColumnas/agregados (default ['*']).
group_byColumnas para GROUP BY.
order_by['col' => 'asc'|'desc'] o ['col'].
whereFiltros estáticos: ['col','op',valor] o ['col',valor]; nullwhereNull.
buckettrue añade columna period_bucket agrupada por el período (ej. un mes desglosado por día).
headingsFila de encabezados.
formatOverride de formato.
diskOverride de disco.
pathOverride de carpeta.
chunk_sizeSi se setea (y no hay group_by/bucket), pagina por chunk_column en vez de un único get() — memoria plana para reportes de detalle grandes (ej. una fila por venta, cientos de miles de filas al mes). Incompatible con group_by/bucket.
chunk_columnColumna ascendente y única usada para paginar cuando se usa chunk_size (default 'id').

Reportes de detalle grandes: para exportar filas crudas (no agregadas) de tablas con muchos registros por período, usar chunk_size junto con format: 'csv'. CsvWriter ya escribe en streaming a disco, así que combinado con la lectura paginada la memoria se mantiene plana de punta a punta. El writer de Excel (xlsx/xls) arma el libro completo en memoria vía PhpSpreadsheet sin importar chunk_size, así que no es apto para exportar el detalle de tablas muy grandes.

'ventas_detalle' => [
    'table'        => 'orders',
    'date_column'  => 'created_at',
    'select'       => ['id', 'status', 'amount', 'created_at'],
    'chunk_size'   => 2000,
    'chunk_column' => 'id',
    'headings'     => ['ID', 'Estado', 'Monto', 'Fecha'],
    'format'       => 'csv',
    'path'         => 'reports/ventas',
],

Ejemplo "bucket" (un mes desglosado por día):

'ventas_diarias' => [
    'table'       => 'orders',
    'date_column' => 'created_at',
    'select'      => ['COUNT(*) as total', 'SUM(amount) as monto'],
    'bucket'      => true,
    'headings'    => ['Cantidad', 'Monto', 'Periodo'],
    'format'      => 'csv',
],
php artisan reports:generate ventas_diarias --period=month --date=2026-06

Configuración global (config/unnaki-reports.php)

ClaveDefaultDescripción
formatxlsxFormato por defecto.
disklocalDisco por defecto.
pathreportsCarpeta por defecto.
timezonenullZona horaria para resolver períodos.
writers...Opciones por formato (delimitador CSV, BOM, título de hoja, auto-size...).
reports[]Definiciones de reportes.

CSV con ;: por defecto el delimitador es ; (convención de DownloadReport). Cámbialo con UNNAKI_REPORTS_CSV_DELIMITER=, o en writers.csv.delimiter.

Formatos y compatibilidad

FormatoDependenciaNotas
csvNinguna (nativo)Incluye BOM UTF-8 por defecto para que Excel muestre bien los acentos. Delimitador ; por defecto.
xlsxphpoffice/phpspreadsheetExcel moderno.
xlsphpoffice/phpspreadsheetExcel legado (BIFF).

Si pides xlsx/xls sin PhpSpreadsheet instalado, el paquete lanza un error claro indicando cómo instalarlo. En proyectos Laravel 5 muy antiguos puedes quedarte solo con CSV.

Writers personalizados

use Unnaki\Reports\Writers\WriterFactory;

app(WriterFactory::class)->extend('json', function ($config) {
    return new \App\Reports\Writers\JsonWriter();
});

Tests

composer install
vendor/bin/phpunit

Licencia

MIT.