Files
app-padel/src/services/analytics/export.service.ts
Ivan Alcaraz 5e50dd766f 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
2026-01-31 09:13:03 +00:00

555 lines
13 KiB
TypeScript

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