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
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->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}
{$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 [];
}
}