✅ 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",
|
||||
"nodemailer": "^6.9.8",
|
||||
"winston": "^3.11.0",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1229,6 +1230,15 @@
|
||||
"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": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
@@ -1486,6 +1496,19 @@
|
||||
"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": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
@@ -1512,6 +1535,15 @@
|
||||
"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": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz",
|
||||
@@ -1661,6 +1693,18 @@
|
||||
"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": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -2353,6 +2397,15 @@
|
||||
"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": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
@@ -3944,6 +3997,18 @@
|
||||
"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": {
|
||||
"version": "0.0.10",
|
||||
"resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",
|
||||
@@ -4317,6 +4382,24 @@
|
||||
"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": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
@@ -4333,6 +4416,27 @@
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"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": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"morgan": "^1.10.0",
|
||||
"nodemailer": "^6.9.8",
|
||||
"winston": "^3.11.0",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"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';
|
||||
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
|
||||
// ============================================
|
||||
|
||||
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;
|
||||
|
||||
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