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:
2026-01-31 09:13:03 +00:00
parent b8a964dc2c
commit 5e50dd766f
31 changed files with 6068 additions and 3 deletions

79
INTEGRATION.md Normal file
View 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
View 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

View File

@@ -21,6 +21,7 @@
"morgan": "^1.10.0", "morgan": "^1.10.0",
"nodemailer": "^6.9.8", "nodemailer": "^6.9.8",
"winston": "^3.11.0", "winston": "^3.11.0",
"xlsx": "^0.18.5",
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
"devDependencies": { "devDependencies": {
@@ -1229,6 +1230,15 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
} }
}, },
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/agent-base": { "node_modules/agent-base": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
@@ -1486,6 +1496,19 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chalk": { "node_modules/chalk": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -1512,6 +1535,15 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/color": { "node_modules/color": {
"version": "5.0.3", "version": "5.0.3",
"resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz",
@@ -1661,6 +1693,18 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2353,6 +2397,15 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fresh": { "node_modules/fresh": {
"version": "0.5.2", "version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
@@ -3944,6 +3997,18 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/stack-trace": { "node_modules/stack-trace": {
"version": "0.0.10", "version": "0.0.10",
"resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",
@@ -4317,6 +4382,24 @@
"node": ">= 12.0.0" "node": ">= 12.0.0"
} }
}, },
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word-wrap": { "node_modules/word-wrap": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -4333,6 +4416,27 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",

View File

@@ -35,6 +35,7 @@
"morgan": "^1.10.0", "morgan": "^1.10.0",
"nodemailer": "^6.9.8", "nodemailer": "^6.9.8",
"winston": "^3.11.0", "winston": "^3.11.0",
"xlsx": "^0.18.5",
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
"devDependencies": { "devDependencies": {

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@@ -98,6 +98,13 @@ router.use('/payments', paymentRoutes);
import subscriptionRoutes from './subscription.routes'; import subscriptionRoutes from './subscription.routes';
router.use('/', subscriptionRoutes); router.use('/', subscriptionRoutes);
// ============================================
// Rutas de Analytics y Dashboard (Fase 5.1)
// ============================================
import analyticsRoutes from './analytics.routes';
router.use('/analytics', analyticsRoutes);
// ============================================ // ============================================
// Rutas de Clases con Profesores (Fase 4.4) - Desactivado temporalmente // Rutas de Clases con Profesores (Fase 4.4) - Desactivado temporalmente
// ============================================ // ============================================

View 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;

View 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;

View 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;

View 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;

View 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,
};

View File

@@ -336,3 +336,38 @@ export const UserSubscriptionStatus = {
} as const; } as const;
export type UserSubscriptionStatusType = typeof UserSubscriptionStatus[keyof typeof UserSubscriptionStatus]; export type UserSubscriptionStatusType = typeof UserSubscriptionStatus[keyof typeof UserSubscriptionStatus];
// ============================================
// Constantes de Reportes y Analytics (Fase 5.2)
// ============================================
// Tipos de reportes
export const ReportType = {
REVENUE: 'REVENUE', // Reporte de ingresos
OCCUPANCY: 'OCCUPANCY', // Reporte de ocupación
USERS: 'USERS', // Reporte de usuarios
SUMMARY: 'SUMMARY', // Resumen ejecutivo
TOURNAMENT: 'TOURNAMENT', // Reporte de torneos
COACH: 'COACH', // Reporte de coaches
} as const;
export type ReportTypeType = typeof ReportType[keyof typeof ReportType];
// Períodos de agrupación
export const GroupByPeriod = {
DAY: 'day', // Por día
WEEK: 'week', // Por semana
MONTH: 'month', // Por mes
YEAR: 'year', // Por año
} as const;
export type GroupByPeriodType = typeof GroupByPeriod[keyof typeof GroupByPeriod];
// Formatos de exportación de reportes
export const ReportFormat = {
JSON: 'json', // Formato JSON
PDF: 'pdf', // Formato PDF
EXCEL: 'excel', // Formato Excel/CSV
} as const;
export type ReportFormatType = typeof ReportFormat[keyof typeof ReportFormat];

View 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>;

View File

@@ -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
View 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"
}
}

View 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
View File

@@ -0,0 +1 @@
export * from './export.constants';

View 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'
});
}
}

View File

@@ -0,0 +1,2 @@
export * from './userAnalytics.controller';
export * from './export.controller';

View 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'
});
}
}

View 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;

View 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)}%`;
}

View File

@@ -0,0 +1,2 @@
export * from './userAnalytics.service';
export * from './export.service';

View 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;
}

View 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
View File

@@ -0,0 +1 @@
export * from './analytics.types';

166
src/utils/export.ts Normal file
View 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;
}