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

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';
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
// ============================================

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