✅ 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:
554
src/services/analytics/export.service.ts
Normal file
554
src/services/analytics/export.service.ts
Normal file
@@ -0,0 +1,554 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { ExportFormat } from '../../constants/export.constants';
|
||||
import {
|
||||
BookingExportData,
|
||||
UserExportData,
|
||||
PaymentExportData,
|
||||
TournamentResultExportData,
|
||||
ExcelWorkbookData,
|
||||
ExportFilters
|
||||
} from '../../types/analytics.types';
|
||||
import { formatDateForExport } from '../../utils/export';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
/**
|
||||
* Exporta reservas a CSV/JSON
|
||||
*/
|
||||
export async function exportBookings(
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
format: ExportFormat = ExportFormat.CSV
|
||||
): Promise<{ data: string | Buffer; filename: string }> {
|
||||
const bookings = await prisma.booking.findMany({
|
||||
where: {
|
||||
date: {
|
||||
gte: startDate,
|
||||
lte: endDate
|
||||
}
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true
|
||||
}
|
||||
},
|
||||
court: {
|
||||
select: {
|
||||
name: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
date: 'asc'
|
||||
}
|
||||
});
|
||||
|
||||
const exportData: BookingExportData[] = bookings.map(booking => ({
|
||||
id: booking.id,
|
||||
user: `${booking.user.firstName} ${booking.user.lastName}`,
|
||||
userEmail: booking.user.email,
|
||||
court: booking.court.name,
|
||||
date: formatDateForExport(booking.date),
|
||||
time: booking.startTime,
|
||||
price: booking.price,
|
||||
status: booking.status,
|
||||
createdAt: formatDateForExport(booking.createdAt)
|
||||
}));
|
||||
|
||||
const filename = `reservas_${startDate.toISOString().split('T')[0]}_${endDate.toISOString().split('T')[0]}`;
|
||||
|
||||
if (format === ExportFormat.JSON) {
|
||||
return {
|
||||
data: JSON.stringify(exportData, null, 2),
|
||||
filename: `${filename}.json`
|
||||
};
|
||||
}
|
||||
|
||||
// CSV por defecto
|
||||
const headers = {
|
||||
id: 'ID',
|
||||
user: 'Usuario',
|
||||
userEmail: 'Email',
|
||||
court: 'Pista',
|
||||
date: 'Fecha',
|
||||
time: 'Hora',
|
||||
price: 'Precio',
|
||||
status: 'Estado',
|
||||
createdAt: 'Fecha Creación'
|
||||
};
|
||||
|
||||
const csv = convertToCSV(exportData, headers);
|
||||
return { data: csv, filename: `${filename}.csv` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Exporta usuarios a CSV/JSON
|
||||
*/
|
||||
export async function exportUsers(
|
||||
format: ExportFormat = ExportFormat.CSV,
|
||||
filters: ExportFilters = {}
|
||||
): Promise<{ data: string | Buffer; filename: string }> {
|
||||
const where: Record<string, unknown> = {};
|
||||
|
||||
if (filters.level) {
|
||||
where.level = filters.level;
|
||||
}
|
||||
|
||||
if (filters.city) {
|
||||
where.city = filters.city;
|
||||
}
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where,
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
bookings: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
}
|
||||
});
|
||||
|
||||
const exportData: UserExportData[] = users.map(user => ({
|
||||
id: user.id,
|
||||
name: `${user.firstName} ${user.lastName}`,
|
||||
email: user.email,
|
||||
level: user.level || 'Sin nivel',
|
||||
city: user.city || 'Sin ciudad',
|
||||
joinedAt: formatDateForExport(user.createdAt),
|
||||
bookingsCount: user._count.bookings
|
||||
}));
|
||||
|
||||
const filename = `usuarios_${new Date().toISOString().split('T')[0]}`;
|
||||
|
||||
if (format === ExportFormat.JSON) {
|
||||
return {
|
||||
data: JSON.stringify(exportData, null, 2),
|
||||
filename: `${filename}.json`
|
||||
};
|
||||
}
|
||||
|
||||
const headers = {
|
||||
id: 'ID',
|
||||
name: 'Nombre',
|
||||
email: 'Email',
|
||||
level: 'Nivel',
|
||||
city: 'Ciudad',
|
||||
joinedAt: 'Fecha Registro',
|
||||
bookingsCount: 'Total Reservas'
|
||||
};
|
||||
|
||||
const csv = convertToCSV(exportData, headers);
|
||||
return { data: csv, filename: `${filename}.csv` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Exporta pagos a CSV/JSON
|
||||
*/
|
||||
export async function exportPayments(
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
format: ExportFormat = ExportFormat.CSV
|
||||
): Promise<{ data: string | Buffer; filename: string }> {
|
||||
// Asumiendo que existe un modelo Payment o similar
|
||||
// Aquí usamos bookings como referencia de pagos
|
||||
const bookings = await prisma.booking.findMany({
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: startDate,
|
||||
lte: endDate
|
||||
},
|
||||
status: {
|
||||
in: ['PAID', 'CONFIRMED', 'COMPLETED']
|
||||
}
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'asc'
|
||||
}
|
||||
});
|
||||
|
||||
const exportData: PaymentExportData[] = bookings.map(booking => ({
|
||||
id: booking.id,
|
||||
user: `${booking.user.firstName} ${booking.user.lastName}`,
|
||||
userEmail: booking.user.email,
|
||||
type: 'Reserva',
|
||||
amount: booking.price,
|
||||
status: booking.status,
|
||||
date: formatDateForExport(booking.createdAt)
|
||||
}));
|
||||
|
||||
const filename = `pagos_${startDate.toISOString().split('T')[0]}_${endDate.toISOString().split('T')[0]}`;
|
||||
|
||||
if (format === ExportFormat.JSON) {
|
||||
return {
|
||||
data: JSON.stringify(exportData, null, 2),
|
||||
filename: `${filename}.json`
|
||||
};
|
||||
}
|
||||
|
||||
const headers = {
|
||||
id: 'ID',
|
||||
user: 'Usuario',
|
||||
userEmail: 'Email',
|
||||
type: 'Tipo',
|
||||
amount: 'Monto',
|
||||
status: 'Estado',
|
||||
date: 'Fecha'
|
||||
};
|
||||
|
||||
const csv = convertToCSV(exportData, headers);
|
||||
return { data: csv, filename: `${filename}.csv` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Exporta resultados de un torneo
|
||||
*/
|
||||
export async function exportTournamentResults(
|
||||
tournamentId: string,
|
||||
format: ExportFormat = ExportFormat.CSV
|
||||
): Promise<{ data: string | Buffer; filename: string }> {
|
||||
const tournament = await prisma.tournament.findUnique({
|
||||
where: { id: tournamentId },
|
||||
include: {
|
||||
participants: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
matches: {
|
||||
where: {
|
||||
confirmed: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!tournament) {
|
||||
throw new Error('Torneo no encontrado');
|
||||
}
|
||||
|
||||
// Calcular estadísticas por participante
|
||||
const participantStats = new Map<string, {
|
||||
matchesPlayed: number;
|
||||
wins: number;
|
||||
losses: number;
|
||||
points: number;
|
||||
}>();
|
||||
|
||||
for (const match of tournament.matches) {
|
||||
// Actualizar estadísticas para cada jugador del partido
|
||||
const matchParticipants = await prisma.tournamentParticipant.findMany({
|
||||
where: {
|
||||
tournamentId,
|
||||
userId: {
|
||||
in: [match.player1Id, match.player2Id].filter(Boolean) as string[]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (const participant of matchParticipants) {
|
||||
const stats = participantStats.get(participant.userId) || {
|
||||
matchesPlayed: 0,
|
||||
wins: 0,
|
||||
losses: 0,
|
||||
points: 0
|
||||
};
|
||||
|
||||
stats.matchesPlayed++;
|
||||
|
||||
if (match.winnerId === participant.userId) {
|
||||
stats.wins++;
|
||||
stats.points += 3; // 3 puntos por victoria
|
||||
} else if (match.winnerId) {
|
||||
stats.losses++;
|
||||
stats.points += 1; // 1 punto por participar
|
||||
}
|
||||
|
||||
participantStats.set(participant.userId, stats);
|
||||
}
|
||||
}
|
||||
|
||||
const exportData: TournamentResultExportData[] = tournament.participants
|
||||
.map((participant, index) => {
|
||||
const stats = participantStats.get(participant.userId) || {
|
||||
matchesPlayed: 0,
|
||||
wins: 0,
|
||||
losses: 0,
|
||||
points: 0
|
||||
};
|
||||
|
||||
return {
|
||||
position: index + 1,
|
||||
player: `${participant.user.firstName} ${participant.user.lastName}`,
|
||||
email: participant.user.email,
|
||||
matchesPlayed: stats.matchesPlayed,
|
||||
wins: stats.wins,
|
||||
losses: stats.losses,
|
||||
points: stats.points
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.points - a.points)
|
||||
.map((item, index) => ({ ...item, position: index + 1 }));
|
||||
|
||||
const filename = `torneo_${tournament.name.replace(/\s+/g, '_').toLowerCase()}_${tournamentId}`;
|
||||
|
||||
if (format === ExportFormat.JSON) {
|
||||
return {
|
||||
data: JSON.stringify(exportData, null, 2),
|
||||
filename: `${filename}.json`
|
||||
};
|
||||
}
|
||||
|
||||
const headers = {
|
||||
position: 'Posición',
|
||||
player: 'Jugador',
|
||||
email: 'Email',
|
||||
matchesPlayed: 'PJ',
|
||||
wins: 'PG',
|
||||
losses: 'PP',
|
||||
points: 'Puntos'
|
||||
};
|
||||
|
||||
const csv = convertToCSV(exportData, headers);
|
||||
return { data: csv, filename: `${filename}.csv` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera un reporte completo en Excel con múltiples hojas
|
||||
*/
|
||||
export async function generateExcelReport(
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): Promise<{ data: Buffer; filename: string }> {
|
||||
// Importar xlsx dinámicamente para evitar problemas si no está instalado
|
||||
const xlsx = await import('xlsx');
|
||||
|
||||
// 1. Resumen General
|
||||
const totalBookings = await prisma.booking.count({
|
||||
where: {
|
||||
date: {
|
||||
gte: startDate,
|
||||
lte: endDate
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const totalRevenue = await prisma.booking.aggregate({
|
||||
where: {
|
||||
date: {
|
||||
gte: startDate,
|
||||
lte: endDate
|
||||
},
|
||||
status: {
|
||||
in: ['PAID', 'CONFIRMED', 'COMPLETED']
|
||||
}
|
||||
},
|
||||
_sum: {
|
||||
price: true
|
||||
}
|
||||
});
|
||||
|
||||
const newUsers = await prisma.user.count({
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: startDate,
|
||||
lte: endDate
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const activeUsers = await prisma.user.count({
|
||||
where: {
|
||||
bookings: {
|
||||
some: {
|
||||
date: {
|
||||
gte: startDate,
|
||||
lte: endDate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const summaryData = [{
|
||||
metrica: 'Total Reservas',
|
||||
valor: totalBookings
|
||||
}, {
|
||||
metrica: 'Ingresos Totales',
|
||||
valor: totalRevenue._sum.price || 0
|
||||
}, {
|
||||
metrica: 'Nuevos Usuarios',
|
||||
valor: newUsers
|
||||
}, {
|
||||
metrica: 'Usuarios Activos',
|
||||
valor: activeUsers
|
||||
}];
|
||||
|
||||
// 2. Datos de Ingresos por Día
|
||||
const bookingsByDay = await prisma.booking.groupBy({
|
||||
by: ['date'],
|
||||
where: {
|
||||
date: {
|
||||
gte: startDate,
|
||||
lte: endDate
|
||||
},
|
||||
status: {
|
||||
in: ['PAID', 'CONFIRMED', 'COMPLETED']
|
||||
}
|
||||
},
|
||||
_sum: {
|
||||
price: true
|
||||
},
|
||||
_count: {
|
||||
id: true
|
||||
}
|
||||
});
|
||||
|
||||
const revenueData = bookingsByDay.map(day => ({
|
||||
fecha: day.date.toISOString().split('T')[0],
|
||||
reservas: day._count.id,
|
||||
ingresos: day._sum.price || 0
|
||||
}));
|
||||
|
||||
// 3. Datos de Ocupación por Pista
|
||||
const courts = await prisma.court.findMany({
|
||||
include: {
|
||||
bookings: {
|
||||
where: {
|
||||
date: {
|
||||
gte: startDate,
|
||||
lte: endDate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const occupancyData = courts.map(court => ({
|
||||
pista: court.name,
|
||||
tipo: court.surface || 'No especificado',
|
||||
totalReservas: court.bookings.length,
|
||||
tasaOcupacion: calculateOccupancyRate(court.bookings.length, startDate, endDate)
|
||||
}));
|
||||
|
||||
// 4. Datos de Usuarios
|
||||
const topUsers = await prisma.user.findMany({
|
||||
take: 20,
|
||||
include: {
|
||||
bookings: {
|
||||
where: {
|
||||
date: {
|
||||
gte: startDate,
|
||||
lte: endDate
|
||||
}
|
||||
}
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
bookings: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
bookings: {
|
||||
_count: 'desc'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const usersData = topUsers.map(user => ({
|
||||
nombre: `${user.firstName} ${user.lastName}`,
|
||||
email: user.email,
|
||||
nivel: user.level || 'Sin nivel',
|
||||
reservasPeriodo: user.bookings.length,
|
||||
reservasTotales: user._count.bookings
|
||||
}));
|
||||
|
||||
// Crear workbook
|
||||
const workbook = xlsx.utils.book_new();
|
||||
|
||||
// Hoja 1: Resumen
|
||||
const summarySheet = xlsx.utils.json_to_sheet(summaryData);
|
||||
xlsx.utils.book_append_sheet(workbook, summarySheet, 'Resumen');
|
||||
|
||||
// Hoja 2: Ingresos
|
||||
const revenueSheet = xlsx.utils.json_to_sheet(revenueData);
|
||||
xlsx.utils.book_append_sheet(workbook, revenueSheet, 'Ingresos');
|
||||
|
||||
// Hoja 3: Ocupación
|
||||
const occupancySheet = xlsx.utils.json_to_sheet(occupancyData);
|
||||
xlsx.utils.book_append_sheet(workbook, occupancySheet, 'Ocupación');
|
||||
|
||||
// Hoja 4: Usuarios
|
||||
const usersSheet = xlsx.utils.json_to_sheet(usersData);
|
||||
xlsx.utils.book_append_sheet(workbook, usersSheet, 'Usuarios');
|
||||
|
||||
const buffer = xlsx.write(workbook, { type: 'buffer', bookType: 'xlsx' });
|
||||
const filename = `reporte_completo_${startDate.toISOString().split('T')[0]}_${endDate.toISOString().split('T')[0]}.xlsx`;
|
||||
|
||||
return { data: buffer, filename };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convierte datos a formato CSV
|
||||
*/
|
||||
function convertToCSV(data: Record<string, unknown>[], headers: Record<string, string>): string {
|
||||
const CSV_SEPARATOR = ';';
|
||||
|
||||
if (data.length === 0) {
|
||||
return Object.values(headers).join(CSV_SEPARATOR);
|
||||
}
|
||||
|
||||
const headerRow = Object.values(headers).join(CSV_SEPARATOR);
|
||||
const keys = Object.keys(headers);
|
||||
|
||||
const rows = data.map(item => {
|
||||
return keys.map(key => {
|
||||
const value = item[key];
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'string' && (value.includes(CSV_SEPARATOR) || value.includes('"') || value.includes('\n'))) {
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return String(value);
|
||||
}).join(CSV_SEPARATOR);
|
||||
});
|
||||
|
||||
return [headerRow, ...rows].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula la tasa de ocupación estimada
|
||||
*/
|
||||
function calculateOccupancyRate(
|
||||
bookingCount: number,
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): string {
|
||||
const daysDiff = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
const maxPossibleBookings = daysDiff * 10; // Asumiendo 10 franjas horarias por día
|
||||
const rate = maxPossibleBookings > 0 ? (bookingCount / maxPossibleBookings) * 100 : 0;
|
||||
return `${rate.toFixed(1)}%`;
|
||||
}
|
||||
Reference in New Issue
Block a user