toDateString(); // Torneos que vencen HOY $tournaments = Tournament::whereDate('registration_deadline', $today)->get(); if ($tournaments->isEmpty()) { $this->info("No hay torneos que vencen hoy."); return; } $controller = new BracketController(); foreach ($tournaments as $tournament) { // EVITAR REGENERAR if ($tournament->games()->exists()) { $this->info("Torneo {$tournament->id} ya tiene bracket. Saltando..."); continue; } try { $request = Request::create( "/tournaments/{$tournament->id}/generate-bracket", 'POST' ); $controller->generateBracket($request, $tournament); $this->info("✅ Bracket generado para torneo ID {$tournament->id}"); // ✅ NUEVO: Enviar correos a los participantes $this->sendBracketNotifications($tournament); } catch (\Exception $e) { $this->error("❌ Error generando bracket para torneo {$tournament->id}: {$e->getMessage()}"); Log::error("Error en generación automática de bracket", [ 'tournament_id' => $tournament->id, 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString() ]); } } } /** * Envía notificaciones por correo a todos los participantes del torneo */ private function sendBracketNotifications(Tournament $tournament) { $this->info("📧 Enviando notificaciones para torneo {$tournament->id}..."); // Obtener todos los miembros registrados $registeredMembers = $tournament->members()->with('user', 'club')->get(); if ($registeredMembers->isEmpty()) { $this->warn("⚠️ No hay participantes registrados en el torneo {$tournament->id}"); return; } $successCount = 0; $failedCount = 0; foreach ($registeredMembers as $member) { try { // Verificar que el miembro tenga usuario y email if (!$member->user || !$member->user->email) { $this->warn("⚠️ Miembro {$member->id} no tiene email asociado"); $failedCount++; continue; } // Validar formato de email if (!filter_var($member->user->email, FILTER_VALIDATE_EMAIL)) { $this->warn("⚠️ Email inválido para miembro {$member->id}: {$member->user->email}"); $failedCount++; continue; } // Obtener los juegos del miembro en este torneo $memberGames = $tournament->games() ->where(function($query) use ($member) { $query->where('member1_id', $member->id) ->orWhere('member2_id', $member->id); }) ->with(['member1.club', 'member2.club']) ->orderBy('round') ->orderBy('group_name') ->get(); // Enviar el correo (usando queue para no bloquear) Mail::to($member->user->email) ->queue(new BracketGeneratedNotification($tournament, $member, $memberGames)); $successCount++; $this->info(" ✓ Email enviado a {$member->nombre_completo} ({$member->user->email})"); } catch (\Exception $e) { $failedCount++; $this->error(" ✗ Error enviando email a miembro {$member->id}: {$e->getMessage()}"); Log::error("Error enviando notificación de bracket", [ 'tournament_id' => $tournament->id, 'member_id' => $member->id, 'email' => $member->user->email ?? 'N/A', 'error' => $e->getMessage() ]); } } // Resumen del envío $this->info("📊 Resumen de envío para torneo {$tournament->id}:"); $this->info(" ✅ Enviados exitosamente: {$successCount}"); if ($failedCount > 0) { $this->warn(" ❌ Fallidos: {$failedCount}"); } Log::info("Notificaciones de bracket enviadas", [ 'tournament_id' => $tournament->id, 'tournament_name' => $tournament->name, 'total_members' => $registeredMembers->count(), 'success' => $successCount, 'failed' => $failedCount ]); } } info('🔄 Iniciando envío de invitaciones programadas...'); $now = Carbon::now(); // Buscar torneos con envío automático habilitado $tournaments = Tournament::where('system_invitation', true) ->whereNotNull('resend_invitation_schedule') ->where('registration_deadline', '>=', $now) ->get(); if ($tournaments->isEmpty()) { $this->info('✅ No hay torneos con invitaciones programadas.'); return 0; } $this->info("📋 Encontrados {$tournaments->count()} torneos con invitación automática"); $processedCount = 0; foreach ($tournaments as $tournament) { $log = TournamentInvitationLog::firstOrCreate( ['tournament_id' => $tournament->id], [ 'last_sent_at' => null, 'next_send_at' => Carbon::now(), 'send_count' => 0, ] ); // Verificar si es momento de enviar if ($log->next_send_at && $now->gte($log->next_send_at)) { $this->info("📧 Enviando invitaciones para: {$tournament->name}"); try { // Llamar al método de envío $controller = new \App\Http\Controllers\TournamentController(); $result = $this->callSendInvitations($controller, $tournament); // Calcular próximo envío $interval = $this->getIntervalDays($tournament->resend_invitation_schedule); $nextSend = Carbon::now()->addDays($interval); // Actualizar log $log->update([ 'last_sent_at' => Carbon::now(), 'next_send_at' => $nextSend, 'send_count' => $log->send_count + 1, 'send_details' => [ 'success_count' => $result['success_count'] ?? 0, 'failed_count' => $result['failed_count'] ?? 0, 'sent_at' => Carbon::now()->toDateTimeString(), ], ]); $this->info("✅ Invitaciones enviadas exitosamente"); $this->info(" → Éxitos: {$result['success_count']}"); $this->info(" → Fallos: {$result['failed_count']}"); $this->info(" → Próximo envío: {$nextSend->format('Y-m-d H:i')}"); $processedCount++; } catch (\Exception $e) { $this->error("❌ Error al enviar invitaciones: {$e->getMessage()}"); Log::error("Error en envío programado de invitaciones", [ 'tournament_id' => $tournament->id, 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); } } else { $nextSendFormatted = $log->next_send_at ? $log->next_send_at->format('Y-m-d H:i') : 'No programado'; $this->info("⏳ {$tournament->name} - Próximo envío: {$nextSendFormatted}"); } } $this->info("✅ Proceso completado. Torneos procesados: {$processedCount}"); return 0; } private function getIntervalDays(string $schedule): int { return match(strtolower($schedule)) { 'cada 7 días', '7' => 7, 'cada 15 días', '15' => 15, default => 7, }; } private function callSendInvitations($controller, $tournament) { // Usar reflexión para llamar al método privado $reflection = new \ReflectionClass($controller); $method = $reflection->getMethod('sendInvitations'); $method->setAccessible(true); return $method->invoke($controller, $tournament); } } command('tournaments:generate-brackets') ->dailyAt('23:59') ->timezone('America/Guayaquil'); // Enviar invitaciones programadas todos los días a las 9:00 AM $schedule->command('tournaments:send-invitations') ->dailyAt('09:00') ->timezone('America/Guayaquil'); // OPCIONAL: Si prefieres ejecutarlo varias veces al día // $schedule->command('tournaments:send-invitations')->everySixHours(); } /** * Registrar los comandos para tu aplicación. */ protected function commands() { $this->load(__DIR__.'/Commands'); require base_path('routes/console.php'); } } registration_deadline)->endOfDay(); $now = Carbon::now(); return $now->lte($registrationDeadline); } /** * Valida si las inscripciones están abiertas */ private function isRegistrationOpen(Tournament $tournament) { $registrationDeadline = Carbon::parse($tournament->registration_deadline)->endOfDay(); $now = Carbon::now(); return $now->lte($registrationDeadline); } /** * Genera un código único de torneo */ private function generateTournamentCode($clubId, $ligaId) { do { $code = strtoupper(Str::random(8)); } while (Tournament::where('tournament_code', $code)->exists()); return $code; } /** * Genera un código de torneo para mostrar en el formulario */ public function generateCode(Request $request) { $validator = Validator::make($request->all(), [ 'club_id' => 'required|exists:clubs,id', 'liga_id' => 'required|exists:ligas,id', ]); if ($validator->fails()) { return response()->json($validator->errors(), 422); } $code = $this->generateTournamentCode( $request->input('club_id'), $request->input('liga_id') ); return response()->json(['tournament_code' => $code], 200); } /** * Listar todos los torneos con premios incluidos */ public function index(Request $request) { $query = Tournament::withCount(['games', 'members']) ->with([ 'members:id', 'club:id,nombre,imagen', 'liga:id,name', 'prizes' => function($query) { $query->ordered(); } ]); if ($request->has('liga_id')) { $query->where('liga_id', $request->input('liga_id')); } if ($request->has('club_id')) { $query->where('club_id', $request->club_id); } $tournaments = $query->orderBy('created_at', 'desc')->get(); $tournaments->transform(function ($tournament) { if ($tournament->main_image_path) { $tournament->main_image_url = 'https://trollopy-ephraim-hypoxanthic.ngrok-free.dev' . Storage::url($tournament->main_image_path); } else { $tournament->main_image_url = null; } if ($tournament->club && $tournament->club->imagen) { $tournament->club->imagen_url = 'https://trollopy-ephraim-hypoxanthic.ngrok-free.dev/storage/' . $tournament->club->imagen; } $tournament->can_modify = $this->canModifyTournament($tournament); $tournament->registration_open = $this->isRegistrationOpen($tournament); return $tournament; }); return response()->json($tournaments, 200); } /** * Obtener miembros inscritos en un torneo con ranking por liga */ public function showMembers(Request $request, Tournament $tournament) { $request->validate([ 'liga_id' => 'required|exists:ligas,id' ]); $ligaId = $request->input('liga_id'); $members = $tournament->members()->get(); $members->each(function ($member) use ($ligaId) { $member->ranking_liga = $member->getRankingForLiga($ligaId); $member->makeHidden(['club', 'ultimoTraspaso', 'transfersRealizados', 'created_at', 'updated_at']); }); return response()->json($members); } /** * Validar premios antes de guardar */ private function validatePrizes($prizes) { if (!is_array($prizes)) { return ['error' => 'Los premios deben ser un array']; } foreach ($prizes as $index => $prize) { $rules = TournamentPrize::validatePrize($prize); $validator = Validator::make($prize, $rules); if ($validator->fails()) { return [ 'error' => "Error en premio posición {$prize['position']}", 'details' => $validator->errors() ]; } } return null; } private function validateUniqueTournamentInCity(Request $request, ?Tournament $existingTournament = null) { $query = Tournament::where('liga_id', $request->liga_id) ->where('city', $request->city) // NUEVA LÍNEA: Solo torneos cuya inscripción no ha cerrado ->where('registration_deadline', '>=', Carbon::now()->startOfDay()); if ($existingTournament) { $query->where('id', '!=', $existingTournament->id); } // Verificar torneos con el mismo criterio de ranking $conflictingTournament = $query->where(function($q) use ($request) { // Caso 1: Ambos torneos aceptan "todos" los rankings if ($request->ranking_all) { $q->where('ranking_all', true); } else { // Caso 2: El nuevo torneo tiene rango específico $q->where(function($subQ) use ($request) { // Conflicto si existe un torneo que acepta "todos" $subQ->where('ranking_all', true) // O si hay solapamiento de rangos ->orWhere(function($rangeQ) use ($request) { $rangeQ->where('ranking_all', false) ->where('ranking_from', $request->ranking_from) ->where('ranking_to', $request->ranking_to); }); }); } })->first(); return $conflictingTournament; } /** * Crear un nuevo torneo con premios dinámicos */ public function store(Request $request) { $validator = Validator::make($request->all(), [ 'club_id' => 'required|exists:clubs,id', 'liga_id' => 'required|exists:ligas,id', 'tournament_code' => 'nullable|string|unique:tournaments,tournament_code', 'name' => 'required|string|max:255', 'country' => 'required|string', 'province' => 'required|string', 'city' => 'required|string', 'club_name' => 'nullable|string', 'address' => 'required|string', 'date' => 'required|date', 'time' => 'required', 'registration_deadline' => 'required|date', 'modality' => 'required|string', 'match_type' => 'required|string', 'elimination_type' => 'required|string', 'participants_number' => 'required|integer', 'seeding_type' => 'required|string', 'ranking_all' => 'required|boolean', 'ranking_from' => 'nullable|string', 'ranking_to' => 'nullable|string', 'age_all' => 'required|boolean', 'age_from' => 'nullable|integer', 'age_to' => 'nullable|integer', 'gender' => 'required|string', 'affects_ranking' => 'required|boolean', 'system_invitation' => 'required|boolean', 'resend_invitation_schedule' => 'nullable|string', 'main_image' => 'nullable|image|mimes:jpeg,png,jpg,gif|max:2048', 'prizes' => 'nullable|array', 'prizes.*.position' => 'required|integer|min:1', 'prizes.*.type' => 'required|in:Monetario,Otros', 'prizes.*.monetary_value' => 'nullable|numeric|min:0', 'prizes.*.other_prize' => 'nullable|string|max:255', 'prizes.*.description' => 'nullable|string|max:500', 'contact_name' => 'required|string', 'contact_phone' => 'required|string', 'ball_info' => 'required|string', 'advancers_per_group' => 'nullable|integer|min:1', 'tournament_price' => 'nullable|numeric|min:0', 'rubber_type' => 'nullable|in:Liso,Pupo,Todos', 'groups_number' => 'nullable|integer|min:1', 'rounds' => 'nullable|integer|min:1', ]); if ($validator->fails()) { return response()->json($validator->errors(), 422); } // Validar premios si existen if ($request->has('prizes')) { $prizeValidation = $this->validatePrizes($request->input('prizes')); if ($prizeValidation) { return response()->json($prizeValidation, 422); } } $conflictingTournament = $this->validateUniqueTournamentInCity($request); if ($conflictingTournament) { return response()->json([ 'success' => false, 'message' => 'Ya existe un torneo en esta ciudad y liga con el mismo rango de ranking.', 'conflicting_tournament' => [ 'id' => $conflictingTournament->id, 'name' => $conflictingTournament->name, 'city' => $conflictingTournament->city, 'ranking_all' => $conflictingTournament->ranking_all, 'ranking_from' => $conflictingTournament->ranking_from, 'ranking_to' => $conflictingTournament->ranking_to, ] ], 409); // 409 Conflict } DB::beginTransaction(); try { $imagePath = null; if ($request->hasFile('main_image')) { $imagePath = $request->file('main_image')->store('tournament_images', 'public'); } $tournamentData = $request->except(['main_image', 'prizes']); $tournamentData['main_image_path'] = $imagePath; if (empty($tournamentData['tournament_code'])) { $tournamentData['tournament_code'] = $this->generateTournamentCode( $request->input('club_id'), $request->input('liga_id') ); } if (!isset($tournamentData['advancers_per_group']) || $tournamentData['advancers_per_group'] === null) { $tournamentData['advancers_per_group'] = 2; } $tournament = Tournament::create($tournamentData); // Crear premios si existen if ($request->has('prizes')) { foreach ($request->input('prizes') as $prizeData) { TournamentPrize::create([ 'tournament_id' => $tournament->id, 'position' => $prizeData['position'], 'type' => $prizeData['type'], 'monetary_value' => $prizeData['type'] === 'Monetario' ? $prizeData['monetary_value'] : null, 'other_prize' => $prizeData['type'] === 'Otros' ? $prizeData['other_prize'] : null, 'description' => $prizeData['description'] ?? null, ]); } } $tournament->load(['club:id,nombre', 'liga:id,name', 'prizes']); if ($tournamentData['system_invitation']) { $this->sendInvitations($tournament); } DB::commit(); return response()->json([ 'message' => '¡Torneo creado con éxito!', 'data' => $tournament ], 201); } catch (\Exception $e) { DB::rollBack(); if (isset($imagePath) && Storage::disk('public')->exists($imagePath)) { Storage::disk('public')->delete($imagePath); } return response()->json([ 'message' => 'Error al crear el torneo', 'error' => $e->getMessage() ], 500); } } /** * Actualizar un torneo existente con premios */ public function update(Request $request, Tournament $tournament) { if (!$this->canModifyTournament($tournament)) { return response()->json([ 'message' => 'No se puede editar el torneo. La fecha de cierre de inscripciones ya pasó.' ], 403); } $validator = Validator::make($request->all(), [ 'club_id' => 'required|exists:clubs,id', 'liga_id' => 'required|exists:ligas,id', 'tournament_code' => 'nullable|string|unique:tournaments,tournament_code,' . $tournament->id, 'name' => 'required|string|max:255', 'country' => 'required|string', 'province' => 'required|string', 'city' => 'required|string', 'club_name' => 'nullable|string', 'address' => 'required|string', 'date' => 'required|date', 'time' => 'required', 'registration_deadline' => 'required|date', 'modality' => 'required|string', 'match_type' => 'required|string', 'elimination_type' => 'required|string', 'participants_number' => 'required|integer', 'seeding_type' => 'required|string', 'ranking_all' => 'required|boolean', 'ranking_from' => 'nullable|string', 'ranking_to' => 'nullable|string', 'age_all' => 'required|boolean', 'age_from' => 'nullable|integer', 'age_to' => 'nullable|integer', 'gender' => 'required|string', 'affects_ranking' => 'required|boolean', 'system_invitation' => 'required|boolean', 'resend_invitation_schedule' => 'nullable|string', 'main_image' => 'nullable|image|mimes:jpeg,png,jpg,gif|max:2048', 'prizes' => 'nullable|array', 'prizes.*.position' => 'required|integer|min:1', 'prizes.*.type' => 'required|in:Monetario,Otros', 'prizes.*.monetary_value' => 'nullable|numeric|min:0', 'prizes.*.other_prize' => 'nullable|string|max:255', 'prizes.*.description' => 'nullable|string|max:500', 'contact_name' => 'required|string', 'contact_phone' => 'required|string', 'ball_info' => 'required|string', 'advancers_per_group' => 'nullable|integer|min:1', 'tournament_price' => 'nullable|numeric|min:0', 'rubber_type' => 'nullable|in:Liso,Pupo,Todos', 'groups_number' => 'nullable|integer|min:1', 'rounds' => 'nullable|integer|min:1', ]); if ($validator->fails()) { return response()->json($validator->errors(), 422); } // Validar premios si existen if ($request->has('prizes')) { $prizeValidation = $this->validatePrizes($request->input('prizes')); if ($prizeValidation) { return response()->json($prizeValidation, 422); } } $conflictingTournament = $this->validateUniqueTournamentInCity($request, $tournament); if ($conflictingTournament) { return response()->json([ 'success' => false, 'message' => 'Ya existe un torneo en esta ciudad y liga con el mismo rango de ranking.', 'conflicting_tournament' => [ 'id' => $conflictingTournament->id, 'name' => $conflictingTournament->name, 'city' => $conflictingTournament->city, 'ranking_all' => $conflictingTournament->ranking_all, 'ranking_from' => $conflictingTournament->ranking_from, 'ranking_to' => $conflictingTournament->ranking_to, ] ], 409); // 409 Conflict } DB::beginTransaction(); try { if ($request->hasFile('main_image')) { if ($tournament->main_image_path && Storage::disk('public')->exists($tournament->main_image_path)) { Storage::disk('public')->delete($tournament->main_image_path); } $tournament->main_image_path = $request->file('main_image')->store('tournament_images', 'public'); } $tournamentData = $request->except(['main_image', 'prizes']); if (!isset($tournamentData['advancers_per_group']) || $tournamentData['advancers_per_group'] === null) { $tournamentData['advancers_per_group'] = 2; } $tournament->update($tournamentData); // Actualizar premios if ($request->has('prizes')) { // Eliminar premios existentes $tournament->prizes()->delete(); // Crear nuevos premios foreach ($request->input('prizes') as $prizeData) { TournamentPrize::create([ 'tournament_id' => $tournament->id, 'position' => $prizeData['position'], 'type' => $prizeData['type'], 'monetary_value' => $prizeData['type'] === 'Monetario' ? $prizeData['monetary_value'] : null, 'other_prize' => $prizeData['type'] === 'Otros' ? $prizeData['other_prize'] : null, 'description' => $prizeData['description'] ?? null, ]); } } $tournament->load(['club:id,nombre', 'liga:id,name', 'prizes']); DB::commit(); return response()->json([ 'message' => 'Torneo actualizado con éxito', 'data' => $tournament ], 200); } catch (\Exception $e) { DB::rollBack(); return response()->json([ 'message' => 'Error al actualizar el torneo', 'error' => $e->getMessage() ], 500); } } /** * Registrar miembro en torneo */ public function registerMember(Request $request, Tournament $tournament) { $validator = Validator::make($request->all(), [ 'member_id' => 'required|exists:members,id', ]); if ($validator->fails()) { return response()->json($validator->errors(), 422); } if (!$this->isRegistrationOpen($tournament)) { return response()->json([ 'message' => 'Las inscripciones para este torneo han cerrado.' ], 403); } $memberId = $request->input('member_id'); $member = Member::with('club.ligas')->find($memberId); if (!$member->club_id || !$member->club) { return response()->json(['message' => 'Debes pertenecer a un club para inscribirte.'], 403); } $tournament->load('liga'); $memberLigaIds = $member->club->ligas->pluck('id')->toArray(); if (!in_array($tournament->liga_id, $memberLigaIds)) { return response()->json(['message' => 'Tu club no pertenece a la liga de este torneo.'], 403); } if ($tournament->members()->count() >= $tournament->participants_number) { return response()->json(['message' => 'El torneo ya ha alcanzado el número máximo de participantes.'], 409); } if ($tournament->members()->where('member_id', $memberId)->exists()) { return response()->json(['message' => 'Ya estás inscrito en este torneo.'], 409); } $tournament->members()->attach($memberId, ['club_id' => $member->club_id]); return response()->json(['message' => 'Inscripción exitosa.'], 200); } /** * Eliminar torneo */ public function destroy(Tournament $tournament) { if (!$this->canModifyTournament($tournament)) { return response()->json([ 'message' => 'No se puede eliminar el torneo. La fecha de cierre de inscripciones ya pasó.' ], 403); } DB::beginTransaction(); try { if ($tournament->main_image_path && Storage::disk('public')->exists($tournament->main_image_path)) { Storage::disk('public')->delete($tournament->main_image_path); } // Los premios se eliminan automáticamente por la cascada $tournament->delete(); DB::commit(); return response()->json([ 'message' => 'Torneo eliminado con éxito' ], 200); } catch (\Exception $e) { DB::rollBack(); return response()->json([ 'message' => 'Error al eliminar el torneo', 'error' => $e->getMessage() ], 500); } } /** * Obtener opciones de premios tipo "Otros" */ public function getPrizeOptions() { return response()->json([ 'other_prize_options' => TournamentPrize::$otherPrizeOptions ], 200); } private function validateGroupConfiguration( int $participantsNumber, int $groupsNumber, int $advancersPerGroup ): array { $errors = []; // 1. Validar número mínimo de participantes por grupo $participantsPerGroup = floor($participantsNumber / $groupsNumber); if ($participantsPerGroup < 2) { $errors[] = "Cada grupo debe tener al menos 2 participantes. Actualmente: {$participantsPerGroup} participantes por grupo."; return ['valid' => false, 'errors' => $errors]; } // 2. Validar que los clasificados sean menores que los participantes del grupo if ($advancersPerGroup >= $participantsPerGroup) { $errors[] = "El número de clasificados por grupo ({$advancersPerGroup}) debe ser menor al número de participantes por grupo ({$participantsPerGroup})."; return ['valid' => false, 'errors' => $errors]; } // 3. Calcular total de clasificados a fase eliminatoria $totalAdvancers = $groupsNumber * $advancersPerGroup; if ($totalAdvancers < 2) { $errors[] = "Se necesitan al menos 2 clasificados totales para la fase eliminatoria. Actualmente: {$totalAdvancers} clasificados."; return ['valid' => false, 'errors' => $errors]; } // 4. ⚠️ VALIDACIÓN CRÍTICA: Verificar desproporción en la distribución $minParticipantsPerGroup = floor($participantsNumber / $groupsNumber); $maxParticipantsPerGroup = ceil($participantsNumber / $groupsNumber); $remainder = $participantsNumber % $groupsNumber; // Si hay resto, algunos grupos tendrán un participante más if ($remainder > 0) { $groupsWithExtra = $remainder; $groupsWithMin = $groupsNumber - $remainder; $errors[] = "Advertencia: Distribución desigual de participantes. " . "{$groupsWithExtra} grupo(s) tendrán {$maxParticipantsPerGroup} participantes y " . "{$groupsWithMin} grupo(s) tendrán {$minParticipantsPerGroup} participantes."; } // 5. ⚠️ VALIDACIÓN POTENCIA DE 2: Verificar si los clasificados forman bracket válido if (!$this->isPowerOfTwo($totalAdvancers)) { $nextPowerOf2 = $this->getNextPowerOfTwo($totalAdvancers); $prevPowerOf2 = $this->getPreviousPowerOfTwo($totalAdvancers); $errors[] = "Advertencia: El total de clasificados ({$totalAdvancers}) no es potencia de 2. " . "Esto puede causar desbalance en la fase eliminatoria. " . "Se recomienda usar {$prevPowerOf2} o {$nextPowerOf2} clasificados."; } // 6. Validar que la desproporción no sea excesiva (máximo 1 participante de diferencia) if (($maxParticipantsPerGroup - $minParticipantsPerGroup) > 1) { $errors[] = "Error: La desproporción entre grupos es demasiado alta. " . "Ajuste el número de grupos o participantes."; return ['valid' => false, 'errors' => $errors]; } // 7. Validar configuraciones problemáticas específicas if ($groupsNumber == 3 && $advancersPerGroup == 2) { // 3 grupos x 2 clasificados = 6 clasificados (no es potencia de 2) $errors[] = "Configuración no recomendada: 3 grupos con 2 clasificados cada uno resulta en 6 clasificados totales, " . "lo que no es potencia de 2 y causará BYEs en la fase eliminatoria. " . "Se recomienda usar 2 o 4 grupos, o cambiar el número de clasificados."; } if ($groupsNumber == 6 && $advancersPerGroup == 1) { // 6 grupos x 1 clasificado = 6 clasificados (no es potencia de 2) $errors[] = "Configuración no recomendada: 6 grupos con 1 clasificado cada uno resulta en 6 clasificados totales, " . "lo que no es potencia de 2 y causará BYEs en la fase eliminatoria."; } // Si solo hay advertencias, es válido pero con warnings $hasErrors = collect($errors)->contains(fn($error) => str_starts_with($error, 'Error:')); return [ 'valid' => !$hasErrors, 'errors' => $errors, 'warnings' => collect($errors)->filter(fn($error) => str_starts_with($error, 'Advertencia:'))->values()->all(), 'config' => [ 'participants_per_group_min' => $minParticipantsPerGroup, 'participants_per_group_max' => $maxParticipantsPerGroup, 'total_advancers' => $totalAdvancers, 'is_power_of_two' => $this->isPowerOfTwo($totalAdvancers), 'distribution' => [ 'groups_with_max' => $remainder, 'groups_with_min' => $groupsNumber - $remainder ] ] ]; } /** * Verifica si un número es potencia de 2 */ private function isPowerOfTwo(int $n): bool { return $n > 0 && ($n & ($n - 1)) === 0; } /** * Obtiene la siguiente potencia de 2 */ private function getNextPowerOfTwo(int $n): int { return pow(2, ceil(log($n, 2))); } /** * Obtiene la potencia de 2 anterior */ private function getPreviousPowerOfTwo(int $n): int { return pow(2, floor(log($n, 2))); } /** * Endpoint para validar configuración de grupos antes de crear el torneo * POST /api/tournaments/validate-groups */ public function validateGroups(Request $request) { $validator = Validator::make($request->all(), [ 'participants_number' => 'required|integer|min:4', 'groups_number' => 'required|integer|min:2', 'advancers_per_group' => 'required|integer|min:1', ]); if ($validator->fails()) { return response()->json($validator->errors(), 422); } $validation = $this->validateGroupConfiguration( $request->input('participants_number'), $request->input('groups_number'), $request->input('advancers_per_group') ); return response()->json($validation, $validation['valid'] ? 200 : 400); } /** * Modificar el método generateBracket para incluir la validación */ public function generateBracketWithValidation(Request $request, Tournament $tournament) { $eliminationType = $tournament->elimination_type; if ($eliminationType === 'groups' || $eliminationType === 'mixed') { $validation = $this->validateGroupConfiguration( (int)$request->query('participants', $tournament->members()->count()), (int)$request->query('groups_number', 4), (int)$request->query('advancers_per_group', 2) ); if (!$validation['valid']) { return response()->json([ 'message' => 'Configuración de grupos inválida', 'errors' => $validation['errors'], 'config' => $validation['config'] ?? null ], 400); } // Si hay advertencias, incluirlas en la respuesta pero permitir continuar if (!empty($validation['warnings'])) { logger()->warning('Generando bracket con advertencias', [ 'tournament_id' => $tournament->id, 'warnings' => $validation['warnings'] ]); } } // Continuar con la generación normal del bracket return $this->generateBracket($request, $tournament); } /** * Sugerir configuraciones válidas para un número de participantes * GET /api/tournaments/suggest-group-config?participants=24 */ public function suggestGroupConfiguration(Request $request) { $participants = (int)$request->query('participants', 16); if ($participants < 4) { return response()->json([ 'message' => 'Se necesitan al menos 4 participantes para sugerir configuraciones.' ], 400); } $suggestions = []; // Probar diferentes configuraciones for ($groups = 2; $groups <= min(8, floor($participants / 2)); $groups++) { $participantsPerGroup = floor($participants / $groups); if ($participantsPerGroup < 2) continue; for ($advancers = 1; $advancers < $participantsPerGroup; $advancers++) { $totalAdvancers = $groups * $advancers; if ($totalAdvancers < 2) continue; $isPowerOf2 = $this->isPowerOfTwo($totalAdvancers); $remainder = $participants % $groups; $isBalanced = $remainder == 0; // Calcular score de calidad (100 = perfecto) $score = 100; if (!$isPowerOf2) $score -= 30; if (!$isBalanced) $score -= 20; if ($remainder > 1) $score -= 10; $suggestions[] = [ 'groups_number' => $groups, 'advancers_per_group' => $advancers, 'participants_per_group_min' => floor($participants / $groups), 'participants_per_group_max' => ceil($participants / $groups), 'total_advancers' => $totalAdvancers, 'is_power_of_two' => $isPowerOf2, 'is_balanced' => $isBalanced, 'quality_score' => $score, 'recommendation' => $score >= 80 ? 'Excelente' : ($score >= 60 ? 'Buena' : 'Aceptable') ]; } } // Ordenar por score de calidad usort($suggestions, fn($a, $b) => $b['quality_score'] - $a['quality_score']); return response()->json([ 'participants' => $participants, 'suggestions' => array_slice($suggestions, 0, 10), // Top 10 mejores 'best_configuration' => $suggestions[0] ?? null ]); } public function calculateResults(Tournament $tournament, TournamentResultService $resultService) { // Verificar que el torneo haya finalizado if (!$resultService->isTournamentFinished($tournament)) { return response()->json([ 'success' => false, 'message' => 'El torneo aún no ha finalizado. Completa todos los partidos primero.' ], 400); } $result = $resultService->calculateFinalPositions($tournament); if ($result['success']) { return response()->json($result, 200); } return response()->json($result, 500); } /** * Obtiene jugadores del censo según filtros del torneo */ private function getCensusPlayers(Tournament $tournament) { try { $response = Http::timeout(10)->get('https://api.gorankeds.com/api/registro-rapido'); if (!$response->successful()) { Log::warning("Error al obtener datos del censo", [ 'status' => $response->status(), 'tournament_id' => $tournament->id ]); return collect([]); } $responseData = $response->json(); // CORRECCIÓN: La API retorna {success: true, data: [...]} // Necesitamos acceder a responseData['data'] if (!isset($responseData['success']) || !$responseData['success']) { Log::warning("API del censo no retornó success", [ 'tournament_id' => $tournament->id, 'response' => $responseData ]); return collect([]); } $censusData = $responseData['data'] ?? []; Log::info("Datos del censo obtenidos", [ 'tournament_id' => $tournament->id, 'total_registros' => count($censusData) ]); if (!is_array($censusData) || empty($censusData)) { Log::warning("No hay datos del censo", [ 'tournament_id' => $tournament->id ]); return collect([]); } $filtered = collect($censusData)->filter(function ($player) use ($tournament) { // Validar que tenga email if (empty($player['email'])) { Log::debug("Jugador rechazado: sin email"); return false; } // Filtro por Ranking if (!$tournament->ranking_all) { $ranking = isset($player['ranking']) ? (int) $player['ranking'] : null; if ($ranking === null) { Log::debug("Jugador rechazado: sin ranking", [ 'email' => $player['email'] ]); return false; } // Convertir ranking_from y ranking_to a enteros $rankingFrom = (int) str_replace('U', '', $tournament->ranking_from); $rankingTo = (int) str_replace('U', '', $tournament->ranking_to); if ($ranking < $rankingFrom || $ranking > $rankingTo) { Log::debug("Jugador rechazado: ranking fuera de rango", [ 'email' => $player['email'], 'ranking' => $ranking, 'from' => $rankingFrom, 'to' => $rankingTo ]); return false; } } // Filtro por Edad if (!$tournament->age_all) { // La API retorna 'age' ya calculado $age = $player['age'] ?? null; if ($age === null) { Log::debug("Jugador rechazado: sin edad", [ 'email' => $player['email'] ]); return false; } if ($age < $tournament->age_from || $age > $tournament->age_to) { Log::debug("Jugador rechazado: edad fuera de rango", [ 'email' => $player['email'], 'age' => $age, 'from' => $tournament->age_from, 'to' => $tournament->age_to ]); return false; } } // Filtro por Género if ($tournament->gender !== 'todos') { $gender = $player['gender'] ?? ''; if (strtolower($gender) !== strtolower($tournament->gender)) { Log::debug("Jugador rechazado: género no coincide", [ 'email' => $player['email'], 'gender' => $gender, 'required' => $tournament->gender ]); return false; } } Log::info("Jugador del censo aprobado", [ 'email' => $player['email'], 'name' => $player['full_name'] ?? 'N/A' ]); return true; }); Log::info("Filtrado de censo completado", [ 'tournament_id' => $tournament->id, 'total_original' => count($censusData), 'total_filtrado' => $filtered->count() ]); return $filtered; } catch (\Exception $e) { Log::error("Error al obtener jugadores del censo", [ 'tournament_id' => $tournament->id, 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString() ]); return collect([]); } } /** * Envía invitaciones a miembros y jugadores del censo */ private function sendInvitations(Tournament $tournament) { $successCount = 0; $failedEmails = []; // 1. ENVIAR A MIEMBROS EXISTENTES $liga = Liga::with('clubs.members.user')->find($tournament->liga_id); if ($liga) { $members = $liga->clubs->flatMap(function ($club) { return $club->members; }); $filteredMembers = $members->filter(function ($member) use ($tournament) { $ranking = $member->getRankingForLiga($tournament->liga_id); // Filtro por Ranking if (!$tournament->ranking_all) { $rankingFrom = (int) str_replace('U', '', $tournament->ranking_from); $rankingTo = (int) str_replace('U', '', $tournament->ranking_to); if ($ranking < $rankingFrom || $ranking > $rankingTo) { return false; } } // Filtro por Edad if (!$tournament->age_all) { if ($member->age < $tournament->age_from || $member->age > $tournament->age_to) { return false; } } // Filtro por Género if ($tournament->gender !== 'todos' && strtolower($member->genero) !== strtolower($tournament->gender)) { return false; } return true; }); foreach ($filteredMembers as $member) { if ($member->user && $member->user->email) { try { if (!filter_var($member->user->email, FILTER_VALIDATE_EMAIL)) { $failedEmails[] = [ 'email' => $member->user->email, 'type' => 'member', 'id' => $member->id, 'reason' => 'Formato de email inválido' ]; continue; } Mail::to($member->user->email)->queue(new TournamentInvitation($tournament, $member)); $successCount++; } catch (\Exception $e) { Log::warning("Error al enviar invitación a miembro", [ 'member_id' => $member->id, 'email' => $member->user->email, 'tournament_id' => $tournament->id, 'error' => $e->getMessage() ]); $failedEmails[] = [ 'email' => $member->user->email, 'type' => 'member', 'id' => $member->id, 'reason' => $e->getMessage() ]; } } } } // 2. ENVIAR A JUGADORES DEL CENSO $censusPlayers = $this->getCensusPlayers($tournament); foreach ($censusPlayers as $player) { try { if (!filter_var($player['email'], FILTER_VALIDATE_EMAIL)) { $failedEmails[] = [ 'email' => $player['email'], 'type' => 'census', 'name' => ($player['first_name'] ?? '') . ' ' . ($player['last_name'] ?? ''), 'reason' => 'Formato de email inválido' ]; continue; } Mail::to($player['email'])->queue(new CensusPlayerInvitation($tournament, $player)); $successCount++; } catch (\Exception $e) { Log::warning("Error al enviar invitación a jugador del censo", [ 'email' => $player['email'], 'tournament_id' => $tournament->id, 'error' => $e->getMessage() ]); $failedEmails[] = [ 'email' => $player['email'], 'type' => 'census', 'name' => ($player['first_name'] ?? '') . ' ' . ($player['last_name'] ?? ''), 'reason' => $e->getMessage() ]; } } // ✅ NUEVO: Registrar el envío en tournament_invitation_logs if ($tournament->system_invitation && $tournament->resend_invitation_schedule) { $interval = $this->getResendInterval($tournament->resend_invitation_schedule); $now = Carbon::now(); TournamentInvitationLog::updateOrCreate( ['tournament_id' => $tournament->id], [ 'last_sent_at' => $now, 'next_send_at' => $now->copy()->addDays($interval), 'send_count' => \DB::raw('send_count + 1'), 'send_details' => [ 'success_count' => $successCount, 'failed_count' => count($failedEmails), 'sent_at' => $now->toDateTimeString(), ], ] ); Log::info("Registro de invitación creado/actualizado", [ 'tournament_id' => $tournament->id, 'next_send_at' => $now->copy()->addDays($interval)->toDateTimeString(), ]); } // Log del resumen Log::info("Invitaciones del torneo {$tournament->id} procesadas", [ 'total_miembros' => $filteredMembers->count() ?? 0, 'total_censo' => $censusPlayers->count(), 'enviados_exitosamente' => $successCount, 'fallidos' => count($failedEmails), 'emails_fallidos' => $failedEmails ]); return [ 'success_count' => $successCount, 'failed_count' => count($failedEmails), 'failed_emails' => $failedEmails ]; } /** * Obtiene el intervalo de días según la configuración */ private function getResendInterval(string $schedule): int { $schedule = strtolower($schedule); if (strpos($schedule, '7') !== false) { return 7; } if (strpos($schedule, '15') !== false) { return 15; } return 7; // Default } } members()->orderBy('pivot_created_at')->get(); if ($participants->count() < 2) { return response()->json(['message' => 'No hay suficientes participantes inscritos para generar el cuadro.'], 400); } $seedingType = $request->query('seeding_type', 'aleatorio'); $participantsNumber = (int)$request->query('participants', $participants->count()); $eliminationType = $tournament->elimination_type; $advancersPerGroup = (int)$request->query('advancers_per_group', 2); $groupsNumber = (int)$request->query('groups_number', 4); $rounds = (int)$request->query('rounds', 1); if ($eliminationType === 'groups') { if ($participantsNumber < $groupsNumber * 2) { return response()->json([ 'message' => "Se necesitan al menos " . ($groupsNumber * 2) . " participantes para crear {$groupsNumber} grupos con mínimo 2 participantes por grupo. Participantes disponibles: {$participantsNumber}." ], 400); } $participantsPerGroup = floor($participantsNumber / $groupsNumber); if ($advancersPerGroup >= $participantsPerGroup) { return response()->json([ 'message' => "El número de clasificados por grupo ({$advancersPerGroup}) debe ser menor al número de participantes por grupo ({$participantsPerGroup})." ], 400); } $totalAdvancers = $groupsNumber * $advancersPerGroup; if ($totalAdvancers < 2) { return response()->json([ 'message' => "Se necesitan al menos 2 clasificados totales para la fase eliminatoria. Actualmente: {$totalAdvancers} clasificados." ], 400); } if ($groupsNumber < 2) { return response()->json(['message' => 'Se necesitan al menos 2 grupos para el formato de eliminación por grupos.'], 400); } if ($rounds < 1 || $rounds > 2) { return response()->json(['message' => 'El número de vueltas debe ser 1 (ida) o 2 (ida y vuelta).'], 400); } if ($advancersPerGroup < 1) { return response()->json(['message' => 'Debe clasificar al menos 1 participante por grupo.'], 400); } } $sortedParticipants = $this->sortParticipants($participants, $seedingType); $gamesData = $this->generateGamesArray( $sortedParticipants, $tournament->id, $eliminationType, $participantsNumber, $advancersPerGroup, $groupsNumber, $rounds ); $tournament->games()->delete(); if (!empty($gamesData)) { Game::insert($gamesData); } return response()->json(['message' => 'Cuadro generado correctamente.'], 201); } /** * Maneja GET /tournaments/{tournament}/bracket * Obtiene los partidos ya generados desde la base de datos. */ public function getBracket(Tournament $tournament, Request $request) { // ✅ Obtener liga_id del request $ligaId = $request->query('liga_id'); // Obtener los juegos con las relaciones necesarias $games = $tournament->games()->with('member1.club', 'member2.club')->get(); // Transformar los datos para incluir el ranking específico de la liga $games->transform(function ($game) use ($ligaId) { // Agregar imagen URL del club del jugador 1 if ($game->member1 && $game->member1->club && $game->member1->club->imagen) { $game->member1->club->imagen_url = 'https://trollopy-ephraim-hypoxanthic.ngrok-free.dev/storage/' . $game->member1->club->imagen; } else if ($game->member1 && $game->member1->club) { $game->member1->club->imagen_url = null; } // Agregar imagen URL del club del jugador 2 if ($game->member2 && $game->member2->club && $game->member2->club->imagen) { $game->member2->club->imagen_url = 'https://trollopy-ephraim-hypoxanthic.ngrok-free.dev/storage/' . $game->member2->club->imagen; } else if ($game->member2 && $game->member2->club) { $game->member2->club->imagen_url = null; } // ✅ CRÍTICO: Agregar el ranking específico de la liga if ($game->member1 && $ligaId) { $game->member1->ranking_liga = $game->member1->getRankingForLiga($ligaId); } if ($game->member2 && $ligaId) { $game->member2->ranking_liga = $game->member2->getRankingForLiga($ligaId); } // ✅ AGREGAR ESTE BLOQUE $game->is_bye = $game->status === 'bye' || is_null($game->member2_id); return $game; }); return response()->json($games); } /** * GET /tournaments/{tournament}/standings * Calcula y devuelve la tabla de posiciones final para un torneo. */ public function getStandings(Tournament $tournament, Request $request) { $ligaId = $request->query('liga_id'); // ✅ PRIMERO: Verificar si ya existen resultados calculados en tournament_results $existingResults = TournamentResult::where('tournament_id', $tournament->id) ->with(['member.club', 'prize']) ->orderBy('final_position') ->get(); if ($existingResults->isNotEmpty()) { // ✅ Devolver resultados ya guardados desde tournament_results $standings = $existingResults->map(function ($result) use ($tournament, $ligaId) { $member = $result->member; // Obtener cambio de ranking del torneo $firstRanking = \App\Models\RankingHistory::forMember($member->id) ->where('tournament_id', $tournament->id) ->oldest() ->first(); $lastRanking = \App\Models\RankingHistory::forMember($member->id) ->where('tournament_id', $tournament->id) ->latest() ->first(); $rankingChange = null; $initialRanking = $member->ranking; $finalRanking = $member->ranking; if ($firstRanking && $lastRanking) { $rankingChange = $lastRanking->ranking - $firstRanking->previous_ranking; $initialRanking = $firstRanking->previous_ranking; $finalRanking = $lastRanking->ranking; } return [ 'member_id' => $member->id, 'name' => $member->name, 'ranking' => $member->ranking, 'ranking_liga' => $ligaId ? $member->getRankingForLiga($ligaId) : null, 'final_position' => $result->final_position, 'prize' => $result->prize ? [ 'id' => $result->prize->id, 'type' => $result->prize->type, 'value' => $result->prize->formatted_value, 'description' => $result->prize->description, 'position' => $result->prize->position, ] : null, 'ranking_change' => $rankingChange, 'initial_ranking' => $initialRanking, 'final_ranking' => $finalRanking, 'club' => $member->club ? [ 'id' => $member->club->id, 'nombre' => $member->club->nombre, 'imagen_url' => $member->club->imagen ? 'https://trollopy-ephraim-hypoxanthic.ngrok-free.dev/storage/' . $member->club->imagen : null ] : null ]; }); return response()->json($standings); } // ✅ SI NO HAY RESULTADOS: Calcular standings como antes (código existente) $allGames = $tournament->games()->with('member1.club', 'member2.club')->get(); // ✅ ACTUALIZADO: Incluir 'wo' como juego completado $allGamesCompleted = $allGames->every(fn($game) => in_array($game->status, ['completed', 'bye', 'wo']) ); if (!$allGamesCompleted) { return response()->json(['message' => 'El torneo aún no ha finalizado. Complete todos los partidos para ver la tabla de posiciones.'], 400); } $standings = []; $tournament->members->each(function ($member) use (&$standings) { $standings[$member->id] = [ 'member_id' => $member->id, 'name' => $member->name, 'ranking' => $member->ranking, 'points' => 0, 'wins' => 0, 'draws' => 0, 'losses' => 0, 'games_played' => 0, 'sets_won' => 0, 'sets_lost' => 0, 'sets_difference' => 0, 'total_points' => 0, 'walkovers_won' => 0, 'walkovers_lost' => 0, 'club' => $member->club ? [ 'id' => $member->club->id, 'nombre' => $member->club->nombre, 'imagen_url' => $member->club->imagen ? 'https://trollopy-ephraim-hypoxanthic.ngrok-free.dev/storage/' . $member->club->imagen : null ] : null ]; }); // Procesar todos los juegos foreach ($allGames as $game) { if (!$game->member1_id) continue; $member1Id = $game->member1_id; $member2Id = $game->member2_id; // Contar juego solo para quien jugó realmente $standings[$member1Id]['games_played']++; if ($member2Id) { $standings[$member2Id]['games_played']++; } // ✅ NUEVO: Procesar Walk Overs if ($game->status === 'wo') { if ($game->winner_id) { // El ganador obtiene 3 puntos por victoria WO $standings[$game->winner_id]['points'] += 3; $standings[$game->winner_id]['wins']++; $standings[$game->winner_id]['walkovers_won']++; // El perdedor (ausente) registra la derrota $loserId = ($game->winner_id == $member1Id) ? $member2Id : $member1Id; if ($loserId && isset($standings[$loserId])) { $standings[$loserId]['losses']++; $standings[$loserId]['walkovers_lost']++; } } continue; // No procesar sets en WO } // Procesar sets solo si no es BYE ni WO if ($game->status !== 'bye' && $member2Id) { if ($game->sets && is_array($game->sets)) { foreach ($game->sets as $set) { $standings[$member1Id]['total_points'] += $set['score1']; $standings[$member2Id]['total_points'] += $set['score2']; } } $standings[$member1Id]['sets_won'] += $game->sets_member1 ?? 0; $standings[$member1Id]['sets_lost'] += $game->sets_member2 ?? 0; $standings[$member2Id]['sets_won'] += $game->sets_member2 ?? 0; $standings[$member2Id]['sets_lost'] += $game->sets_member1 ?? 0; } // Procesar victorias (completed normal y bye) if ($game->winner_id && $game->status !== 'wo') { $standings[$game->winner_id]['points'] += 3; $standings[$game->winner_id]['wins']++; if ($member2Id && $game->winner_id != $member2Id) { $standings[$member2Id]['losses']++; } elseif ($game->winner_id != $member1Id) { $standings[$member1Id]['losses']++; } } } // Calcular diferencia de sets foreach ($standings as $memberId => &$standing) { $standing['sets_difference'] = $standing['sets_won'] - $standing['sets_lost']; // Agregar cambio de ranking del torneo $firstRanking = \App\Models\RankingHistory::forMember($memberId) ->where('tournament_id', $tournament->id) ->oldest() ->first(); $lastRanking = \App\Models\RankingHistory::forMember($memberId) ->where('tournament_id', $tournament->id) ->latest() ->first(); if ($firstRanking && $lastRanking) { $standing['ranking_change'] = $lastRanking->ranking - $firstRanking->previous_ranking; $standing['initial_ranking'] = $firstRanking->previous_ranking; $standing['final_ranking'] = $lastRanking->ranking; } else { $standing['ranking_change'] = null; $standing['initial_ranking'] = $standing['ranking']; $standing['final_ranking'] = $standing['ranking']; } } // Ordenar standings usort($standings, function($a, $b) { if ($b['points'] !== $a['points']) { return $b['points'] - $a['points']; } if ($b['sets_difference'] !== $a['sets_difference']) { return $b['sets_difference'] - $a['sets_difference']; } if ($b['total_points'] !== $a['total_points']) { return $b['total_points'] - $a['total_points']; } return $b['wins'] - $a['wins']; }); return response()->json(array_values($standings)); } private function sortParticipants(Collection $participants, string $seedingType): Collection { return match ($seedingType) { 'tradicional' => $this->applyCulebritaSeeding($participants), 'aleatorio' => $participants->shuffle(), 'secuencial' => $participants, default => $participants, }; } private function applyCulebritaSeeding(Collection $participants): Collection { $sortedByRanking = $participants->sortByDesc('ranking')->values(); $numGroups = max(1, floor($participants->count() / 4)); $groups = array_fill(0, $numGroups, []); foreach ($sortedByRanking as $index => $participant) { $groupRound = floor($index / $numGroups); if ($groupRound % 2 === 0) { $groupIndex = $index % $numGroups; } else { $groupIndex = $numGroups - 1 - ($index % $numGroups); } $groups[$groupIndex][] = $participant; } $result = []; foreach ($groups as $group) { $result = array_merge($result, $group); } return collect($result); } private function generateGamesArray( Collection $participants, int $tournamentId, string $eliminationType, int $participantsNumber, int $advancersPerGroup = 2, int $groupsNumber = 4, int $rounds = 1 ): array { return match ($eliminationType) { 'direct' => $this->createDirectEliminationGames($participants, $tournamentId), 'groups' => $this->createGroupPlayoffGames($participants, $tournamentId, $participantsNumber, $advancersPerGroup, $groupsNumber, $rounds), 'round_robin' => $this->createRoundRobinGames($participants, $tournamentId), 'mixed' => $this->createGroupPlayoffGames($participants, $tournamentId, $participantsNumber, $advancersPerGroup, $groupsNumber, $rounds), default => [], }; } private function createDirectEliminationGames(Collection $participants, int $tournamentId): array { $games = []; $players = $participants->values()->all(); $numPlayers = count($players); if ($numPlayers < 2) { return []; } // Calcular el tamaño del bracket (siguiente potencia de 2) $totalRounds = ceil(log($numPlayers, 2)); $bracketSize = pow(2, $totalRounds); $numByes = $bracketSize - $numPlayers; // Obtener liga_id del torneo para ordenar por ranking $tournament = \App\Models\Tournament::find($tournamentId); $ligaId = $tournament ? $tournament->liga_id : null; // Ordenar jugadores por ranking de liga (descendente = mejor ranking primero) if ($ligaId) { usort($players, function($a, $b) use ($ligaId) { $rankingA = $a->getRankingForLiga($ligaId); $rankingB = $b->getRankingForLiga($ligaId); return $rankingB - $rankingA; // Descendente }); } // Los primeros $numByes jugadores reciben BYE $playersWithBye = array_slice($players, 0, $numByes); $playersWithoutBye = array_slice($players, $numByes); // ✅ NUEVA LÓGICA: Crear primera ronda Y avanzar ganadores $roundNumber = 1; $nextRoundPlayers = []; // Jugadores que avanzan a ronda 2 // Juegos con BYE (ganadores automáticos) foreach ($playersWithBye as $player) { $games[] = [ 'tournament_id' => $tournamentId, 'round' => $roundNumber, 'group_name' => null, 'member1_id' => $player->id, 'member2_id' => null, 'points_member1' => null, 'points_member2' => null, 'sets' => null, 'sets_member1' => 0, 'sets_member2' => 0, 'max_sets' => 5, 'winner_id' => $player->id, 'status' => 'bye', 'elimination_game_id' => null, 'pairing_info' => json_encode(['is_bye' => true]), 'created_at' => now()->toDateTimeString(), 'updated_at' => now()->toDateTimeString(), ]; $nextRoundPlayers[] = $player; // ✅ Avanza automáticamente } // Juegos normales (pendientes de jugar) for ($i = 0; $i < count($playersWithoutBye); $i += 2) { $player1 = $playersWithoutBye[$i]; $player2 = $playersWithoutBye[$i + 1] ?? null; if ($player2) { // Partido normal $games[] = [ 'tournament_id' => $tournamentId, 'round' => $roundNumber, 'group_name' => null, 'member1_id' => $player1->id, 'member2_id' => $player2->id, 'points_member1' => null, 'points_member2' => null, 'sets' => null, 'sets_member1' => 0, 'sets_member2' => 0, 'max_sets' => 5, 'winner_id' => null, 'status' => 'pending', 'elimination_game_id' => null, 'pairing_info' => null, 'created_at' => now()->toDateTimeString(), 'updated_at' => now()->toDateTimeString(), ]; } else { // BYE impar $games[] = [ 'tournament_id' => $tournamentId, 'round' => $roundNumber, 'group_name' => null, 'member1_id' => $player1->id, 'member2_id' => null, 'points_member1' => null, 'points_member2' => null, 'sets' => null, 'sets_member1' => 0, 'sets_member2' => 0, 'max_sets' => 5, 'winner_id' => $player1->id, 'status' => 'bye', 'elimination_game_id' => null, 'pairing_info' => json_encode(['is_bye' => true]), 'created_at' => now()->toDateTimeString(), 'updated_at' => now()->toDateTimeString(), ]; $nextRoundPlayers[] = $player1; // ✅ Avanza automáticamente } } // ✅ CREAR RONDAS SIGUIENTES con jugadores ya colocados donde corresponda $currentRoundPlayers = $nextRoundPlayers; $roundNumber++; while (count($currentRoundPlayers) + (count($games) - count($nextRoundPlayers)) * 2 > 1) { $gamesInThisRound = ceil(($bracketSize / pow(2, $roundNumber))); // Determinar cuántos slots ya están ocupados por ganadores de BYE $occupiedSlots = count($currentRoundPlayers); $totalSlotsNeeded = $gamesInThisRound * 2; $gameIndex = 0; // Crear juegos de esta ronda for ($i = 0; $i < $gamesInThisRound; $i++) { $member1 = null; $member2 = null; $status = 'waiting_for_winner'; // ✅ Colocar ganadores de BYE en los primeros slots if ($gameIndex * 2 < count($currentRoundPlayers)) { $member1 = $currentRoundPlayers[$gameIndex * 2]->id ?? null; } if ($gameIndex * 2 + 1 < count($currentRoundPlayers)) { $member2 = $currentRoundPlayers[$gameIndex * 2 + 1]->id ?? null; } // Si ambos jugadores están definidos, el partido está listo if ($member1 && $member2) { $status = 'pending'; } $games[] = [ 'tournament_id' => $tournamentId, 'round' => $roundNumber, 'group_name' => null, 'member1_id' => $member1, 'member2_id' => $member2, 'points_member1' => null, 'points_member2' => null, 'sets' => null, 'sets_member1' => 0, 'sets_member2' => 0, 'max_sets' => 5, 'winner_id' => null, 'status' => $status, 'elimination_game_id' => null, 'pairing_info' => null, 'created_at' => now()->toDateTimeString(), 'updated_at' => now()->toDateTimeString(), ]; $gameIndex++; } $currentRoundPlayers = []; $roundNumber++; // Salir si ya llegamos a la final if ($gamesInThisRound <= 1) { break; } } return $games; } private function createGroupPlayoffGames( Collection $participants, int $tournamentId, int $participantsNumber, int $advancersPerGroup, int $groupsNumber, int $rounds ): array { $participantsArray = $participants->values()->all(); $numParticipants = $participants->count(); $participantsToDistribute = min($numParticipants, $participantsNumber); $groups = array_fill(0, $groupsNumber, []); for ($i = 0; $i < $participantsToDistribute; $i++) { $groupIndex = $i % $groupsNumber; $round = floor($i / $groupsNumber); if ($round % 2 != 0) { $groupIndex = $groupsNumber - 1 - $groupIndex; } $groups[$groupIndex][] = $participantsArray[$i]; } $allGames = []; foreach ($groups as $index => $groupParticipants) { if (count($groupParticipants) > 1) { $groupName = 'Grupo ' . chr(65 + $index); $groupGames = $this->createRoundRobinGames(collect($groupParticipants), $tournamentId, $groupName, $rounds); $allGames = array_merge($allGames, $groupGames); } } $totalAdvancers = $groupsNumber * $advancersPerGroup; $eliminationRounds = $totalAdvancers > 1 ? ceil(log($totalAdvancers, 2)) : 0; $this->createEliminationPhaseStructure($tournamentId, $groupsNumber, $advancersPerGroup, $eliminationRounds, $allGames); return $allGames; } private function createEliminationPhaseStructure( int $tournamentId, int $numGroups, int $advancersPerGroup, int $eliminationRounds, array &$allGames ): void { $totalAdvancers = $numGroups * $advancersPerGroup; if ($totalAdvancers < 2) return; $currentRound = 1; $playersInRound = $totalAdvancers; while ($playersInRound > 1 && $currentRound <= $eliminationRounds) { $gamesInRound = floor($playersInRound / 2); $roundName = $this->getEliminationRoundName($currentRound, $eliminationRounds); for ($gameNum = 0; $gameNum < $gamesInRound; $gameNum++) { $pairingInfo = $this->calculateEliminationPairing($gameNum, $currentRound, $numGroups, $advancersPerGroup); $allGames[] = [ 'tournament_id' => $tournamentId, 'round' => $currentRound, 'group_name' => $roundName, 'member1_id' => null, 'member2_id' => null, 'status' => 'waiting_for_groups', 'elimination_game_id' => null, 'points_member1' => null, 'points_member2' => null, 'pairing_info' => json_encode($pairingInfo), 'created_at' => now()->toDateTimeString(), 'updated_at' => now()->toDateTimeString(), ]; } $playersInRound = $gamesInRound; $currentRound++; } } private function calculateEliminationPairing(int $gameNum, int $round, int $numGroups, int $advancersPerGroup): array { if ($round == 1) { if ($advancersPerGroup == 2 && $numGroups >= 2) { if ($numGroups == 2) { if ($gameNum == 0) { return [ 'type' => 'group_winners', 'participant1' => ['group' => 0, 'position' => 1], // 1° Grupo A 'participant2' => ['group' => 1, 'position' => 2], // 2° Grupo B ]; } else { return [ 'type' => 'group_winners', 'participant1' => ['group' => 1, 'position' => 1], // 1° Grupo B 'participant2' => ['group' => 0, 'position' => 2], // 2° Grupo A ]; } } else { $group1 = $gameNum % $numGroups; $group2 = ($gameNum + floor($numGroups/2)) % $numGroups; $position1 = ($gameNum < floor($numGroups / 2)) ? 1 : 2; $position2 = 3 - $position1; return [ 'type' => 'group_winners', 'participant1' => ['group' => $group1, 'position' => $position1], 'participant2' => ['group' => $group2, 'position' => $position2], ]; } } else { $group1 = $gameNum * 2; $group2 = $group1 + 1; return [ 'type' => 'group_winners', 'participant1' => ['group' => $group1, 'position' => 1], 'participant2' => ['group' => $group2, 'position' => 1], ]; } } else { $prevGame1 = $gameNum * 2; $prevGame2 = $prevGame1 + 1; return [ 'type' => 'game_winners', 'participant1' => ['prev_round' => $round - 1, 'game' => $prevGame1], 'participant2' => ['prev_round' => $round - 1, 'game' => $prevGame2], 'description' => "Ganador Semifinal " . ($prevGame1 + 1) . " vs Ganador Semifinal " . ($prevGame2 + 1) ]; } } private function getEliminationRoundName(int $round, int $totalRounds): string { $remainingRounds = $totalRounds - $round + 1; return match($remainingRounds) { 1 => 'Final', 2 => 'Semifinal', 3 => 'Cuartos de Final', 4 => 'Octavos de Final', default => 'Eliminatoria Ronda ' . $round }; } private function createRoundRobinGames(Collection $participants, int $tournamentId, ?string $groupName = null, int $rounds = 1): array { $games = []; $players = $participants->values()->all(); if (count($players) < 2) return []; if (count($players) % 2 !== 0) { $players[] = null; } $numRounds = count($players) - 1; $numPlayers = count($players); for ($vuelta = 1; $vuelta <= $rounds; $vuelta++) { for ($round = 0; $round < $numRounds; $round++) { for ($i = 0; $i < $numPlayers / 2; $i++) { $player1 = $players[$i]; $player2 = $players[$numPlayers - 1 - $i]; if ($player1 && $player2) { if ($vuelta == 2) { [$player1, $player2] = [$player2, $player1]; } $games[] = [ 'tournament_id' => $tournamentId, 'round' => $groupName ? null : (($vuelta - 1) * $numRounds + $round + 1), 'group_name' => $groupName, 'member1_id' => $player1->id, 'member2_id' => $player2->id, 'status' => 'pending', 'points_member1' => null, 'points_member2' => null, 'elimination_game_id' => null, 'pairing_info' => $rounds > 1 ? json_encode(['vuelta' => $vuelta]) : null, 'created_at' => now()->toDateTimeString(), 'updated_at' => now()->toDateTimeString(), ]; } } $lastPlayer = array_pop($players); array_splice($players, 1, 0, [$lastPlayer]); } } return $games; } public static function placeAdvancersInElimination(int $tournamentId, string $groupName, int $advancersPerGroup) { $groupGames = Game::where('tournament_id', $tournamentId) ->where('group_name', $groupName) ->get(); if ($groupGames->count() === 0 || !$groupGames->every(fn($game) => $game->status === 'completed')) { return; } // Inicializar estadísticas $standings = []; $groupGames->pluck('member1_id')->merge($groupGames->pluck('member2_id'))->unique()->filter()->each(function ($memberId) use (&$standings) { $standings[$memberId] = [ 'member_id' => $memberId, 'points' => 0, 'wins' => 0, 'sets_won' => 0, 'sets_lost' => 0, 'sets_difference' => 0, 'total_points' => 0 ]; }); // ✅ Procesar juegos con nuevo sistema foreach ($groupGames as $game) { // Sumar puntos de sets if ($game->sets && is_array($game->sets)) { foreach ($game->sets as $set) { $standings[$game->member1_id]['total_points'] += $set['score1']; $standings[$game->member2_id]['total_points'] += $set['score2']; } } // Contar sets $standings[$game->member1_id]['sets_won'] += $game->sets_member1 ?? 0; $standings[$game->member1_id]['sets_lost'] += $game->sets_member2 ?? 0; $standings[$game->member2_id]['sets_won'] += $game->sets_member2 ?? 0; $standings[$game->member2_id]['sets_lost'] += $game->sets_member1 ?? 0; // Procesar resultado if ($game->winner_id) { $standings[$game->winner_id]['points'] += 2; $standings[$game->winner_id]['wins']++; $loserId = ($game->winner_id == $game->member1_id) ? $game->member2_id : $game->member1_id; if (isset($standings[$loserId])) { $standings[$loserId]['points'] += 1; } } } // Calcular diferencia de sets foreach ($standings as &$standing) { $standing['sets_difference'] = $standing['sets_won'] - $standing['sets_lost']; } // ✅ NUEVO ORDEN en grupos usort($standings, function ($a, $b) { if ($b['points'] !== $a['points']) { return $b['points'] - $a['points']; } if ($b['sets_difference'] !== $a['sets_difference']) { return $b['sets_difference'] - $a['sets_difference']; } if ($b['total_points'] !== $a['total_points']) { return $b['total_points'] - $a['total_points']; } return $b['wins'] - $a['wins']; }); $advancingMembers = collect($standings)->slice(0, $advancersPerGroup); $groupIndex = ord(substr($groupName, -1)) - ord('A'); // Colocar clasificados en fase eliminatoria foreach ($advancingMembers as $position => $memberData) { $memberId = $memberData['member_id']; $positionInGroup = $position + 1; $eliminationGames = Game::where('tournament_id', $tournamentId) ->where('group_name', '!=', $groupName) ->where('status', 'waiting_for_groups') ->whereNotNull('pairing_info') ->get(); foreach ($eliminationGames as $game) { $pairingInfo = json_decode($game->pairing_info, true); if ($pairingInfo && $pairingInfo['type'] === 'group_winners') { $assigned = false; if (!$game->member1_id && $pairingInfo['participant1']['group'] == $groupIndex && $pairingInfo['participant1']['position'] == $positionInGroup) { $game->member1_id = $memberId; $assigned = true; } if (!$game->member2_id && $pairingInfo['participant2']['group'] == $groupIndex && $pairingInfo['participant2']['position'] == $positionInGroup) { $game->member2_id = $memberId; $assigned = true; } if ($assigned) { if ($game->member1_id && $game->member2_id) { $game->status = 'pending'; } $game->save(); break; } } } } } } withCount(['members', 'tournaments']) ->orderBy('nombre'); // Filtrar por liga si se proporciona el parámetro liga_id if ($request->has('liga_id')) { $ligaId = $request->liga_id; $query->whereHas('ligas', function ($q) use ($ligaId) { $q->where('ligas.id', $ligaId); }); } $clubs = $query->get(); $clubs->transform(function ($club) { if ($club->imagen) { $club->imagen_url = 'https://trollopy-ephraim-hypoxanthic.ngrok-free.dev/storage/' . $club->imagen; } else { $club->imagen_url = null; } return $club; }); return response()->json($clubs); } public function store(Request $request): JsonResponse { try { $validatedData = $request->validate([ 'nombre' => 'required|string|max:255', 'imagen' => 'required|image|mimes:jpeg,png,jpg|max:2048', 'ruc' => 'required|string|max:20', 'ligas' => 'required|array|min:1', // Ahora es un array de ligas 'ligas.*' => 'exists:ligas,id', 'pais' => 'required|string|max:100', 'provincia' => 'required|string|max:100', 'ciudad' => 'required|string|max:100', 'direccion' => 'required|string|max:255', 'celular' => 'required|string|max:20', 'google_maps_url' => 'required|url|max:500', 'representante_nombre' => 'required|string|max:255', 'representante_telefono' => 'required|string|max:20', 'representante_email' => 'required|email|max:255', 'email' => 'required|string|email|max:255', 'password' => 'nullable|string|min:6', 'admin1_nombre' => 'nullable|string|max:255', 'admin1_telefono' => 'nullable|string|max:20', 'admin1_email' => 'nullable|email|max:255', 'admin2_nombre' => 'nullable|string|max:255', 'admin2_telefono' => 'nullable|string|max:20', 'admin2_email' => 'nullable|email|max:255', 'admin3_nombre' => 'nullable|string|max:255', 'admin3_telefono' => 'nullable|string|max:20', 'admin3_email' => 'nullable|email|max:255', ]); $club = null; DB::transaction(function () use ($request, $validatedData, &$club) { $user = User::firstOrCreate( ['email' => $validatedData['email']], [ 'password' => Hash::make($validatedData['password'] ?? 'temporal123'), 'role' => 'club', 'is_active' => true, ] ); if (!$user->wasRecentlyCreated && isset($validatedData['password'])) { $user->password = Hash::make($validatedData['password']); $user->save(); } $clubData = $request->except(['email', 'password', 'imagen', 'ligas']); $clubData['user_id'] = $user->id; if ($request->hasFile('imagen')) { $imagePath = $request->file('imagen')->store('clubs', 'public'); $clubData['imagen'] = $imagePath; } $club = Club::create($clubData); // Asociar el club con múltiples ligas $club->ligas()->attach($validatedData['ligas']); UserRole::create([ 'user_id' => $user->id, 'role' => 'club', 'profile_id' => $club->id, ]); }); if ($club && $club->imagen) { $club->imagen_url = 'https://trollopy-ephraim-hypoxanthic.ngrok-free.dev/storage/' . $club->imagen; } return response()->json([ 'success' => true, 'message' => 'Club creado exitosamente.', 'data' => $club->load('ligas') ], 201); } catch (ValidationException $e) { return response()->json([ 'success' => false, 'message' => 'Error de validación.', 'errors' => $e->errors(), ], 422); } } public function show(Club $club): JsonResponse { $club->load(['members', 'ligas', 'tournaments']); if ($club->imagen) { $club->imagen_url = 'https://trollopy-ephraim-hypoxanthic.ngrok-free.dev/storage/' . $club->imagen; } else { $club->imagen_url = null; } return response()->json($club); } public function update(Request $request, Club $club): JsonResponse { $request->validate([ 'nombre' => 'required|string|max:255', 'imagen' => 'nullable|image|mimes:jpeg,png,jpg|max:2048', 'ligas' => 'sometimes|array|min:1', 'ligas.*' => 'exists:ligas,id' ]); $club->nombre = $request->nombre; if ($request->hasFile('imagen')) { if ($club->imagen && Storage::disk('public')->exists($club->imagen)) { Storage::disk('public')->delete($club->imagen); } $imagePath = $request->file('imagen')->store('clubs', 'public'); $club->imagen = $imagePath; } $club->save(); // Actualizar ligas si se proporcionan if ($request->has('ligas')) { $club->ligas()->sync($request->ligas); } if ($club->imagen) { $club->imagen_url = 'https://trollopy-ephraim-hypoxanthic.ngrok-free.dev/storage/' . $club->imagen; } else { $club->imagen_url = null; } return response()->json($club->load('ligas')); } public function destroy(Club $club): JsonResponse { DB::transaction(function () use ($club) { if ($club->imagen && Storage::disk('public')->exists($club->imagen)) { Storage::disk('public')->delete($club->imagen); } if ($club->user) { $club->user->delete(); } $club->delete(); }); return response()->json(['message' => 'Club eliminado correctamente']); } public function tournaments(Club $club): JsonResponse { $tournaments = $club->tournaments() ->withCount(['games', 'members']) ->orderBy('created_at', 'desc') ->get(); return response()->json($tournaments); } } all(), [ 'absent_player_id' => 'required|integer|exists:members,id', 'liga_id' => 'required|integer|exists:ligas,id', ]); if ($validator->fails()) { return response()->json($validator->errors(), 400); } return DB::transaction(function () use ($request, $id) { $game = Game::findOrFail($id); $absentPlayerId = $request->input('absent_player_id'); $ligaId = $request->input('liga_id'); // Validaciones if ($game->status === 'completed') { return response()->json([ 'message' => 'Este juego ya fue completado con un resultado normal.' ], 400); } if ($game->status === 'wo') { return response()->json([ 'message' => 'Este juego ya fue registrado como Walk Over.' ], 400); } if ($game->status === 'bye') { return response()->json([ 'message' => 'No se puede registrar WO en un juego con BYE.' ], 400); } if (!$game->member1_id || !$game->member2_id) { return response()->json([ 'message' => 'El juego debe tener ambos jugadores asignados para registrar un WO.' ], 400); } // Verificar que absent_player_id sea uno de los jugadores del partido if ($absentPlayerId !== $game->member1_id && $absentPlayerId !== $game->member2_id) { return response()->json([ 'message' => 'El jugador ausente debe ser uno de los participantes del partido.' ], 400); } // Determinar ganador (el que NO está ausente) $winnerId = ($absentPlayerId === $game->member1_id) ? $game->member2_id : $game->member1_id; // Actualizar el juego $game->winner_id = $winnerId; $game->absent_player_id = $absentPlayerId; $game->status = 'wo'; $game->sets = null; $game->sets_member1 = 0; $game->sets_member2 = 0; $game->save(); Log::info("Walk Over registrado", [ 'game_id' => $game->id, 'winner_id' => $winnerId, 'absent_player_id' => $absentPlayerId, 'tournament_id' => $game->tournament_id, 'note' => 'NO afecta ranking' ]); // Avanzar ganador al siguiente round $isGroupStageMatch = $game->group_name && str_contains($game->group_name, 'Grupo'); if ($isGroupStageMatch) { $tournament = Tournament::find($game->tournament_id); if ($tournament) { $advancersPerGroup = $tournament->advancers_per_group ?? 2; BracketController::placeAdvancersInElimination( $game->tournament_id, $game->group_name, $advancersPerGroup ); } } else { $advanced = $this->advanceWinnerToNextRound($game); if (!$advanced && $winnerId) { $this->advanceWinnerLegacy($game, $winnerId); } } // Cargar relaciones para respuesta $response = $game->fresh()->load(['member1.club', 'member2.club', 'absentPlayer']); return response()->json([ 'message' => 'Walk Over registrado exitosamente. El ganador avanza pero no se afecta el ranking.', 'game' => $response, 'ranking_affected' => false ]); }); } /** * Update the specified game in storage with sets scoring. */ public function update(Request $request, $id) { $validator = Validator::make($request->all(), [ 'sets' => 'required|array|min:1', 'sets.*.score1' => 'required|integer|min:0', 'sets.*.score2' => 'required|integer|min:0', 'max_sets' => 'sometimes|integer|in:5,7', 'liga_id' => 'required|integer|exists:ligas,id', ]); if ($validator->fails()) { return response()->json($validator->errors(), 400); } return DB::transaction(function () use ($request, $id) { $game = Game::findOrFail($id); $tournament = $game->tournament; // Validaciones de BYE y WO if ($game->status === 'bye') { return response()->json([ 'message' => 'No se puede registrar resultado en un juego con BYE.' ], 400); } if ($game->status === 'wo') { return response()->json([ 'message' => 'No se puede registrar resultado en un juego con Walk Over (WO).' ], 400); } $ligaId = $request->input('liga_id'); if (!$game->member1_id || !$game->member2_id) { return response()->json([ 'message' => 'No se puede registrar un resultado para un partido sin ambos jugadores.' ], 400); } $sets = $request->input('sets'); $maxSets = $request->input('max_sets', $game->max_sets ?? 5); // Validar sets y determinar ganador (código existente...) $setResults = []; $setsMember1 = 0; $setsMember2 = 0; foreach ($sets as $index => $set) { $score1 = (int)$set['score1']; $score2 = (int)$set['score2']; if (!Game::isValidSet($score1, $score2)) { return response()->json([ 'message' => "Set " . ($index + 1) . " inválido.", 'set' => ['score1' => $score1, 'score2' => $score2] ], 400); } $setWinner = Game::getSetWinner($score1, $score2); if ($setWinner === 1) { $setsMember1++; } elseif ($setWinner === 2) { $setsMember2++; } $setResults[] = [ 'score1' => $score1, 'score2' => $score2, 'winner' => $setWinner ]; } if (count($sets) > $maxSets) { return response()->json([ 'message' => "No se pueden jugar más de {$maxSets} sets." ], 400); } $setsToWin = ceil($maxSets / 2); if ($setsMember1 < $setsToWin && $setsMember2 < $setsToWin) { return response()->json([ 'message' => "El partido no ha terminado. Se necesitan {$setsToWin} sets para ganar." ], 400); } // Determinar ganador $winnerId = null; if ($setsMember1 > $setsMember2) { $winnerId = $game->member1_id; } elseif ($setsMember2 > $setsMember1) { $winnerId = $game->member2_id; } // Guardar resultados $game->sets = $setResults; $game->sets_member1 = $setsMember1; $game->sets_member2 = $setsMember2; $game->max_sets = $maxSets; $game->winner_id = $winnerId; $game->status = 'completed'; $game->save(); // Procesar ranking (código existente...) $rankingUpdate = null; try { $registration = DB::table('tournament_registrations') ->where('tournament_id', $game->tournament_id) ->where('member_id', $game->member1_id) ->first(); $clubId = $registration ? $registration->club_id : null; $rankingUpdate = $this->processGameRankingUpdate($game, $clubId, $ligaId); } catch (\Exception $e) { Log::error("Error procesando ranking", [ 'error' => $e->getMessage(), 'game_id' => $game->id ]); } // Avanzar ganadores (código existente...) $isGroupStageMatch = $game->group_name && str_contains($game->group_name, 'Grupo'); if ($isGroupStageMatch) { $tournament = Tournament::find($game->tournament_id); if ($tournament) { $advancersPerGroup = $tournament->advancers_per_group ?? 2; BracketController::placeAdvancersInElimination( $game->tournament_id, $game->group_name, $advancersPerGroup ); } } else { $advanced = $this->advanceWinnerToNextRound($game); if (!$advanced && $winnerId) { $this->advanceWinnerLegacy($game, $winnerId); } } $response = $game->fresh()->load(['member1.club', 'member2.club']); if ($rankingUpdate && $rankingUpdate['success']) { $response->ranking_update = $rankingUpdate; } // ✅ NUEVO: Calcular posiciones automáticamente si el torneo terminó try { $tournament = Tournament::find($game->tournament_id); $resultService = app(\App\Services\TournamentResultService::class); if ($resultService->isTournamentFinished($tournament)) { Log::info("🏆 Último partido completado. Calculando posiciones automáticamente", [ 'tournament_id' => $tournament->id, 'game_id' => $game->id ]); $result = $resultService->calculateFinalPositions($tournament); if ($result['success']) { $response->tournament_finished = true; $response->positions_calculated = true; $response->positions = $result['positions']; Log::info("✅ Posiciones calculadas automáticamente", [ 'tournament_id' => $tournament->id, 'positions_count' => count($result['positions']) ]); } } } catch (\Exception $e) { Log::error("❌ Error al calcular posiciones automáticamente", [ 'tournament_id' => $game->tournament_id, 'game_id' => $game->id, 'error' => $e->getMessage() ]); // No interrumpir el flujo, solo loggear el error } return response()->json($response); }); } private function advanceWinnerToNextRound(Game $game) { if ($game->status !== 'completed' && $game->status !== 'wo') { return false; } if (!$game->winner_id) { return false; } $isGroupStageMatch = $game->group_name && str_contains($game->group_name, 'Grupo'); if ($isGroupStageMatch) { return false; } $nextRoundGames = Game::where('tournament_id', $game->tournament_id) ->where('round', $game->round + 1) ->whereIn('status', ['waiting_for_groups', 'waiting_for_winner']) ->get(); foreach ($nextRoundGames as $nextGame) { $pairingInfo = json_decode($nextGame->pairing_info, true); if ($pairingInfo && $pairingInfo['type'] === 'game_winners') { $currentRoundGames = Game::where('tournament_id', $game->tournament_id) ->where('round', $game->round) ->orderBy('id') ->get(); $gameIndex = null; foreach ($currentRoundGames as $index => $roundGame) { if ($roundGame->id === $game->id) { $gameIndex = $index; break; } } if ($gameIndex === null) { continue; } $assigned = false; if (!$nextGame->member1_id && isset($pairingInfo['participant1']) && $pairingInfo['participant1']['prev_round'] == $game->round && $pairingInfo['participant1']['game'] == $gameIndex) { $nextGame->member1_id = $game->winner_id; $assigned = true; } if (!$nextGame->member2_id && isset($pairingInfo['participant2']) && $pairingInfo['participant2']['prev_round'] == $game->round && $pairingInfo['participant2']['game'] == $gameIndex) { $nextGame->member2_id = $game->winner_id; $assigned = true; } if ($assigned) { if ($nextGame->member1_id && $nextGame->member2_id) { $nextGame->status = 'pending'; } $nextGame->save(); return true; } } else { $currentRoundGames = Game::where('tournament_id', $game->tournament_id) ->where('round', $game->round) ->orderBy('id') ->get(); $gameIndex = null; foreach ($currentRoundGames as $index => $roundGame) { if ($roundGame->id === $game->id) { $gameIndex = $index; break; } } if ($gameIndex === null) { continue; } $nextGameIndex = floor($gameIndex / 2); $nextRoundGamesOrdered = Game::where('tournament_id', $game->tournament_id) ->where('round', $game->round + 1) ->orderBy('id') ->get(); if (isset($nextRoundGamesOrdered[$nextGameIndex]) && $nextRoundGamesOrdered[$nextGameIndex]->id === $nextGame->id) { $slot = ($gameIndex % 2 === 0) ? 'member1_id' : 'member2_id'; if (is_null($nextGame->{$slot})) { $nextGame->{$slot} = $game->winner_id; if ($nextGame->member1_id && $nextGame->member2_id) { $nextGame->status = 'pending'; } $nextGame->save(); return true; } } } } return false; } private function advanceWinnerLegacy(Game $completedGame, int $winnerId) { if (!$completedGame->round) { return; } $roundGames = Game::where('tournament_id', $completedGame->tournament_id) ->where('round', $completedGame->round) ->orderBy('id', 'asc') ->get(); $gameIndex = null; foreach ($roundGames as $index => $roundGame) { if ($roundGame->id === $completedGame->id) { $gameIndex = $index; break; } } if ($gameIndex !== null) { $nextGameIndex = floor($gameIndex / 2); $nextRoundGames = Game::where('tournament_id', $completedGame->tournament_id) ->where('round', $completedGame->round + 1) ->whereIn('status', ['waiting_for_groups', 'waiting_for_winner', 'pending']) ->orderBy('id', 'asc') ->get(); if (isset($nextRoundGames[$nextGameIndex])) { $nextGame = $nextRoundGames[$nextGameIndex]; $slot = ($gameIndex % 2 === 0) ? 'member1_id' : 'member2_id'; if (is_null($nextGame->{$slot})) { $nextGame->{$slot} = $winnerId; if ($nextGame->member1_id && $nextGame->member2_id) { $nextGame->status = 'pending'; } $nextGame->save(); } } } } public function getRankingSystemInfo() { return response()->json($this->getRankingSystemSummary()); } } has('deporte_id')) { $query->where('deporte_id', $request->deporte_id); } $ligas = $query->orderBy('name')->get(); return response()->json([ 'success' => true, 'data' => $ligas ]); } public function store(Request $request): JsonResponse { try { $validatedData = $request->validate([ 'name' => 'required|string|max:255', 'pais' => 'nullable|string|max:255', 'provincia' => 'nullable|string|max:255', 'ciudad' => 'nullable|string|max:255', 'celular' => 'nullable|string|max:20', 'email' => 'required|string|email|max:255', 'password' => 'nullable|string|min:6', 'deporte_id' => 'required|exists:deportes,id', // Nuevos campos del dueño 'owner_name' => 'required|string|max:255', 'owner_email' => 'required|string|email|max:255', 'owner_celular' => 'nullable|string|max:20' ]); $liga = null; DB::transaction(function () use ($validatedData, &$liga) { // Buscar o crear usuario $user = User::firstOrCreate( ['email' => $validatedData['email']], [ 'password' => Hash::make($validatedData['password'] ?? 'temporal123'), 'role' => 'liga', 'is_active' => true, ] ); if (!$user->wasRecentlyCreated && isset($validatedData['password'])) { $user->password = Hash::make($validatedData['password']); $user->save(); } // Crear liga con los nuevos campos $liga = Liga::create([ 'name' => $validatedData['name'], 'pais' => $validatedData['pais'], 'provincia' => $validatedData['provincia'], 'ciudad' => $validatedData['ciudad'], 'celular' => $validatedData['celular'], 'deporte_id' => $validatedData['deporte_id'] ?? 1, 'user_id' => $user->id, 'owner_name' => $validatedData['owner_name'], 'owner_email' => $validatedData['owner_email'], 'owner_celular' => $validatedData['owner_celular'] ?? null, ]); // Crear registro en user_roles UserRole::create([ 'user_id' => $user->id, 'role' => 'liga', 'profile_id' => $liga->id, ]); }); return response()->json([ 'success' => true, 'message' => 'Liga creada exitosamente.', 'data' => $liga ], 201); } catch (ValidationException $e) { return response()->json([ 'success' => false, 'message' => 'Error de validación.', 'errors' => $e->errors(), ], 422); } } public function show(Liga $liga): JsonResponse { $liga->load(['deporte', 'clubs.members', 'categorias', 'user']); return response()->json([ 'success' => true, 'data' => $liga ]); } public function update(Request $request, Liga $liga): JsonResponse { $validated = $request->validate([ 'name' => 'required|string|max:255', 'deporte_id' => 'required|exists:deportes,id', 'pais' => 'nullable|string|max:255', 'provincia' => 'nullable|string|max:255', 'ciudad' => 'nullable|string|max:255', // Nuevos campos del dueño 'owner_name' => 'nullable|string|max:255', 'owner_email' => 'nullable|string|email|max:255', 'owner_celular' => 'nullable|string|max:20' ], [ 'name.required' => 'El nombre de la liga es requerido', 'deporte_id.required' => 'El deporte es requerido', 'deporte_id.exists' => 'El deporte seleccionado no existe', 'owner_email.email' => 'El correo del dueño debe ser válido' ]); $liga->update($validated); $liga->load('deporte'); return response()->json([ 'success' => true, 'message' => 'Liga actualizada exitosamente', 'data' => $liga ]); } public function destroy(Liga $liga): JsonResponse { if ($liga->user) { $liga->user->delete(); } $liga->delete(); return response()->json([ 'success' => true, 'message' => 'Liga eliminada correctamente' ]); } } input('liga_id'); // Filtrar por club_id si se proporciona if ($request->has('club_id')) { $query->where('club_id', $request->club_id); } // Si el usuario autenticado es un club, solo mostrar sus miembros $user = $request->user(); if ($user && $user->role === 'club') { $userRole = UserRole::where('user_id', $user->id) ->where('role', 'club') ->first(); if ($userRole) { $query->where('club_id', $userRole->profile_id); } } $members = $query->orderBy('name')->get(); // Agregar ranking específico por liga y URL de foto $members->each(function ($member) use ($ligaId) { if ($ligaId) { $member->ranking_liga = $member->getRankingForLiga($ligaId); $member->latest_change = $member->getLatestChangeForLiga($ligaId); } else { $latestChange = RankingHistory::forMember($member->id) ->latest() ->first(); $member->latest_change = $latestChange ? $latestChange->change : null; } // Agregar URL de la foto if ($member->photo) { $member->photo_url = 'https://trollopy-ephraim-hypoxanthic.ngrok-free.dev/storage/' . $member->photo; } else { $member->photo_url = null; } }); return response()->json($members); } public function store(Request $request): JsonResponse { try { $validatedData = $request->validate([ 'name' => 'required|string|max:255', 'segundo_nombre' => 'nullable|string|max:255', 'primer_apellido' => 'nullable|string|max:255', 'segundo_apellido' => 'nullable|string|max:255', 'ranking' => 'nullable|integer', 'age' => 'nullable|integer', 'cedula' => 'nullable|string|max:255', 'fecha_nacimiento' => 'nullable|date', 'genero' => 'nullable|in:Masculino,Femenino,Otro', 'pais' => 'nullable|string|max:255', 'provincia' => 'nullable|string|max:255', 'ciudad' => 'nullable|string|max:255', 'celular' => 'nullable|string|max:20', 'club_id' => 'required|exists:clubs,id', 'photo' => 'nullable|image|mimes:jpeg,png,jpg,gif|max:2048', 'lado_juego' => 'nullable|in:Derecho,Zurdo', 'tipo_juego' => 'nullable|in:Clásico,Lapicero', 'raqueta_marca' => 'nullable|string|max:255', 'raqueta_modelo' => 'nullable|string|max:255', 'drive_marca' => 'nullable|string|max:255', 'drive_modelo' => 'nullable|string|max:255', 'drive_tipo' => 'nullable|in:Antitopsping,Liso,Pupo Corto,Pupo Largo,Todos', 'drive_color' => 'nullable|in:Rojo,Negro', 'drive_esponja' => 'nullable|string|max:10', 'drive_hardness' => 'nullable|string|max:50', 'back_marca' => 'nullable|string|max:255', 'back_modelo' => 'nullable|string|max:255', 'back_tipo' => 'nullable|in:Antitopsping,Liso,Pupo Corto,Pupo Largo,Todos', 'back_color' => 'nullable|in:Rojo,Negro', 'back_esponja' => 'nullable|string|max:10', 'back_hardness' => 'nullable|string|max:50', 'email' => 'required|string|email|max:255', 'password' => 'nullable|string|min:6', ]); $member = null; DB::transaction(function () use ($validatedData, &$member, $request) { $user = User::firstOrCreate( ['email' => $validatedData['email']], [ 'password' => Hash::make($validatedData['password'] ?? 'temporal123'), 'role' => 'miembro', 'is_active' => true, ] ); if (!$user->wasRecentlyCreated && isset($validatedData['password'])) { $user->password = Hash::make($validatedData['password']); $user->save(); } $memberData = collect($validatedData)->except(['email', 'password', 'photo'])->toArray(); // Manejar la foto si existe if ($request->hasFile('photo')) { $photoPath = $request->file('photo')->store('members/photos', 'public'); $memberData['photo'] = $photoPath; } $memberData['user_id'] = $user->id; $memberData['fecha_ingreso_club'] = now(); $member = Member::create($memberData); UserRole::create([ 'user_id' => $user->id, 'role' => 'miembro', 'profile_id' => $member->id, ]); }); // Agregar URL de la foto if ($member && $member->photo) { $member->photo_url = 'https://trollopy-ephraim-hypoxanthic.ngrok-free.dev/storage/' . $member->photo; } return response()->json($member->load('club.ligas'), 201); } catch (ValidationException $e) { return response()->json([ 'success' => false, 'message' => 'Error de validación.', 'errors' => $e->errors(), ], 422); } } public function show(Member $member): JsonResponse { $member->load(['club.ligas', 'ultimoTraspaso', 'transfersRealizados.clubAnterior', 'transfersRealizados.clubNuevo']); // Agregar URL de la foto if ($member->photo) { $member->photo_url = 'https://trollopy-ephraim-hypoxanthic.ngrok-free.dev/storage/' . $member->photo; } else { $member->photo_url = null; } return response()->json($member); } public function update(Request $request, Member $member): JsonResponse { $validatedData = $request->validate([ 'name' => 'sometimes|required|string|max:255', 'segundo_nombre' => 'nullable|string|max:255', 'primer_apellido' => 'nullable|string|max:255', 'segundo_apellido' => 'nullable|string|max:255', 'ranking' => 'nullable|integer', 'age' => 'nullable|integer', 'cedula' => 'nullable|string|max:255', 'fecha_nacimiento' => 'nullable|date', 'genero' => 'nullable|in:Masculino,Femenino,Otro', 'pais' => 'nullable|string|max:255', 'provincia' => 'nullable|string|max:255', 'ciudad' => 'nullable|string|max:255', 'celular' => 'nullable|string|max:20', 'email' => 'nullable|email|max:255|unique:users,email,' . $member->user_id, 'photo' => 'nullable|image|mimes:jpeg,png,jpg,gif|max:2048', 'lado_juego' => 'nullable|in:Derecho,Zurdo', 'tipo_juego' => 'nullable|in:Clásico,Lapicero', 'raqueta_marca' => 'nullable|string|max:255', 'raqueta_modelo' => 'nullable|string|max:255', 'drive_marca' => 'nullable|string|max:255', 'drive_modelo' => 'nullable|string|max:255', 'drive_tipo' => 'nullable|in:Antitopsping,Liso,Pupo Corto,Pupo Largo,Todos', 'drive_color' => 'nullable|in:Rojo,Negro', 'drive_esponja' => 'nullable|string|max:10', 'drive_hardness' => 'nullable|string|max:50', 'back_marca' => 'nullable|string|max:255', 'back_modelo' => 'nullable|string|max:255', 'back_tipo' => 'nullable|in:Antitopsping,Liso,Pupo Corto,Pupo Largo,Todos', 'back_color' => 'nullable|in:Rojo,Negro', 'back_esponja' => 'nullable|string|max:10', 'back_hardness' => 'nullable|string|max:50', 'password' => 'nullable|string|min:6' ]); DB::transaction(function () use ($member, $validatedData, $request) { // Manejar la foto si se actualiza if ($request->hasFile('photo')) { // Eliminar foto anterior si existe if ($member->photo && Storage::disk('public')->exists($member->photo)) { Storage::disk('public')->delete($member->photo); } // Guardar nueva foto $photoPath = $request->file('photo')->store('members/photos', 'public'); $validatedData['photo'] = $photoPath; } $member->update(collect($validatedData)->except(['email', 'password'])->toArray()); if ($member->user) { if ($request->filled('email')) { $member->user->email = $validatedData['email']; } if ($request->filled('password')) { $member->user->password = Hash::make($validatedData['password']); } $member->user->save(); } }); // Agregar URL de la foto if ($member->photo) { $member->photo_url = 'https://trollopy-ephraim-hypoxanthic.ngrok-free.dev/storage/' . $member->photo; } else { $member->photo_url = null; } return response()->json($member->load('club.ligas')); } public function destroy(Member $member): JsonResponse { DB::transaction(function() use ($member) { // Eliminar foto si existe if ($member->photo && Storage::disk('public')->exists($member->photo)) { Storage::disk('public')->delete($member->photo); } if ($member->user) { $member->user->delete(); } $member->delete(); }); return response()->json(['message' => 'Member eliminado correctamente']); } /** * Solicitar traspaso a otro club */ public function requestTransfer(Request $request, Member $member): JsonResponse { try { $validatedData = $request->validate([ 'club_nuevo_id' => 'required|exists:clubs,id', 'motivo' => 'nullable|string|max:500', ]); $clubActual = $member->club; $clubNuevo = Club::find($validatedData['club_nuevo_id']); if (!$clubActual) { return response()->json([ 'success' => false, 'message' => 'No tienes un club asignado actualmente.', ], 422); } if ($clubActual->id === $clubNuevo->id) { return response()->json([ 'success' => false, 'message' => 'Ya perteneces a este club.', ], 422); } if (!$member->puede_traspasar) { $diasRestantes = abs($member->dias_para_traspaso); $fechaPermitida = $member->getFechaProximoTraspasoPermitido()->format('d/m/Y'); return response()->json([ 'success' => false, 'message' => "Debes esperar {$diasRestantes} días para poder hacer un traspaso. Podrás traspasar después del {$fechaPermitida}.", 'dias_restantes' => $diasRestantes, 'fecha_permitida' => $fechaPermitida, ], 422); } DB::transaction(function () use ($member, $clubActual, $clubNuevo, $validatedData) { MemberTransfer::create([ 'member_id' => $member->id, 'club_anterior_id' => $clubActual->id, 'club_nuevo_id' => $clubNuevo->id, 'fecha_traspaso' => now(), 'motivo' => $validatedData['motivo'] ?? null, 'estado' => 'aprobado', ]); $member->update([ 'club_id' => $clubNuevo->id, 'fecha_ingreso_club' => now() ]); }); // Agregar URL de la foto $memberRefresh = $member->fresh()->load('club.ligas'); if ($memberRefresh->photo) { $memberRefresh->photo_url = 'https://trollopy-ephraim-hypoxanthic.ngrok-free.dev/storage/' . $memberRefresh->photo; } return response()->json([ 'success' => true, 'message' => "Traspaso realizado exitosamente al club '{$clubNuevo->nombre}'.", 'member' => $memberRefresh, 'proximo_traspaso_permitido' => Carbon::now()->addMonths(6)->format('d/m/Y'), ], 200); } catch (ValidationException $e) { return response()->json([ 'success' => false, 'message' => 'Error de validación.', 'errors' => $e->errors(), ], 422); } } /** * Obtiene la lista de clubes disponibles para traspaso */ public function getAvailableClubs(Member $member): JsonResponse { $clubActual = $member->club; if (!$clubActual) { $availableClubs = Club::with('ligas') ->orderBy('nombre') ->get(); } else { $availableClubs = Club::with('ligas') ->where('id', '!=', $clubActual->id) ->orderBy('nombre') ->get(); } return response()->json([ 'success' => true, 'clubs' => $availableClubs, 'current_club' => $clubActual, 'puede_traspasar' => $member->puede_traspasar, 'dias_para_traspaso' => $member->puede_traspasar ? 0 : abs($member->dias_para_traspaso), 'fecha_proximo_traspaso' => $member->puede_traspasar ? null : $member->getFechaProximoTraspasoPermitido()->format('d/m/Y'), ]); } /** * Obtener historial de traspasos de un miembro */ public function getTransferHistory(Member $member): JsonResponse { $transfers = $member->transfersRealizados() ->with(['clubAnterior.ligas', 'clubNuevo.ligas']) ->orderBy('fecha_traspaso', 'desc') ->get(); return response()->json([ 'success' => true, 'transfers' => $transfers, 'total_transfers' => $transfers->count(), ]); } /** * Verificar si un miembro puede hacer traspaso */ public function checkTransferEligibility(Member $member): JsonResponse { $ultimoTraspaso = $member->ultimoTraspaso; $clubActual = $member->club; $data = [ 'puede_traspasar' => $member->puede_traspasar, 'club_actual' => $clubActual, ]; if (!$member->puede_traspasar && $ultimoTraspaso) { $data['ultimo_traspaso'] = [ 'fecha' => $ultimoTraspaso->fecha_traspaso->format('d/m/Y H:i'), 'club_anterior' => $ultimoTraspaso->clubAnterior->nombre, 'club_nuevo' => $ultimoTraspaso->clubNuevo->nombre, ]; $data['dias_restantes'] = abs($member->dias_para_traspaso); $data['fecha_permitida'] = $member->getFechaProximoTraspasoPermitido()->format('d/m/Y'); $data['mensaje'] = "Debes esperar {$data['dias_restantes']} días más para poder hacer otro traspaso."; } else { $data['mensaje'] = 'Puedes realizar un traspaso cuando lo desees.'; } return response()->json([ 'success' => true, 'data' => $data, ]); } /** * Actualizar el ranking de un miembro en una liga específica * POST /api/members/{member}/update-ranking */ public function updateRanking(Request $request, Member $member): JsonResponse { try { $validatedData = $request->validate([ 'ranking' => 'required|integer|min:0', 'liga_id' => 'required|exists:ligas,id', ]); $newRanking = $validatedData['ranking']; $ligaId = $validatedData['liga_id']; // Obtener el ranking actual de esa liga $currentRanking = $member->getRankingForLiga($ligaId); // Calcular el cambio $change = $newRanking - $currentRanking; // Crear el registro en el historial RankingHistory::create([ 'member_id' => $member->id, 'club_id' => $member->club_id, 'liga_id' => $ligaId, 'ranking' => $newRanking, 'previous_ranking' => $currentRanking, 'change' => $change, 'game_id' => null, 'tournament_id' => null, 'reason' => 'manual_adjustment' ]); return response()->json([ 'success' => true, 'message' => 'Ranking actualizado exitosamente', 'data' => [ 'member_id' => $member->id, 'name' => $member->name, 'liga_id' => $ligaId, 'previous_ranking' => $currentRanking, 'new_ranking' => $newRanking, 'change' => $change ] ], 200); } catch (ValidationException $e) { return response()->json([ 'success' => false, 'message' => 'Error de validación.', 'errors' => $e->errors(), ], 422); } catch (\Exception $e) { return response()->json([ 'success' => false, 'message' => 'Error al actualizar el ranking.', 'error' => $e->getMessage() ], 500); } } /** * Obtener opciones de configuración para formularios * GET /api/members/options */ public function getOptions(): JsonResponse { return response()->json([ 'success' => true, 'options' => [ 'lados_juego' => Member::getLadosJuego(), 'tipos_juego' => Member::getTiposJuego(), 'tipos_caucho' => Member::getTiposCaucho(), 'colores_caucho' => Member::getColoresCaucho(), 'esponjas' => Member::getEsponjas(), 'hardness' => Member::getHardness(), 'marcas_drive' => Member::getMarcasDrive(), 'modelos_drive' => Member::getModelosDrive(), 'marcas_back' => Member::getMarcasBack(), 'modelos_back' => Member::getModelosBack(), 'marcas_raqueta' => Member::getMarcasRaqueta(), ] ]); } public function getPrizes(Request $request, Member $member): JsonResponse { $request->validate([ 'liga_id' => 'required|exists:ligas,id' ]); $ligaId = $request->input('liga_id'); // Obtener todos los resultados del jugador en esa liga $results = TournamentResult::forMember($member->id) ->forLiga($ligaId) ->with(['tournament:id,name,date', 'prize']) ->orderBy('final_position') ->get(); // Agrupar premios por tipo $prizesSummary = [ 'monetary_total' => 0, 'other_prizes' => [], 'total_tournaments' => $results->count(), 'positions' => [ 'first' => 0, 'second' => 0, 'third' => 0, 'other' => 0, ], 'details' => [] ]; foreach ($results as $result) { // Contar posiciones if ($result->final_position === 1) { $prizesSummary['positions']['first']++; } elseif ($result->final_position === 2) { $prizesSummary['positions']['second']++; } elseif ($result->final_position === 3) { $prizesSummary['positions']['third']++; } else { $prizesSummary['positions']['other']++; } // Procesar premios if ($result->prize) { if ($result->prize->type === 'Monetario') { $prizesSummary['monetary_total'] += $result->prize->monetary_value; } else { $prizeName = $result->prize->other_prize; if (!isset($prizesSummary['other_prizes'][$prizeName])) { $prizesSummary['other_prizes'][$prizeName] = 0; } $prizesSummary['other_prizes'][$prizeName]++; } } // Agregar detalles $prizesSummary['details'][] = [ 'tournament_name' => $result->tournament->name, 'tournament_date' => $result->tournament->date, 'position' => $result->final_position, 'position_label' => $result->position_label, 'prize' => $result->prize ? [ 'type' => $result->prize->type, 'value' => $result->prize->formatted_value, 'description' => $result->prize->description, ] : null, ]; } return response()->json([ 'success' => true, 'member' => [ 'id' => $member->id, 'name' => $member->nombre_completo, ], 'liga_id' => $ligaId, 'summary' => $prizesSummary, ]); } public function getStatistics(Member $member, Request $request) { $request->validate([ 'liga_id' => 'required|exists:ligas,id' ]); $ligaId = $request->input('liga_id'); // Obtener todos los torneos de la liga en los que participó $tournaments = Tournament::where('liga_id', $ligaId) ->whereHas('members', function($query) use ($member) { $query->where('member_id', $member->id); }) ->pluck('id'); // Obtener todos los juegos del jugador en esos torneos $games = Game::whereIn('tournament_id', $tournaments) ->where(function($query) use ($member) { $query->where('member1_id', $member->id) ->orWhere('member2_id', $member->id); }) ->whereIn('status', ['completed', 'wo']) // Incluir WO pero excluir BYE ->get(); // Inicializar contadores $pointsScored = 0; $pointsReceived = 0; $matchesPlayed = 0; $matchesWon = 0; $matchesLost = 0; foreach ($games as $game) { // Solo contar si no es BYE if ($game->status === 'bye') { continue; } $matchesPlayed++; // Determinar si el jugador es member1 o member2 $isMember1 = ($game->member1_id === $member->id); // Contar victorias/derrotas if ($game->winner_id === $member->id) { $matchesWon++; } else if ($game->winner_id) { $matchesLost++; } // Sumar puntos solo de juegos completed (no WO) if ($game->status === 'completed' && $game->sets && is_array($game->sets)) { foreach ($game->sets as $set) { if ($isMember1) { $pointsScored += $set['score1'] ?? 0; $pointsReceived += $set['score2'] ?? 0; } else { $pointsScored += $set['score2'] ?? 0; $pointsReceived += $set['score1'] ?? 0; } } } } return response()->json([ 'member_id' => $member->id, 'member_name' => $member->name, 'liga_id' => $ligaId, 'statistics' => [ 'points_scored' => $pointsScored, 'points_received' => $pointsReceived, 'point_difference' => $pointsScored - $pointsReceived, 'matches_played' => $matchesPlayed, 'matches_won' => $matchesWon, 'matches_lost' => $matchesLost, 'win_rate' => $matchesPlayed > 0 ? round(($matchesWon / $matchesPlayed) * 100, 2) : 0, 'tournaments_participated' => $tournaments->count(), ] ]); } } tournament = $tournament; $this->member = $member; $this->memberGames = $memberGames; } /** * Build the message. */ public function build() { $subject = "¡Brackets Generados! - {$this->tournament->name}"; return $this->subject($subject) ->view('emails.bracket-generated') ->with([ 'tournamentName' => $this->tournament->name, 'tournamentDate' => $this->tournament->date, 'tournamentTime' => $this->tournament->time, 'tournamentAddress' => $this->tournament->address, 'tournamentCity' => $this->tournament->city, 'memberName' => $this->member->nombre_completo, 'games' => $this->memberGames, 'eliminationType' => $this->tournament->elimination_type, 'totalGames' => $this->tournament->games()->count(), ]); } } belongsTo(User::class); } /** * Relación hasMany: Un club tiene muchos miembros */ public function members() { return $this->hasMany(Member::class); } /** * Relación many-to-many: Un club puede estar en varias ligas */ public function ligas() { return $this->belongsToMany(Liga::class, 'club_liga') ->withTimestamps(); } public function tournaments() { return $this->hasMany(Tournament::class); } } 'array', ]; public function tournament(): BelongsTo { return $this->belongsTo(Tournament::class); } public function member1(): BelongsTo { return $this->belongsTo(Member::class, 'member1_id'); } public function member2(): BelongsTo { return $this->belongsTo(Member::class, 'member2_id'); } public function winner(): BelongsTo { return $this->belongsTo(Member::class, 'winner_id'); } // ✅ NUEVO: Relación con el jugador ausente public function absentPlayer(): BelongsTo { return $this->belongsTo(Member::class, 'absent_player_id'); } /** * Valida si un set es válido */ public static function isValidSet($score1, $score2) { if ($score1 < 0 || $score2 < 0) { return false; } if ($score1 < 11 && $score2 < 11) { return false; } if ($score1 >= 11 || $score2 >= 11) { $diff = abs($score1 - $score2); if ($diff < 2) { return false; } } return true; } /** * Determina el ganador de un set */ public static function getSetWinner($score1, $score2) { if ($score1 > $score2 && $score1 >= 11 && ($score1 - $score2) >= 2) { return 1; } elseif ($score2 > $score1 && $score2 >= 11 && ($score2 - $score1) >= 2) { return 2; } return null; } /** * ✅ NUEVO: Verifica si el juego es un BYE */ public function isByeGame(): bool { return $this->status === 'bye' || is_null($this->member2_id); } /** * ✅ NUEVO: Verifica si el juego es un Walk Over (WO) */ public function isWalkOver(): bool { return $this->status === 'wo'; } /** * ✅ NUEVO: Verifica si el juego afecta el ranking * Un juego afecta el ranking solo si: * - Está completado normalmente (no es BYE ni WO) * - El torneo tiene affects_ranking = true */ public function shouldAffectRanking(): bool { if ($this->status !== 'completed') { return false; } if ($this->isByeGame() || $this->isWalkOver()) { return false; } return true; } /** * Scope para excluir juegos con BYE */ public function scopeNotByes($query) { return $query->where('status', '!=', 'bye'); } /** * ✅ NUEVO: Scope para excluir WO */ public function scopeNotWalkOvers($query) { return $query->where('status', '!=', 'wo'); } /** * ✅ NUEVO: Scope para solo juegos que afectan ranking */ public function scopeAffectsRanking($query) { return $query->where('status', 'completed') ->whereNotIn('status', ['bye', 'wo']); } } belongsTo(User::class); } public function deporte() { return $this->belongsTo(Deporte::class); } public function clubs() { return $this->belongsToMany(Club::class, 'club_liga') ->withTimestamps(); } public function categorias() { return $this->hasMany(Categoria::class); } } 'date', 'fecha_ingreso_club' => 'datetime', ]; protected $appends = ['puede_traspasar', 'dias_para_traspaso', 'nombre_completo']; protected $with = ['club']; /** * Accessor para obtener el nombre completo */ public function getNombreCompletoAttribute() { $parts = array_filter([ $this->name, $this->segundo_nombre, $this->primer_apellido, $this->segundo_apellido ]); return implode(' ', $parts); } public function tournaments() { return $this->belongsToMany(Tournament::class, 'tournament_registrations'); } public function user() { return $this->belongsTo(User::class); } /** * Relación belongsTo: Un miembro pertenece a un solo club */ public function club() { return $this->belongsTo(Club::class)->with('ligas'); } /** * Historial de traspasos */ public function transfersRealizados() { return $this->hasMany(MemberTransfer::class, 'member_id'); } /** * Último traspaso realizado */ public function ultimoTraspaso() { return $this->hasOne(MemberTransfer::class, 'member_id')->latest('fecha_traspaso'); } /** * Relación con historial de rankings */ public function rankingHistory() { return $this->hasMany(RankingHistory::class); } /** * Obtener el ranking actual del miembro en una liga específica */ public function getRankingForLiga($ligaId) { $latestRanking = RankingHistory::forMember($this->id) ->forLiga($ligaId) ->orderBy('created_at', 'desc') ->first(); return $latestRanking ? $latestRanking->ranking : 1000; } /** * Obtener el último cambio de ranking en una liga específica */ public function getLatestChangeForLiga($ligaId) { $latestChange = RankingHistory::forMember($this->id) ->forLiga($ligaId) ->orderBy('created_at', 'desc') ->first(); return $latestChange ? $latestChange->change : null; } /** * Accessor para saber si puede hacer traspaso */ public function getPuedeTraspasarAttribute() { $ultimoTraspaso = $this->ultimoTraspaso; if (!$ultimoTraspaso) { return true; } $fechaTraspaso = Carbon::parse($ultimoTraspaso->fecha_traspaso); $fechaPermitida = $fechaTraspaso->addMonths(6); return Carbon::now()->gte($fechaPermitida); } /** * Accessor para saber cuántos días faltan para poder traspasar */ public function getDiasParaTraspasoAttribute() { if ($this->puede_traspasar) { return 0; } $ultimoTraspaso = $this->ultimoTraspaso; $fechaTraspaso = Carbon::parse($ultimoTraspaso->fecha_traspaso); $fechaPermitida = $fechaTraspaso->addMonths(6); return Carbon::now()->diffInDays($fechaPermitida, false); } /** * Método para obtener la fecha en que podrá hacer el próximo traspaso */ public function getFechaProximoTraspasoPermitido() { if ($this->puede_traspasar) { return null; } $ultimoTraspaso = $this->ultimoTraspaso; $fechaTraspaso = Carbon::parse($ultimoTraspaso->fecha_traspaso); return $fechaTraspaso->addMonths(6); } // ✅ AQUÍ van estos métodos (FUERA del método anterior) /** * Resultados en torneos */ public function tournamentResults() { return $this->hasMany(TournamentResult::class); } /** * Obtener premios ganados en una liga específica */ public function getPrizesForLiga($ligaId) { return TournamentResult::forMember($this->id) ->forLiga($ligaId) ->with(['tournament', 'prize']) ->orderBy('created_at', 'desc') ->get(); } // ✅ Luego siguen los métodos estáticos public static function getLadosJuego() { return ['Derecho', 'Zurdo']; } public static function getTiposJuego() { return ['Clásico', 'Lapicero']; } public static function getTiposCaucho() { return [ 'Antitopsping', 'Liso', 'Pupo Corto', 'Pupo Largo', 'Todos' ]; } public static function getColoresCaucho() { return ['Rojo', 'Negro']; } public static function getEsponjas() { return ['1.5', '1.8', '2.0', '2.2', 'MAX']; } public static function getHardness() { return array_map('strval', range(30, 50)); } public static function getMarcasDrive() { return ['Friendship']; } public static function getModelosDrive() { return ['Cross 729']; } public static function getMarcasBack() { return ['Saviga']; } public static function getModelosBack() { return ['V']; } public static function getMarcasRaqueta() { return [ 'Butterfly', 'Stiga', 'Donic', 'Yasaka', 'Xiom', 'Tibhar', 'Joola', 'DHS', 'Nittaku' ]; } } hasMany(TournamentPrize::class)->ordered(); } // Relaciones existentes (mantener) public function club() { return $this->belongsTo(Club::class); } public function liga() { return $this->belongsTo(Liga::class); } public function members() { return $this->belongsToMany(Member::class, 'tournament_registrations') ->withTimestamps(); } public function games() { return $this->hasMany(Game::class); } } 'datetime', 'next_send_at' => 'datetime', 'send_details' => 'array', ]; public function tournament() { return $this->belongsTo(Tournament::class); } } Brackets Generados

🎾 ¡Brackets Generados!

{{ $tournamentName }}

Hola {{ $memberName }},

Los brackets del torneo han sido generados exitosamente. A continuación encontrarás la información de tus partidos programados:

📅 Fecha: {{ \Carbon\Carbon::parse($tournamentDate)->format('d/m/Y') }}

🕒 Hora: {{ $tournamentTime }}

📍 Lugar: {{ $tournamentAddress }}

🏙️ Ciudad: {{ $tournamentCity }}

🎯 Modalidad: @if($eliminationType === 'direct') Eliminación Directa @elseif($eliminationType === 'groups') Por Grupos @elseif($eliminationType === 'round_robin') Todos contra Todos @else Mixta @endif

Tus Partidos Programados

@if(count($games) > 0) @foreach($games as $game)
@if($game->group_name) {{ $game->group_name }} @elseif($game->round) Ronda {{ $game->round }} @else Partido @endif @if($game->status === 'bye') BYE @elseif($game->status === 'pending') PENDIENTE @elseif($game->status === 'waiting_for_winner' || $game->status === 'waiting_for_groups') EN ESPERA @endif
@if($game->status === 'bye')
✓ Pasas automáticamente a la siguiente ronda (BYE)
@elseif(!$game->member2_id)
{{ $memberName }} @if($game->member1->club)
{{ $game->member1->club->nombre }} @endif
Esperando rival...
@else @if($game->member1_id === $member->id)
{{ $memberName }} (TÚ) @if($game->member1->club)
{{ $game->member1->club->nombre }} @endif
VS
{{ $game->member2->nombre_completo }} @if($game->member2->club)
{{ $game->member2->club->nombre }} @endif
@else
{{ $game->member1->nombre_completo }} @if($game->member1->club)
{{ $game->member1->club->nombre }} @endif
VS
{{ $memberName }} (TÚ) @if($game->member2->club)
{{ $game->member2->club->nombre }} @endif
@endif @endif
@endforeach @else

No tienes partidos programados en esta etapa. Los brackets se actualizarán conforme avance el torneo.

@endif

Total de partidos en el torneo: {{ $totalGames }}

group(function () { // Traspasos (NUEVO) Route::get('/available-clubs', [MemberController::class, 'getAvailableClubs']); Route::post('/transfer', [MemberController::class, 'requestTransfer']); Route::get('/transfer-history', [MemberController::class, 'getTransferHistory']); Route::get('/transfer-eligibility', [MemberController::class, 'checkTransferEligibility']); // ✅ NUEVA RUTA: Actualizar ranking por liga Route::post('/update-ranking', [MemberController::class, 'updateRanking']); Route::get('statistics', [MemberController::class, 'getStatistics']); // Ranking History Route::get('ranking-history', [RankingHistoryController::class, 'show']); Route::get('ranking-history/detailed', [RankingHistoryController::class, 'detailed']); Route::get('ranking-stats', [RankingHistoryController::class, 'stats']); }); Route::get('/tournaments/prize-options', [TournamentController::class, 'getPrizeOptions']); // CRUD de premios individuales (opcional, si quieres endpoints específicos) Route::prefix('tournaments/{tournament}/prizes')->group(function () { Route::post('/', [TournamentPrizeController::class, 'store']); Route::put('/{prize}', [TournamentPrizeController::class, 'update']); Route::delete('/{prize}', [TournamentPrizeController::class, 'destroy']); }); // Nuevas rutas para gestionar múltiples roles Route::prefix('users')->group(function () { Route::post('find-by-email', [UserRolesController::class, 'findByEmail']); Route::prefix('{user}')->group(function () { Route::get('roles', [UserRolesController::class, 'index']); Route::post('roles', [UserRolesController::class, 'addRole']); Route::delete('roles/{userRole}', [UserRolesController::class, 'removeRole']); Route::put('primary-role', [UserRolesController::class, 'setPrimaryRole']); }); }); Route::get('/tournaments/{tournament}/members', [TournamentController::class, 'showMembers']); Route::put('/games/{id}', [GameController::class, 'update']); Route::post('/games/{id}/walkover', [GameController::class, 'registerWalkOver']); // --- Superadmin Routes --- Route::prefix('superadmin')->group(function () { Route::get('/users', [SuperadminController::class, 'listUsers']); Route::post('/users/status', [SuperadminController::class, 'updateUserStatus']); Route::delete('/users/{user}', [SuperadminController::class, 'destroy'])->where('user', '[0-9]+'); }); tournament = $tournament; $this->member = $member; } public function envelope() { return new Envelope( subject: 'Invitación al Torneo: ' . $this->tournament->name, ); } public function content() { return new Content( htmlString: $this->buildHtmlContent(), ); } protected function buildHtmlContent() { // Calcular URL de la imagen desde main_image_path $imageUrl = null; if ($this->tournament->main_image_path) { $imageUrl = 'https://trollopy-ephraim-hypoxanthic.ngrok-free.dev' . \Storage::url($this->tournament->main_image_path); } // Si no hay imagen, usar placeholder if (!$imageUrl) { $imageUrl = 'https://i.postimg.cc/pT9qzSJn/PUBLI-MOODLE-1.png'; } $tournamentUrl = 'https://gorankedsv2.vercel.app/member-home'; $clubName = $this->tournament->club->nombre ?? 'Club'; $leagueName = $this->tournament->liga->name ?? 'Liga'; $tournamentCode = $this->tournament->tournament_code ?? '---'; // Nombre completo: name + primer_apellido $memberFullName = trim(($this->member->name ?? '') . ' ' . ($this->member->primer_apellido ?? '')); if (empty($memberFullName)) { $memberFullName = 'Jugador'; } $memberRanking = $this->member->getRankingForLiga($this->tournament->liga_id) ?? '---'; $memberAge = $this->member->age ?? '---'; $modality = $this->tournament->modality ?? 'Singles'; $rankingText = $this->tournament->ranking_all ? 'Todos' : "{$this->tournament->ranking_from} - {$this->tournament->ranking_to}"; $ageText = $this->tournament->age_all ? 'Todas las edades' : "{$this->tournament->age_from} - {$this->tournament->age_to} años"; $genderMap = [ 'masculino' => 'Masculino', 'femenino' => 'Femenino', 'todos' => 'Todos' ]; $genderText = $genderMap[strtolower($this->tournament->gender)] ?? 'Todos'; $registrationDeadline = Carbon::parse($this->tournament->registration_deadline)->format('d-m-Y'); $currentYear = date('Y'); return " Invitación Torneo {$this->tournament->name}
{$this->tournament->name}

{$this->tournament->name}

{$leagueName}

COD: {$tournamentCode}

{$memberFullName}

¡Estás invitado al torneo!

RANKING
{$memberRanking}
EDAD
{$memberAge}
CLUB
{$clubName}
MODALIDAD
{$modality}
RANKING
{$rankingText}
EDAD
{$ageText}
GÉNERO
{$genderText}
CÓDIGO
{$tournamentCode}

Fecha límite de inscripción: {$registrationDeadline}

INSCRIPCIÓN

{$clubName} - {$this->tournament->name} {$currentYear}

"; } public function attachments() { return []; } }