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.
Requires
- php: >=7.1
- illuminate/console: >=5.0 <14.0
- illuminate/database: >=5.0 <14.0
- illuminate/filesystem: >=5.0 <14.0
- illuminate/support: >=5.0 <14.0
Requires (Dev)
- orchestra/testbench: ^7.0 || ^8.0 || ^9.0 || ^10.0
- phpoffice/phpspreadsheet: ^1.6 || ^2.0 || ^3.0
- phpunit/phpunit: >=6.0
Suggests
- phpoffice/phpspreadsheet: Required to export reports to real Excel formats (xlsx/xls). Not needed for CSV output. (^1.6 || ^2.0 || ^3.0)
Provides
None
Conflicts
None
Replaces
None
This package is not auto-updated.
Last update: 2026-10-15 02:15:23 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) + puenteQueryReport(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).
Este README cubre el módulo ETL Reports (el original, sobre tablas de tu BD transaccional). El paquete incluye además un segundo módulo opt-in, Analytics, para ejecutar SQL sobre datasets CSV/Parquet vía un Execution Engine externo — ver la sección más abajo o
docs/AI_ONBOARDING.mdpara el panorama completo de ambos módulos.
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
| Pieza | Rol |
|---|---|
| Report | Define qué datos exportar (config array, clase PHP o QueryReport). |
| Period | Rango [desde, hasta) + etiqueta, para hour\|day\|week\|month. |
| Writer | Convierte filas en archivo (csv, xlsx, xls). |
| Storage | Guarda el archivo en un disco y nombra por período. |
| Runner | Orquesta: 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 vista | Se queda en tu ReportsService (la web no cambia) |
El paquete no reemplaza la parte web (
paginate(40)). Esa lógica sigue en tuReportsService. 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ámetro | Descripción |
|---|---|
$resolver | function ($period) { ... } → devuelve un query builder (se le hace get()), Collection, array o Generator. |
$headings | Array 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ón | Descripción |
|---|---|
report | Clave 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). |
--current | Usa el período que contiene la fecha/now en vez del anterior. |
--all | Genera 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)
| Clave | Req. | Descripción |
|---|---|---|
table | ✅ | Tabla origen. |
connection | Conexión de BD (null = default). Ej: 'replica'. | |
date_column | Columna para filtrar al rango del período. | |
select | Columnas/agregados (default ['*']). | |
group_by | Columnas para GROUP BY. | |
order_by | ['col' => 'asc'|'desc'] o ['col']. | |
where | Filtros estáticos: ['col','op',valor] o ['col',valor]; null → whereNull. | |
bucket | true añade columna period_bucket agrupada por el período (ej. un mes desglosado por día). | |
headings | Fila de encabezados. | |
format | Override de formato. | |
disk | Override de disco. | |
path | Override de carpeta. | |
chunk_size | Si 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_column | Columna 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)
| Clave | Default | Descripción |
|---|---|---|
format | xlsx | Formato por defecto. |
disk | local | Disco por defecto. |
path | reports | Carpeta por defecto. |
timezone | null | Zona 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 deDownloadReport). Cámbialo conUNNAKI_REPORTS_CSV_DELIMITER=,o enwriters.csv.delimiter.
Formatos y compatibilidad
| Formato | Dependencia | Notas |
|---|---|---|
csv | Ninguna (nativo) | Incluye BOM UTF-8 por defecto para que Excel muestre bien los acentos. Delimitador ; por defecto. |
xlsx | phpoffice/phpspreadsheet | Excel moderno. |
xls | phpoffice/phpspreadsheet | Excel 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();
});
Módulo Analytics (opt-in)
Un segundo motor de reportes, totalmente independiente del módulo ETL de arriba (namespace separado Unnaki\Reports\Analytics\*, sin lógica compartida). En vez de agregar sobre tablas de la BD transaccional, administra metadata de datasets CSV/Parquet y despacha ejecuciones de SQL hacia un Execution Engine Python/DuckDB externo (fuera de este paquete). Pensado para volúmenes que ya no entran cómodos en el Query Builder (millones de filas, archivos de varios GB).
Requiere PostgreSQL (usa
jsonbyFOR UPDATE SKIP LOCKED). El módulo ETL de arriba sigue siendo multi-driver (MySQL/MariaDB, PostgreSQL, SQLite, SQL Server); si tu proyecto no usa Postgres, quedate con ETL.
Instalación
php artisan vendor:publish --tag=unnaki-reports-analytics-migrations
php artisan migrate
Publicar las migraciones activa automáticamente el módulo (auto-detección: busca en database/migrations/ del host un archivo *_create_reporting_datasets_table.php). Podés forzar el comportamiento sin depender de esa detección con UNNAKI_REPORTS_ANALYTICS_ENABLED=true|false en tu .env.
Esto no instala ni corre el Execution Engine Python — es un proceso/repositorio separado (ver engine/README.md). Sin él, las ejecuciones quedan en pending indefinidamente; eso es el comportamiento esperado, no un bug del módulo Laravel.
Cómo funciona
1) Setup (una vez):
reporting:dataset:register → dataset (ej. "ventas")
reporting:dataset:column → columnas + tipo DuckDB + metadata de agregación
reporting:file:register → archivos CSV/Parquet concretos
reporting:report:define → SQL validado por SqlDefinitionValidator
2) Despacho:
reporting:execute <key> → INSERT reporting_report_executions (status='pending')
3) Fuera de este paquete — Execution Engine Python:
poll (FOR UPDATE SKIP LOCKED) → DuckDB → escribe resultado a disco/S3
→ UPDATE status='done' + INSERT reporting_report_results
4) Consulta:
reporting:execution:status [id]
Laravel nunca ejecuta el SQL analítico ni abre DuckDB — solo administra metadata y el "buzón" de ejecuciones.
Modelo de datos
| Tabla | Qué guarda |
|---|---|
reporting_datasets | Un dataset CSV/Parquet (key, source_type, file_glob). |
reporting_dataset_columns | Columnas del dataset: tipo DuckDB, si es filtrable/agrupable/agregable. |
reporting_dataset_files | Archivos concretos registrados para un dataset, por fecha. |
reporting_report_definitions | Un reporte: SQL validado, opcionalmente ligado a un dataset. |
reporting_report_executions | "Buzón" polleado por el Execution Engine (pending → claimed → running → done\|failed). |
reporting_report_results | Referencia al archivo de salida de una ejecución (nunca las filas). |
reporting_report_users | Destinatarios de notificaciones: channels (email/telegram) y reports (keys de report_definitions asignadas a ese usuario). Solo tabla + modelo por ahora — todavía no hay comando/servicio que la use para despachar notificaciones. |
Todas usan el prefijo configurable analytics.table_prefix (default reporting_), pensado para compartir esquema entre proyectos/tenants.
Comandos Artisan
| Comando | Signature | Ejemplo |
|---|---|---|
reporting:dataset:register | {key} {name} {source_type} {file_glob?} | php artisan reporting:dataset:register ventas "Ventas Diarias" csv "ventas_*.csv" |
reporting:dataset:column | {dataset_key} {column_name} {data_type} [--filterable] [--groupable] [--aggregatable] [--agg=] | php artisan reporting:dataset:column ventas monto DOUBLE --aggregatable --agg=SUM,AVG |
reporting:file:register | {dataset_key} {name} {path} {file_date} | php artisan reporting:file:register ventas ventas_20260724.csv /data/ventas_20260724.csv 2026-07-24 |
reporting:report:define | {key} {dataset_key} [--sql=] [--name=] | php artisan reporting:report:define ventas_mensuales ventas --sql="SELECT ..." (prompt interactivo si se omite --sql) |
reporting:execute | {report_key} | php artisan reporting:execute ventas_mensuales |
reporting:execution:status | {execution_id?} | php artisan reporting:execution:status (resumen) o php artisan reporting:execution:status 42 (detalle) |
Desde código (Services)
use Unnaki\Reports\Analytics\Services\DatasetService;
use Unnaki\Reports\Analytics\Services\DatasetColumnService;
use Unnaki\Reports\Analytics\Services\ReportDefinitionService;
use Unnaki\Reports\Analytics\Services\ExecutionDispatcher;
app(DatasetService::class)->register('ventas', 'Ventas Diarias', 'csv', 'ventas_*.csv');
app(DatasetColumnService::class)->add('ventas', 'monto', 'DOUBLE', [
'aggregatable' => true,
'allowed_aggregations' => ['SUM', 'AVG'],
]);
app(ReportDefinitionService::class)->register(
'ventas_mensuales',
'ventas',
'sql',
['sql' => 'SELECT status, SUM(monto) AS total FROM ventas GROUP BY status']
);
$executionId = app(ExecutionDispatcher::class)->dispatchByKey('ventas_mensuales');
Más ejemplos completos (incluyendo cómo se rechaza SQL inválido y cómo extender validadores/eventos) en examples/analytics/.
Seguridad del SQL (V1)
Como en V1 el SQL de cada reporte lo escribe un humano a mano, SqlDefinitionValidator actúa de gatekeeper antes de guardar cualquier ReportDefinition de tipo sql: exige un único statement SELECT/WITH, bloquea keywords como DROP/DELETE/INSERT/ATTACH/COPY/PRAGMA, y solo permite tablas que sean datasets ya registrados. El resto del sandboxing (límites de memoria DuckDB, timeout, sin acceso a filesystem externo) corre del lado del Execution Engine Python.
Extensión
| Qué | Cómo |
|---|---|
Nuevo definition_type (ej. semantic para una futura capa de IA) | app(ReportDefinitionService::class)->registerValidator('semantic', new MiValidador()) implementando DefinitionValidator. |
Reaccionar a eventos (DatasetCreated, ExecutionCompleted, ExecutionFailed, ...) | Listeners en el EventServiceProvider del host — el paquete nunca se escucha a sí mismo. |
| Notificar al Execution Engine sin esperar el polling | Configurar analytics.engine_webhook + una cola no-sync; ExecutionDispatcher despacha NotifyEngineJob automáticamente. |
Qué NO hace (todavía)
- No genera SQL con IA (roadmap V2), no tiene UI de dashboards (roadmap V3).
- No incluye ni corre el Execution Engine Python — es un proceso separado (
engine/README.mdes solo su especificación). - Solo Postgres, no multi-driver como el módulo ETL.
reporting_report_userstodavía no tiene servicio/comando de notificación — por ahora es solo el modelo de datos (recipientes + canales + reportes asignados).
Documentación completa
docs/AI_ONBOARDING.md— inventario de archivos, referencia completa de config/comandos, modelo de seguridad, estado real de implementación.docs/ANALYTICS_ARCHITECTURE.md— arquitectura V1 completa, decisiones y alternativas descartadas, roadmap V1→V2→V3.engine/README.md— especificación del Execution Engine Python.
Tests
composer install
vendor/bin/phpunit
Licencia
MIT.