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
555 lines
13 KiB
TypeScript
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)}%`;
|
|
}
|