Reportes
Los reportes archivan las operaciones del sistema —el registro diario, el balance de masa, el inventario, los movimientos— y la auditoría como documentos PDF/xlsx descargables y verificables. Cada reporte se genera en background y se notifica en tiempo real cuando está listo; no hay vista previa dentro de la aplicación —el reporte es el PDF o el XLSX externo, no una pantalla—. Esta página está orientada al supervisor y al administrador: cómo generar, descargar y verificar un reporte paso a paso. El visor de auditoría (la pantalla que registra quién cambió qué) se documenta en la hermana auditoría.
Quién genera y quién exporta
| Rol | Puede |
|---|---|
| operador · supervisor | Ver y generar reportes operacionales (reports:read, reports:generate) |
| administrador | Ver y generar reportes (reports:read, reports:generate) |
| administrador (único) | Exportar el log de auditoría en CSV (admin:system) — el tipo AUDIT_CSV |
El ingeniero no posee reports:read, así que el ítem "Reportes" no aparece en su menú. El export
del audit log en CSV es exclusivo del administrador (admin:system); el resto de los tipos los puede
generar también el operador y el supervisor.
Los 5 tipos de reporte
| Tipo | Qué archiva | Quién |
|---|---|---|
DAILY_LOG | Registro diario de operaciones del tanque | operador / supervisor / admin |
MASS_BALANCE | Balance de masa por período | operador / supervisor / admin |
INVENTORY | Inventario de productos por tanque o por sitio | operador / supervisor / admin |
MOVEMENT | Movimientos de entrada y salida por período | operador / supervisor / admin |
AUDIT_CSV | Exportación del log de auditoría | admin (único) — ver auditoría |
Los cuatro primeros son reportes operacionales: cualquier rol con reports:generate los puede crear.
El quinto, AUDIT_CSV, es el export del registro de auditoría y es admin-only: corresponde al
mismo artefacto que se documenta en la página de auditoría.
Generar un reporte — paso a paso
- Abrir la página de reportes. Ruta
/reports. Es un Server Component (RSC) que pre-cachea la lista inicial de reportes archivados (ArchivedReport) con sus metadatos ya resueltos (scopeLabel,generatedByLabel). - Pulsar "Generar reporte". El CTA abre el
GenerateDrawer: un formulario validado contraGenerateReportRequestSchema(Zod). Allí se elige el tipo, el scope (tanque, producto, sitio o tipo de movimiento) y el rango de fechas (periodStart/periodEnd). - Solicitar la generación. Al confirmar, el formulario hace
POST /api/v1/reports/generate(autoridadreports:generate). La respuesta es202 Acceptedcon el DTO delArchivedReporty un flagidempotent(ver abajo) — el reporte se procesa después, no de forma síncrona. - El job procesa en background. El reporte entra en una cola por tipo y un worker ejecuta la query, genera el PDF y el XLSX y los sube a almacenamiento (ver Procesamiento y estados).
- Notificación en tiempo real. Cuando el worker termina, el sistema avisa al usuario y la lista se refresca sola (ver Procesamiento y estados).
- Descargar y verificar. Una vez listo (
READY), el reporte se descarga en PDF o XLSX y su integridad se puede verificar con SHA-256 (ver Descargar y verificar).
GenerateReportRequestSchema aplica un clamp de 366 días sobre el rango (periodEnd − periodStart).
Un rango más largo se rechaza en la validación: evita que una solicitud arrastre meses de telemetría y
sature el worker. Si necesitas un horizonte mayor, divídelo en varios reportes consecutivos.
Screenshot pendiente del barrido diferido — ver 61-DEFERRED-SWEEP.md (/reports GenerateDrawer abierto (tipo/scope/rango)).
Idempotencia por scopeHash — no se duplica el trabajo
Cuando solicitas un reporte, el sistema calcula su scopeHash —el SHA-256 del scope— y busca si ya
existe un reporte con la misma combinación (type, scopeHash, periodStart, periodEnd) que no haya
sido reemplazado (supersededById nulo). Si lo encuentra, te devuelve el existente con el flag
idempotent: true en lugar de re-ejecutar la query: dos personas que pidan "el balance de masa del tanque
TK-101 de mayo" obtienen exactamente el mismo documento, sin trabajo duplicado.
El scopeHash es una columna GENERATED en Postgres
(encode(digest(scope::text, 'sha256'), 'hex')): la base de datos lo calcula sola a partir del scope —
nunca se almacena ni se compone a mano—, de modo que dos scopes idénticos siempre colisionan al
mismo hash y dos distintos nunca.
¿Y si necesitas una versión fresca (porque cambiaron los datos de origen)? Entonces regeneras: el
sistema crea un reporte nuevo y le pone supersededById apuntando al anterior. Se forma así una
cadena de versiones —el original nunca se pisa ni se borra—; la idempotencia siempre devuelve el
último vigente (el que aún no tiene supersededById).
Procesamiento y estados
El reporte recorre una pequeña máquina de estados mientras se genera:
| Estado | Significado |
|---|---|
PENDING | Encolado, esperando worker |
RUNNING | El worker está ejecutando la query y renderizando el PDF/XLSX |
READY | Listo y disponible para descargar |
FAILED | Falló; se reintenta hasta un máximo (retryCount ≤ MAX) |
EXPIRED | Agotó los reintentos sin éxito |
Detrás del telón, cada tipo tiene su cola (q:reports:daily-log, q:reports:mass-balance, …) y un
worker dedicado: ejecuta la query SQL, renderiza el PDF y el XLSX, los sube al almacenamiento
de objetos y persiste sus huellas pdfSha256 y xlsxSha256. Un cron de recuperación reintenta
los reportes que se quedaran atascados en RUNNING, y otro detecta los que perdieron su archivo en el
almacenamiento.
La notificación en tiempo real la entrega ReportsSocket: suscribe al usuario a su sala privada
(reports:user:{userId}) y, al terminar el worker, el backend emite report.ready o report.failed;
la lista se invalida sola. Mientras tanto, el InFlightTray —el panel persistente de la derecha—
muestra los jobs en vuelo con un navy pulse dot que late hasta que el reporte queda listo.
Descargar y verificar la integridad
La descarga (GET /api/v1/reports/:id/pdf o .../xlsx) responde con un 302 hacia una URL
presignada del almacenamiento de objetos, con TTL corto (por defecto ~15 minutos). Esa URL nunca
se loguea ni se guarda: solo se audita la emisión del enlace con @Audit({action:'VIEW', debounce:'1h'}) —como máximo un registro de auditoría por usuario y artefacto cada hora—, no la URL en
sí. Pasado el TTL, el enlace caduca y hay que volver a solicitar la descarga.
Para confirmar que el archivo no se alteró, usa la verificación de integridad:
GET /api/v1/reports/:id/verify recalcula el SHA-256 sobre los bytes reales del almacenamiento y lo
compara con el persistido:
200 { match: true }— íntegro: el archivo es bit a bit el que se generó.409 { match: false, computedSha256, persistedSha256 }— drift: el archivo no coincide; se reportan ambos hashes para diagnóstico.
TankOS almacena en SI y convierte solo al renderizar. El mismo reporte se puede leer en SI o en imperial según la preferencia de cada usuario —volúmenes en m³ o en barriles, temperaturas en °C o °F—, y el valor custody almacenado es reproducible bit a bit con independencia de la unidad mostrada. Ver el sistema de unidades SI ⇄ Imperial y la hermana preferencias de unidades.
La lista de reportes
La página /reports muestra una tabla virtualizada con los reportes generados y permite filtrar por
tipo y estado desde la URL (?type=&state=). Cada fila ofrece sus acciones —descargar PDF o
XLSX; eliminar solo el administrador— y, a la derecha, el tray de jobs en vuelo late mientras
hay reportes generándose.

Páginas relacionadas
- La auditoría documenta el visor de
audit_logy el exportAUDIT_CSV(admin-only) que se referencia arriba. - Los tickets de custodia también se firman y se verifican
con SHA-256 (
GET /:id/verify), igual que los reportes. - El sistema de unidades SI ⇄ Imperial y las preferencias de unidades explican cómo leer el mismo reporte en SI o en imperial sin perder precisión.
- Las guías por rol (qué reportes genera cada perfil en su día a día) se publicarán próximamente.