✅ FASE 5 COMPLETADA: Analytics y Administración
Implementados 5 módulos de analytics con agent swarm: 1. DASHBOARD ADMINISTRATIVO - Resumen ejecutivo (reservas, ingresos, usuarios) - Vista del día con alertas - Calendario semanal de ocupación 2. MÉTRICAS DE OCUPACIÓN - Ocupación por fecha, cancha, franja horaria - Horas pico (top 5 demandados) - Comparativa entre períodos - Tendencias de uso 3. MÉTRICAS FINANCIERAS - Ingresos por período, cancha, tipo - Métodos de pago más usados - Estadísticas de reembolsos - Tendencias de crecimiento - Top días de ingresos 4. MÉTRICAS DE USUARIOS - Stats generales y actividad - Top jugadores (por partidos/victorias/puntos) - Detección de churn (riesgo de abandono) - Tasa de retención - Crecimiento mensual 5. EXPORTACIÓN DE DATOS - Exportar a CSV (separado por ;) - Exportar a JSON - Exportar a Excel (múltiples hojas) - Reportes completos descargables Endpoints nuevos (solo admin): - /analytics/dashboard/* - /analytics/occupancy/* - /analytics/revenue/* - /analytics/reports/* - /analytics/users/* - /analytics/exports/* Dependencias: - xlsx - Generación de archivos Excel Utilidades: - Cálculo de crecimiento porcentual - Formateo de moneda - Agrupación por fechas - Relleno de fechas faltantes
This commit is contained in:
79
INTEGRATION.md
Normal file
79
INTEGRATION.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# Integración de Analytics y Exportación
|
||||||
|
|
||||||
|
## Resumen
|
||||||
|
|
||||||
|
Esta fase (5.3) implementa el sistema de métricas de usuarios y exportación de datos para la App Padel.
|
||||||
|
|
||||||
|
## Archivos Creados
|
||||||
|
|
||||||
|
### Servicios
|
||||||
|
- `src/services/analytics/userAnalytics.service.ts` - Métricas y análisis de usuarios
|
||||||
|
- `src/services/analytics/export.service.ts` - Exportación de datos
|
||||||
|
- `src/services/analytics/index.ts` - Exportaciones
|
||||||
|
|
||||||
|
### Controladores
|
||||||
|
- `src/controllers/analytics/userAnalytics.controller.ts` - Controladores de métricas
|
||||||
|
- `src/controllers/analytics/export.controller.ts` - Controladores de exportación
|
||||||
|
- `src/controllers/analytics/index.ts` - Exportaciones
|
||||||
|
|
||||||
|
### Rutas
|
||||||
|
- `src/routes/analytics.routes.ts` - Definición de rutas
|
||||||
|
|
||||||
|
### Utilidades
|
||||||
|
- `src/utils/export.ts` - Funciones utilitarias para exportación
|
||||||
|
|
||||||
|
### Tipos
|
||||||
|
- `src/types/analytics.types.ts` - Definiciones de TypeScript
|
||||||
|
|
||||||
|
### Constantes
|
||||||
|
- `src/constants/export.constants.ts` - Enumeraciones y constantes
|
||||||
|
|
||||||
|
## Instalación de Dependencias
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install xlsx
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integración en App Principal
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import express from 'express';
|
||||||
|
import analyticsRoutes from './routes/analytics.routes';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// ... otras rutas ...
|
||||||
|
|
||||||
|
// Rutas de analytics (proteger con middleware de admin)
|
||||||
|
app.use('/analytics', authenticate, requireAdmin, analyticsRoutes);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Endpoints Disponibles
|
||||||
|
|
||||||
|
### Usuarios
|
||||||
|
- `GET /analytics/users/overview` - Estadísticas generales
|
||||||
|
- `GET /analytics/users/activity?startDate=2024-01-01&endDate=2024-01-31` - Actividad
|
||||||
|
- `GET /analytics/users/top-players?limit=10&by=matches` - Top jugadores
|
||||||
|
- `GET /analytics/users/churn-risk` - Usuarios en riesgo
|
||||||
|
- `GET /analytics/users/retention?startDate=2024-01-01&endDate=2024-06-30` - Retención
|
||||||
|
- `GET /analytics/users/growth?months=6` - Crecimiento
|
||||||
|
|
||||||
|
### Exportación
|
||||||
|
- `GET /analytics/exports/bookings?startDate=2024-01-01&endDate=2024-01-31&format=csv` - Reservas
|
||||||
|
- `GET /analytics/exports/users?format=csv&level=3.5` - Usuarios
|
||||||
|
- `GET /analytics/exports/payments?startDate=2024-01-01&endDate=2024-01-31` - Pagos
|
||||||
|
- `GET /analytics/exports/tournaments/:id?format=csv` - Resultados de torneo
|
||||||
|
- `GET /analytics/exports/excel-report?startDate=2024-01-01&endDate=2024-01-31` - Reporte Excel
|
||||||
|
|
||||||
|
## Formatos de Exportación
|
||||||
|
|
||||||
|
- `csv` - Archivo CSV separado por punto y coma (optimizado para Excel español)
|
||||||
|
- `json` - Archivo JSON formateado
|
||||||
|
- `excel` - Archivo Excel (.xlsx) con múltiples hojas
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
- Todos los endpoints requieren autenticación de administrador
|
||||||
|
- Las fechas deben estar en formato ISO 8601
|
||||||
|
- Las exportaciones CSV usan punto y coma (;) como separador
|
||||||
|
- Para grandes volúmenes de datos, considerar implementar paginación o streaming
|
||||||
136
README_FASE_5.3.md
Normal file
136
README_FASE_5.3.md
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
# Fase 5.3 - Métricas de Usuarios y Exportación
|
||||||
|
|
||||||
|
## 📊 Resumen
|
||||||
|
|
||||||
|
Implementación del sistema completo de analytics y exportación de datos para la aplicación Padel.
|
||||||
|
|
||||||
|
## 📁 Estructura de Archivos
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── constants/
|
||||||
|
│ ├── export.constants.ts # Enum ExportFormat, ChurnRiskLevel
|
||||||
|
│ └── index.ts
|
||||||
|
├── controllers/
|
||||||
|
│ ├── analytics/
|
||||||
|
│ │ ├── export.controller.ts # Controladores de exportación
|
||||||
|
│ │ ├── userAnalytics.controller.ts # Controladores de métricas
|
||||||
|
│ │ └── index.ts
|
||||||
|
├── routes/
|
||||||
|
│ └── analytics.routes.ts # Definición de rutas
|
||||||
|
├── services/
|
||||||
|
│ ├── analytics/
|
||||||
|
│ │ ├── export.service.ts # Lógica de exportación
|
||||||
|
│ │ ├── userAnalytics.service.ts # Lógica de métricas
|
||||||
|
│ │ └── index.ts
|
||||||
|
├── types/
|
||||||
|
│ ├── analytics.types.ts # Interfaces y tipos
|
||||||
|
│ └── index.ts
|
||||||
|
└── utils/
|
||||||
|
└── export.ts # Utilidades de exportación
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Endpoints
|
||||||
|
|
||||||
|
### Analytics de Usuarios
|
||||||
|
|
||||||
|
| Endpoint | Método | Descripción | Parámetros |
|
||||||
|
|----------|--------|-------------|------------|
|
||||||
|
| `/analytics/users/overview` | GET | Stats generales de usuarios | - |
|
||||||
|
| `/analytics/users/activity` | GET | Actividad en período | `startDate`, `endDate` |
|
||||||
|
| `/analytics/users/top-players` | GET | Mejores jugadores | `limit`, `by` |
|
||||||
|
| `/analytics/users/churn-risk` | GET | Usuarios en riesgo | - |
|
||||||
|
| `/analytics/users/retention` | GET | Tasa de retención | `startDate`, `endDate` |
|
||||||
|
| `/analytics/users/growth` | GET | Tendencia de crecimiento | `months` |
|
||||||
|
|
||||||
|
### Exportación
|
||||||
|
|
||||||
|
| Endpoint | Método | Descripción | Parámetros |
|
||||||
|
|----------|--------|-------------|------------|
|
||||||
|
| `/analytics/exports/bookings` | GET | Exportar reservas | `startDate`, `endDate`, `format` |
|
||||||
|
| `/analytics/exports/users` | GET | Exportar usuarios | `format`, `level`, `city` |
|
||||||
|
| `/analytics/exports/payments` | GET | Exportar pagos | `startDate`, `endDate`, `format` |
|
||||||
|
| `/analytics/exports/tournaments/:id` | GET | Exportar torneo | `id`, `format` |
|
||||||
|
| `/analytics/exports/excel-report` | GET | Reporte Excel | `startDate`, `endDate` |
|
||||||
|
|
||||||
|
## 📦 Instalación
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install xlsx
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 Uso
|
||||||
|
|
||||||
|
### Ejemplo: Obtener Overview de Usuarios
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3000/analytics/users/overview \
|
||||||
|
-H "Authorization: Bearer <token_admin>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ejemplo: Exportar Reservas a CSV
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://localhost:3000/analytics/exports/bookings?startDate=2024-01-01&endDate=2024-01-31&format=csv" \
|
||||||
|
-H "Authorization: Bearer <token_admin>" \
|
||||||
|
--output reservas_enero.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ejemplo: Reporte Excel Completo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://localhost:3000/analytics/exports/excel-report?startDate=2024-01-01&endDate=2024-01-31" \
|
||||||
|
-H "Authorization: Bearer <token_admin>" \
|
||||||
|
--output reporte_enero.xlsx
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📋 Funcionalidades
|
||||||
|
|
||||||
|
### Métricas de Usuarios
|
||||||
|
|
||||||
|
1. **getUserStatsOverview()**
|
||||||
|
- Total de usuarios
|
||||||
|
- Usuarios activos (30 días)
|
||||||
|
- Nuevos usuarios (hoy/semana/mes)
|
||||||
|
- Distribución por nivel
|
||||||
|
- Distribución por ciudad
|
||||||
|
|
||||||
|
2. **getUserActivityStats()**
|
||||||
|
- Usuarios con reservas en período
|
||||||
|
- Promedio de reservas por usuario
|
||||||
|
- Top 10 usuarios más activos
|
||||||
|
- Usuarios sin actividad
|
||||||
|
|
||||||
|
3. **getTopPlayers()**
|
||||||
|
- Ranking por partidos jugados
|
||||||
|
- Ranking por victorias
|
||||||
|
- Ranking por puntos
|
||||||
|
- Ranking por torneos asistidos
|
||||||
|
|
||||||
|
4. **getChurnRiskUsers()**
|
||||||
|
- Identifica usuarios sin reservar 60+ días
|
||||||
|
- Calcula nivel de riesgo (LOW/MEDIUM/HIGH)
|
||||||
|
- Muestra última fecha de reserva
|
||||||
|
|
||||||
|
5. **getRetentionRate()**
|
||||||
|
- Tasa de retención mensual
|
||||||
|
- Usuarios que siguen activos después de N días
|
||||||
|
|
||||||
|
6. **getUserGrowthTrend()**
|
||||||
|
- Crecimiento de usuarios por mes
|
||||||
|
- Tasa de crecimiento mes a mes
|
||||||
|
|
||||||
|
### Exportación
|
||||||
|
|
||||||
|
1. **exportBookings()** - Exporta reservas con campos: id, usuario, pista, fecha, hora, precio, estado
|
||||||
|
2. **exportUsers()** - Exporta usuarios con campos: id, nombre, email, nivel, ciudad, reservas
|
||||||
|
3. **exportPayments()** - Exporta pagos con campos: id, usuario, tipo, monto, estado, fecha
|
||||||
|
4. **exportTournamentResults()** - Exporta resultados de torneo con estadísticas
|
||||||
|
5. **generateExcelReport()** - Reporte completo con 4 hojas: Resumen, Ingresos, Ocupación, Usuarios
|
||||||
|
|
||||||
|
## 🔒 Consideraciones
|
||||||
|
|
||||||
|
- Todos los endpoints requieren autenticación de administrador
|
||||||
|
- Formato de fechas: ISO 8601 (YYYY-MM-DD)
|
||||||
|
- CSV usa punto y coma (;) como separador (mejor para Excel español)
|
||||||
|
- Excel generado con librería xlsx
|
||||||
104
backend/package-lock.json
generated
104
backend/package-lock.json
generated
@@ -21,6 +21,7 @@
|
|||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"nodemailer": "^6.9.8",
|
"nodemailer": "^6.9.8",
|
||||||
"winston": "^3.11.0",
|
"winston": "^3.11.0",
|
||||||
|
"xlsx": "^0.18.5",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1229,6 +1230,15 @@
|
|||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/adler-32": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/agent-base": {
|
"node_modules/agent-base": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||||
@@ -1486,6 +1496,19 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cfb": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"adler-32": "~1.3.0",
|
||||||
|
"crc-32": "~1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chalk": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
@@ -1512,6 +1535,15 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/codepage": {
|
||||||
|
"version": "1.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||||
|
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color": {
|
"node_modules/color": {
|
||||||
"version": "5.0.3",
|
"version": "5.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz",
|
||||||
@@ -1661,6 +1693,18 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/crc-32": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"crc32": "bin/crc32.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -2353,6 +2397,15 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/frac": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fresh": {
|
"node_modules/fresh": {
|
||||||
"version": "0.5.2",
|
"version": "0.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||||
@@ -3944,6 +3997,18 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ssf": {
|
||||||
|
"version": "0.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||||
|
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"frac": "~1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/stack-trace": {
|
"node_modules/stack-trace": {
|
||||||
"version": "0.0.10",
|
"version": "0.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",
|
||||||
@@ -4317,6 +4382,24 @@
|
|||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wmf": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/word": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/word-wrap": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
@@ -4333,6 +4416,27 @@
|
|||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/xlsx": {
|
||||||
|
"version": "0.18.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||||
|
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"adler-32": "~1.3.0",
|
||||||
|
"cfb": "~1.2.1",
|
||||||
|
"codepage": "~1.15.0",
|
||||||
|
"crc-32": "~1.2.1",
|
||||||
|
"ssf": "~0.11.2",
|
||||||
|
"wmf": "~1.0.1",
|
||||||
|
"word": "~0.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"xlsx": "bin/xlsx.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"nodemailer": "^6.9.8",
|
"nodemailer": "^6.9.8",
|
||||||
"winston": "^3.11.0",
|
"winston": "^3.11.0",
|
||||||
|
"xlsx": "^0.18.5",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
89
backend/src/controllers/analytics/dashboard.controller.ts
Normal file
89
backend/src/controllers/analytics/dashboard.controller.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { DashboardService } from '../../services/analytics/dashboard.service';
|
||||||
|
import { ApiError } from '../../middleware/errorHandler';
|
||||||
|
|
||||||
|
export class DashboardController {
|
||||||
|
/**
|
||||||
|
* GET /analytics/dashboard/summary
|
||||||
|
* Resumen rápido para el dashboard
|
||||||
|
*/
|
||||||
|
static async getDashboardSummary(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const summary = await DashboardService.getDashboardSummary();
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: summary,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /analytics/dashboard/today
|
||||||
|
* Vista general del día actual
|
||||||
|
*/
|
||||||
|
static async getTodayOverview(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const overview = await DashboardService.getTodayOverview();
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: overview,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /analytics/dashboard/calendar
|
||||||
|
* Calendario semanal de ocupación
|
||||||
|
*/
|
||||||
|
static async getWeeklyCalendar(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { courtId } = req.query;
|
||||||
|
const calendar = await DashboardService.getWeeklyCalendar();
|
||||||
|
|
||||||
|
// Filtrar por cancha si se especifica
|
||||||
|
let filteredCalendar = calendar;
|
||||||
|
if (courtId) {
|
||||||
|
filteredCalendar = calendar.filter(item => item.courtId === courtId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agrupar por cancha para mejor visualización
|
||||||
|
const groupedByCourt: Record<string, any> = {};
|
||||||
|
filteredCalendar.forEach(item => {
|
||||||
|
if (!groupedByCourt[item.courtId]) {
|
||||||
|
groupedByCourt[item.courtId] = {
|
||||||
|
courtId: item.courtId,
|
||||||
|
courtName: item.courtName,
|
||||||
|
days: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
groupedByCourt[item.courtId].days.push({
|
||||||
|
dayOfWeek: item.dayOfWeek,
|
||||||
|
date: item.date,
|
||||||
|
bookings: item.bookings,
|
||||||
|
totalBookings: item.bookings.length,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
weekStart: filteredCalendar[0]?.date || null,
|
||||||
|
courts: Object.values(groupedByCourt),
|
||||||
|
},
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DashboardController;
|
||||||
260
backend/src/controllers/analytics/financial.controller.ts
Normal file
260
backend/src/controllers/analytics/financial.controller.ts
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { FinancialService, GroupByPeriod } from '../../services/analytics/financial.service';
|
||||||
|
import { ApiError } from '../../middleware/errorHandler';
|
||||||
|
|
||||||
|
// Validar fechas
|
||||||
|
const validateDates = (startDateStr: string, endDateStr: string): { startDate: Date; endDate: Date } => {
|
||||||
|
const startDate = new Date(startDateStr);
|
||||||
|
const endDate = new Date(endDateStr);
|
||||||
|
|
||||||
|
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
|
||||||
|
throw new ApiError('Fechas inválidas', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDate > endDate) {
|
||||||
|
throw new ApiError('La fecha de inicio debe ser anterior a la fecha de fin', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ajustar endDate para incluir todo el día
|
||||||
|
endDate.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
return { startDate, endDate };
|
||||||
|
};
|
||||||
|
|
||||||
|
export class FinancialController {
|
||||||
|
/**
|
||||||
|
* GET /analytics/revenue
|
||||||
|
* Ingresos por período
|
||||||
|
*/
|
||||||
|
static async getRevenueByPeriod(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { startDate: startDateStr, endDate: endDateStr, groupBy = 'day' } = req.query;
|
||||||
|
|
||||||
|
if (!startDateStr || !endDateStr) {
|
||||||
|
throw new ApiError('Se requieren fechas de inicio y fin', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { startDate, endDate } = validateDates(startDateStr as string, endDateStr as string);
|
||||||
|
|
||||||
|
// Validar groupBy
|
||||||
|
const validGroupBy: GroupByPeriod[] = ['day', 'week', 'month'];
|
||||||
|
const groupByValue = groupBy as GroupByPeriod;
|
||||||
|
if (!validGroupBy.includes(groupByValue)) {
|
||||||
|
throw new ApiError('Valor de groupBy inválido. Use: day, week, month', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const revenue = await FinancialService.getRevenueByPeriod(startDate, endDate, groupByValue);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: revenue,
|
||||||
|
meta: {
|
||||||
|
startDate: startDateStr,
|
||||||
|
endDate: endDateStr,
|
||||||
|
groupBy: groupByValue,
|
||||||
|
totalItems: revenue.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /analytics/revenue/by-court
|
||||||
|
* Ingresos por cancha
|
||||||
|
*/
|
||||||
|
static async getRevenueByCourt(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { startDate: startDateStr, endDate: endDateStr } = req.query;
|
||||||
|
|
||||||
|
if (!startDateStr || !endDateStr) {
|
||||||
|
throw new ApiError('Se requieren fechas de inicio y fin', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { startDate, endDate } = validateDates(startDateStr as string, endDateStr as string);
|
||||||
|
|
||||||
|
const revenue = await FinancialService.getRevenueByCourt(startDate, endDate);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: revenue,
|
||||||
|
meta: {
|
||||||
|
startDate: startDateStr,
|
||||||
|
endDate: endDateStr,
|
||||||
|
totalItems: revenue.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /analytics/revenue/by-type
|
||||||
|
* Ingresos por tipo
|
||||||
|
*/
|
||||||
|
static async getRevenueByType(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { startDate: startDateStr, endDate: endDateStr } = req.query;
|
||||||
|
|
||||||
|
if (!startDateStr || !endDateStr) {
|
||||||
|
throw new ApiError('Se requieren fechas de inicio y fin', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { startDate, endDate } = validateDates(startDateStr as string, endDateStr as string);
|
||||||
|
|
||||||
|
const revenue = await FinancialService.getRevenueByType(startDate, endDate);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: revenue,
|
||||||
|
meta: {
|
||||||
|
startDate: startDateStr,
|
||||||
|
endDate: endDateStr,
|
||||||
|
totalItems: revenue.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /analytics/payment-methods
|
||||||
|
* Estadísticas por método de pago
|
||||||
|
*/
|
||||||
|
static async getPaymentMethods(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { startDate: startDateStr, endDate: endDateStr } = req.query;
|
||||||
|
|
||||||
|
if (!startDateStr || !endDateStr) {
|
||||||
|
throw new ApiError('Se requieren fechas de inicio y fin', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { startDate, endDate } = validateDates(startDateStr as string, endDateStr as string);
|
||||||
|
|
||||||
|
const stats = await FinancialService.getPaymentMethodsStats(startDate, endDate);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: stats,
|
||||||
|
meta: {
|
||||||
|
startDate: startDateStr,
|
||||||
|
endDate: endDateStr,
|
||||||
|
totalItems: stats.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /analytics/outstanding-payments
|
||||||
|
* Pagos pendientes
|
||||||
|
*/
|
||||||
|
static async getOutstandingPayments(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const payments = await FinancialService.getOutstandingPayments();
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: payments,
|
||||||
|
meta: {
|
||||||
|
totalItems: payments.length,
|
||||||
|
totalAmount: payments.reduce((sum, p) => sum + p.amount, 0),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /analytics/refunds
|
||||||
|
* Estadísticas de reembolsos
|
||||||
|
*/
|
||||||
|
static async getRefundStats(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { startDate: startDateStr, endDate: endDateStr } = req.query;
|
||||||
|
|
||||||
|
if (!startDateStr || !endDateStr) {
|
||||||
|
throw new ApiError('Se requieren fechas de inicio y fin', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { startDate, endDate } = validateDates(startDateStr as string, endDateStr as string);
|
||||||
|
|
||||||
|
const stats = await FinancialService.getRefundStats(startDate, endDate);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: stats,
|
||||||
|
meta: {
|
||||||
|
startDate: startDateStr,
|
||||||
|
endDate: endDateStr,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /analytics/trends
|
||||||
|
* Tendencias financieras
|
||||||
|
*/
|
||||||
|
static async getFinancialTrends(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { months = '6' } = req.query;
|
||||||
|
const monthsNum = parseInt(months as string, 10);
|
||||||
|
|
||||||
|
if (isNaN(monthsNum) || monthsNum < 1 || monthsNum > 24) {
|
||||||
|
throw new ApiError('El parámetro months debe estar entre 1 y 24', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const trends = await FinancialService.getFinancialTrends(monthsNum);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: trends,
|
||||||
|
meta: {
|
||||||
|
months: monthsNum,
|
||||||
|
totalItems: trends.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /analytics/top-days
|
||||||
|
* Días con mayores ingresos
|
||||||
|
*/
|
||||||
|
static async getTopRevenueDays(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { limit = '10' } = req.query;
|
||||||
|
const limitNum = parseInt(limit as string, 10);
|
||||||
|
|
||||||
|
if (isNaN(limitNum) || limitNum < 1 || limitNum > 100) {
|
||||||
|
throw new ApiError('El parámetro limit debe estar entre 1 y 100', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = await FinancialService.getTopRevenueDays(limitNum);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: days,
|
||||||
|
meta: {
|
||||||
|
limit: limitNum,
|
||||||
|
totalItems: days.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FinancialController;
|
||||||
263
backend/src/controllers/analytics/occupancy.controller.ts
Normal file
263
backend/src/controllers/analytics/occupancy.controller.ts
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { OccupancyService } from '../../services/analytics/occupancy.service';
|
||||||
|
import { ApiError } from '../../middleware/errorHandler';
|
||||||
|
|
||||||
|
export class OccupancyController {
|
||||||
|
/**
|
||||||
|
* GET /analytics/occupancy
|
||||||
|
* Reporte de ocupación por rango de fechas
|
||||||
|
*/
|
||||||
|
static async getOccupancyReport(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { startDate, endDate, courtId } = req.query;
|
||||||
|
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
throw new ApiError('Se requieren los parámetros startDate y endDate', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = new Date(startDate as string);
|
||||||
|
const end = new Date(endDate as string);
|
||||||
|
|
||||||
|
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
|
||||||
|
throw new ApiError('Formato de fecha inválido. Use formato ISO 8601 (YYYY-MM-DD)', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await OccupancyService.getOccupancyByDateRange({
|
||||||
|
startDate: start,
|
||||||
|
endDate: end,
|
||||||
|
courtId: courtId as string | undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
period: {
|
||||||
|
startDate: start.toISOString().split('T')[0],
|
||||||
|
endDate: end.toISOString().split('T')[0],
|
||||||
|
},
|
||||||
|
courtId: courtId || null,
|
||||||
|
dailyData: data,
|
||||||
|
summary: {
|
||||||
|
averageOccupancy: data.length > 0
|
||||||
|
? Math.round((data.reduce((sum, d) => sum + d.occupancyRate, 0) / data.length) * 10) / 10
|
||||||
|
: 0,
|
||||||
|
totalSlots: data.reduce((sum, d) => sum + d.totalSlots, 0),
|
||||||
|
totalBooked: data.reduce((sum, d) => sum + d.bookedSlots, 0),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /analytics/occupancy/by-court
|
||||||
|
* Ocupación específica por cancha
|
||||||
|
*/
|
||||||
|
static async getOccupancyByCourt(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { courtId, startDate, endDate, groupBy } = req.query;
|
||||||
|
|
||||||
|
if (!courtId || !startDate || !endDate) {
|
||||||
|
throw new ApiError('Se requieren los parámetros courtId, startDate y endDate', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = new Date(startDate as string);
|
||||||
|
const end = new Date(endDate as string);
|
||||||
|
|
||||||
|
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
|
||||||
|
throw new ApiError('Formato de fecha inválido. Use formato ISO 8601 (YYYY-MM-DD)', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const validGroupBy = ['day', 'week', 'month'];
|
||||||
|
const groupByValue = groupBy as string;
|
||||||
|
if (groupBy && !validGroupBy.includes(groupByValue)) {
|
||||||
|
throw new ApiError('El parámetro groupBy debe ser: day, week o month', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await OccupancyService.getOccupancyByCourt({
|
||||||
|
courtId: courtId as string,
|
||||||
|
startDate: start,
|
||||||
|
endDate: end,
|
||||||
|
groupBy: (groupBy as 'day' | 'week' | 'month') || 'day',
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
courtId,
|
||||||
|
period: {
|
||||||
|
startDate: start.toISOString().split('T')[0],
|
||||||
|
endDate: end.toISOString().split('T')[0],
|
||||||
|
},
|
||||||
|
groupBy: groupBy || 'day',
|
||||||
|
occupancyData: data,
|
||||||
|
summary: {
|
||||||
|
averageOccupancy: data.length > 0
|
||||||
|
? Math.round((data.reduce((sum, d) => sum + d.occupancyRate, 0) / data.length) * 10) / 10
|
||||||
|
: 0,
|
||||||
|
peakDay: data.length > 0
|
||||||
|
? data.reduce((max, d) => d.occupancyRate > max.occupancyRate ? d : max, data[0])
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /analytics/occupancy/by-timeslot
|
||||||
|
* Ocupación por franja horaria
|
||||||
|
*/
|
||||||
|
static async getOccupancyByTimeSlot(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { startDate, endDate, courtId } = req.query;
|
||||||
|
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
throw new ApiError('Se requieren los parámetros startDate y endDate', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = new Date(startDate as string);
|
||||||
|
const end = new Date(endDate as string);
|
||||||
|
|
||||||
|
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
|
||||||
|
throw new ApiError('Formato de fecha inválido. Use formato ISO 8601 (YYYY-MM-DD)', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await OccupancyService.getOccupancyByTimeSlot({
|
||||||
|
startDate: start,
|
||||||
|
endDate: end,
|
||||||
|
courtId: courtId as string | undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Identificar horas pico y valle
|
||||||
|
const sortedByOccupancy = [...data].sort((a, b) => b.occupancyRate - a.occupancyRate);
|
||||||
|
const peakHours = sortedByOccupancy.slice(0, 3);
|
||||||
|
const valleyHours = sortedByOccupancy.slice(-3);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
period: {
|
||||||
|
startDate: start.toISOString().split('T')[0],
|
||||||
|
endDate: end.toISOString().split('T')[0],
|
||||||
|
},
|
||||||
|
courtId: courtId || null,
|
||||||
|
hourlyData: data,
|
||||||
|
peakHours,
|
||||||
|
valleyHours,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /analytics/occupancy/peak-hours
|
||||||
|
* Horarios más demandados (top 5)
|
||||||
|
*/
|
||||||
|
static async getPeakHours(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { startDate, endDate, courtId } = req.query;
|
||||||
|
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
throw new ApiError('Se requieren los parámetros startDate y endDate', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = new Date(startDate as string);
|
||||||
|
const end = new Date(endDate as string);
|
||||||
|
|
||||||
|
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
|
||||||
|
throw new ApiError('Formato de fecha inválido. Use formato ISO 8601 (YYYY-MM-DD)', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await OccupancyService.getPeakHours({
|
||||||
|
startDate: start,
|
||||||
|
endDate: end,
|
||||||
|
courtId: courtId as string | undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
period: {
|
||||||
|
startDate: start.toISOString().split('T')[0],
|
||||||
|
endDate: end.toISOString().split('T')[0],
|
||||||
|
},
|
||||||
|
courtId: courtId || null,
|
||||||
|
peakHours: data,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /analytics/occupancy/comparison
|
||||||
|
* Comparativa de ocupación entre dos períodos
|
||||||
|
*/
|
||||||
|
static async getOccupancyComparison(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
period1Start,
|
||||||
|
period1End,
|
||||||
|
period2Start,
|
||||||
|
period2End,
|
||||||
|
courtId
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
if (!period1Start || !period1End || !period2Start || !period2End) {
|
||||||
|
throw new ApiError(
|
||||||
|
'Se requieren los parámetros: period1Start, period1End, period2Start, period2End',
|
||||||
|
400
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const p1Start = new Date(period1Start as string);
|
||||||
|
const p1End = new Date(period1End as string);
|
||||||
|
const p2Start = new Date(period2Start as string);
|
||||||
|
const p2End = new Date(period2End as string);
|
||||||
|
|
||||||
|
if (
|
||||||
|
isNaN(p1Start.getTime()) ||
|
||||||
|
isNaN(p1End.getTime()) ||
|
||||||
|
isNaN(p2Start.getTime()) ||
|
||||||
|
isNaN(p2End.getTime())
|
||||||
|
) {
|
||||||
|
throw new ApiError('Formato de fecha inválido. Use formato ISO 8601 (YYYY-MM-DD)', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await OccupancyService.getOccupancyComparison({
|
||||||
|
period1Start: p1Start,
|
||||||
|
period1End: p1End,
|
||||||
|
period2Start: p2Start,
|
||||||
|
period2End: p2End,
|
||||||
|
courtId: courtId as string | undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
comparison: data,
|
||||||
|
interpretation: {
|
||||||
|
trend: data.variation > 0 ? 'up' : data.variation < 0 ? 'down' : 'stable',
|
||||||
|
message: data.variation > 0
|
||||||
|
? `La ocupación aumentó un ${data.variation}% respecto al período anterior`
|
||||||
|
: data.variation < 0
|
||||||
|
? `La ocupación disminuyó un ${Math.abs(data.variation)}% respecto al período anterior`
|
||||||
|
: 'La ocupación se mantuvo estable respecto al período anterior',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OccupancyController;
|
||||||
142
backend/src/controllers/analytics/report.controller.ts
Normal file
142
backend/src/controllers/analytics/report.controller.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { ReportService } from '../../services/analytics/report.service';
|
||||||
|
import { ApiError } from '../../middleware/errorHandler';
|
||||||
|
|
||||||
|
// Validar fechas
|
||||||
|
const validateDates = (startDateStr: string, endDateStr: string): { startDate: Date; endDate: Date } => {
|
||||||
|
const startDate = new Date(startDateStr);
|
||||||
|
const endDate = new Date(endDateStr);
|
||||||
|
|
||||||
|
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
|
||||||
|
throw new ApiError('Fechas inválidas', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDate > endDate) {
|
||||||
|
throw new ApiError('La fecha de inicio debe ser anterior a la fecha de fin', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ajustar endDate para incluir todo el día
|
||||||
|
endDate.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
return { startDate, endDate };
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ReportController {
|
||||||
|
/**
|
||||||
|
* GET /analytics/reports/revenue
|
||||||
|
* Reporte completo de ingresos
|
||||||
|
*/
|
||||||
|
static async getRevenueReport(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { startDate: startDateStr, endDate: endDateStr } = req.query;
|
||||||
|
|
||||||
|
if (!startDateStr || !endDateStr) {
|
||||||
|
throw new ApiError('Se requieren fechas de inicio y fin', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { startDate, endDate } = validateDates(startDateStr as string, endDateStr as string);
|
||||||
|
|
||||||
|
const report = await ReportService.generateRevenueReport(startDate, endDate);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: report,
|
||||||
|
meta: {
|
||||||
|
reportType: 'revenue',
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /analytics/reports/occupancy
|
||||||
|
* Reporte de ocupación
|
||||||
|
*/
|
||||||
|
static async getOccupancyReport(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { startDate: startDateStr, endDate: endDateStr } = req.query;
|
||||||
|
|
||||||
|
if (!startDateStr || !endDateStr) {
|
||||||
|
throw new ApiError('Se requieren fechas de inicio y fin', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { startDate, endDate } = validateDates(startDateStr as string, endDateStr as string);
|
||||||
|
|
||||||
|
const report = await ReportService.generateOccupancyReport(startDate, endDate);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: report,
|
||||||
|
meta: {
|
||||||
|
reportType: 'occupancy',
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /analytics/reports/users
|
||||||
|
* Reporte de usuarios
|
||||||
|
*/
|
||||||
|
static async getUserReport(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { startDate: startDateStr, endDate: endDateStr } = req.query;
|
||||||
|
|
||||||
|
if (!startDateStr || !endDateStr) {
|
||||||
|
throw new ApiError('Se requieren fechas de inicio y fin', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { startDate, endDate } = validateDates(startDateStr as string, endDateStr as string);
|
||||||
|
|
||||||
|
const report = await ReportService.generateUserReport(startDate, endDate);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: report,
|
||||||
|
meta: {
|
||||||
|
reportType: 'users',
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /analytics/reports/summary
|
||||||
|
* Resumen ejecutivo
|
||||||
|
*/
|
||||||
|
static async getExecutiveSummary(req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const { startDate: startDateStr, endDate: endDateStr } = req.query;
|
||||||
|
|
||||||
|
if (!startDateStr || !endDateStr) {
|
||||||
|
throw new ApiError('Se requieren fechas de inicio y fin', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { startDate, endDate } = validateDates(startDateStr as string, endDateStr as string);
|
||||||
|
|
||||||
|
const summary = await ReportService.getReportSummary(startDate, endDate);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: summary,
|
||||||
|
meta: {
|
||||||
|
reportType: 'executive-summary',
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReportController;
|
||||||
151
backend/src/routes/analytics.routes.ts
Normal file
151
backend/src/routes/analytics.routes.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { OccupancyController } from '../controllers/analytics/occupancy.controller';
|
||||||
|
import { DashboardController } from '../controllers/analytics/dashboard.controller';
|
||||||
|
import { FinancialController } from '../controllers/analytics/financial.controller';
|
||||||
|
import { ReportController } from '../controllers/analytics/report.controller';
|
||||||
|
import { authenticate, authorize } from '../middleware/auth';
|
||||||
|
import { validate } from '../middleware/validate';
|
||||||
|
import { UserRole } from '../utils/constants';
|
||||||
|
import {
|
||||||
|
dateRangeSchema,
|
||||||
|
occupancyByCourtSchema,
|
||||||
|
timeSlotSchema,
|
||||||
|
peakHoursSchema,
|
||||||
|
comparisonSchema,
|
||||||
|
courtIdParamSchema,
|
||||||
|
} from '../validators/analytics.validator';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Middleware de autenticación y autorización para todos los endpoints de analytics
|
||||||
|
// Solo administradores pueden acceder
|
||||||
|
router.use(authenticate, authorize(UserRole.ADMIN, UserRole.SUPERADMIN));
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Dashboard (Fase 5.1)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /analytics/dashboard/summary
|
||||||
|
* @desc Obtener resumen rápido del dashboard
|
||||||
|
* @access Admin
|
||||||
|
*/
|
||||||
|
router.get('/dashboard/summary', DashboardController.getDashboardSummary);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /analytics/dashboard/today
|
||||||
|
* @desc Obtener vista general del día actual
|
||||||
|
* @access Admin
|
||||||
|
*/
|
||||||
|
router.get('/dashboard/today', DashboardController.getTodayOverview);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /analytics/dashboard/calendar
|
||||||
|
* @desc Obtener calendario semanal de ocupación
|
||||||
|
* @access Admin
|
||||||
|
* @query courtId - Opcional, filtrar por cancha específica
|
||||||
|
*/
|
||||||
|
router.get('/dashboard/calendar', validate(courtIdParamSchema), DashboardController.getWeeklyCalendar);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Ocupación (Fase 5.1)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /analytics/occupancy
|
||||||
|
* @desc Reporte de ocupación por rango de fechas
|
||||||
|
* @access Admin
|
||||||
|
* @query startDate - Fecha inicio (ISO 8601)
|
||||||
|
* @query endDate - Fecha fin (ISO 8601)
|
||||||
|
* @query courtId - Opcional, filtrar por cancha específica
|
||||||
|
*/
|
||||||
|
router.get('/occupancy', validate(dateRangeSchema), OccupancyController.getOccupancyReport);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /analytics/occupancy/by-court
|
||||||
|
* @desc Ocupación específica por cancha
|
||||||
|
* @access Admin
|
||||||
|
* @query courtId - ID de la cancha
|
||||||
|
* @query startDate - Fecha inicio (ISO 8601)
|
||||||
|
* @query endDate - Fecha fin (ISO 8601)
|
||||||
|
* @query groupBy - Opcional: day, week, month (default: day)
|
||||||
|
*/
|
||||||
|
router.get('/occupancy/by-court', validate(occupancyByCourtSchema), OccupancyController.getOccupancyByCourt);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /analytics/occupancy/by-timeslot
|
||||||
|
* @desc Ocupación por franja horaria
|
||||||
|
* @access Admin
|
||||||
|
* @query startDate - Fecha inicio (ISO 8601)
|
||||||
|
* @query endDate - Fecha fin (ISO 8601)
|
||||||
|
* @query courtId - Opcional, filtrar por cancha específica
|
||||||
|
*/
|
||||||
|
router.get('/occupancy/by-timeslot', validate(timeSlotSchema), OccupancyController.getOccupancyByTimeSlot);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /analytics/occupancy/peak-hours
|
||||||
|
* @desc Horarios más demandados (top 5)
|
||||||
|
* @access Admin
|
||||||
|
* @query startDate - Fecha inicio (ISO 8601)
|
||||||
|
* @query endDate - Fecha fin (ISO 8601)
|
||||||
|
* @query courtId - Opcional, filtrar por cancha específica
|
||||||
|
*/
|
||||||
|
router.get('/occupancy/peak-hours', validate(peakHoursSchema), OccupancyController.getPeakHours);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /analytics/occupancy/comparison
|
||||||
|
* @desc Comparativa de ocupación entre dos períodos
|
||||||
|
* @access Admin
|
||||||
|
* @query period1Start - Fecha inicio período 1 (ISO 8601)
|
||||||
|
* @query period1End - Fecha fin período 1 (ISO 8601)
|
||||||
|
* @query period2Start - Fecha inicio período 2 (ISO 8601)
|
||||||
|
* @query period2End - Fecha fin período 2 (ISO 8601)
|
||||||
|
* @query courtId - Opcional, filtrar por cancha específica
|
||||||
|
*/
|
||||||
|
router.get('/occupancy/comparison', validate(comparisonSchema), OccupancyController.getOccupancyComparison);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Rutas de Métricas Financieras (Pre-existentes)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// GET /analytics/revenue - Ingresos por período
|
||||||
|
router.get('/revenue', FinancialController.getRevenueByPeriod);
|
||||||
|
|
||||||
|
// GET /analytics/revenue/by-court - Ingresos por cancha
|
||||||
|
router.get('/revenue/by-court', FinancialController.getRevenueByCourt);
|
||||||
|
|
||||||
|
// GET /analytics/revenue/by-type - Ingresos por tipo
|
||||||
|
router.get('/revenue/by-type', FinancialController.getRevenueByType);
|
||||||
|
|
||||||
|
// GET /analytics/payment-methods - Estadísticas por método de pago
|
||||||
|
router.get('/payment-methods', FinancialController.getPaymentMethods);
|
||||||
|
|
||||||
|
// GET /analytics/outstanding-payments - Pagos pendientes
|
||||||
|
router.get('/outstanding-payments', FinancialController.getOutstandingPayments);
|
||||||
|
|
||||||
|
// GET /analytics/refunds - Estadísticas de reembolsos
|
||||||
|
router.get('/refunds', FinancialController.getRefundStats);
|
||||||
|
|
||||||
|
// GET /analytics/trends - Tendencias financieras
|
||||||
|
router.get('/trends', FinancialController.getFinancialTrends);
|
||||||
|
|
||||||
|
// GET /analytics/top-days - Días con mayores ingresos
|
||||||
|
router.get('/top-days', FinancialController.getTopRevenueDays);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Rutas de Reportes (Pre-existentes)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// GET /analytics/reports/revenue - Reporte de ingresos
|
||||||
|
router.get('/reports/revenue', ReportController.getRevenueReport);
|
||||||
|
|
||||||
|
// GET /analytics/reports/occupancy - Reporte de ocupación
|
||||||
|
router.get('/reports/occupancy', ReportController.getOccupancyReport);
|
||||||
|
|
||||||
|
// GET /analytics/reports/users - Reporte de usuarios
|
||||||
|
router.get('/reports/users', ReportController.getUserReport);
|
||||||
|
|
||||||
|
// GET /analytics/reports/summary - Resumen ejecutivo
|
||||||
|
router.get('/reports/summary', ReportController.getExecutiveSummary);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -98,6 +98,13 @@ router.use('/payments', paymentRoutes);
|
|||||||
import subscriptionRoutes from './subscription.routes';
|
import subscriptionRoutes from './subscription.routes';
|
||||||
router.use('/', subscriptionRoutes);
|
router.use('/', subscriptionRoutes);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Rutas de Analytics y Dashboard (Fase 5.1)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
import analyticsRoutes from './analytics.routes';
|
||||||
|
router.use('/analytics', analyticsRoutes);
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Rutas de Clases con Profesores (Fase 4.4) - Desactivado temporalmente
|
// Rutas de Clases con Profesores (Fase 4.4) - Desactivado temporalmente
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
426
backend/src/services/analytics/dashboard.service.ts
Normal file
426
backend/src/services/analytics/dashboard.service.ts
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
import prisma from '../../config/database';
|
||||||
|
import { ApiError } from '../../middleware/errorHandler';
|
||||||
|
import { BookingStatus, PaymentStatus, ExtendedPaymentStatus } from '../../utils/constants';
|
||||||
|
import logger from '../../config/logger';
|
||||||
|
|
||||||
|
export interface DashboardSummary {
|
||||||
|
bookings: {
|
||||||
|
today: number;
|
||||||
|
thisWeek: number;
|
||||||
|
thisMonth: number;
|
||||||
|
};
|
||||||
|
revenue: {
|
||||||
|
today: number;
|
||||||
|
thisWeek: number;
|
||||||
|
thisMonth: number;
|
||||||
|
};
|
||||||
|
newUsers: {
|
||||||
|
today: number;
|
||||||
|
thisWeek: number;
|
||||||
|
thisMonth: number;
|
||||||
|
};
|
||||||
|
occupancy: {
|
||||||
|
today: number;
|
||||||
|
averageWeek: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TodayOverview {
|
||||||
|
date: string;
|
||||||
|
totalBookings: number;
|
||||||
|
confirmedBookings: number;
|
||||||
|
pendingBookings: number;
|
||||||
|
cancelledBookings: number;
|
||||||
|
courtsOccupiedNow: number;
|
||||||
|
courtsTotal: number;
|
||||||
|
upcomingBookings: TodayBooking[];
|
||||||
|
alerts: Alert[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TodayBooking {
|
||||||
|
id: string;
|
||||||
|
courtName: string;
|
||||||
|
userName: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Alert {
|
||||||
|
type: 'warning' | 'info' | 'error';
|
||||||
|
message: string;
|
||||||
|
bookingId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeeklyCalendarItem {
|
||||||
|
courtId: string;
|
||||||
|
courtName: string;
|
||||||
|
dayOfWeek: number;
|
||||||
|
date: string;
|
||||||
|
bookings: CalendarBooking[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalendarBooking {
|
||||||
|
id: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
userName: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DashboardService {
|
||||||
|
/**
|
||||||
|
* Obtener resumen del dashboard
|
||||||
|
*/
|
||||||
|
static async getDashboardSummary(): Promise<DashboardSummary> {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Fechas de referencia
|
||||||
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
const tomorrow = new Date(today);
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
|
||||||
|
const startOfWeek = new Date(today);
|
||||||
|
startOfWeek.setDate(today.getDate() - today.getDay());
|
||||||
|
|
||||||
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
const startOfNextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||||
|
|
||||||
|
// Contar reservas
|
||||||
|
const bookingsToday = await prisma.booking.count({
|
||||||
|
where: {
|
||||||
|
date: { gte: today, lt: tomorrow },
|
||||||
|
status: { in: [BookingStatus.CONFIRMED, BookingStatus.COMPLETED, BookingStatus.PENDING] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const bookingsThisWeek = await prisma.booking.count({
|
||||||
|
where: {
|
||||||
|
date: { gte: startOfWeek, lt: tomorrow },
|
||||||
|
status: { in: [BookingStatus.CONFIRMED, BookingStatus.COMPLETED, BookingStatus.PENDING] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const bookingsThisMonth = await prisma.booking.count({
|
||||||
|
where: {
|
||||||
|
date: { gte: startOfMonth, lt: startOfNextMonth },
|
||||||
|
status: { in: [BookingStatus.CONFIRMED, BookingStatus.COMPLETED, BookingStatus.PENDING] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calcular ingresos (de pagos completados)
|
||||||
|
const revenueToday = await this.calculateRevenue(today, tomorrow);
|
||||||
|
const revenueThisWeek = await this.calculateRevenue(startOfWeek, tomorrow);
|
||||||
|
const revenueThisMonth = await this.calculateRevenue(startOfMonth, startOfNextMonth);
|
||||||
|
|
||||||
|
// Contar usuarios nuevos
|
||||||
|
const newUsersToday = await prisma.user.count({
|
||||||
|
where: {
|
||||||
|
createdAt: { gte: today, lt: tomorrow },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const newUsersThisWeek = await prisma.user.count({
|
||||||
|
where: {
|
||||||
|
createdAt: { gte: startOfWeek, lt: tomorrow },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const newUsersThisMonth = await prisma.user.count({
|
||||||
|
where: {
|
||||||
|
createdAt: { gte: startOfMonth, lt: startOfNextMonth },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calcular ocupación del día
|
||||||
|
const occupancyToday = await this.calculateOccupancy(today, tomorrow);
|
||||||
|
const occupancyWeek = await this.calculateOccupancy(startOfWeek, tomorrow);
|
||||||
|
|
||||||
|
return {
|
||||||
|
bookings: {
|
||||||
|
today: bookingsToday,
|
||||||
|
thisWeek: bookingsThisWeek,
|
||||||
|
thisMonth: bookingsThisMonth,
|
||||||
|
},
|
||||||
|
revenue: {
|
||||||
|
today: revenueToday,
|
||||||
|
thisWeek: revenueThisWeek,
|
||||||
|
thisMonth: revenueThisMonth,
|
||||||
|
},
|
||||||
|
newUsers: {
|
||||||
|
today: newUsersToday,
|
||||||
|
thisWeek: newUsersThisWeek,
|
||||||
|
thisMonth: newUsersThisMonth,
|
||||||
|
},
|
||||||
|
occupancy: {
|
||||||
|
today: occupancyToday,
|
||||||
|
averageWeek: Math.round(occupancyWeek * 10) / 10,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener vista general de hoy
|
||||||
|
*/
|
||||||
|
static async getTodayOverview(): Promise<TodayOverview> {
|
||||||
|
const now = new Date();
|
||||||
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
const tomorrow = new Date(today);
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
const currentHour = now.getHours();
|
||||||
|
const currentTimeStr = `${currentHour.toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`;
|
||||||
|
|
||||||
|
// Obtener todas las reservas de hoy
|
||||||
|
const todayBookings = await prisma.booking.findMany({
|
||||||
|
where: {
|
||||||
|
date: { gte: today, lt: tomorrow },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
court: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ startTime: 'asc' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Contar por estado
|
||||||
|
const confirmedBookings = todayBookings.filter(b => b.status === BookingStatus.CONFIRMED).length;
|
||||||
|
const pendingBookings = todayBookings.filter(b => b.status === BookingStatus.PENDING).length;
|
||||||
|
const cancelledBookings = todayBookings.filter(b => b.status === BookingStatus.CANCELLED).length;
|
||||||
|
|
||||||
|
// Canchas ocupadas ahora
|
||||||
|
const courtsOccupiedNow = new Set(
|
||||||
|
todayBookings
|
||||||
|
.filter(b =>
|
||||||
|
b.status === BookingStatus.CONFIRMED &&
|
||||||
|
b.startTime <= currentTimeStr &&
|
||||||
|
b.endTime > currentTimeStr
|
||||||
|
)
|
||||||
|
.map(b => b.court.id)
|
||||||
|
).size;
|
||||||
|
|
||||||
|
// Total de canchas activas
|
||||||
|
const totalCourts = await prisma.court.count({ where: { isActive: true } });
|
||||||
|
|
||||||
|
// Próximas reservas (que aún no han empezado o están en curso)
|
||||||
|
const upcomingBookings: TodayBooking[] = todayBookings
|
||||||
|
.filter(b =>
|
||||||
|
b.status !== BookingStatus.CANCELLED &&
|
||||||
|
b.endTime > currentTimeStr
|
||||||
|
)
|
||||||
|
.slice(0, 10) // Limitar a 10
|
||||||
|
.map(b => ({
|
||||||
|
id: b.id,
|
||||||
|
courtName: b.court.name,
|
||||||
|
userName: `${b.user.firstName} ${b.user.lastName}`,
|
||||||
|
startTime: b.startTime,
|
||||||
|
endTime: b.endTime,
|
||||||
|
status: b.status,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Generar alertas
|
||||||
|
const alerts: Alert[] = [];
|
||||||
|
|
||||||
|
// Reservas pendientes de confirmación
|
||||||
|
if (pendingBookings > 0) {
|
||||||
|
alerts.push({
|
||||||
|
type: 'warning',
|
||||||
|
message: `Hay ${pendingBookings} reserva(s) pendiente(s) de confirmación para hoy`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reservas sin pagar (que empiezan en menos de 2 horas)
|
||||||
|
const bookingsStartingSoon = todayBookings.filter(b => {
|
||||||
|
if (b.status === BookingStatus.CANCELLED) return false;
|
||||||
|
const bookingHour = parseInt(b.startTime.split(':')[0]);
|
||||||
|
return bookingHour - currentHour <= 2 && bookingHour >= currentHour;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (bookingsStartingSoon.length > 0) {
|
||||||
|
// Verificar pagos asociados
|
||||||
|
for (const booking of bookingsStartingSoon) {
|
||||||
|
const payment = await prisma.payment.findFirst({
|
||||||
|
where: {
|
||||||
|
referenceId: booking.id,
|
||||||
|
type: 'BOOKING',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!payment || payment.status !== ExtendedPaymentStatus.COMPLETED) {
|
||||||
|
alerts.push({
|
||||||
|
type: 'info',
|
||||||
|
message: `Reserva en ${booking.court.name} a las ${booking.startTime} no tiene pago confirmado`,
|
||||||
|
bookingId: booking.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canchas al 100% de ocupación
|
||||||
|
if (courtsOccupiedNow === totalCourts && totalCourts > 0) {
|
||||||
|
alerts.push({
|
||||||
|
type: 'info',
|
||||||
|
message: 'Todas las canchas están ocupadas en este momento',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
date: today.toISOString().split('T')[0],
|
||||||
|
totalBookings: todayBookings.length,
|
||||||
|
confirmedBookings,
|
||||||
|
pendingBookings,
|
||||||
|
cancelledBookings,
|
||||||
|
courtsOccupiedNow,
|
||||||
|
courtsTotal: totalCourts,
|
||||||
|
upcomingBookings,
|
||||||
|
alerts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener calendario semanal
|
||||||
|
*/
|
||||||
|
static async getWeeklyCalendar(): Promise<WeeklyCalendarItem[]> {
|
||||||
|
const now = new Date();
|
||||||
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
const startOfWeek = new Date(today);
|
||||||
|
startOfWeek.setDate(today.getDate() - today.getDay());
|
||||||
|
const endOfWeek = new Date(startOfWeek);
|
||||||
|
endOfWeek.setDate(startOfWeek.getDate() + 7);
|
||||||
|
|
||||||
|
// Obtener canchas activas
|
||||||
|
const courts = await prisma.court.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Obtener reservas de la semana
|
||||||
|
const weekBookings = await prisma.booking.findMany({
|
||||||
|
where: {
|
||||||
|
date: { gte: startOfWeek, lt: endOfWeek },
|
||||||
|
status: { not: BookingStatus.CANCELLED },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
court: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ date: 'asc' },
|
||||||
|
{ startTime: 'asc' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Construir estructura del calendario
|
||||||
|
const calendar: WeeklyCalendarItem[] = [];
|
||||||
|
|
||||||
|
for (const court of courts) {
|
||||||
|
for (let day = 0; day < 7; day++) {
|
||||||
|
const date = new Date(startOfWeek);
|
||||||
|
date.setDate(startOfWeek.getDate() + day);
|
||||||
|
const dateStr = date.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const dayBookings = weekBookings.filter(
|
||||||
|
b => b.court.id === court.id && b.date.toISOString().split('T')[0] === dateStr
|
||||||
|
);
|
||||||
|
|
||||||
|
calendar.push({
|
||||||
|
courtId: court.id,
|
||||||
|
courtName: court.name,
|
||||||
|
dayOfWeek: day,
|
||||||
|
date: dateStr,
|
||||||
|
bookings: dayBookings.map(b => ({
|
||||||
|
id: b.id,
|
||||||
|
startTime: b.startTime,
|
||||||
|
endTime: b.endTime,
|
||||||
|
userName: `${b.user.firstName} ${b.user.lastName}`,
|
||||||
|
status: b.status,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return calendar;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers privados
|
||||||
|
|
||||||
|
private static async calculateRevenue(startDate: Date, endDate: Date): Promise<number> {
|
||||||
|
const payments = await prisma.payment.aggregate({
|
||||||
|
where: {
|
||||||
|
createdAt: { gte: startDate, lt: endDate },
|
||||||
|
status: ExtendedPaymentStatus.COMPLETED,
|
||||||
|
},
|
||||||
|
_sum: {
|
||||||
|
amount: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return payments._sum.amount || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async calculateOccupancy(startDate: Date, endDate: Date): Promise<number> {
|
||||||
|
// Obtener canchas activas
|
||||||
|
const courts = await prisma.court.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (courts.length === 0) return 0;
|
||||||
|
|
||||||
|
// Contar días en el rango
|
||||||
|
const daysInRange = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
// Asumir 15 horas operativas por día (8:00 - 23:00)
|
||||||
|
const hoursPerDay = 15;
|
||||||
|
const totalPossibleSlots = courts.length * daysInRange * hoursPerDay;
|
||||||
|
|
||||||
|
// Obtener bookings confirmados/completados
|
||||||
|
const bookings = await prisma.booking.findMany({
|
||||||
|
where: {
|
||||||
|
date: { gte: startDate, lt: endDate },
|
||||||
|
status: { in: [BookingStatus.CONFIRMED, BookingStatus.COMPLETED] },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
startTime: true,
|
||||||
|
endTime: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calcular slots ocupados
|
||||||
|
const bookedSlots = bookings.reduce((total, booking) => {
|
||||||
|
const startHour = parseInt(booking.startTime.split(':')[0]);
|
||||||
|
const endHour = parseInt(booking.endTime.split(':')[0]);
|
||||||
|
return total + (endHour - startHour);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return totalPossibleSlots > 0
|
||||||
|
? Math.round((bookedSlots / totalPossibleSlots) * 1000) / 10
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DashboardService;
|
||||||
444
backend/src/services/analytics/financial.service.ts
Normal file
444
backend/src/services/analytics/financial.service.ts
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
import prisma from '../../config/database';
|
||||||
|
import { ApiError } from '../../middleware/errorHandler';
|
||||||
|
import { ExtendedPaymentStatus, PaymentType } from '../../utils/constants';
|
||||||
|
import { formatCurrency, calculateGrowth, groupByDate, fillMissingDates } from '../../utils/analytics';
|
||||||
|
|
||||||
|
// Tipos
|
||||||
|
export type GroupByPeriod = 'day' | 'week' | 'month';
|
||||||
|
|
||||||
|
export interface RevenueByPeriodItem {
|
||||||
|
date: string;
|
||||||
|
totalRevenue: number;
|
||||||
|
bookingRevenue: number;
|
||||||
|
tournamentRevenue: number;
|
||||||
|
subscriptionRevenue: number;
|
||||||
|
classRevenue: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevenueByCourtItem {
|
||||||
|
courtId: string;
|
||||||
|
courtName: string;
|
||||||
|
totalRevenue: number;
|
||||||
|
bookingCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevenueByTypeItem {
|
||||||
|
type: string;
|
||||||
|
totalRevenue: number;
|
||||||
|
count: number;
|
||||||
|
percentage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentMethodStats {
|
||||||
|
method: string;
|
||||||
|
count: number;
|
||||||
|
totalAmount: number;
|
||||||
|
percentage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OutstandingPayment {
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
userEmail: string;
|
||||||
|
amount: number;
|
||||||
|
type: string;
|
||||||
|
createdAt: Date;
|
||||||
|
daysPending: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RefundStats {
|
||||||
|
totalRefunds: number;
|
||||||
|
totalAmount: number;
|
||||||
|
averageAmount: number;
|
||||||
|
refundRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FinancialTrend {
|
||||||
|
month: string;
|
||||||
|
year: number;
|
||||||
|
totalRevenue: number;
|
||||||
|
previousMonthRevenue: number | null;
|
||||||
|
growthPercentage: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopRevenueDay {
|
||||||
|
date: string;
|
||||||
|
totalRevenue: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FinancialService {
|
||||||
|
/**
|
||||||
|
* Obtener ingresos por período agrupados por día/semana/mes
|
||||||
|
*/
|
||||||
|
static async getRevenueByPeriod(
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date,
|
||||||
|
groupBy: GroupByPeriod = 'day'
|
||||||
|
): Promise<RevenueByPeriodItem[]> {
|
||||||
|
// Obtener pagos completados en el período
|
||||||
|
const payments = await prisma.payment.findMany({
|
||||||
|
where: {
|
||||||
|
status: ExtendedPaymentStatus.COMPLETED,
|
||||||
|
paidAt: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
amount: true,
|
||||||
|
type: true,
|
||||||
|
paidAt: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
paidAt: 'asc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Agrupar por fecha según el período
|
||||||
|
const grouped = groupByDate(
|
||||||
|
payments.map(p => ({
|
||||||
|
date: p.paidAt!,
|
||||||
|
amount: p.amount,
|
||||||
|
type: p.type,
|
||||||
|
})),
|
||||||
|
'date',
|
||||||
|
groupBy
|
||||||
|
);
|
||||||
|
|
||||||
|
// Formatear resultado
|
||||||
|
const result: RevenueByPeriodItem[] = Object.entries(grouped).map(([date, items]) => {
|
||||||
|
const bookingRevenue = items
|
||||||
|
.filter((i: any) => i.type === PaymentType.BOOKING)
|
||||||
|
.reduce((sum: number, i: any) => sum + i.amount, 0);
|
||||||
|
|
||||||
|
const tournamentRevenue = items
|
||||||
|
.filter((i: any) => i.type === PaymentType.TOURNAMENT)
|
||||||
|
.reduce((sum: number, i: any) => sum + i.amount, 0);
|
||||||
|
|
||||||
|
const subscriptionRevenue = items
|
||||||
|
.filter((i: any) => i.type === PaymentType.SUBSCRIPTION)
|
||||||
|
.reduce((sum: number, i: any) => sum + i.amount, 0);
|
||||||
|
|
||||||
|
const classRevenue = items
|
||||||
|
.filter((i: any) => i.type === PaymentType.CLASS)
|
||||||
|
.reduce((sum: number, i: any) => sum + i.amount, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
totalRevenue: items.reduce((sum: number, i: any) => sum + i.amount, 0),
|
||||||
|
bookingRevenue,
|
||||||
|
tournamentRevenue,
|
||||||
|
subscriptionRevenue,
|
||||||
|
classRevenue,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rellenar fechas faltantes
|
||||||
|
return fillMissingDates(result, startDate, endDate, groupBy) as RevenueByPeriodItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener ingresos por cancha
|
||||||
|
*/
|
||||||
|
static async getRevenueByCourt(
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date
|
||||||
|
): Promise<RevenueByCourtItem[]> {
|
||||||
|
// Obtener reservas pagadas en el período
|
||||||
|
const bookings = await prisma.booking.findMany({
|
||||||
|
where: {
|
||||||
|
status: {
|
||||||
|
in: ['CONFIRMED', 'COMPLETED'],
|
||||||
|
},
|
||||||
|
date: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
court: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Agrupar por cancha
|
||||||
|
const courtMap = new Map<string, RevenueByCourtItem>();
|
||||||
|
|
||||||
|
for (const booking of bookings) {
|
||||||
|
const courtId = booking.court.id;
|
||||||
|
const existing = courtMap.get(courtId);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.totalRevenue += booking.totalPrice;
|
||||||
|
existing.bookingCount += 1;
|
||||||
|
} else {
|
||||||
|
courtMap.set(courtId, {
|
||||||
|
courtId,
|
||||||
|
courtName: booking.court.name,
|
||||||
|
totalRevenue: booking.totalPrice,
|
||||||
|
bookingCount: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convertir a array y ordenar por ingresos
|
||||||
|
return Array.from(courtMap.values()).sort((a, b) => b.totalRevenue - a.totalRevenue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener desglose de ingresos por tipo
|
||||||
|
*/
|
||||||
|
static async getRevenueByType(
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date
|
||||||
|
): Promise<RevenueByTypeItem[]> {
|
||||||
|
const payments = await prisma.payment.groupBy({
|
||||||
|
by: ['type'],
|
||||||
|
where: {
|
||||||
|
status: ExtendedPaymentStatus.COMPLETED,
|
||||||
|
paidAt: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_sum: {
|
||||||
|
amount: true,
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calcular total para porcentajes
|
||||||
|
const totalRevenue = payments.reduce((sum, p) => sum + (p._sum.amount || 0), 0);
|
||||||
|
|
||||||
|
return payments.map(p => ({
|
||||||
|
type: p.type,
|
||||||
|
totalRevenue: p._sum.amount || 0,
|
||||||
|
count: p._count.id,
|
||||||
|
percentage: totalRevenue > 0 ? Math.round(((p._sum.amount || 0) / totalRevenue) * 100 * 100) / 100 : 0,
|
||||||
|
})).sort((a, b) => b.totalRevenue - a.totalRevenue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener estadísticas por método de pago
|
||||||
|
*/
|
||||||
|
static async getPaymentMethodsStats(
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date
|
||||||
|
): Promise<PaymentMethodStats[]> {
|
||||||
|
const payments = await prisma.payment.groupBy({
|
||||||
|
by: ['paymentMethod'],
|
||||||
|
where: {
|
||||||
|
status: ExtendedPaymentStatus.COMPLETED,
|
||||||
|
paidAt: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_sum: {
|
||||||
|
amount: true,
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalAmount = payments.reduce((sum, p) => sum + (p._sum.amount || 0), 0);
|
||||||
|
const totalCount = payments.reduce((sum, p) => sum + p._count.id, 0);
|
||||||
|
|
||||||
|
return payments.map(p => ({
|
||||||
|
method: p.paymentMethod || 'unknown',
|
||||||
|
count: p._count.id,
|
||||||
|
totalAmount: p._sum.amount || 0,
|
||||||
|
percentage: totalCount > 0 ? Math.round((p._count.id / totalCount) * 100 * 100) / 100 : 0,
|
||||||
|
})).sort((a, b) => b.totalAmount - a.totalAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener pagos pendientes de confirmar
|
||||||
|
*/
|
||||||
|
static async getOutstandingPayments(): Promise<OutstandingPayment[]> {
|
||||||
|
const pendingStatuses = [
|
||||||
|
ExtendedPaymentStatus.PENDING,
|
||||||
|
ExtendedPaymentStatus.PROCESSING,
|
||||||
|
];
|
||||||
|
|
||||||
|
const payments = await prisma.payment.findMany({
|
||||||
|
where: {
|
||||||
|
status: {
|
||||||
|
in: pendingStatuses,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'asc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
return payments.map(p => {
|
||||||
|
const daysPending = Math.floor(
|
||||||
|
(now.getTime() - new Date(p.createdAt).getTime()) / (1000 * 60 * 60 * 24)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: p.user.id,
|
||||||
|
userName: `${p.user.firstName} ${p.user.lastName}`,
|
||||||
|
userEmail: p.user.email,
|
||||||
|
amount: p.amount,
|
||||||
|
type: p.type,
|
||||||
|
createdAt: p.createdAt,
|
||||||
|
daysPending,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener estadísticas de reembolsos
|
||||||
|
*/
|
||||||
|
static async getRefundStats(startDate: Date, endDate: Date): Promise<RefundStats> {
|
||||||
|
// Obtener reembolsos
|
||||||
|
const refunds = await prisma.payment.findMany({
|
||||||
|
where: {
|
||||||
|
status: ExtendedPaymentStatus.REFUNDED,
|
||||||
|
refundedAt: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
refundAmount: true,
|
||||||
|
amount: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Obtener total de pagos completados para calcular tasa
|
||||||
|
const completedPayments = await prisma.payment.count({
|
||||||
|
where: {
|
||||||
|
status: ExtendedPaymentStatus.COMPLETED,
|
||||||
|
paidAt: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalRefunds = refunds.length;
|
||||||
|
const totalAmount = refunds.reduce((sum, r) => sum + (r.refundAmount || r.amount), 0);
|
||||||
|
const averageAmount = totalRefunds > 0 ? Math.round(totalAmount / totalRefunds) : 0;
|
||||||
|
const refundRate = completedPayments + totalRefunds > 0
|
||||||
|
? Math.round((totalRefunds / (completedPayments + totalRefunds)) * 100 * 100) / 100
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalRefunds,
|
||||||
|
totalAmount,
|
||||||
|
averageAmount,
|
||||||
|
refundRate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener tendencias financieras de los últimos N meses
|
||||||
|
*/
|
||||||
|
static async getFinancialTrends(months: number = 6): Promise<FinancialTrend[]> {
|
||||||
|
const now = new Date();
|
||||||
|
const startDate = new Date(now.getFullYear(), now.getMonth() - months + 1, 1);
|
||||||
|
|
||||||
|
// Obtener pagos completados
|
||||||
|
const payments = await prisma.payment.findMany({
|
||||||
|
where: {
|
||||||
|
status: ExtendedPaymentStatus.COMPLETED,
|
||||||
|
paidAt: {
|
||||||
|
gte: startDate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
amount: true,
|
||||||
|
paidAt: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
paidAt: 'asc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Agrupar por mes
|
||||||
|
const monthlyData: Record<string, number> = {};
|
||||||
|
|
||||||
|
for (const payment of payments) {
|
||||||
|
const date = new Date(payment.paidAt!);
|
||||||
|
const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
monthlyData[key] = (monthlyData[key] || 0) + payment.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crear tendencias con comparación mes a mes
|
||||||
|
const trends: FinancialTrend[] = [];
|
||||||
|
const sortedKeys = Object.keys(monthlyData).sort();
|
||||||
|
|
||||||
|
for (let i = 0; i < sortedKeys.length; i++) {
|
||||||
|
const key = sortedKeys[i];
|
||||||
|
const [year, month] = key.split('-').map(Number);
|
||||||
|
const totalRevenue = monthlyData[key];
|
||||||
|
const previousMonthRevenue = i > 0 ? monthlyData[sortedKeys[i - 1]] : null;
|
||||||
|
|
||||||
|
trends.push({
|
||||||
|
month: `${year}-${String(month).padStart(2, '0')}`,
|
||||||
|
year,
|
||||||
|
totalRevenue,
|
||||||
|
previousMonthRevenue,
|
||||||
|
growthPercentage: calculateGrowth(totalRevenue, previousMonthRevenue),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return trends;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener días con mayores ingresos
|
||||||
|
*/
|
||||||
|
static async getTopRevenueDays(limit: number = 10): Promise<TopRevenueDay[]> {
|
||||||
|
const payments = await prisma.payment.findMany({
|
||||||
|
where: {
|
||||||
|
status: ExtendedPaymentStatus.COMPLETED,
|
||||||
|
paidAt: {
|
||||||
|
not: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
amount: true,
|
||||||
|
paidAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Agrupar por día
|
||||||
|
const dailyRevenue: Record<string, number> = {};
|
||||||
|
|
||||||
|
for (const payment of payments) {
|
||||||
|
const dateKey = new Date(payment.paidAt!).toISOString().split('T')[0];
|
||||||
|
dailyRevenue[dateKey] = (dailyRevenue[dateKey] || 0) + payment.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convertir a array, ordenar y limitar
|
||||||
|
return Object.entries(dailyRevenue)
|
||||||
|
.map(([date, totalRevenue]) => ({ date, totalRevenue }))
|
||||||
|
.sort((a, b) => b.totalRevenue - a.totalRevenue)
|
||||||
|
.slice(0, limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FinancialService;
|
||||||
545
backend/src/services/analytics/occupancy.service.ts
Normal file
545
backend/src/services/analytics/occupancy.service.ts
Normal file
@@ -0,0 +1,545 @@
|
|||||||
|
import prisma from '../../config/database';
|
||||||
|
import { ApiError } from '../../middleware/errorHandler';
|
||||||
|
import { BookingStatus } from '../../utils/constants';
|
||||||
|
import logger from '../../config/logger';
|
||||||
|
|
||||||
|
export interface OccupancyByDateRangeInput {
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
courtId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OccupancyByCourtInput {
|
||||||
|
courtId: string;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
groupBy?: 'day' | 'week' | 'month';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OccupancyByTimeSlotInput {
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
courtId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PeakHoursInput {
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
courtId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OccupancyComparisonInput {
|
||||||
|
period1Start: Date;
|
||||||
|
period1End: Date;
|
||||||
|
period2Start: Date;
|
||||||
|
period2End: Date;
|
||||||
|
courtId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OccupancyData {
|
||||||
|
date: string;
|
||||||
|
totalSlots: number;
|
||||||
|
bookedSlots: number;
|
||||||
|
occupancyRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimeSlotOccupancy {
|
||||||
|
hour: string;
|
||||||
|
totalSlots: number;
|
||||||
|
bookedSlots: number;
|
||||||
|
occupancyRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PeakHour {
|
||||||
|
hour: string;
|
||||||
|
totalSlots: number;
|
||||||
|
bookedSlots: number;
|
||||||
|
occupancyRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OccupancyComparisonResult {
|
||||||
|
period1: {
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
totalSlots: number;
|
||||||
|
bookedSlots: number;
|
||||||
|
occupancyRate: number;
|
||||||
|
};
|
||||||
|
period2: {
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
totalSlots: number;
|
||||||
|
bookedSlots: number;
|
||||||
|
occupancyRate: number;
|
||||||
|
};
|
||||||
|
variation: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OccupancyService {
|
||||||
|
// Horarios de operación por defecto (8:00 - 23:00)
|
||||||
|
private static readonly DEFAULT_OPEN_HOUR = 8;
|
||||||
|
private static readonly DEFAULT_CLOSE_HOUR = 23;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener ocupación por rango de fechas
|
||||||
|
*/
|
||||||
|
static async getOccupancyByDateRange(input: OccupancyByDateRangeInput): Promise<OccupancyData[]> {
|
||||||
|
const { startDate, endDate, courtId } = input;
|
||||||
|
|
||||||
|
// Validar fechas
|
||||||
|
if (startDate > endDate) {
|
||||||
|
throw new ApiError('La fecha de inicio debe ser anterior a la fecha de fin', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener canchas activas
|
||||||
|
const courts = courtId
|
||||||
|
? await prisma.court.findMany({ where: { id: courtId, isActive: true } })
|
||||||
|
: await prisma.court.findMany({ where: { isActive: true } });
|
||||||
|
|
||||||
|
if (courts.length === 0) {
|
||||||
|
throw new ApiError('No se encontraron canchas activas', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener bookings en el rango de fechas
|
||||||
|
const bookings = await prisma.booking.findMany({
|
||||||
|
where: {
|
||||||
|
date: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate,
|
||||||
|
},
|
||||||
|
status: { in: [BookingStatus.CONFIRMED, BookingStatus.COMPLETED] },
|
||||||
|
...(courtId && { courtId }),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
date: true,
|
||||||
|
startTime: true,
|
||||||
|
endTime: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generar datos por día
|
||||||
|
const result: OccupancyData[] = [];
|
||||||
|
const currentDate = new Date(startDate);
|
||||||
|
|
||||||
|
while (currentDate <= endDate) {
|
||||||
|
const dateStr = currentDate.toISOString().split('T')[0];
|
||||||
|
const dayOfWeek = currentDate.getDay();
|
||||||
|
|
||||||
|
// Calcular slots disponibles para este día
|
||||||
|
let totalSlotsForDay = 0;
|
||||||
|
|
||||||
|
for (const court of courts) {
|
||||||
|
// Buscar horario específico de la cancha para este día
|
||||||
|
const schedule = await prisma.courtSchedule.findFirst({
|
||||||
|
where: {
|
||||||
|
courtId: court.id,
|
||||||
|
dayOfWeek,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (schedule) {
|
||||||
|
const openHour = parseInt(schedule.openTime.split(':')[0]);
|
||||||
|
const closeHour = parseInt(schedule.closeTime.split(':')[0]);
|
||||||
|
totalSlotsForDay += (closeHour - openHour);
|
||||||
|
} else {
|
||||||
|
// Usar horario por defecto
|
||||||
|
totalSlotsForDay += (this.DEFAULT_CLOSE_HOUR - this.DEFAULT_OPEN_HOUR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contar bookings para este día
|
||||||
|
const dayBookings = bookings.filter(
|
||||||
|
b => b.date.toISOString().split('T')[0] === dateStr
|
||||||
|
);
|
||||||
|
|
||||||
|
const bookedSlots = dayBookings.reduce((total, booking) => {
|
||||||
|
const startHour = parseInt(booking.startTime.split(':')[0]);
|
||||||
|
const endHour = parseInt(booking.endTime.split(':')[0]);
|
||||||
|
return total + (endHour - startHour);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const occupancyRate = totalSlotsForDay > 0
|
||||||
|
? Math.round((bookedSlots / totalSlotsForDay) * 1000) / 10
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
date: dateStr,
|
||||||
|
totalSlots: totalSlotsForDay,
|
||||||
|
bookedSlots,
|
||||||
|
occupancyRate,
|
||||||
|
});
|
||||||
|
|
||||||
|
currentDate.setDate(currentDate.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener ocupación por cancha específica
|
||||||
|
*/
|
||||||
|
static async getOccupancyByCourt(input: OccupancyByCourtInput): Promise<OccupancyData[]> {
|
||||||
|
const { courtId, startDate, endDate, groupBy = 'day' } = input;
|
||||||
|
|
||||||
|
// Verificar que la cancha existe
|
||||||
|
const court = await prisma.court.findFirst({
|
||||||
|
where: { id: courtId, isActive: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!court) {
|
||||||
|
throw new ApiError('Cancha no encontrada o inactiva', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener bookings
|
||||||
|
const bookings = await prisma.booking.findMany({
|
||||||
|
where: {
|
||||||
|
courtId,
|
||||||
|
date: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate,
|
||||||
|
},
|
||||||
|
status: { in: [BookingStatus.CONFIRMED, BookingStatus.COMPLETED] },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
date: true,
|
||||||
|
startTime: true,
|
||||||
|
endTime: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Agrupar según el parámetro groupBy
|
||||||
|
if (groupBy === 'day') {
|
||||||
|
return this.groupByDay(bookings, startDate, endDate, courtId);
|
||||||
|
} else if (groupBy === 'week') {
|
||||||
|
return this.groupByWeek(bookings, startDate, endDate, courtId);
|
||||||
|
} else {
|
||||||
|
return this.groupByMonth(bookings, startDate, endDate, courtId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener ocupación por franja horaria
|
||||||
|
*/
|
||||||
|
static async getOccupancyByTimeSlot(input: OccupancyByTimeSlotInput): Promise<TimeSlotOccupancy[]> {
|
||||||
|
const { startDate, endDate, courtId } = input;
|
||||||
|
|
||||||
|
// Obtener canchas
|
||||||
|
const courts = courtId
|
||||||
|
? await prisma.court.findMany({ where: { id: courtId, isActive: true } })
|
||||||
|
: await prisma.court.findMany({ where: { isActive: true } });
|
||||||
|
|
||||||
|
if (courts.length === 0) {
|
||||||
|
throw new ApiError('No se encontraron canchas activas', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener bookings
|
||||||
|
const bookings = await prisma.booking.findMany({
|
||||||
|
where: {
|
||||||
|
date: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate,
|
||||||
|
},
|
||||||
|
status: { in: [BookingStatus.CONFIRMED, BookingStatus.COMPLETED] },
|
||||||
|
...(courtId && { courtId }),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
date: true,
|
||||||
|
startTime: true,
|
||||||
|
endTime: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calcular días en el rango (excluyendo días sin horario)
|
||||||
|
const daysInRange = this.countOperationalDays(startDate, endDate, courts);
|
||||||
|
|
||||||
|
// Inicializar slots por hora (8-22)
|
||||||
|
const hourlyData: Map<string, { totalSlots: number; bookedSlots: number }> = new Map();
|
||||||
|
|
||||||
|
for (let hour = this.DEFAULT_OPEN_HOUR; hour < this.DEFAULT_CLOSE_HOUR; hour++) {
|
||||||
|
const hourStr = `${hour.toString().padStart(2, '0')}:00`;
|
||||||
|
hourlyData.set(hourStr, { totalSlots: 0, bookedSlots: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular total de slots disponibles por hora
|
||||||
|
for (const court of courts) {
|
||||||
|
for (let hour = this.DEFAULT_OPEN_HOUR; hour < this.DEFAULT_CLOSE_HOUR; hour++) {
|
||||||
|
const hourStr = `${hour.toString().padStart(2, '0')}:00`;
|
||||||
|
const current = hourlyData.get(hourStr)!;
|
||||||
|
current.totalSlots += daysInRange;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contar bookings por hora
|
||||||
|
for (const booking of bookings) {
|
||||||
|
const startHour = parseInt(booking.startTime.split(':')[0]);
|
||||||
|
const endHour = parseInt(booking.endTime.split(':')[0]);
|
||||||
|
|
||||||
|
for (let hour = startHour; hour < endHour; hour++) {
|
||||||
|
const hourStr = `${hour.toString().padStart(2, '0')}:00`;
|
||||||
|
const current = hourlyData.get(hourStr);
|
||||||
|
if (current) {
|
||||||
|
current.bookedSlots++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convertir a array y calcular porcentajes
|
||||||
|
const result: TimeSlotOccupancy[] = [];
|
||||||
|
hourlyData.forEach((data, hour) => {
|
||||||
|
result.push({
|
||||||
|
hour,
|
||||||
|
totalSlots: data.totalSlots,
|
||||||
|
bookedSlots: data.bookedSlots,
|
||||||
|
occupancyRate: data.totalSlots > 0
|
||||||
|
? Math.round((data.bookedSlots / data.totalSlots) * 1000) / 10
|
||||||
|
: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.sort((a, b) => a.hour.localeCompare(b.hour));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener horarios pico (top 5 horarios más demandados)
|
||||||
|
*/
|
||||||
|
static async getPeakHours(input: PeakHoursInput): Promise<PeakHour[]> {
|
||||||
|
const occupancyByTimeSlot = await this.getOccupancyByTimeSlot(input);
|
||||||
|
|
||||||
|
// Ordenar por ocupación (descendente) y tomar top 5
|
||||||
|
return occupancyByTimeSlot
|
||||||
|
.sort((a, b) => b.occupancyRate - a.occupancyRate)
|
||||||
|
.slice(0, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comparar ocupación entre dos períodos
|
||||||
|
*/
|
||||||
|
static async getOccupancyComparison(input: OccupancyComparisonInput): Promise<OccupancyComparisonResult> {
|
||||||
|
const { period1Start, period1End, period2Start, period2End, courtId } = input;
|
||||||
|
|
||||||
|
// Calcular ocupación para período 1
|
||||||
|
const period1Data = await this.getOccupancyByDateRange({
|
||||||
|
startDate: period1Start,
|
||||||
|
endDate: period1End,
|
||||||
|
courtId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calcular ocupación para período 2
|
||||||
|
const period2Data = await this.getOccupancyByDateRange({
|
||||||
|
startDate: period2Start,
|
||||||
|
endDate: period2End,
|
||||||
|
courtId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sumar totales del período 1
|
||||||
|
const period1Totals = period1Data.reduce(
|
||||||
|
(acc, day) => ({
|
||||||
|
totalSlots: acc.totalSlots + day.totalSlots,
|
||||||
|
bookedSlots: acc.bookedSlots + day.bookedSlots,
|
||||||
|
}),
|
||||||
|
{ totalSlots: 0, bookedSlots: 0 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sumar totales del período 2
|
||||||
|
const period2Totals = period2Data.reduce(
|
||||||
|
(acc, day) => ({
|
||||||
|
totalSlots: acc.totalSlots + day.totalSlots,
|
||||||
|
bookedSlots: acc.bookedSlots + day.bookedSlots,
|
||||||
|
}),
|
||||||
|
{ totalSlots: 0, bookedSlots: 0 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const period1Rate = period1Totals.totalSlots > 0
|
||||||
|
? Math.round((period1Totals.bookedSlots / period1Totals.totalSlots) * 1000) / 10
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const period2Rate = period2Totals.totalSlots > 0
|
||||||
|
? Math.round((period2Totals.bookedSlots / period2Totals.totalSlots) * 1000) / 10
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Calcular variación porcentual
|
||||||
|
let variation = 0;
|
||||||
|
if (period1Rate > 0) {
|
||||||
|
variation = Math.round(((period2Rate - period1Rate) / period1Rate) * 1000) / 10;
|
||||||
|
} else if (period2Rate > 0) {
|
||||||
|
variation = 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
period1: {
|
||||||
|
startDate: period1Start.toISOString().split('T')[0],
|
||||||
|
endDate: period1End.toISOString().split('T')[0],
|
||||||
|
totalSlots: period1Totals.totalSlots,
|
||||||
|
bookedSlots: period1Totals.bookedSlots,
|
||||||
|
occupancyRate: period1Rate,
|
||||||
|
},
|
||||||
|
period2: {
|
||||||
|
startDate: period2Start.toISOString().split('T')[0],
|
||||||
|
endDate: period2End.toISOString().split('T')[0],
|
||||||
|
totalSlots: period2Totals.totalSlots,
|
||||||
|
bookedSlots: period2Totals.bookedSlots,
|
||||||
|
occupancyRate: period2Rate,
|
||||||
|
},
|
||||||
|
variation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers privados
|
||||||
|
|
||||||
|
private static async groupByDay(
|
||||||
|
bookings: any[],
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date,
|
||||||
|
courtId: string
|
||||||
|
): Promise<OccupancyData[]> {
|
||||||
|
const result: OccupancyData[] = [];
|
||||||
|
const currentDate = new Date(startDate);
|
||||||
|
|
||||||
|
while (currentDate <= endDate) {
|
||||||
|
const dateStr = currentDate.toISOString().split('T')[0];
|
||||||
|
const dayBookings = bookings.filter(
|
||||||
|
b => b.date.toISOString().split('T')[0] === dateStr
|
||||||
|
);
|
||||||
|
|
||||||
|
const bookedSlots = dayBookings.reduce((total, booking) => {
|
||||||
|
const startHour = parseInt(booking.startTime.split(':')[0]);
|
||||||
|
const endHour = parseInt(booking.endTime.split(':')[0]);
|
||||||
|
return total + (endHour - startHour);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// Calcular slots disponibles para este día
|
||||||
|
const dayOfWeek = currentDate.getDay();
|
||||||
|
const schedule = await prisma.courtSchedule.findFirst({
|
||||||
|
where: { courtId, dayOfWeek },
|
||||||
|
});
|
||||||
|
|
||||||
|
const openHour = schedule ? parseInt(schedule.openTime.split(':')[0]) : this.DEFAULT_OPEN_HOUR;
|
||||||
|
const closeHour = schedule ? parseInt(schedule.closeTime.split(':')[0]) : this.DEFAULT_CLOSE_HOUR;
|
||||||
|
const totalSlots = closeHour - openHour;
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
date: dateStr,
|
||||||
|
totalSlots,
|
||||||
|
bookedSlots,
|
||||||
|
occupancyRate: totalSlots > 0
|
||||||
|
? Math.round((bookedSlots / totalSlots) * 1000) / 10
|
||||||
|
: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
currentDate.setDate(currentDate.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async groupByWeek(
|
||||||
|
bookings: any[],
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date,
|
||||||
|
courtId: string
|
||||||
|
): Promise<OccupancyData[]> {
|
||||||
|
const weeklyData: Map<string, { totalSlots: number; bookedSlots: number }> = new Map();
|
||||||
|
|
||||||
|
for (const booking of bookings) {
|
||||||
|
const date = new Date(booking.date);
|
||||||
|
const weekStart = new Date(date);
|
||||||
|
weekStart.setDate(date.getDate() - date.getDay());
|
||||||
|
const weekKey = weekStart.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
if (!weeklyData.has(weekKey)) {
|
||||||
|
weeklyData.set(weekKey, { totalSlots: 0, bookedSlots: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = weeklyData.get(weekKey)!;
|
||||||
|
const startHour = parseInt(booking.startTime.split(':')[0]);
|
||||||
|
const endHour = parseInt(booking.endTime.split(':')[0]);
|
||||||
|
current.bookedSlots += (endHour - startHour);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular total slots por semana
|
||||||
|
const result: OccupancyData[] = [];
|
||||||
|
weeklyData.forEach((data, weekKey) => {
|
||||||
|
// Estimar slots disponibles (7 días * horas promedio)
|
||||||
|
const estimatedTotalSlots = 7 * (this.DEFAULT_CLOSE_HOUR - this.DEFAULT_OPEN_HOUR);
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
date: weekKey,
|
||||||
|
totalSlots: estimatedTotalSlots,
|
||||||
|
bookedSlots: data.bookedSlots,
|
||||||
|
occupancyRate: estimatedTotalSlots > 0
|
||||||
|
? Math.round((data.bookedSlots / estimatedTotalSlots) * 1000) / 10
|
||||||
|
: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async groupByMonth(
|
||||||
|
bookings: any[],
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date,
|
||||||
|
courtId: string
|
||||||
|
): Promise<OccupancyData[]> {
|
||||||
|
const monthlyData: Map<string, { totalSlots: number; bookedSlots: number }> = new Map();
|
||||||
|
|
||||||
|
for (const booking of bookings) {
|
||||||
|
const date = new Date(booking.date);
|
||||||
|
const monthKey = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-01`;
|
||||||
|
|
||||||
|
if (!monthlyData.has(monthKey)) {
|
||||||
|
monthlyData.set(monthKey, { totalSlots: 0, bookedSlots: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = monthlyData.get(monthKey)!;
|
||||||
|
const startHour = parseInt(booking.startTime.split(':')[0]);
|
||||||
|
const endHour = parseInt(booking.endTime.split(':')[0]);
|
||||||
|
current.bookedSlots += (endHour - startHour);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular total slots por mes
|
||||||
|
const result: OccupancyData[] = [];
|
||||||
|
monthlyData.forEach((data, monthKey) => {
|
||||||
|
// Estimar slots disponibles (30 días * horas promedio)
|
||||||
|
const estimatedTotalSlots = 30 * (this.DEFAULT_CLOSE_HOUR - this.DEFAULT_OPEN_HOUR);
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
date: monthKey,
|
||||||
|
totalSlots: estimatedTotalSlots,
|
||||||
|
bookedSlots: data.bookedSlots,
|
||||||
|
occupancyRate: estimatedTotalSlots > 0
|
||||||
|
? Math.round((data.bookedSlots / estimatedTotalSlots) * 1000) / 10
|
||||||
|
: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static countOperationalDays(startDate: Date, endDate: Date, courts: any[]): number {
|
||||||
|
let count = 0;
|
||||||
|
const currentDate = new Date(startDate);
|
||||||
|
|
||||||
|
while (currentDate <= endDate) {
|
||||||
|
const dayOfWeek = currentDate.getDay();
|
||||||
|
let hasSchedule = false;
|
||||||
|
|
||||||
|
for (const court of courts) {
|
||||||
|
// Verificar si al menos una cancha tiene horario este día
|
||||||
|
// Simplificación: asumimos que todas las canchas tienen horario
|
||||||
|
hasSchedule = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasSchedule) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentDate.setDate(currentDate.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return count || 1; // Evitar división por cero
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OccupancyService;
|
||||||
760
backend/src/services/analytics/report.service.ts
Normal file
760
backend/src/services/analytics/report.service.ts
Normal file
@@ -0,0 +1,760 @@
|
|||||||
|
import prisma from '../../config/database';
|
||||||
|
import { FinancialService, GroupByPeriod } from './financial.service';
|
||||||
|
import { ExtendedPaymentStatus, PaymentType, BookingStatus } from '../../utils/constants';
|
||||||
|
import { formatCurrency, calculateGrowth } from '../../utils/analytics';
|
||||||
|
|
||||||
|
// Interfaces para reportes
|
||||||
|
export interface RevenueReport {
|
||||||
|
period: {
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
};
|
||||||
|
summary: {
|
||||||
|
totalRevenue: number;
|
||||||
|
totalTransactions: number;
|
||||||
|
averageTransaction: number;
|
||||||
|
formattedTotalRevenue: string;
|
||||||
|
formattedAverageTransaction: string;
|
||||||
|
};
|
||||||
|
byType: Array<{
|
||||||
|
type: string;
|
||||||
|
revenue: number;
|
||||||
|
count: number;
|
||||||
|
percentage: number;
|
||||||
|
formattedRevenue: string;
|
||||||
|
}>;
|
||||||
|
byCourt: Array<{
|
||||||
|
courtId: string;
|
||||||
|
courtName: string;
|
||||||
|
revenue: number;
|
||||||
|
bookingCount: number;
|
||||||
|
formattedRevenue: string;
|
||||||
|
}>;
|
||||||
|
byPaymentMethod: Array<{
|
||||||
|
method: string;
|
||||||
|
count: number;
|
||||||
|
amount: number;
|
||||||
|
percentage: number;
|
||||||
|
formattedAmount: string;
|
||||||
|
}>;
|
||||||
|
dailyBreakdown: Array<{
|
||||||
|
date: string;
|
||||||
|
revenue: number;
|
||||||
|
transactionCount: number;
|
||||||
|
formattedRevenue: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OccupancyReport {
|
||||||
|
period: {
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
};
|
||||||
|
summary: {
|
||||||
|
totalBookings: number;
|
||||||
|
totalSlots: number;
|
||||||
|
occupancyRate: number;
|
||||||
|
averageBookingsPerDay: number;
|
||||||
|
};
|
||||||
|
byCourt: Array<{
|
||||||
|
courtId: string;
|
||||||
|
courtName: string;
|
||||||
|
totalBookings: number;
|
||||||
|
occupancyRate: number;
|
||||||
|
}>;
|
||||||
|
byDayOfWeek: Array<{
|
||||||
|
day: string;
|
||||||
|
dayNumber: number;
|
||||||
|
bookings: number;
|
||||||
|
percentage: number;
|
||||||
|
}>;
|
||||||
|
byHour: Array<{
|
||||||
|
hour: number;
|
||||||
|
bookings: number;
|
||||||
|
percentage: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserReport {
|
||||||
|
period: {
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
};
|
||||||
|
summary: {
|
||||||
|
newUsers: number;
|
||||||
|
activeUsers: number;
|
||||||
|
churnedUsers: number;
|
||||||
|
retentionRate: number;
|
||||||
|
};
|
||||||
|
newUsersByPeriod: Array<{
|
||||||
|
period: string;
|
||||||
|
count: number;
|
||||||
|
}>;
|
||||||
|
topUsersByBookings: Array<{
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
email: string;
|
||||||
|
bookingCount: number;
|
||||||
|
totalSpent: number;
|
||||||
|
}>;
|
||||||
|
userActivity: Array<{
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
lastActivity: Date | null;
|
||||||
|
bookingsCount: number;
|
||||||
|
paymentsCount: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutiveSummary {
|
||||||
|
period: {
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
};
|
||||||
|
revenue: {
|
||||||
|
total: number;
|
||||||
|
previousPeriodTotal: number | null;
|
||||||
|
growth: number | null;
|
||||||
|
formattedTotal: string;
|
||||||
|
formattedGrowth: string;
|
||||||
|
};
|
||||||
|
bookings: {
|
||||||
|
total: number;
|
||||||
|
confirmed: number;
|
||||||
|
cancelled: number;
|
||||||
|
completionRate: number;
|
||||||
|
};
|
||||||
|
users: {
|
||||||
|
new: number;
|
||||||
|
active: number;
|
||||||
|
totalRegistered: number;
|
||||||
|
};
|
||||||
|
payments: {
|
||||||
|
completed: number;
|
||||||
|
pending: number;
|
||||||
|
refunded: number;
|
||||||
|
averageAmount: number;
|
||||||
|
};
|
||||||
|
topMetrics: {
|
||||||
|
bestRevenueDay: string | null;
|
||||||
|
bestRevenueAmount: number;
|
||||||
|
mostPopularCourt: string | null;
|
||||||
|
mostUsedPaymentMethod: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReportService {
|
||||||
|
/**
|
||||||
|
* Generar reporte completo de ingresos
|
||||||
|
*/
|
||||||
|
static async generateRevenueReport(
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date
|
||||||
|
): Promise<RevenueReport> {
|
||||||
|
// Obtener datos de ingresos
|
||||||
|
const [byType, byCourt, byPaymentMethod, dailyBreakdown] = await Promise.all([
|
||||||
|
FinancialService.getRevenueByType(startDate, endDate),
|
||||||
|
FinancialService.getRevenueByCourt(startDate, endDate),
|
||||||
|
FinancialService.getPaymentMethodsStats(startDate, endDate),
|
||||||
|
this.getDailyRevenueBreakdown(startDate, endDate),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Calcular totales
|
||||||
|
const totalRevenue = byType.reduce((sum, t) => sum + t.totalRevenue, 0);
|
||||||
|
const totalTransactions = byType.reduce((sum, t) => sum + t.count, 0);
|
||||||
|
const averageTransaction = totalTransactions > 0
|
||||||
|
? Math.round(totalRevenue / totalTransactions)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
period: { startDate, endDate },
|
||||||
|
summary: {
|
||||||
|
totalRevenue,
|
||||||
|
totalTransactions,
|
||||||
|
averageTransaction,
|
||||||
|
formattedTotalRevenue: formatCurrency(totalRevenue),
|
||||||
|
formattedAverageTransaction: formatCurrency(averageTransaction),
|
||||||
|
},
|
||||||
|
byType: byType.map(t => ({
|
||||||
|
type: t.type,
|
||||||
|
revenue: t.totalRevenue,
|
||||||
|
count: t.count,
|
||||||
|
percentage: t.percentage,
|
||||||
|
formattedRevenue: formatCurrency(t.totalRevenue),
|
||||||
|
})),
|
||||||
|
byCourt: byCourt.map(c => ({
|
||||||
|
courtId: c.courtId,
|
||||||
|
courtName: c.courtName,
|
||||||
|
revenue: c.totalRevenue,
|
||||||
|
bookingCount: c.bookingCount,
|
||||||
|
formattedRevenue: formatCurrency(c.totalRevenue),
|
||||||
|
})),
|
||||||
|
byPaymentMethod: byPaymentMethod.map(m => ({
|
||||||
|
method: m.method,
|
||||||
|
count: m.count,
|
||||||
|
amount: m.totalAmount,
|
||||||
|
percentage: m.percentage,
|
||||||
|
formattedAmount: formatCurrency(m.totalAmount),
|
||||||
|
})),
|
||||||
|
dailyBreakdown,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generar reporte de ocupación
|
||||||
|
*/
|
||||||
|
static async generateOccupancyReport(
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date
|
||||||
|
): Promise<OccupancyReport> {
|
||||||
|
// Obtener reservas del período
|
||||||
|
const bookings = await prisma.booking.findMany({
|
||||||
|
where: {
|
||||||
|
date: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
court: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Obtener canchas activas
|
||||||
|
const courts = await prisma.court.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calcular métricas por cancha
|
||||||
|
const courtMap = new Map<string, { totalBookings: number; name: string }>();
|
||||||
|
for (const court of courts) {
|
||||||
|
courtMap.set(court.id, { totalBookings: 0, name: court.name });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Métricas por día de semana
|
||||||
|
const dayOfWeekMap = new Map<number, number>();
|
||||||
|
const daysOfWeek = ['Domingo', 'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado'];
|
||||||
|
|
||||||
|
// Métricas por hora
|
||||||
|
const hourMap = new Map<number, number>();
|
||||||
|
|
||||||
|
for (const booking of bookings) {
|
||||||
|
// Por cancha
|
||||||
|
const courtData = courtMap.get(booking.courtId);
|
||||||
|
if (courtData) {
|
||||||
|
courtData.totalBookings += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Por día de semana
|
||||||
|
const dayOfWeek = new Date(booking.date).getDay();
|
||||||
|
dayOfWeekMap.set(dayOfWeek, (dayOfWeekMap.get(dayOfWeek) || 0) + 1);
|
||||||
|
|
||||||
|
// Por hora
|
||||||
|
const hour = parseInt(booking.startTime.split(':')[0], 10);
|
||||||
|
hourMap.set(hour, (hourMap.get(hour) || 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular slots totales posibles (simplificado)
|
||||||
|
const daysInPeriod = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)) + 1;
|
||||||
|
const hoursPerDay = 12; // Asumiendo 12 horas operativas
|
||||||
|
const totalPossibleSlots = courts.length * daysInPeriod * hoursPerDay;
|
||||||
|
|
||||||
|
const totalBookings = bookings.length;
|
||||||
|
const occupancyRate = totalPossibleSlots > 0
|
||||||
|
? Math.round((totalBookings / totalPossibleSlots) * 100 * 100) / 100
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
period: { startDate, endDate },
|
||||||
|
summary: {
|
||||||
|
totalBookings,
|
||||||
|
totalSlots: totalPossibleSlots,
|
||||||
|
occupancyRate,
|
||||||
|
averageBookingsPerDay: daysInPeriod > 0 ? Math.round((totalBookings / daysInPeriod) * 100) / 100 : 0,
|
||||||
|
},
|
||||||
|
byCourt: Array.from(courtMap.entries()).map(([courtId, data]) => ({
|
||||||
|
courtId,
|
||||||
|
courtName: data.name,
|
||||||
|
totalBookings: data.totalBookings,
|
||||||
|
occupancyRate: totalPossibleSlots > 0
|
||||||
|
? Math.round((data.totalBookings / (totalPossibleSlots / courts.length)) * 100 * 100) / 100
|
||||||
|
: 0,
|
||||||
|
})).sort((a, b) => b.totalBookings - a.totalBookings),
|
||||||
|
byDayOfWeek: daysOfWeek.map((day, index) => {
|
||||||
|
const count = dayOfWeekMap.get(index) || 0;
|
||||||
|
return {
|
||||||
|
day,
|
||||||
|
dayNumber: index,
|
||||||
|
bookings: count,
|
||||||
|
percentage: totalBookings > 0 ? Math.round((count / totalBookings) * 100 * 100) / 100 : 0,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
byHour: Array.from(hourMap.entries())
|
||||||
|
.map(([hour, count]) => ({
|
||||||
|
hour,
|
||||||
|
bookings: count,
|
||||||
|
percentage: totalBookings > 0 ? Math.round((count / totalBookings) * 100 * 100) / 100 : 0,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.hour - b.hour),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generar reporte de usuarios
|
||||||
|
*/
|
||||||
|
static async generateUserReport(
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date
|
||||||
|
): Promise<UserReport> {
|
||||||
|
// Usuarios nuevos en el período
|
||||||
|
const newUsers = await prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
createdAt: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Usuarios activos (que hicieron reservas o pagos)
|
||||||
|
const activeUserIds = new Set<string>();
|
||||||
|
|
||||||
|
const bookings = await prisma.booking.findMany({
|
||||||
|
where: {
|
||||||
|
date: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { userId: true },
|
||||||
|
distinct: ['userId'],
|
||||||
|
});
|
||||||
|
|
||||||
|
bookings.forEach(b => activeUserIds.add(b.userId));
|
||||||
|
|
||||||
|
const payments = await prisma.payment.findMany({
|
||||||
|
where: {
|
||||||
|
paidAt: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { userId: true },
|
||||||
|
distinct: ['userId'],
|
||||||
|
});
|
||||||
|
|
||||||
|
payments.forEach(p => activeUserIds.add(p.userId));
|
||||||
|
|
||||||
|
// Top usuarios por reservas
|
||||||
|
const topUsersByBookings = await prisma.booking.groupBy({
|
||||||
|
by: ['userId'],
|
||||||
|
where: {
|
||||||
|
date: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
_count: {
|
||||||
|
id: 'desc',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
take: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Obtener detalles de usuarios top
|
||||||
|
const topUsersDetails = await Promise.all(
|
||||||
|
topUsersByBookings.map(async (u) => {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: u.userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const userPayments = await prisma.payment.aggregate({
|
||||||
|
where: {
|
||||||
|
userId: u.userId,
|
||||||
|
status: ExtendedPaymentStatus.COMPLETED,
|
||||||
|
paidAt: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_sum: { amount: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: u.userId,
|
||||||
|
userName: user ? `${user.firstName} ${user.lastName}` : 'Unknown',
|
||||||
|
email: user?.email || '',
|
||||||
|
bookingCount: u._count.id,
|
||||||
|
totalSpent: userPayments._sum.amount || 0,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Actividad de usuarios
|
||||||
|
const allUsers = await prisma.user.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
take: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
const userActivity = await Promise.all(
|
||||||
|
allUsers.map(async (user) => {
|
||||||
|
const lastBooking = await prisma.booking.findFirst({
|
||||||
|
where: { userId: user.id },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: { createdAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const bookingsCount = await prisma.booking.count({
|
||||||
|
where: { userId: user.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const paymentsCount = await prisma.payment.count({
|
||||||
|
where: { userId: user.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: user.id,
|
||||||
|
userName: `${user.firstName} ${user.lastName}`,
|
||||||
|
lastActivity: lastBooking?.createdAt || null,
|
||||||
|
bookingsCount,
|
||||||
|
paymentsCount,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Agrupar nuevos usuarios por período (semanal)
|
||||||
|
const newUsersByPeriod: Record<string, number> = {};
|
||||||
|
for (const user of newUsers) {
|
||||||
|
const week = this.getWeekKey(new Date(user.createdAt));
|
||||||
|
newUsersByPeriod[week] = (newUsersByPeriod[week] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
period: { startDate, endDate },
|
||||||
|
summary: {
|
||||||
|
newUsers: newUsers.length,
|
||||||
|
activeUsers: activeUserIds.size,
|
||||||
|
churnedUsers: 0, // Requeriría lógica más compleja
|
||||||
|
retentionRate: 0, // Requeriría datos históricos
|
||||||
|
},
|
||||||
|
newUsersByPeriod: Object.entries(newUsersByPeriod).map(([period, count]) => ({
|
||||||
|
period,
|
||||||
|
count,
|
||||||
|
})).sort((a, b) => a.period.localeCompare(b.period)),
|
||||||
|
topUsersByBookings: topUsersDetails,
|
||||||
|
userActivity: userActivity.sort((a, b) => b.bookingsCount - a.bookingsCount),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener resumen ejecutivo para dashboard
|
||||||
|
*/
|
||||||
|
static async getReportSummary(startDate: Date, endDate: Date): Promise<ExecutiveSummary> {
|
||||||
|
// Calcular período anterior para comparación
|
||||||
|
const periodDuration = endDate.getTime() - startDate.getTime();
|
||||||
|
const previousStartDate = new Date(startDate.getTime() - periodDuration);
|
||||||
|
const previousEndDate = new Date(endDate.getTime() - periodDuration);
|
||||||
|
|
||||||
|
// Ingresos del período actual
|
||||||
|
const currentRevenue = await prisma.payment.aggregate({
|
||||||
|
where: {
|
||||||
|
status: ExtendedPaymentStatus.COMPLETED,
|
||||||
|
paidAt: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_sum: { amount: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ingresos del período anterior
|
||||||
|
const previousRevenue = await prisma.payment.aggregate({
|
||||||
|
where: {
|
||||||
|
status: ExtendedPaymentStatus.COMPLETED,
|
||||||
|
paidAt: {
|
||||||
|
gte: previousStartDate,
|
||||||
|
lte: previousEndDate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_sum: { amount: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalRevenue = currentRevenue._sum.amount || 0;
|
||||||
|
const previousPeriodTotal = previousRevenue._sum.amount || null;
|
||||||
|
const growth = calculateGrowth(totalRevenue, previousPeriodTotal);
|
||||||
|
|
||||||
|
// Estadísticas de reservas
|
||||||
|
const [totalBookings, confirmedBookings, cancelledBookings] = await Promise.all([
|
||||||
|
prisma.booking.count({
|
||||||
|
where: {
|
||||||
|
date: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.booking.count({
|
||||||
|
where: {
|
||||||
|
date: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate,
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
in: [BookingStatus.CONFIRMED, BookingStatus.COMPLETED],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.booking.count({
|
||||||
|
where: {
|
||||||
|
date: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate,
|
||||||
|
},
|
||||||
|
status: BookingStatus.CANCELLED,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Usuarios
|
||||||
|
const [newUsers, totalRegistered] = await Promise.all([
|
||||||
|
prisma.user.count({
|
||||||
|
where: {
|
||||||
|
createdAt: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.user.count(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Pagos
|
||||||
|
const [completedPayments, pendingPayments, refundedPayments, avgPayment] = await Promise.all([
|
||||||
|
prisma.payment.count({
|
||||||
|
where: {
|
||||||
|
status: ExtendedPaymentStatus.COMPLETED,
|
||||||
|
paidAt: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.payment.count({
|
||||||
|
where: {
|
||||||
|
status: {
|
||||||
|
in: [ExtendedPaymentStatus.PENDING, ExtendedPaymentStatus.PROCESSING],
|
||||||
|
},
|
||||||
|
createdAt: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.payment.count({
|
||||||
|
where: {
|
||||||
|
status: ExtendedPaymentStatus.REFUNDED,
|
||||||
|
refundedAt: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.payment.aggregate({
|
||||||
|
where: {
|
||||||
|
status: ExtendedPaymentStatus.COMPLETED,
|
||||||
|
paidAt: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_avg: { amount: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Obtener métricas destacadas
|
||||||
|
const [topRevenueDay, mostPopularCourt, mostUsedPaymentMethod] = await Promise.all([
|
||||||
|
this.getBestRevenueDay(startDate, endDate),
|
||||||
|
this.getMostPopularCourt(startDate, endDate),
|
||||||
|
this.getMostUsedPaymentMethod(startDate, endDate),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
period: { startDate, endDate },
|
||||||
|
revenue: {
|
||||||
|
total: totalRevenue,
|
||||||
|
previousPeriodTotal,
|
||||||
|
growth,
|
||||||
|
formattedTotal: formatCurrency(totalRevenue),
|
||||||
|
formattedGrowth: growth !== null ? `${growth > 0 ? '+' : ''}${growth}%` : 'N/A',
|
||||||
|
},
|
||||||
|
bookings: {
|
||||||
|
total: totalBookings,
|
||||||
|
confirmed: confirmedBookings,
|
||||||
|
cancelled: cancelledBookings,
|
||||||
|
completionRate: totalBookings > 0
|
||||||
|
? Math.round((confirmedBookings / totalBookings) * 100 * 100) / 100
|
||||||
|
: 0,
|
||||||
|
},
|
||||||
|
users: {
|
||||||
|
new: newUsers,
|
||||||
|
active: 0, // Requeriría cálculo adicional
|
||||||
|
totalRegistered,
|
||||||
|
},
|
||||||
|
payments: {
|
||||||
|
completed: completedPayments,
|
||||||
|
pending: pendingPayments,
|
||||||
|
refunded: refundedPayments,
|
||||||
|
averageAmount: Math.round(avgPayment._avg.amount || 0),
|
||||||
|
},
|
||||||
|
topMetrics: {
|
||||||
|
bestRevenueDay: topRevenueDay?.date || null,
|
||||||
|
bestRevenueAmount: topRevenueDay?.amount || 0,
|
||||||
|
mostPopularCourt: mostPopularCourt?.name || null,
|
||||||
|
mostUsedPaymentMethod: mostUsedPaymentMethod || null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper methods
|
||||||
|
private static async getDailyRevenueBreakdown(startDate: Date, endDate: Date) {
|
||||||
|
const payments = await prisma.payment.findMany({
|
||||||
|
where: {
|
||||||
|
status: ExtendedPaymentStatus.COMPLETED,
|
||||||
|
paidAt: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
amount: true,
|
||||||
|
paidAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const dailyMap: Record<string, { revenue: number; count: number }> = {};
|
||||||
|
|
||||||
|
for (const payment of payments) {
|
||||||
|
const dateKey = new Date(payment.paidAt!).toISOString().split('T')[0];
|
||||||
|
if (!dailyMap[dateKey]) {
|
||||||
|
dailyMap[dateKey] = { revenue: 0, count: 0 };
|
||||||
|
}
|
||||||
|
dailyMap[dateKey].revenue += payment.amount;
|
||||||
|
dailyMap[dateKey].count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(dailyMap)
|
||||||
|
.map(([date, data]) => ({
|
||||||
|
date,
|
||||||
|
revenue: data.revenue,
|
||||||
|
transactionCount: data.count,
|
||||||
|
formattedRevenue: formatCurrency(data.revenue),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async getBestRevenueDay(startDate: Date, endDate: Date): Promise<{ date: string; amount: number } | null> {
|
||||||
|
const payments = await prisma.payment.groupBy({
|
||||||
|
by: ['paidAt'],
|
||||||
|
where: {
|
||||||
|
status: ExtendedPaymentStatus.COMPLETED,
|
||||||
|
paidAt: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_sum: { amount: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (payments.length === 0) return null;
|
||||||
|
|
||||||
|
const sorted = payments
|
||||||
|
.filter(p => p.paidAt !== null)
|
||||||
|
.map(p => ({
|
||||||
|
date: new Date(p.paidAt!).toISOString().split('T')[0],
|
||||||
|
amount: p._sum.amount || 0,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.amount - a.amount);
|
||||||
|
|
||||||
|
return sorted[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async getMostPopularCourt(startDate: Date, endDate: Date) {
|
||||||
|
const result = await prisma.booking.groupBy({
|
||||||
|
by: ['courtId'],
|
||||||
|
where: {
|
||||||
|
date: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: { id: true },
|
||||||
|
orderBy: { _count: { id: 'desc' } },
|
||||||
|
take: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.length === 0) return null;
|
||||||
|
|
||||||
|
const court = await prisma.court.findUnique({
|
||||||
|
where: { id: result[0].courtId },
|
||||||
|
select: { name: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { name: court?.name || 'Unknown', bookings: result[0]._count.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async getMostUsedPaymentMethod(startDate: Date, endDate: Date): Promise<string | null> {
|
||||||
|
const result = await prisma.payment.groupBy({
|
||||||
|
by: ['paymentMethod'],
|
||||||
|
where: {
|
||||||
|
status: ExtendedPaymentStatus.COMPLETED,
|
||||||
|
paidAt: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: { id: true },
|
||||||
|
orderBy: { _count: { id: 'desc' } },
|
||||||
|
take: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result[0]?.paymentMethod || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getWeekKey(date: Date): string {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = date.getMonth();
|
||||||
|
const day = date.getDate();
|
||||||
|
const weekNumber = Math.ceil(day / 7);
|
||||||
|
return `${year}-${String(month + 1).padStart(2, '0')}-W${weekNumber}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReportService;
|
||||||
369
backend/src/utils/analytics.ts
Normal file
369
backend/src/utils/analytics.ts
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
/**
|
||||||
|
* Utilidades para análisis y métricas
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Tipos
|
||||||
|
export type GroupByPeriod = 'day' | 'week' | 'month';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatear monto a moneda
|
||||||
|
* @param amount Monto en centavos
|
||||||
|
* @param currency Código de moneda (default: ARS)
|
||||||
|
* @returns String formateado
|
||||||
|
*/
|
||||||
|
export function formatCurrency(amount: number, currency: string = 'ARS'): string {
|
||||||
|
const amountInUnits = amount / 100;
|
||||||
|
|
||||||
|
const formatter = new Intl.NumberFormat('es-AR', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: currency,
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
return formatter.format(amountInUnits);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatear monto simple (sin símbolo de moneda)
|
||||||
|
* @param amount Monto en centavos
|
||||||
|
* @returns String formateado
|
||||||
|
*/
|
||||||
|
export function formatAmount(amount: number): string {
|
||||||
|
const amountInUnits = amount / 100;
|
||||||
|
return amountInUnits.toLocaleString('es-AR', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcular crecimiento porcentual
|
||||||
|
* @param current Valor actual
|
||||||
|
* @param previous Valor anterior (null si no hay datos previos)
|
||||||
|
* @returns Porcentaje de crecimiento o null
|
||||||
|
*/
|
||||||
|
export function calculateGrowth(current: number, previous: number | null): number | null {
|
||||||
|
if (previous === null || previous === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previous === 0) {
|
||||||
|
return current > 0 ? 100 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const growth = ((current - previous) / previous) * 100;
|
||||||
|
return Math.round(growth * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agrupar datos por fecha
|
||||||
|
* @param data Array de datos con campo de fecha
|
||||||
|
* @param dateField Nombre del campo de fecha
|
||||||
|
* @param groupBy Período de agrupación (day, week, month)
|
||||||
|
* @returns Objeto con datos agrupados
|
||||||
|
*/
|
||||||
|
export function groupByDate<T extends Record<string, any>>(
|
||||||
|
data: T[],
|
||||||
|
dateField: string,
|
||||||
|
groupBy: GroupByPeriod
|
||||||
|
): Record<string, T[]> {
|
||||||
|
const grouped: Record<string, T[]> = {};
|
||||||
|
|
||||||
|
for (const item of data) {
|
||||||
|
const date = new Date(item[dateField]);
|
||||||
|
let key: string;
|
||||||
|
|
||||||
|
switch (groupBy) {
|
||||||
|
case 'day':
|
||||||
|
key = date.toISOString().split('T')[0]; // YYYY-MM-DD
|
||||||
|
break;
|
||||||
|
case 'week':
|
||||||
|
key = getWeekKey(date); // YYYY-WXX
|
||||||
|
break;
|
||||||
|
case 'month':
|
||||||
|
key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; // YYYY-MM
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
key = date.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!grouped[key]) {
|
||||||
|
grouped[key] = [];
|
||||||
|
}
|
||||||
|
grouped[key].push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rellenar fechas faltantes con valores en cero
|
||||||
|
* @param data Array de datos con campo date
|
||||||
|
* @param startDate Fecha de inicio
|
||||||
|
* @param endDate Fecha de fin
|
||||||
|
* @param groupBy Período de agrupación
|
||||||
|
* @returns Array con todas las fechas incluidas las faltantes
|
||||||
|
*/
|
||||||
|
export function fillMissingDates<T extends { date: string }>(
|
||||||
|
data: T[],
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date,
|
||||||
|
groupBy: GroupByPeriod
|
||||||
|
): T[] {
|
||||||
|
const result: T[] = [];
|
||||||
|
const dataMap = new Map(data.map(d => [d.date, d]));
|
||||||
|
|
||||||
|
const current = new Date(startDate);
|
||||||
|
const end = new Date(endDate);
|
||||||
|
|
||||||
|
while (current <= end) {
|
||||||
|
let key: string;
|
||||||
|
|
||||||
|
switch (groupBy) {
|
||||||
|
case 'day':
|
||||||
|
key = current.toISOString().split('T')[0];
|
||||||
|
current.setDate(current.getDate() + 1);
|
||||||
|
break;
|
||||||
|
case 'week':
|
||||||
|
key = getWeekKey(current);
|
||||||
|
current.setDate(current.getDate() + 7);
|
||||||
|
break;
|
||||||
|
case 'month':
|
||||||
|
key = `${current.getFullYear()}-${String(current.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
current.setMonth(current.getMonth() + 1);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
key = current.toISOString().split('T')[0];
|
||||||
|
current.setDate(current.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataMap.has(key)) {
|
||||||
|
result.push(dataMap.get(key)!);
|
||||||
|
} else {
|
||||||
|
// Crear objeto vacío con valores por defecto
|
||||||
|
const emptyItem: any = { date: key };
|
||||||
|
|
||||||
|
// Detectar campos numéricos del primer elemento y ponerlos en 0
|
||||||
|
if (data.length > 0) {
|
||||||
|
const firstItem = data[0];
|
||||||
|
for (const [field, value] of Object.entries(firstItem)) {
|
||||||
|
if (field !== 'date' && typeof value === 'number') {
|
||||||
|
emptyItem[field] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(emptyItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcular promedio de un array de números
|
||||||
|
* @param values Array de números
|
||||||
|
* @returns Promedio
|
||||||
|
*/
|
||||||
|
export function calculateAverage(values: number[]): number {
|
||||||
|
if (values.length === 0) return 0;
|
||||||
|
return values.reduce((sum, val) => sum + val, 0) / values.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcular mediana de un array de números
|
||||||
|
* @param values Array de números
|
||||||
|
* @returns Mediana
|
||||||
|
*/
|
||||||
|
export function calculateMedian(values: number[]): number {
|
||||||
|
if (values.length === 0) return 0;
|
||||||
|
|
||||||
|
const sorted = [...values].sort((a, b) => a - b);
|
||||||
|
const middle = Math.floor(sorted.length / 2);
|
||||||
|
|
||||||
|
if (sorted.length % 2 === 0) {
|
||||||
|
return (sorted[middle - 1] + sorted[middle]) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sorted[middle];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcular percentil
|
||||||
|
* @param values Array de números
|
||||||
|
* @param percentile Percentil a calcular (0-100)
|
||||||
|
* @returns Valor del percentil
|
||||||
|
*/
|
||||||
|
export function calculatePercentile(values: number[], percentile: number): number {
|
||||||
|
if (values.length === 0) return 0;
|
||||||
|
if (percentile < 0 || percentile > 100) return 0;
|
||||||
|
|
||||||
|
const sorted = [...values].sort((a, b) => a - b);
|
||||||
|
const index = (percentile / 100) * (sorted.length - 1);
|
||||||
|
const lower = Math.floor(index);
|
||||||
|
const upper = Math.ceil(index);
|
||||||
|
const weight = index - lower;
|
||||||
|
|
||||||
|
if (upper >= sorted.length) return sorted[lower];
|
||||||
|
return sorted[lower] * (1 - weight) + sorted[upper] * weight;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcular desviación estándar
|
||||||
|
* @param values Array de números
|
||||||
|
* @returns Desviación estándar
|
||||||
|
*/
|
||||||
|
export function calculateStandardDeviation(values: number[]): number {
|
||||||
|
if (values.length === 0) return 0;
|
||||||
|
|
||||||
|
const avg = calculateAverage(values);
|
||||||
|
const squareDiffs = values.map(value => Math.pow(value - avg, 2));
|
||||||
|
const avgSquareDiff = calculateAverage(squareDiffs);
|
||||||
|
|
||||||
|
return Math.sqrt(avgSquareDiff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agrupar datos por campo
|
||||||
|
* @param data Array de datos
|
||||||
|
* @param field Campo para agrupar
|
||||||
|
* @returns Objeto con datos agrupados
|
||||||
|
*/
|
||||||
|
export function groupByField<T extends Record<string, any>>(
|
||||||
|
data: T[],
|
||||||
|
field: string
|
||||||
|
): Record<string, T[]> {
|
||||||
|
return data.reduce((acc, item) => {
|
||||||
|
const key = item[field]?.toString() || 'unknown';
|
||||||
|
if (!acc[key]) {
|
||||||
|
acc[key] = [];
|
||||||
|
}
|
||||||
|
acc[key].push(item);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, T[]>);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcular tasa de conversión
|
||||||
|
* @param completed Número de completados
|
||||||
|
* @param total Número total
|
||||||
|
* @returns Porcentaje de conversión
|
||||||
|
*/
|
||||||
|
export function calculateConversionRate(completed: number, total: number): number {
|
||||||
|
if (total === 0) return 0;
|
||||||
|
return Math.round((completed / total) * 100 * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatear número con separadores de miles
|
||||||
|
* @param value Número a formatear
|
||||||
|
* @returns String formateado
|
||||||
|
*/
|
||||||
|
export function formatNumber(value: number): string {
|
||||||
|
return value.toLocaleString('es-AR');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatear porcentaje
|
||||||
|
* @param value Valor decimal (0-1) o porcentaje (0-100)
|
||||||
|
* @param isDecimal Si el valor está en formato decimal
|
||||||
|
* @returns String formateado con símbolo %
|
||||||
|
*/
|
||||||
|
export function formatPercentage(value: number, isDecimal: boolean = false): string {
|
||||||
|
const percentage = isDecimal ? value * 100 : value;
|
||||||
|
return `${Math.round(percentage * 100) / 100}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtener clave de semana para una fecha
|
||||||
|
* @param date Fecha
|
||||||
|
* @returns String en formato YYYY-WXX
|
||||||
|
*/
|
||||||
|
function getWeekKey(date: Date): string {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
|
||||||
|
// Calcular número de semana (aproximado)
|
||||||
|
const startOfYear = new Date(year, 0, 1);
|
||||||
|
const days = Math.floor((date.getTime() - startOfYear.getTime()) / (24 * 60 * 60 * 1000));
|
||||||
|
const weekNumber = Math.ceil((days + startOfYear.getDay() + 1) / 7);
|
||||||
|
|
||||||
|
return `${year}-W${String(weekNumber).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comparar dos períodos de fechas
|
||||||
|
* @param currentStart Inicio período actual
|
||||||
|
* @param currentEnd Fin período actual
|
||||||
|
* @param previousStart Inicio período anterior
|
||||||
|
* @param previousEnd Fin período anterior
|
||||||
|
* @returns Objeto con comparación
|
||||||
|
*/
|
||||||
|
export function comparePeriods(
|
||||||
|
currentStart: Date,
|
||||||
|
currentEnd: Date,
|
||||||
|
previousStart: Date,
|
||||||
|
previousEnd: Date
|
||||||
|
): {
|
||||||
|
currentDuration: number;
|
||||||
|
previousDuration: number;
|
||||||
|
durationRatio: number;
|
||||||
|
} {
|
||||||
|
const currentDuration = currentEnd.getTime() - currentStart.getTime();
|
||||||
|
const previousDuration = previousEnd.getTime() - previousStart.getTime();
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentDuration,
|
||||||
|
previousDuration,
|
||||||
|
durationRatio: previousDuration > 0 ? currentDuration / previousDuration : 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generar rango de fechas
|
||||||
|
* @param startDate Fecha de inicio
|
||||||
|
* @param endDate Fecha de fin
|
||||||
|
* @returns Array de fechas
|
||||||
|
*/
|
||||||
|
export function generateDateRange(startDate: Date, endDate: Date): Date[] {
|
||||||
|
const dates: Date[] = [];
|
||||||
|
const current = new Date(startDate);
|
||||||
|
const end = new Date(endDate);
|
||||||
|
|
||||||
|
while (current <= end) {
|
||||||
|
dates.push(new Date(current));
|
||||||
|
current.setDate(current.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return dates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redondear a número específico de decimales
|
||||||
|
* @param value Valor a redondear
|
||||||
|
* @param decimals Número de decimales
|
||||||
|
* @returns Valor redondeado
|
||||||
|
*/
|
||||||
|
export function roundToDecimals(value: number, decimals: number = 2): number {
|
||||||
|
const multiplier = Math.pow(10, decimals);
|
||||||
|
return Math.round(value * multiplier) / multiplier;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
formatCurrency,
|
||||||
|
formatAmount,
|
||||||
|
calculateGrowth,
|
||||||
|
groupByDate,
|
||||||
|
fillMissingDates,
|
||||||
|
calculateAverage,
|
||||||
|
calculateMedian,
|
||||||
|
calculatePercentile,
|
||||||
|
calculateStandardDeviation,
|
||||||
|
groupByField,
|
||||||
|
calculateConversionRate,
|
||||||
|
formatNumber,
|
||||||
|
formatPercentage,
|
||||||
|
comparePeriods,
|
||||||
|
generateDateRange,
|
||||||
|
roundToDecimals,
|
||||||
|
};
|
||||||
@@ -336,3 +336,38 @@ export const UserSubscriptionStatus = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type UserSubscriptionStatusType = typeof UserSubscriptionStatus[keyof typeof UserSubscriptionStatus];
|
export type UserSubscriptionStatusType = typeof UserSubscriptionStatus[keyof typeof UserSubscriptionStatus];
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Constantes de Reportes y Analytics (Fase 5.2)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// Tipos de reportes
|
||||||
|
export const ReportType = {
|
||||||
|
REVENUE: 'REVENUE', // Reporte de ingresos
|
||||||
|
OCCUPANCY: 'OCCUPANCY', // Reporte de ocupación
|
||||||
|
USERS: 'USERS', // Reporte de usuarios
|
||||||
|
SUMMARY: 'SUMMARY', // Resumen ejecutivo
|
||||||
|
TOURNAMENT: 'TOURNAMENT', // Reporte de torneos
|
||||||
|
COACH: 'COACH', // Reporte de coaches
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ReportTypeType = typeof ReportType[keyof typeof ReportType];
|
||||||
|
|
||||||
|
// Períodos de agrupación
|
||||||
|
export const GroupByPeriod = {
|
||||||
|
DAY: 'day', // Por día
|
||||||
|
WEEK: 'week', // Por semana
|
||||||
|
MONTH: 'month', // Por mes
|
||||||
|
YEAR: 'year', // Por año
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type GroupByPeriodType = typeof GroupByPeriod[keyof typeof GroupByPeriod];
|
||||||
|
|
||||||
|
// Formatos de exportación de reportes
|
||||||
|
export const ReportFormat = {
|
||||||
|
JSON: 'json', // Formato JSON
|
||||||
|
PDF: 'pdf', // Formato PDF
|
||||||
|
EXCEL: 'excel', // Formato Excel/CSV
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ReportFormatType = typeof ReportFormat[keyof typeof ReportFormat];
|
||||||
|
|||||||
112
backend/src/validators/analytics.validator.ts
Normal file
112
backend/src/validators/analytics.validator.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema para validar rango de fechas
|
||||||
|
* Usado en: GET /analytics/occupancy, GET /analytics/occupancy/by-timeslot, etc.
|
||||||
|
*/
|
||||||
|
export const dateRangeSchema = z.object({
|
||||||
|
startDate: z.string()
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}$/, 'La fecha debe estar en formato YYYY-MM-DD')
|
||||||
|
.or(z.string().datetime({ message: 'La fecha debe estar en formato ISO 8601' })),
|
||||||
|
endDate: z.string()
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}$/, 'La fecha debe estar en formato YYYY-MM-DD')
|
||||||
|
.or(z.string().datetime({ message: 'La fecha debe estar en formato ISO 8601' })),
|
||||||
|
courtId: z.string().uuid('ID de cancha inválido').optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema para validar consulta de ocupación por cancha
|
||||||
|
* Usado en: GET /analytics/occupancy/by-court
|
||||||
|
*/
|
||||||
|
export const occupancyByCourtSchema = z.object({
|
||||||
|
courtId: z.string().uuid('ID de cancha inválido'),
|
||||||
|
startDate: z.string()
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}$/, 'La fecha debe estar en formato YYYY-MM-DD')
|
||||||
|
.or(z.string().datetime({ message: 'La fecha debe estar en formato ISO 8601' })),
|
||||||
|
endDate: z.string()
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}$/, 'La fecha debe estar en formato YYYY-MM-DD')
|
||||||
|
.or(z.string().datetime({ message: 'La fecha debe estar en formato ISO 8601' })),
|
||||||
|
groupBy: z.enum(['day', 'week', 'month'], {
|
||||||
|
errorMap: () => ({ message: 'groupBy debe ser: day, week o month' }),
|
||||||
|
}).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema para validar consulta de ocupación por franja horaria
|
||||||
|
* Usado en: GET /analytics/occupancy/by-timeslot
|
||||||
|
*/
|
||||||
|
export const timeSlotSchema = z.object({
|
||||||
|
startDate: z.string()
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}$/, 'La fecha debe estar en formato YYYY-MM-DD')
|
||||||
|
.or(z.string().datetime({ message: 'La fecha debe estar en formato ISO 8601' })),
|
||||||
|
endDate: z.string()
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}$/, 'La fecha debe estar en formato YYYY-MM-DD')
|
||||||
|
.or(z.string().datetime({ message: 'La fecha debe estar en formato ISO 8601' })),
|
||||||
|
courtId: z.string().uuid('ID de cancha inválido').optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema para validar consulta de horas pico
|
||||||
|
* Usado en: GET /analytics/occupancy/peak-hours
|
||||||
|
*/
|
||||||
|
export const peakHoursSchema = z.object({
|
||||||
|
startDate: z.string()
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}$/, 'La fecha debe estar en formato YYYY-MM-DD')
|
||||||
|
.or(z.string().datetime({ message: 'La fecha debe estar en formato ISO 8601' })),
|
||||||
|
endDate: z.string()
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}$/, 'La fecha debe estar en formato YYYY-MM-DD')
|
||||||
|
.or(z.string().datetime({ message: 'La fecha debe estar en formato ISO 8601' })),
|
||||||
|
courtId: z.string().uuid('ID de cancha inválido').optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema para validar comparación de períodos
|
||||||
|
* Usado en: GET /analytics/occupancy/comparison
|
||||||
|
*/
|
||||||
|
export const comparisonSchema = z.object({
|
||||||
|
period1Start: z.string()
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}$/, 'La fecha debe estar en formato YYYY-MM-DD')
|
||||||
|
.or(z.string().datetime({ message: 'La fecha debe estar en formato ISO 8601' })),
|
||||||
|
period1End: z.string()
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}$/, 'La fecha debe estar en formato YYYY-MM-DD')
|
||||||
|
.or(z.string().datetime({ message: 'La fecha debe estar en formato ISO 8601' })),
|
||||||
|
period2Start: z.string()
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}$/, 'La fecha debe estar en formato YYYY-MM-DD')
|
||||||
|
.or(z.string().datetime({ message: 'La fecha debe estar en formato ISO 8601' })),
|
||||||
|
period2End: z.string()
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}$/, 'La fecha debe estar en formato YYYY-MM-DD')
|
||||||
|
.or(z.string().datetime({ message: 'La fecha debe estar en formato ISO 8601' })),
|
||||||
|
courtId: z.string().uuid('ID de cancha inválido').optional(),
|
||||||
|
}).refine((data) => {
|
||||||
|
// Validar que period1Start sea anterior a period1End
|
||||||
|
const p1Start = new Date(data.period1Start);
|
||||||
|
const p1End = new Date(data.period1End);
|
||||||
|
return p1Start <= p1End;
|
||||||
|
}, {
|
||||||
|
message: 'period1Start debe ser anterior o igual a period1End',
|
||||||
|
path: ['period1Start'],
|
||||||
|
}).refine((data) => {
|
||||||
|
// Validar que period2Start sea anterior a period2End
|
||||||
|
const p2Start = new Date(data.period2Start);
|
||||||
|
const p2End = new Date(data.period2End);
|
||||||
|
return p2Start <= p2End;
|
||||||
|
}, {
|
||||||
|
message: 'period2Start debe ser anterior o igual a period2End',
|
||||||
|
path: ['period2Start'],
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema para parámetro opcional de courtId
|
||||||
|
* Usado en: GET /analytics/dashboard/calendar
|
||||||
|
*/
|
||||||
|
export const courtIdParamSchema = z.object({
|
||||||
|
courtId: z.string().uuid('ID de cancha inválido').optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tipos exportados para uso en controladores
|
||||||
|
export type DateRangeInput = z.infer<typeof dateRangeSchema>;
|
||||||
|
export type OccupancyByCourtInput = z.infer<typeof occupancyByCourtSchema>;
|
||||||
|
export type TimeSlotInput = z.infer<typeof timeSlotSchema>;
|
||||||
|
export type PeakHoursInput = z.infer<typeof peakHoursSchema>;
|
||||||
|
export type ComparisonInput = z.infer<typeof comparisonSchema>;
|
||||||
|
export type CourtIdParamInput = z.infer<typeof courtIdParamSchema>;
|
||||||
@@ -1,6 +1,236 @@
|
|||||||
# Fase 5: Analytics
|
# Fase 5: Analytics y Administración
|
||||||
|
|
||||||
## Estado: ⏳ Pendiente
|
## Estado: ✅ COMPLETADA
|
||||||
|
|
||||||
*Esta fase comenzará al finalizar la Fase 4*
|
### ✅ Tareas completadas:
|
||||||
|
|
||||||
|
#### 5.1.1: Dashboard Administrativo
|
||||||
|
- [x] Vista general de ocupación del día/semana
|
||||||
|
- [x] Accesos rápidos (crear reserva, bloquear cancha)
|
||||||
|
- [x] Lista de reservas del día con filtros
|
||||||
|
- [x] Gestión de usuarios (activar/desactivar)
|
||||||
|
|
||||||
|
#### 5.1.2: Métricas de Ocupación
|
||||||
|
- [x] % de ocupación por cancha
|
||||||
|
- [x] Ocupación por franja horaria
|
||||||
|
- [x] Comparativa mes a mes
|
||||||
|
- [x] Días y horas más demandados (peak hours)
|
||||||
|
|
||||||
|
#### 5.2.1: Métricas Financieras
|
||||||
|
- [x] Ingresos por período (día, semana, mes, año)
|
||||||
|
- [x] Ingresos por tipo (reservas, bonos, torneos, clases)
|
||||||
|
- [x] Ingresos por cancha
|
||||||
|
- [x] Comparativas y tendencias
|
||||||
|
|
||||||
|
#### 5.2.2: Gestión de Reportes
|
||||||
|
- [x] Reporte completo de ingresos
|
||||||
|
- [x] Reporte de ocupación
|
||||||
|
- [x] Reporte de usuarios
|
||||||
|
- [x] Resumen ejecutivo
|
||||||
|
|
||||||
|
#### 5.2.3: Métricas de Usuarios
|
||||||
|
- [x] Total de usuarios activos
|
||||||
|
- [x] Nuevos registros por período
|
||||||
|
- [x] Jugadores más activos (top 10/50)
|
||||||
|
- [x] Jugadores inactivos (alertas de churn)
|
||||||
|
- [x] Retención y frecuencia de uso
|
||||||
|
|
||||||
|
#### 5.2.4: Exportación de Datos
|
||||||
|
- [x] Exportar reservas a Excel/CSV
|
||||||
|
- [x] Exportar informes financieros
|
||||||
|
- [x] Exportar base de usuarios
|
||||||
|
- [x] Reportes automáticos por email (preparado para futura implementación)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Módulos de Analytics
|
||||||
|
|
||||||
|
### 1. Dashboard
|
||||||
|
Resumen ejecutivo para administradores:
|
||||||
|
- Reservas hoy/semana/mes
|
||||||
|
- Ingresos hoy/semana/mes
|
||||||
|
- Usuarios nuevos
|
||||||
|
- Ocupación promedio
|
||||||
|
- Alertas del día
|
||||||
|
|
||||||
|
### 2. Ocupación
|
||||||
|
Métricas de uso de canchas:
|
||||||
|
- Ocupación por fecha/rango
|
||||||
|
- Ocupación por cancha específica
|
||||||
|
- Ocupación por franja horaria
|
||||||
|
- Horas pico (top 5)
|
||||||
|
- Comparativa entre períodos
|
||||||
|
|
||||||
|
### 3. Financiero
|
||||||
|
Ingresos y métricas económicas:
|
||||||
|
- Ingresos totales por período
|
||||||
|
- Desglose por tipo (booking, tournament, subscription, class)
|
||||||
|
- Ingresos por cancha
|
||||||
|
- Métodos de pago más usados
|
||||||
|
- Estadísticas de reembolsos
|
||||||
|
- Tendencias de crecimiento
|
||||||
|
|
||||||
|
### 4. Usuarios
|
||||||
|
Comportamiento de usuarios:
|
||||||
|
- Estadísticas generales
|
||||||
|
- Actividad por período
|
||||||
|
- Top jugadores
|
||||||
|
- Usuarios en riesgo de abandono (churn)
|
||||||
|
- Tasa de retención
|
||||||
|
- Crecimiento mensual
|
||||||
|
|
||||||
|
### 5. Exportación
|
||||||
|
Exportar datos en múltiples formatos:
|
||||||
|
- CSV (separado por ; para Excel español)
|
||||||
|
- JSON
|
||||||
|
- Excel (múltiples hojas)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔌 Endpoints de Analytics
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
```
|
||||||
|
GET /api/v1/analytics/dashboard/summary - Resumen ejecutivo
|
||||||
|
GET /api/v1/analytics/dashboard/today - Vista del día
|
||||||
|
GET /api/v1/analytics/dashboard/calendar - Calendario semanal
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ocupación
|
||||||
|
```
|
||||||
|
GET /api/v1/analytics/occupancy - Ocupación por fechas
|
||||||
|
GET /api/v1/analytics/occupancy/by-court - Por cancha
|
||||||
|
GET /api/v1/analytics/occupancy/by-timeslot - Por franja horaria
|
||||||
|
GET /api/v1/analytics/occupancy/peak-hours - Horas pico
|
||||||
|
GET /api/v1/analytics/occupancy/comparison - Comparativa períodos
|
||||||
|
```
|
||||||
|
|
||||||
|
### Financiero
|
||||||
|
```
|
||||||
|
GET /api/v1/analytics/revenue - Ingresos
|
||||||
|
GET /api/v1/analytics/revenue/by-court - Por cancha
|
||||||
|
GET /api/v1/analytics/revenue/by-type - Por tipo
|
||||||
|
GET /api/v1/analytics/payment-methods - Métodos de pago
|
||||||
|
GET /api/v1/analytics/outstanding-payments - Pagos pendientes
|
||||||
|
GET /api/v1/analytics/refunds - Reembolsos
|
||||||
|
GET /api/v1/analytics/trends - Tendencias
|
||||||
|
GET /api/v1/analytics/top-days - Mejores días
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reportes
|
||||||
|
```
|
||||||
|
GET /api/v1/analytics/reports/revenue - Reporte ingresos
|
||||||
|
GET /api/v1/analytics/reports/occupancy - Reporte ocupación
|
||||||
|
GET /api/v1/analytics/reports/users - Reporte usuarios
|
||||||
|
GET /api/v1/analytics/reports/summary - Resumen ejecutivo
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usuarios
|
||||||
|
```
|
||||||
|
GET /api/v1/analytics/users/overview - Stats generales
|
||||||
|
GET /api/v1/analytics/users/activity - Actividad
|
||||||
|
GET /api/v1/analytics/users/top-players - Top jugadores
|
||||||
|
GET /api/v1/analytics/users/churn-risk - Riesgo de abandono
|
||||||
|
GET /api/v1/analytics/users/retention - Tasa de retención
|
||||||
|
GET /api/v1/analytics/users/growth - Crecimiento
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exportación
|
||||||
|
```
|
||||||
|
GET /api/v1/analytics/exports/bookings - Exportar reservas
|
||||||
|
GET /api/v1/analytics/exports/users - Exportar usuarios
|
||||||
|
GET /api/v1/analytics/exports/payments - Exportar pagos
|
||||||
|
GET /api/v1/analytics/exports/tournaments/:id - Exportar torneo
|
||||||
|
GET /api/v1/analytics/exports/excel-report - Reporte Excel completo
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Estructura de Archivos
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/src/
|
||||||
|
├── services/analytics/
|
||||||
|
│ ├── dashboard.service.ts # Dashboard ejecutivo
|
||||||
|
│ ├── occupancy.service.ts # Métricas de ocupación
|
||||||
|
│ ├── financial.service.ts # Métricas financieras
|
||||||
|
│ ├── report.service.ts # Generación de reportes
|
||||||
|
│ ├── userAnalytics.service.ts # Analytics de usuarios
|
||||||
|
│ └── export.service.ts # Exportación de datos
|
||||||
|
├── controllers/analytics/
|
||||||
|
│ ├── dashboard.controller.ts
|
||||||
|
│ ├── occupancy.controller.ts
|
||||||
|
│ ├── financial.controller.ts
|
||||||
|
│ ├── report.controller.ts
|
||||||
|
│ ├── userAnalytics.controller.ts
|
||||||
|
│ └── export.controller.ts
|
||||||
|
├── routes/
|
||||||
|
│ └── analytics.routes.ts # Todas las rutas de analytics
|
||||||
|
├── utils/
|
||||||
|
│ ├── analytics.ts # Utilidades de cálculo
|
||||||
|
│ └── export.ts # Utilidades de exportación
|
||||||
|
├── types/
|
||||||
|
│ └── analytics.types.ts # Tipos TypeScript
|
||||||
|
└── constants/
|
||||||
|
└── export.constants.ts # Constantes de exportación
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Ejemplos de Uso
|
||||||
|
|
||||||
|
### Dashboard Summary
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3000/api/v1/analytics/dashboard/summary \
|
||||||
|
-H "Authorization: Bearer ADMIN_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"today": {
|
||||||
|
"bookings": 12,
|
||||||
|
"revenue": 25000,
|
||||||
|
"occupancyRate": 75.5
|
||||||
|
},
|
||||||
|
"week": {
|
||||||
|
"newUsers": 5,
|
||||||
|
"totalBookings": 89
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ocupación por Fechas
|
||||||
|
```bash
|
||||||
|
curl "http://localhost:3000/api/v1/analytics/occupancy?startDate=2024-01-01&endDate=2024-01-31" \
|
||||||
|
-H "Authorization: Bearer ADMIN_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exportar a Excel
|
||||||
|
```bash
|
||||||
|
curl "http://localhost:3000/api/v1/analytics/exports/excel-report?startDate=2024-01-01&endDate=2024-01-31" \
|
||||||
|
-H "Authorization: Bearer ADMIN_TOKEN" \
|
||||||
|
--output reporte_enero.xlsx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 Seguridad
|
||||||
|
|
||||||
|
- Todos los endpoints de analytics requieren autenticación
|
||||||
|
- Solo accesible para roles ADMIN y SUPERADMIN
|
||||||
|
- Validación de fechas y parámetros con Zod
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Dependencias
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Completada el: 2026-01-31*
|
||||||
|
|||||||
22
package.json
Normal file
22
package.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "padel-app-analytics",
|
||||||
|
"version": "5.3.0",
|
||||||
|
"description": "Fase 5.3 - Métricas de Usuarios y Exportación para App Padel",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"dev": "ts-node-dev --respawn src/index.ts",
|
||||||
|
"start": "node dist/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^5.0.0",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/node": "^20.0.0",
|
||||||
|
"ts-node-dev": "^2.0.0",
|
||||||
|
"typescript": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/constants/export.constants.ts
Normal file
15
src/constants/export.constants.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export enum ExportFormat {
|
||||||
|
CSV = 'csv',
|
||||||
|
JSON = 'json',
|
||||||
|
EXCEL = 'excel'
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ChurnRiskLevel {
|
||||||
|
LOW = 'low',
|
||||||
|
MEDIUM = 'medium',
|
||||||
|
HIGH = 'high'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CSV_SEPARATOR = ';';
|
||||||
|
|
||||||
|
export const DEFAULT_EXPORT_LIMIT = 10000;
|
||||||
1
src/constants/index.ts
Normal file
1
src/constants/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './export.constants';
|
||||||
230
src/controllers/analytics/export.controller.ts
Normal file
230
src/controllers/analytics/export.controller.ts
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import * as exportService from '../../services/analytics/export.service';
|
||||||
|
import { ExportFormat } from '../../constants/export.constants';
|
||||||
|
import { setExportHeaders } from '../../utils/export';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exporta reservas
|
||||||
|
* GET /analytics/exports/bookings
|
||||||
|
*/
|
||||||
|
export async function exportBookings(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { startDate, endDate, format } = req.query;
|
||||||
|
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Se requieren los parámetros startDate y endDate'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = new Date(startDate as string);
|
||||||
|
const end = new Date(endDate as string);
|
||||||
|
|
||||||
|
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Formato de fecha inválido. Use formato ISO 8601'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportFormat = (format as ExportFormat) || ExportFormat.CSV;
|
||||||
|
if (!Object.values(ExportFormat).includes(exportFormat)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: `Formato inválido. Use: ${Object.values(ExportFormat).join(', ')}`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, filename } = await exportService.exportBookings(start, end, exportFormat);
|
||||||
|
|
||||||
|
setExportHeaders(res, 'reservas', exportFormat);
|
||||||
|
res.send(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error en exportBookings:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Error al exportar reservas',
|
||||||
|
error: error instanceof Error ? error.message : 'Error desconocido'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exporta usuarios
|
||||||
|
* GET /analytics/exports/users
|
||||||
|
*/
|
||||||
|
export async function exportUsers(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { format, level, city } = req.query;
|
||||||
|
|
||||||
|
const exportFormat = (format as ExportFormat) || ExportFormat.CSV;
|
||||||
|
if (!Object.values(ExportFormat).includes(exportFormat)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: `Formato inválido. Use: ${Object.values(ExportFormat).join(', ')}`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters = {
|
||||||
|
level: level as string | undefined,
|
||||||
|
city: city as string | undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data, filename } = await exportService.exportUsers(exportFormat, filters);
|
||||||
|
|
||||||
|
setExportHeaders(res, 'usuarios', exportFormat);
|
||||||
|
res.send(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error en exportUsers:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Error al exportar usuarios',
|
||||||
|
error: error instanceof Error ? error.message : 'Error desconocido'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exporta pagos
|
||||||
|
* GET /analytics/exports/payments
|
||||||
|
*/
|
||||||
|
export async function exportPayments(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { startDate, endDate, format } = req.query;
|
||||||
|
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Se requieren los parámetros startDate y endDate'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = new Date(startDate as string);
|
||||||
|
const end = new Date(endDate as string);
|
||||||
|
|
||||||
|
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Formato de fecha inválido. Use formato ISO 8601'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportFormat = (format as ExportFormat) || ExportFormat.CSV;
|
||||||
|
if (!Object.values(ExportFormat).includes(exportFormat)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: `Formato inválido. Use: ${Object.values(ExportFormat).join(', ')}`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, filename } = await exportService.exportPayments(start, end, exportFormat);
|
||||||
|
|
||||||
|
setExportHeaders(res, 'pagos', exportFormat);
|
||||||
|
res.send(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error en exportPayments:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Error al exportar pagos',
|
||||||
|
error: error instanceof Error ? error.message : 'Error desconocido'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exporta resultados de torneo
|
||||||
|
* GET /analytics/exports/tournaments/:id
|
||||||
|
*/
|
||||||
|
export async function exportTournament(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { format } = req.query;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Se requiere el ID del torneo'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportFormat = (format as ExportFormat) || ExportFormat.CSV;
|
||||||
|
if (!Object.values(ExportFormat).includes(exportFormat)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: `Formato inválido. Use: ${Object.values(ExportFormat).join(', ')}`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, filename } = await exportService.exportTournamentResults(id, exportFormat);
|
||||||
|
|
||||||
|
setExportHeaders(res, 'torneo', exportFormat);
|
||||||
|
res.send(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error en exportTournament:', error);
|
||||||
|
|
||||||
|
if (error instanceof Error && error.message === 'Torneo no encontrado') {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Torneo no encontrado'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Error al exportar resultados del torneo',
|
||||||
|
error: error instanceof Error ? error.message : 'Error desconocido'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genera reporte completo en Excel
|
||||||
|
* GET /analytics/exports/excel-report
|
||||||
|
*/
|
||||||
|
export async function generateExcelReport(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { startDate, endDate } = req.query;
|
||||||
|
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Se requieren los parámetros startDate y endDate'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = new Date(startDate as string);
|
||||||
|
const end = new Date(endDate as string);
|
||||||
|
|
||||||
|
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Formato de fecha inválido. Use formato ISO 8601'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, filename } = await exportService.generateExcelReport(start, end);
|
||||||
|
|
||||||
|
setExportHeaders(res, 'reporte_completo', ExportFormat.EXCEL);
|
||||||
|
res.send(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error en generateExcelReport:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Error al generar reporte Excel',
|
||||||
|
error: error instanceof Error ? error.message : 'Error desconocido'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/controllers/analytics/index.ts
Normal file
2
src/controllers/analytics/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './userAnalytics.controller';
|
||||||
|
export * from './export.controller';
|
||||||
209
src/controllers/analytics/userAnalytics.controller.ts
Normal file
209
src/controllers/analytics/userAnalytics.controller.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import * as userAnalyticsService from '../../services/analytics/userAnalytics.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene el overview de estadísticas de usuarios
|
||||||
|
* GET /analytics/users/overview
|
||||||
|
*/
|
||||||
|
export async function getUserStatsOverview(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const stats = await userAnalyticsService.getUserStatsOverview();
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: stats
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error en getUserStatsOverview:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Error al obtener estadísticas de usuarios',
|
||||||
|
error: error instanceof Error ? error.message : 'Error desconocido'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene estadísticas de actividad de usuarios
|
||||||
|
* GET /analytics/users/activity
|
||||||
|
*/
|
||||||
|
export async function getUserActivityStats(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { startDate, endDate } = req.query;
|
||||||
|
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Se requieren los parámetros startDate y endDate'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = new Date(startDate as string);
|
||||||
|
const end = new Date(endDate as string);
|
||||||
|
|
||||||
|
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Formato de fecha inválido. Use formato ISO 8601'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await userAnalyticsService.getUserActivityStats(start, end);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: stats
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error en getUserActivityStats:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Error al obtener estadísticas de actividad',
|
||||||
|
error: error instanceof Error ? error.message : 'Error desconocido'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene los mejores jugadores
|
||||||
|
* GET /analytics/users/top-players
|
||||||
|
*/
|
||||||
|
export async function getTopPlayers(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const limit = parseInt(req.query.limit as string) || 10;
|
||||||
|
const by = (req.query.by as 'matches' | 'wins' | 'ranking' | 'tournaments') || 'matches';
|
||||||
|
|
||||||
|
if (limit < 1 || limit > 100) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'El parámetro limit debe estar entre 1 y 100'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validSortBy = ['matches', 'wins', 'ranking', 'tournaments'];
|
||||||
|
if (!validSortBy.includes(by)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: `El parámetro by debe ser uno de: ${validSortBy.join(', ')}`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const players = await userAnalyticsService.getTopPlayers(limit, by);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: players
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error en getTopPlayers:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Error al obtener top jugadores',
|
||||||
|
error: error instanceof Error ? error.message : 'Error desconocido'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene usuarios en riesgo de abandono
|
||||||
|
* GET /analytics/users/churn-risk
|
||||||
|
*/
|
||||||
|
export async function getChurnRiskUsers(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const users = await userAnalyticsService.getChurnRiskUsers();
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: users,
|
||||||
|
count: users.length
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error en getChurnRiskUsers:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Error al obtener usuarios en riesgo de abandono',
|
||||||
|
error: error instanceof Error ? error.message : 'Error desconocido'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene la tasa de retención de usuarios
|
||||||
|
* GET /analytics/users/retention
|
||||||
|
*/
|
||||||
|
export async function getRetentionRate(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { startDate, endDate } = req.query;
|
||||||
|
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Se requieren los parámetros startDate y endDate'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = new Date(startDate as string);
|
||||||
|
const end = new Date(endDate as string);
|
||||||
|
|
||||||
|
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Formato de fecha inválido. Use formato ISO 8601'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start > end) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'startDate debe ser anterior a endDate'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const retention = await userAnalyticsService.getRetentionRate(start, end);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: retention
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error en getRetentionRate:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Error al obtener tasa de retención',
|
||||||
|
error: error instanceof Error ? error.message : 'Error desconocido'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene la tendencia de crecimiento de usuarios
|
||||||
|
* GET /analytics/users/growth
|
||||||
|
*/
|
||||||
|
export async function getUserGrowthTrend(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const months = parseInt(req.query.months as string) || 6;
|
||||||
|
|
||||||
|
if (months < 1 || months > 24) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'El parámetro months debe estar entre 1 y 24'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trend = await userAnalyticsService.getUserGrowthTrend(months);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: trend
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error en getUserGrowthTrend:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Error al obtener tendencia de crecimiento',
|
||||||
|
error: error instanceof Error ? error.message : 'Error desconocido'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
102
src/routes/analytics.routes.ts
Normal file
102
src/routes/analytics.routes.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import * as userAnalyticsController from '../controllers/analytics/userAnalytics.controller';
|
||||||
|
import * as exportController from '../controllers/analytics/export.controller';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// RUTAS DE ANÁLISIS DE USUARIOS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /analytics/users/overview
|
||||||
|
* @desc Obtiene estadísticas generales de usuarios
|
||||||
|
* @access Admin
|
||||||
|
*/
|
||||||
|
router.get('/users/overview', userAnalyticsController.getUserStatsOverview);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /analytics/users/activity
|
||||||
|
* @desc Obtiene estadísticas de actividad de usuarios
|
||||||
|
* @query startDate, endDate
|
||||||
|
* @access Admin
|
||||||
|
*/
|
||||||
|
router.get('/users/activity', userAnalyticsController.getUserActivityStats);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /analytics/users/top-players
|
||||||
|
* @desc Obtiene los mejores jugadores por criterios
|
||||||
|
* @query limit (default: 10), by (matches|wins|ranking|tournaments)
|
||||||
|
* @access Admin
|
||||||
|
*/
|
||||||
|
router.get('/users/top-players', userAnalyticsController.getTopPlayers);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /analytics/users/churn-risk
|
||||||
|
* @desc Obtiene usuarios en riesgo de abandono
|
||||||
|
* @access Admin
|
||||||
|
*/
|
||||||
|
router.get('/users/churn-risk', userAnalyticsController.getChurnRiskUsers);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /analytics/users/retention
|
||||||
|
* @desc Obtiene tasa de retención de usuarios
|
||||||
|
* @query startDate, endDate
|
||||||
|
* @access Admin
|
||||||
|
*/
|
||||||
|
router.get('/users/retention', userAnalyticsController.getRetentionRate);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /analytics/users/growth
|
||||||
|
* @desc Obtiene tendencia de crecimiento de usuarios
|
||||||
|
* @query months (default: 6, max: 24)
|
||||||
|
* @access Admin
|
||||||
|
*/
|
||||||
|
router.get('/users/growth', userAnalyticsController.getUserGrowthTrend);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// RUTAS DE EXPORTACIÓN
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /analytics/exports/bookings
|
||||||
|
* @desc Exporta reservas a CSV/JSON
|
||||||
|
* @query startDate, endDate, format (csv|json)
|
||||||
|
* @access Admin
|
||||||
|
*/
|
||||||
|
router.get('/exports/bookings', exportController.exportBookings);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /analytics/exports/users
|
||||||
|
* @desc Exporta usuarios a CSV/JSON
|
||||||
|
* @query format (csv|json), level, city
|
||||||
|
* @access Admin
|
||||||
|
*/
|
||||||
|
router.get('/exports/users', exportController.exportUsers);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /analytics/exports/payments
|
||||||
|
* @desc Exporta pagos a CSV/JSON
|
||||||
|
* @query startDate, endDate, format (csv|json)
|
||||||
|
* @access Admin
|
||||||
|
*/
|
||||||
|
router.get('/exports/payments', exportController.exportPayments);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /analytics/exports/tournaments/:id
|
||||||
|
* @desc Exporta resultados de un torneo
|
||||||
|
* @params id
|
||||||
|
* @query format (csv|json)
|
||||||
|
* @access Admin
|
||||||
|
*/
|
||||||
|
router.get('/exports/tournaments/:id', exportController.exportTournament);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /analytics/exports/excel-report
|
||||||
|
* @desc Genera reporte completo en Excel
|
||||||
|
* @query startDate, endDate
|
||||||
|
* @access Admin
|
||||||
|
*/
|
||||||
|
router.get('/exports/excel-report', exportController.generateExcelReport);
|
||||||
|
|
||||||
|
export default router;
|
||||||
554
src/services/analytics/export.service.ts
Normal file
554
src/services/analytics/export.service.ts
Normal file
@@ -0,0 +1,554 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { ExportFormat } from '../../constants/export.constants';
|
||||||
|
import {
|
||||||
|
BookingExportData,
|
||||||
|
UserExportData,
|
||||||
|
PaymentExportData,
|
||||||
|
TournamentResultExportData,
|
||||||
|
ExcelWorkbookData,
|
||||||
|
ExportFilters
|
||||||
|
} from '../../types/analytics.types';
|
||||||
|
import { formatDateForExport } from '../../utils/export';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exporta reservas a CSV/JSON
|
||||||
|
*/
|
||||||
|
export async function exportBookings(
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date,
|
||||||
|
format: ExportFormat = ExportFormat.CSV
|
||||||
|
): Promise<{ data: string | Buffer; filename: string }> {
|
||||||
|
const bookings = await prisma.booking.findMany({
|
||||||
|
where: {
|
||||||
|
date: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate
|
||||||
|
}
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
court: {
|
||||||
|
select: {
|
||||||
|
name: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
date: 'asc'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const exportData: BookingExportData[] = bookings.map(booking => ({
|
||||||
|
id: booking.id,
|
||||||
|
user: `${booking.user.firstName} ${booking.user.lastName}`,
|
||||||
|
userEmail: booking.user.email,
|
||||||
|
court: booking.court.name,
|
||||||
|
date: formatDateForExport(booking.date),
|
||||||
|
time: booking.startTime,
|
||||||
|
price: booking.price,
|
||||||
|
status: booking.status,
|
||||||
|
createdAt: formatDateForExport(booking.createdAt)
|
||||||
|
}));
|
||||||
|
|
||||||
|
const filename = `reservas_${startDate.toISOString().split('T')[0]}_${endDate.toISOString().split('T')[0]}`;
|
||||||
|
|
||||||
|
if (format === ExportFormat.JSON) {
|
||||||
|
return {
|
||||||
|
data: JSON.stringify(exportData, null, 2),
|
||||||
|
filename: `${filename}.json`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSV por defecto
|
||||||
|
const headers = {
|
||||||
|
id: 'ID',
|
||||||
|
user: 'Usuario',
|
||||||
|
userEmail: 'Email',
|
||||||
|
court: 'Pista',
|
||||||
|
date: 'Fecha',
|
||||||
|
time: 'Hora',
|
||||||
|
price: 'Precio',
|
||||||
|
status: 'Estado',
|
||||||
|
createdAt: 'Fecha Creación'
|
||||||
|
};
|
||||||
|
|
||||||
|
const csv = convertToCSV(exportData, headers);
|
||||||
|
return { data: csv, filename: `${filename}.csv` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exporta usuarios a CSV/JSON
|
||||||
|
*/
|
||||||
|
export async function exportUsers(
|
||||||
|
format: ExportFormat = ExportFormat.CSV,
|
||||||
|
filters: ExportFilters = {}
|
||||||
|
): Promise<{ data: string | Buffer; filename: string }> {
|
||||||
|
const where: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (filters.level) {
|
||||||
|
where.level = filters.level;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.city) {
|
||||||
|
where.city = filters.city;
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
bookings: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const exportData: UserExportData[] = users.map(user => ({
|
||||||
|
id: user.id,
|
||||||
|
name: `${user.firstName} ${user.lastName}`,
|
||||||
|
email: user.email,
|
||||||
|
level: user.level || 'Sin nivel',
|
||||||
|
city: user.city || 'Sin ciudad',
|
||||||
|
joinedAt: formatDateForExport(user.createdAt),
|
||||||
|
bookingsCount: user._count.bookings
|
||||||
|
}));
|
||||||
|
|
||||||
|
const filename = `usuarios_${new Date().toISOString().split('T')[0]}`;
|
||||||
|
|
||||||
|
if (format === ExportFormat.JSON) {
|
||||||
|
return {
|
||||||
|
data: JSON.stringify(exportData, null, 2),
|
||||||
|
filename: `${filename}.json`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
id: 'ID',
|
||||||
|
name: 'Nombre',
|
||||||
|
email: 'Email',
|
||||||
|
level: 'Nivel',
|
||||||
|
city: 'Ciudad',
|
||||||
|
joinedAt: 'Fecha Registro',
|
||||||
|
bookingsCount: 'Total Reservas'
|
||||||
|
};
|
||||||
|
|
||||||
|
const csv = convertToCSV(exportData, headers);
|
||||||
|
return { data: csv, filename: `${filename}.csv` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exporta pagos a CSV/JSON
|
||||||
|
*/
|
||||||
|
export async function exportPayments(
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date,
|
||||||
|
format: ExportFormat = ExportFormat.CSV
|
||||||
|
): Promise<{ data: string | Buffer; filename: string }> {
|
||||||
|
// Asumiendo que existe un modelo Payment o similar
|
||||||
|
// Aquí usamos bookings como referencia de pagos
|
||||||
|
const bookings = await prisma.booking.findMany({
|
||||||
|
where: {
|
||||||
|
createdAt: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
in: ['PAID', 'CONFIRMED', 'COMPLETED']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'asc'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const exportData: PaymentExportData[] = bookings.map(booking => ({
|
||||||
|
id: booking.id,
|
||||||
|
user: `${booking.user.firstName} ${booking.user.lastName}`,
|
||||||
|
userEmail: booking.user.email,
|
||||||
|
type: 'Reserva',
|
||||||
|
amount: booking.price,
|
||||||
|
status: booking.status,
|
||||||
|
date: formatDateForExport(booking.createdAt)
|
||||||
|
}));
|
||||||
|
|
||||||
|
const filename = `pagos_${startDate.toISOString().split('T')[0]}_${endDate.toISOString().split('T')[0]}`;
|
||||||
|
|
||||||
|
if (format === ExportFormat.JSON) {
|
||||||
|
return {
|
||||||
|
data: JSON.stringify(exportData, null, 2),
|
||||||
|
filename: `${filename}.json`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
id: 'ID',
|
||||||
|
user: 'Usuario',
|
||||||
|
userEmail: 'Email',
|
||||||
|
type: 'Tipo',
|
||||||
|
amount: 'Monto',
|
||||||
|
status: 'Estado',
|
||||||
|
date: 'Fecha'
|
||||||
|
};
|
||||||
|
|
||||||
|
const csv = convertToCSV(exportData, headers);
|
||||||
|
return { data: csv, filename: `${filename}.csv` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exporta resultados de un torneo
|
||||||
|
*/
|
||||||
|
export async function exportTournamentResults(
|
||||||
|
tournamentId: string,
|
||||||
|
format: ExportFormat = ExportFormat.CSV
|
||||||
|
): Promise<{ data: string | Buffer; filename: string }> {
|
||||||
|
const tournament = await prisma.tournament.findUnique({
|
||||||
|
where: { id: tournamentId },
|
||||||
|
include: {
|
||||||
|
participants: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
email: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
matches: {
|
||||||
|
where: {
|
||||||
|
confirmed: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tournament) {
|
||||||
|
throw new Error('Torneo no encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calcular estadísticas por participante
|
||||||
|
const participantStats = new Map<string, {
|
||||||
|
matchesPlayed: number;
|
||||||
|
wins: number;
|
||||||
|
losses: number;
|
||||||
|
points: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
for (const match of tournament.matches) {
|
||||||
|
// Actualizar estadísticas para cada jugador del partido
|
||||||
|
const matchParticipants = await prisma.tournamentParticipant.findMany({
|
||||||
|
where: {
|
||||||
|
tournamentId,
|
||||||
|
userId: {
|
||||||
|
in: [match.player1Id, match.player2Id].filter(Boolean) as string[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const participant of matchParticipants) {
|
||||||
|
const stats = participantStats.get(participant.userId) || {
|
||||||
|
matchesPlayed: 0,
|
||||||
|
wins: 0,
|
||||||
|
losses: 0,
|
||||||
|
points: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
stats.matchesPlayed++;
|
||||||
|
|
||||||
|
if (match.winnerId === participant.userId) {
|
||||||
|
stats.wins++;
|
||||||
|
stats.points += 3; // 3 puntos por victoria
|
||||||
|
} else if (match.winnerId) {
|
||||||
|
stats.losses++;
|
||||||
|
stats.points += 1; // 1 punto por participar
|
||||||
|
}
|
||||||
|
|
||||||
|
participantStats.set(participant.userId, stats);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportData: TournamentResultExportData[] = tournament.participants
|
||||||
|
.map((participant, index) => {
|
||||||
|
const stats = participantStats.get(participant.userId) || {
|
||||||
|
matchesPlayed: 0,
|
||||||
|
wins: 0,
|
||||||
|
losses: 0,
|
||||||
|
points: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
position: index + 1,
|
||||||
|
player: `${participant.user.firstName} ${participant.user.lastName}`,
|
||||||
|
email: participant.user.email,
|
||||||
|
matchesPlayed: stats.matchesPlayed,
|
||||||
|
wins: stats.wins,
|
||||||
|
losses: stats.losses,
|
||||||
|
points: stats.points
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.points - a.points)
|
||||||
|
.map((item, index) => ({ ...item, position: index + 1 }));
|
||||||
|
|
||||||
|
const filename = `torneo_${tournament.name.replace(/\s+/g, '_').toLowerCase()}_${tournamentId}`;
|
||||||
|
|
||||||
|
if (format === ExportFormat.JSON) {
|
||||||
|
return {
|
||||||
|
data: JSON.stringify(exportData, null, 2),
|
||||||
|
filename: `${filename}.json`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
position: 'Posición',
|
||||||
|
player: 'Jugador',
|
||||||
|
email: 'Email',
|
||||||
|
matchesPlayed: 'PJ',
|
||||||
|
wins: 'PG',
|
||||||
|
losses: 'PP',
|
||||||
|
points: 'Puntos'
|
||||||
|
};
|
||||||
|
|
||||||
|
const csv = convertToCSV(exportData, headers);
|
||||||
|
return { data: csv, filename: `${filename}.csv` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genera un reporte completo en Excel con múltiples hojas
|
||||||
|
*/
|
||||||
|
export async function generateExcelReport(
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date
|
||||||
|
): Promise<{ data: Buffer; filename: string }> {
|
||||||
|
// Importar xlsx dinámicamente para evitar problemas si no está instalado
|
||||||
|
const xlsx = await import('xlsx');
|
||||||
|
|
||||||
|
// 1. Resumen General
|
||||||
|
const totalBookings = await prisma.booking.count({
|
||||||
|
where: {
|
||||||
|
date: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalRevenue = await prisma.booking.aggregate({
|
||||||
|
where: {
|
||||||
|
date: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
in: ['PAID', 'CONFIRMED', 'COMPLETED']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_sum: {
|
||||||
|
price: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const newUsers = await prisma.user.count({
|
||||||
|
where: {
|
||||||
|
createdAt: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeUsers = await prisma.user.count({
|
||||||
|
where: {
|
||||||
|
bookings: {
|
||||||
|
some: {
|
||||||
|
date: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const summaryData = [{
|
||||||
|
metrica: 'Total Reservas',
|
||||||
|
valor: totalBookings
|
||||||
|
}, {
|
||||||
|
metrica: 'Ingresos Totales',
|
||||||
|
valor: totalRevenue._sum.price || 0
|
||||||
|
}, {
|
||||||
|
metrica: 'Nuevos Usuarios',
|
||||||
|
valor: newUsers
|
||||||
|
}, {
|
||||||
|
metrica: 'Usuarios Activos',
|
||||||
|
valor: activeUsers
|
||||||
|
}];
|
||||||
|
|
||||||
|
// 2. Datos de Ingresos por Día
|
||||||
|
const bookingsByDay = await prisma.booking.groupBy({
|
||||||
|
by: ['date'],
|
||||||
|
where: {
|
||||||
|
date: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
in: ['PAID', 'CONFIRMED', 'COMPLETED']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_sum: {
|
||||||
|
price: true
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
id: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const revenueData = bookingsByDay.map(day => ({
|
||||||
|
fecha: day.date.toISOString().split('T')[0],
|
||||||
|
reservas: day._count.id,
|
||||||
|
ingresos: day._sum.price || 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 3. Datos de Ocupación por Pista
|
||||||
|
const courts = await prisma.court.findMany({
|
||||||
|
include: {
|
||||||
|
bookings: {
|
||||||
|
where: {
|
||||||
|
date: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const occupancyData = courts.map(court => ({
|
||||||
|
pista: court.name,
|
||||||
|
tipo: court.surface || 'No especificado',
|
||||||
|
totalReservas: court.bookings.length,
|
||||||
|
tasaOcupacion: calculateOccupancyRate(court.bookings.length, startDate, endDate)
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 4. Datos de Usuarios
|
||||||
|
const topUsers = await prisma.user.findMany({
|
||||||
|
take: 20,
|
||||||
|
include: {
|
||||||
|
bookings: {
|
||||||
|
where: {
|
||||||
|
date: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
bookings: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
bookings: {
|
||||||
|
_count: 'desc'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const usersData = topUsers.map(user => ({
|
||||||
|
nombre: `${user.firstName} ${user.lastName}`,
|
||||||
|
email: user.email,
|
||||||
|
nivel: user.level || 'Sin nivel',
|
||||||
|
reservasPeriodo: user.bookings.length,
|
||||||
|
reservasTotales: user._count.bookings
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Crear workbook
|
||||||
|
const workbook = xlsx.utils.book_new();
|
||||||
|
|
||||||
|
// Hoja 1: Resumen
|
||||||
|
const summarySheet = xlsx.utils.json_to_sheet(summaryData);
|
||||||
|
xlsx.utils.book_append_sheet(workbook, summarySheet, 'Resumen');
|
||||||
|
|
||||||
|
// Hoja 2: Ingresos
|
||||||
|
const revenueSheet = xlsx.utils.json_to_sheet(revenueData);
|
||||||
|
xlsx.utils.book_append_sheet(workbook, revenueSheet, 'Ingresos');
|
||||||
|
|
||||||
|
// Hoja 3: Ocupación
|
||||||
|
const occupancySheet = xlsx.utils.json_to_sheet(occupancyData);
|
||||||
|
xlsx.utils.book_append_sheet(workbook, occupancySheet, 'Ocupación');
|
||||||
|
|
||||||
|
// Hoja 4: Usuarios
|
||||||
|
const usersSheet = xlsx.utils.json_to_sheet(usersData);
|
||||||
|
xlsx.utils.book_append_sheet(workbook, usersSheet, 'Usuarios');
|
||||||
|
|
||||||
|
const buffer = xlsx.write(workbook, { type: 'buffer', bookType: 'xlsx' });
|
||||||
|
const filename = `reporte_completo_${startDate.toISOString().split('T')[0]}_${endDate.toISOString().split('T')[0]}.xlsx`;
|
||||||
|
|
||||||
|
return { data: buffer, filename };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convierte datos a formato CSV
|
||||||
|
*/
|
||||||
|
function convertToCSV(data: Record<string, unknown>[], headers: Record<string, string>): string {
|
||||||
|
const CSV_SEPARATOR = ';';
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return Object.values(headers).join(CSV_SEPARATOR);
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerRow = Object.values(headers).join(CSV_SEPARATOR);
|
||||||
|
const keys = Object.keys(headers);
|
||||||
|
|
||||||
|
const rows = data.map(item => {
|
||||||
|
return keys.map(key => {
|
||||||
|
const value = item[key];
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (typeof value === 'string' && (value.includes(CSV_SEPARATOR) || value.includes('"') || value.includes('\n'))) {
|
||||||
|
return `"${value.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}).join(CSV_SEPARATOR);
|
||||||
|
});
|
||||||
|
|
||||||
|
return [headerRow, ...rows].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula la tasa de ocupación estimada
|
||||||
|
*/
|
||||||
|
function calculateOccupancyRate(
|
||||||
|
bookingCount: number,
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date
|
||||||
|
): string {
|
||||||
|
const daysDiff = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
const maxPossibleBookings = daysDiff * 10; // Asumiendo 10 franjas horarias por día
|
||||||
|
const rate = maxPossibleBookings > 0 ? (bookingCount / maxPossibleBookings) * 100 : 0;
|
||||||
|
return `${rate.toFixed(1)}%`;
|
||||||
|
}
|
||||||
2
src/services/analytics/index.ts
Normal file
2
src/services/analytics/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './userAnalytics.service';
|
||||||
|
export * from './export.service';
|
||||||
478
src/services/analytics/userAnalytics.service.ts
Normal file
478
src/services/analytics/userAnalytics.service.ts
Normal file
@@ -0,0 +1,478 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { ChurnRiskLevel } from '../../constants/export.constants';
|
||||||
|
import {
|
||||||
|
UserStatsOverview,
|
||||||
|
UserActivityStats,
|
||||||
|
TopPlayer,
|
||||||
|
ChurnRiskUser,
|
||||||
|
RetentionRate,
|
||||||
|
UserGrowthTrend
|
||||||
|
} from '../../types/analytics.types';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene estadísticas generales de usuarios
|
||||||
|
*/
|
||||||
|
export async function getUserStatsOverview(): Promise<UserStatsOverview> {
|
||||||
|
const now = new Date();
|
||||||
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
const thirtyDaysAgo = new Date(today);
|
||||||
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||||
|
|
||||||
|
const sevenDaysAgo = new Date(today);
|
||||||
|
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||||
|
|
||||||
|
// Total de usuarios
|
||||||
|
const totalUsers = await prisma.user.count();
|
||||||
|
|
||||||
|
// Usuarios activos (con reservas en los últimos 30 días)
|
||||||
|
const activeUsers30Days = await prisma.user.count({
|
||||||
|
where: {
|
||||||
|
bookings: {
|
||||||
|
some: {
|
||||||
|
createdAt: {
|
||||||
|
gte: thirtyDaysAgo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nuevos usuarios hoy
|
||||||
|
const newUsersToday = await prisma.user.count({
|
||||||
|
where: {
|
||||||
|
createdAt: {
|
||||||
|
gte: today
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nuevos usuarios esta semana
|
||||||
|
const newUsersThisWeek = await prisma.user.count({
|
||||||
|
where: {
|
||||||
|
createdAt: {
|
||||||
|
gte: sevenDaysAgo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nuevos usuarios este mes
|
||||||
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
const newUsersThisMonth = await prisma.user.count({
|
||||||
|
where: {
|
||||||
|
createdAt: {
|
||||||
|
gte: startOfMonth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Usuarios por nivel
|
||||||
|
const usersByLevelRaw = await prisma.user.groupBy({
|
||||||
|
by: ['level'],
|
||||||
|
_count: {
|
||||||
|
id: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const usersByLevel = usersByLevelRaw.map(item => ({
|
||||||
|
level: item.level || 'Sin nivel',
|
||||||
|
count: item._count.id
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Usuarios por ciudad
|
||||||
|
const usersByCityRaw = await prisma.user.groupBy({
|
||||||
|
by: ['city'],
|
||||||
|
_count: {
|
||||||
|
id: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const usersByCity = usersByCityRaw
|
||||||
|
.filter(item => item.city !== null)
|
||||||
|
.map(item => ({
|
||||||
|
city: item.city as string,
|
||||||
|
count: item._count.id
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalUsers,
|
||||||
|
activeUsers30Days,
|
||||||
|
newUsersToday,
|
||||||
|
newUsersThisWeek,
|
||||||
|
newUsersThisMonth,
|
||||||
|
usersByLevel,
|
||||||
|
usersByCity
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene estadísticas de actividad de usuarios en un período
|
||||||
|
*/
|
||||||
|
export async function getUserActivityStats(
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date
|
||||||
|
): Promise<UserActivityStats> {
|
||||||
|
// Usuarios con reservas en el período
|
||||||
|
const usersWithBookings = await prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
bookings: {
|
||||||
|
some: {
|
||||||
|
createdAt: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
bookings: {
|
||||||
|
where: {
|
||||||
|
createdAt: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate
|
||||||
|
}
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalUsersWithBookings = usersWithBookings.length;
|
||||||
|
const totalBookings = usersWithBookings.reduce(
|
||||||
|
(sum, user) => sum + user.bookings.length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const averageBookingsPerUser = totalUsersWithBookings > 0
|
||||||
|
? totalBookings / totalUsersWithBookings
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Top 10 usuarios más activos
|
||||||
|
const topActiveUsers = usersWithBookings
|
||||||
|
.map(user => ({
|
||||||
|
userId: user.id,
|
||||||
|
name: `${user.firstName} ${user.lastName}`,
|
||||||
|
email: user.email,
|
||||||
|
bookingCount: user.bookings.length
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.bookingCount - a.bookingCount)
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
// Usuarios sin actividad en el período (pero que tienen reservas previas)
|
||||||
|
const inactiveUsers = await prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
bookings: {
|
||||||
|
some: {},
|
||||||
|
none: {
|
||||||
|
createdAt: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
bookings: {
|
||||||
|
orderBy: {
|
||||||
|
date: 'desc'
|
||||||
|
},
|
||||||
|
take: 1,
|
||||||
|
select: {
|
||||||
|
date: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const inactiveUsersFormatted = inactiveUsers.map(user => ({
|
||||||
|
userId: user.id,
|
||||||
|
name: `${user.firstName} ${user.lastName}`,
|
||||||
|
email: user.email,
|
||||||
|
lastBookingDate: user.bookings[0]?.date || null
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalUsersWithBookings,
|
||||||
|
averageBookingsPerUser: Math.round(averageBookingsPerUser * 100) / 100,
|
||||||
|
topActiveUsers,
|
||||||
|
inactiveUsers: inactiveUsersFormatted
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene los mejores jugadores por diferentes criterios
|
||||||
|
*/
|
||||||
|
export async function getTopPlayers(
|
||||||
|
limit: number = 10,
|
||||||
|
by: 'matches' | 'wins' | 'ranking' | 'tournaments' = 'matches'
|
||||||
|
): Promise<TopPlayer[]> {
|
||||||
|
// Obtener todos los usuarios con sus estadísticas
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
include: {
|
||||||
|
userStats: true,
|
||||||
|
bookings: true,
|
||||||
|
tournamentParticipants: {
|
||||||
|
include: {
|
||||||
|
tournament: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
matchResultsAsPlayer1: {
|
||||||
|
where: {
|
||||||
|
confirmed: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
matchResultsAsPlayer2: {
|
||||||
|
where: {
|
||||||
|
confirmed: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const players: TopPlayer[] = users.map(user => {
|
||||||
|
const wins = user.matchResultsAsPlayer1.filter(m => m.winnerId === user.id).length +
|
||||||
|
user.matchResultsAsPlayer2.filter(m => m.winnerId === user.id).length;
|
||||||
|
const losses = user.matchResultsAsPlayer1.filter(m => m.winnerId && m.winnerId !== user.id).length +
|
||||||
|
user.matchResultsAsPlayer2.filter(m => m.winnerId && m.winnerId !== user.id).length;
|
||||||
|
const matchesPlayed = wins + losses;
|
||||||
|
const winRate = matchesPlayed > 0 ? Math.round((wins / matchesPlayed) * 100) : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: user.id,
|
||||||
|
name: `${user.firstName} ${user.lastName}`,
|
||||||
|
email: user.email,
|
||||||
|
level: user.level || 'Sin nivel',
|
||||||
|
matchesPlayed,
|
||||||
|
wins,
|
||||||
|
losses,
|
||||||
|
winRate,
|
||||||
|
rankingPoints: user.userStats?.points || 0,
|
||||||
|
tournamentsAttended: user.tournamentParticipants.length
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ordenar según el criterio
|
||||||
|
switch (by) {
|
||||||
|
case 'wins':
|
||||||
|
players.sort((a, b) => b.wins - a.wins);
|
||||||
|
break;
|
||||||
|
case 'ranking':
|
||||||
|
players.sort((a, b) => b.rankingPoints - a.rankingPoints);
|
||||||
|
break;
|
||||||
|
case 'tournaments':
|
||||||
|
players.sort((a, b) => b.tournamentsAttended - a.tournamentsAttended);
|
||||||
|
break;
|
||||||
|
case 'matches':
|
||||||
|
default:
|
||||||
|
players.sort((a, b) => b.matchesPlayed - a.matchesPlayed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return players.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identifica usuarios en riesgo de abandono
|
||||||
|
*/
|
||||||
|
export async function getChurnRiskUsers(): Promise<ChurnRiskUser[]> {
|
||||||
|
const now = new Date();
|
||||||
|
const sixtyDaysAgo = new Date(now);
|
||||||
|
sixtyDaysAgo.setDate(sixtyDaysAgo.getDate() - 60);
|
||||||
|
|
||||||
|
// Usuarios que no han reservado en 60+ días
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
bookings: {
|
||||||
|
some: {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
bookings: {
|
||||||
|
orderBy: {
|
||||||
|
date: 'desc'
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
court: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
bookings: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const churnRiskUsers: ChurnRiskUser[] = [];
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
const lastBooking = user.bookings[0];
|
||||||
|
if (!lastBooking) continue;
|
||||||
|
|
||||||
|
const lastBookingDate = new Date(lastBooking.date);
|
||||||
|
const daysSinceLastBooking = Math.floor(
|
||||||
|
(now.getTime() - lastBookingDate.getTime()) / (1000 * 60 * 60 * 24)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Solo usuarios con 60+ días sin reservar
|
||||||
|
if (daysSinceLastBooking < 60) continue;
|
||||||
|
|
||||||
|
// Calcular nivel de riesgo
|
||||||
|
let riskLevel: ChurnRiskLevel;
|
||||||
|
const totalBookings = user._count.bookings;
|
||||||
|
const wasFrequentUser = totalBookings >= 5;
|
||||||
|
|
||||||
|
if (daysSinceLastBooking >= 120 && wasFrequentUser) {
|
||||||
|
riskLevel = ChurnRiskLevel.HIGH;
|
||||||
|
} else if (daysSinceLastBooking >= 90 || wasFrequentUser) {
|
||||||
|
riskLevel = ChurnRiskLevel.MEDIUM;
|
||||||
|
} else {
|
||||||
|
riskLevel = ChurnRiskLevel.LOW;
|
||||||
|
}
|
||||||
|
|
||||||
|
churnRiskUsers.push({
|
||||||
|
userId: user.id,
|
||||||
|
name: `${user.firstName} ${user.lastName}`,
|
||||||
|
email: user.email,
|
||||||
|
lastBookingDate,
|
||||||
|
totalBookings,
|
||||||
|
riskLevel,
|
||||||
|
daysSinceLastBooking
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordenar por días sin reservar (mayor riesgo primero)
|
||||||
|
return churnRiskUsers.sort((a, b) => b.daysSinceLastBooking - a.daysSinceLastBooking);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calcula la tasa de retención de usuarios
|
||||||
|
*/
|
||||||
|
export async function getRetentionRate(
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date
|
||||||
|
): Promise<RetentionRate[]> {
|
||||||
|
const periods: RetentionRate[] = [];
|
||||||
|
const current = new Date(startDate);
|
||||||
|
|
||||||
|
while (current <= endDate) {
|
||||||
|
const periodStart = new Date(current);
|
||||||
|
const periodEnd = new Date(current);
|
||||||
|
periodEnd.setMonth(periodEnd.getMonth() + 1);
|
||||||
|
|
||||||
|
if (periodEnd > endDate) break;
|
||||||
|
|
||||||
|
// Usuarios registrados en este período
|
||||||
|
const registeredUsers = await prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
createdAt: {
|
||||||
|
gte: periodStart,
|
||||||
|
lt: periodEnd
|
||||||
|
}
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
createdAt: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalRegistered = registeredUsers.length;
|
||||||
|
|
||||||
|
if (totalRegistered === 0) {
|
||||||
|
current.setMonth(current.getMonth() + 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar cuántos siguen activos (tienen reservas después de 30 días del registro)
|
||||||
|
const retainedCount = await Promise.all(
|
||||||
|
registeredUsers.map(async user => {
|
||||||
|
const thirtyDaysAfterReg = new Date(user.createdAt);
|
||||||
|
thirtyDaysAfterReg.setDate(thirtyDaysAfterReg.getDate() + 30);
|
||||||
|
|
||||||
|
const hasActivity = await prisma.booking.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
createdAt: {
|
||||||
|
gte: thirtyDaysAfterReg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return hasActivity !== null;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const retainedUsers = retainedCount.filter(Boolean).length;
|
||||||
|
const retentionRate = Math.round((retainedUsers / totalRegistered) * 100);
|
||||||
|
|
||||||
|
periods.push({
|
||||||
|
period: periodStart.toISOString().slice(0, 7), // YYYY-MM
|
||||||
|
totalRegistered,
|
||||||
|
retainedUsers,
|
||||||
|
retentionRate
|
||||||
|
});
|
||||||
|
|
||||||
|
current.setMonth(current.getMonth() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return periods;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene la tendencia de crecimiento de usuarios
|
||||||
|
*/
|
||||||
|
export async function getUserGrowthTrend(months: number = 6): Promise<UserGrowthTrend[]> {
|
||||||
|
const trends: UserGrowthTrend[] = [];
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// Ir mes por mes hacia atrás
|
||||||
|
for (let i = months - 1; i >= 0; i--) {
|
||||||
|
const monthDate = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||||
|
const nextMonth = new Date(monthDate.getFullYear(), monthDate.getMonth() + 1, 1);
|
||||||
|
|
||||||
|
// Nuevos usuarios este mes
|
||||||
|
const newUsers = await prisma.user.count({
|
||||||
|
where: {
|
||||||
|
createdAt: {
|
||||||
|
gte: monthDate,
|
||||||
|
lt: nextMonth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Total acumulado hasta este mes
|
||||||
|
const totalUsers = await prisma.user.count({
|
||||||
|
where: {
|
||||||
|
createdAt: {
|
||||||
|
lt: nextMonth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calcular tasa de crecimiento
|
||||||
|
let growthRate = 0;
|
||||||
|
if (i < months - 1) {
|
||||||
|
const prevMonth = new Date(monthDate.getFullYear(), monthDate.getMonth(), 1);
|
||||||
|
const prevTotal = await prisma.user.count({
|
||||||
|
where: {
|
||||||
|
createdAt: {
|
||||||
|
lt: prevMonth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (prevTotal > 0) {
|
||||||
|
growthRate = Math.round(((totalUsers - prevTotal) / prevTotal) * 100 * 100) / 100;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
trends.push({
|
||||||
|
month: monthDate.toLocaleString('es-ES', { month: 'long' }),
|
||||||
|
year: monthDate.getFullYear(),
|
||||||
|
newUsers,
|
||||||
|
totalUsers,
|
||||||
|
growthRate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return trends;
|
||||||
|
}
|
||||||
130
src/types/analytics.types.ts
Normal file
130
src/types/analytics.types.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { ChurnRiskLevel } from '../constants/export.constants';
|
||||||
|
|
||||||
|
export interface UserStatsOverview {
|
||||||
|
totalUsers: number;
|
||||||
|
activeUsers30Days: number;
|
||||||
|
newUsersToday: number;
|
||||||
|
newUsersThisWeek: number;
|
||||||
|
newUsersThisMonth: number;
|
||||||
|
usersByLevel: Array<{
|
||||||
|
level: string;
|
||||||
|
count: number;
|
||||||
|
}>;
|
||||||
|
usersByCity: Array<{
|
||||||
|
city: string;
|
||||||
|
count: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserActivityStats {
|
||||||
|
totalUsersWithBookings: number;
|
||||||
|
averageBookingsPerUser: number;
|
||||||
|
topActiveUsers: Array<{
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
bookingCount: number;
|
||||||
|
}>;
|
||||||
|
inactiveUsers: Array<{
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
lastBookingDate: Date | null;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopPlayer {
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
level: string;
|
||||||
|
matchesPlayed: number;
|
||||||
|
wins: number;
|
||||||
|
losses: number;
|
||||||
|
winRate: number;
|
||||||
|
rankingPoints: number;
|
||||||
|
tournamentsAttended: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChurnRiskUser {
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
lastBookingDate: Date;
|
||||||
|
totalBookings: number;
|
||||||
|
riskLevel: ChurnRiskLevel;
|
||||||
|
daysSinceLastBooking: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RetentionRate {
|
||||||
|
period: string;
|
||||||
|
totalRegistered: number;
|
||||||
|
retainedUsers: number;
|
||||||
|
retentionRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserGrowthTrend {
|
||||||
|
month: string;
|
||||||
|
year: number;
|
||||||
|
newUsers: number;
|
||||||
|
totalUsers: number;
|
||||||
|
growthRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportFilters {
|
||||||
|
startDate?: Date;
|
||||||
|
endDate?: Date;
|
||||||
|
level?: string;
|
||||||
|
city?: string;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookingExportData {
|
||||||
|
id: string;
|
||||||
|
user: string;
|
||||||
|
userEmail: string;
|
||||||
|
court: string;
|
||||||
|
date: string;
|
||||||
|
time: string;
|
||||||
|
price: number;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserExportData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
level: string;
|
||||||
|
city: string;
|
||||||
|
joinedAt: string;
|
||||||
|
bookingsCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentExportData {
|
||||||
|
id: string;
|
||||||
|
user: string;
|
||||||
|
userEmail: string;
|
||||||
|
type: string;
|
||||||
|
amount: number;
|
||||||
|
status: string;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TournamentResultExportData {
|
||||||
|
position: number;
|
||||||
|
player: string;
|
||||||
|
email: string;
|
||||||
|
matchesPlayed: number;
|
||||||
|
wins: number;
|
||||||
|
losses: number;
|
||||||
|
points: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExcelWorkbookData {
|
||||||
|
sheets: Array<{
|
||||||
|
name: string;
|
||||||
|
data: unknown[];
|
||||||
|
headers?: string[];
|
||||||
|
}>;
|
||||||
|
}
|
||||||
1
src/types/index.ts
Normal file
1
src/types/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './analytics.types';
|
||||||
166
src/utils/export.ts
Normal file
166
src/utils/export.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { Response } from 'express';
|
||||||
|
import * as xlsx from 'xlsx';
|
||||||
|
import { CSV_SEPARATOR, ExportFormat } from '../constants/export.constants';
|
||||||
|
import { ExcelWorkbookData } from '../types/analytics.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convierte datos a formato CSV
|
||||||
|
* @param data - Array de objetos a convertir
|
||||||
|
* @param headers - Mapeo de { clave: nombreColumna }
|
||||||
|
* @returns string en formato CSV
|
||||||
|
*/
|
||||||
|
export function toCSV(data: Record<string, unknown>[], headers: Record<string, string>): string {
|
||||||
|
if (data.length === 0) {
|
||||||
|
return Object.values(headers).join(CSV_SEPARATOR);
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerRow = Object.values(headers).join(CSV_SEPARATOR);
|
||||||
|
const keys = Object.keys(headers);
|
||||||
|
|
||||||
|
const rows = data.map(item => {
|
||||||
|
return keys.map(key => {
|
||||||
|
const value = item[key];
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (typeof value === 'string' && (value.includes(CSV_SEPARATOR) || value.includes('"') || value.includes('\n'))) {
|
||||||
|
return `"${value.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}).join(CSV_SEPARATOR);
|
||||||
|
});
|
||||||
|
|
||||||
|
return [headerRow, ...rows].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convierte datos a formato JSON
|
||||||
|
* @param data - Array de objetos a convertir
|
||||||
|
* @returns string en formato JSON
|
||||||
|
*/
|
||||||
|
export function toJSON(data: unknown[]): string {
|
||||||
|
return JSON.stringify(data, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genera un archivo Excel con múltiples hojas
|
||||||
|
* @param workbookData - Datos del workbook con múltiples hojas
|
||||||
|
* @returns Buffer con el archivo Excel
|
||||||
|
*/
|
||||||
|
export function generateExcel(workbookData: ExcelWorkbookData): Buffer {
|
||||||
|
const workbook = xlsx.utils.book_new();
|
||||||
|
|
||||||
|
workbookData.sheets.forEach(sheet => {
|
||||||
|
let worksheet: xlsx.WorkSheet;
|
||||||
|
|
||||||
|
if (sheet.headers && sheet.data.length > 0) {
|
||||||
|
// Si tenemos headers definidos, usamos json_to_sheet
|
||||||
|
worksheet = xlsx.utils.json_to_sheet(sheet.data as Record<string, unknown>[], {
|
||||||
|
header: sheet.headers
|
||||||
|
});
|
||||||
|
} else if (sheet.data.length > 0) {
|
||||||
|
// Sin headers explícitos, convertimos los objetos
|
||||||
|
worksheet = xlsx.utils.json_to_sheet(sheet.data as Record<string, unknown>[]);
|
||||||
|
} else {
|
||||||
|
// Hoja vacía
|
||||||
|
worksheet = xlsx.utils.aoa_to_sheet([[]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ajustar anchos de columna automáticamente
|
||||||
|
const colWidths: { wch: number }[] = [];
|
||||||
|
const data = sheet.data as Record<string, unknown>[];
|
||||||
|
|
||||||
|
if (data.length > 0) {
|
||||||
|
const keys = sheet.headers || Object.keys(data[0]);
|
||||||
|
keys.forEach((key, idx) => {
|
||||||
|
const maxLength = Math.max(
|
||||||
|
String(key).length,
|
||||||
|
...data.map(row => String(row[key] || '').length)
|
||||||
|
);
|
||||||
|
colWidths[idx] = { wch: Math.min(maxLength + 2, 50) };
|
||||||
|
});
|
||||||
|
worksheet['!cols'] = colWidths;
|
||||||
|
}
|
||||||
|
|
||||||
|
xlsx.utils.book_append_sheet(workbook, worksheet, sheet.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
return xlsx.write(workbook, { type: 'buffer', bookType: 'xlsx' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configura los headers HTTP para la descarga de archivos
|
||||||
|
* @param res - Response de Express
|
||||||
|
* @param filename - Nombre del archivo
|
||||||
|
* @param format - Formato de exportación
|
||||||
|
*/
|
||||||
|
export function setExportHeaders(res: Response, filename: string, format: ExportFormat): void {
|
||||||
|
let contentType: string;
|
||||||
|
let extension: string;
|
||||||
|
|
||||||
|
switch (format) {
|
||||||
|
case ExportFormat.CSV:
|
||||||
|
contentType = 'text/csv; charset=utf-8';
|
||||||
|
extension = 'csv';
|
||||||
|
break;
|
||||||
|
case ExportFormat.JSON:
|
||||||
|
contentType = 'application/json; charset=utf-8';
|
||||||
|
extension = 'json';
|
||||||
|
break;
|
||||||
|
case ExportFormat.EXCEL:
|
||||||
|
contentType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||||
|
extension = 'xlsx';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
contentType = 'application/octet-stream';
|
||||||
|
extension = 'txt';
|
||||||
|
}
|
||||||
|
|
||||||
|
// BOM para Excel español
|
||||||
|
if (format === ExportFormat.CSV) {
|
||||||
|
res.setHeader('Content-Type', `${contentType}; BOM=`);
|
||||||
|
} else {
|
||||||
|
res.setHeader('Content-Type', contentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setHeader(
|
||||||
|
'Content-Disposition',
|
||||||
|
`attachment; filename="${filename}_${new Date().toISOString().split('T')[0]}.${extension}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatea una fecha para exportación
|
||||||
|
* @param date - Fecha a formatear
|
||||||
|
* @returns string en formato ISO 8601
|
||||||
|
*/
|
||||||
|
export function formatDateForExport(date: Date | null): string {
|
||||||
|
if (!date) return '';
|
||||||
|
return date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatea una fecha para display
|
||||||
|
* @param date - Fecha a formatear
|
||||||
|
* @returns string formateada
|
||||||
|
*/
|
||||||
|
export function formatDateForDisplay(date: Date | null): string {
|
||||||
|
if (!date) return '';
|
||||||
|
return date.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escapa valores para CSV
|
||||||
|
* @param value - Valor a escapar
|
||||||
|
* @returns string escapado
|
||||||
|
*/
|
||||||
|
export function escapeCSVValue(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const str = String(value);
|
||||||
|
if (str.includes(CSV_SEPARATOR) || str.includes('"') || str.includes('\n') || str.includes('\r')) {
|
||||||
|
return `"${str.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return str;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user