Agrego archivos iniciales

This commit is contained in:
Laucha1312
2026-06-04 14:47:50 -03:00
commit ed94601e34
76 changed files with 7737 additions and 0 deletions
+138
View File
@@ -0,0 +1,138 @@
<?php
namespace App\Services;
use App\Models\Torneo;
use App\Models\Equipo;
use Carbon\Carbon;
class FixtureService
{
/**
* Genera un fixture Round-Robin para los equipos de un torneo.
*
* @param Torneo $torneo
* @param string $fechaInicio 'Y-m-d'
* @param int $diasEntreJornadas
* @param string $sedeDefault
* @param bool $dobleRueda Si true, genera ida y vuelta
* @return array Array de arrays con los datos de cada evento a crear
*/
public function generarRoundRobin(
Torneo $torneo,
string $fechaInicio,
int $diasEntreJornadas = 7,
?string $sedeDefault = '',
bool $dobleRueda = false
): array {
$equipos = $torneo->equipos()->get()->values()->toArray();
$n = count($equipos);
if ($n < 2) {
throw new \InvalidArgumentException('Se necesitan al menos 2 equipos para generar un fixture.');
}
// Si número impar, agregar BYE
$hayBye = false;
if ($n % 2 !== 0) {
$equipos[] = null; // BYE
$n++;
$hayBye = true;
}
$mitad = $n / 2;
$jornadas = $n - 1;
$partidos = [];
$fechaActual = Carbon::parse($fechaInicio);
// Algoritmo Round-Robin: fijar el primer equipo, rotar el resto
$lista = range(0, $n - 1);
for ($jornada = 0; $jornada < $jornadas; $jornada++) {
$jornadaPartidos = [];
for ($i = 0; $i < $mitad; $i++) {
$localIdx = $lista[$i];
$visitanteIdx= $lista[$n - 1 - $i];
// Si alguno es BYE (null), saltar
if ($equipos[$localIdx] === null || $equipos[$visitanteIdx] === null) continue;
$local = $equipos[$localIdx];
$visitante = $equipos[$visitanteIdx];
// Alternar localía: en jornadas pares, invertir
if ($jornada % 2 === 1) {
[$local, $visitante] = [$visitante, $local];
}
$jornadaPartidos[] = [
'id_equipo_local' => $local['id_equipo'],
'id_equipo_visitante' => $visitante['id_equipo'],
'fecha_evento' => $fechaActual->format('Y-m-d'),
'hora_inicio' => '20:00:00',
'hora_fin' => '22:00:00',
'sede' => $sedeDefault,
'id_torneo' => $torneo->id,
'nombre_evento' => null, // se genera automáticamente
'precio' => 0,
'jornada' => $jornada + 1,
];
}
$partidos = array_merge($partidos, $jornadaPartidos);
$fechaActual->addDays($diasEntreJornadas);
// Rotar la lista (el primer elemento fijo)
$rotatable = array_slice($lista, 1);
array_push($rotatable, array_shift($rotatable));
$lista = array_merge([$lista[0]], $rotatable);
}
// Doble rueda: agregar vuelta con localías invertidas
if ($dobleRueda && !empty($partidos)) {
$vuelta = [];
foreach ($partidos as $p) {
$vuelta[] = array_merge($p, [
'id_equipo_local' => $p['id_equipo_visitante'],
'id_equipo_visitante' => $p['id_equipo_local'],
'fecha_evento' => Carbon::parse($p['fecha_evento'])->addDays($jornadas * $diasEntreJornadas)->format('Y-m-d'),
'jornada' => $p['jornada'] + $jornadas,
]);
}
$partidos = array_merge($partidos, $vuelta);
}
return $partidos;
}
/**
* Persiste el fixture generado como Evento records en la DB.
*/
public function persistirFixture(array $partidos, Torneo $torneo): int
{
$contador = 0;
foreach ($partidos as $p) {
$local = Equipo::with('club')->find($p['id_equipo_local']);
$visitante = Equipo::with('club')->find($p['id_equipo_visitante']);
$nombreEvento = ($local->club->nombre ?? '?') . ' vs ' . ($visitante->club->nombre ?? '?');
\App\Models\Evento::create([
'id_evento' => uniqid('ev_'),
'nombre_evento' => $nombreEvento,
'id_equipo_local' => $p['id_equipo_local'],
'id_equipo_visitante' => $p['id_equipo_visitante'],
'fecha_evento' => $p['fecha_evento'],
'hora_inicio' => $p['hora_inicio'],
'hora_fin' => $p['hora_fin'],
'sede' => $p['sede'] ?: null,
'id_torneo' => $torneo->id,
'precio' => $p['precio'] ?? 0,
]);
$contador++;
}
return $contador;
}
}
+229
View File
@@ -0,0 +1,229 @@
<?php
namespace App\Services;
use App\AI\Prompts\SystemPromptAdmin;
use App\AI\Prompts\SystemPromptPublic;
use App\AI\Tools\CargarPuntajeTool;
use App\AI\Tools\CrearPartidoTool;
use App\AI\Tools\EliminarNoticiaTool;
use App\AI\Tools\EliminarPartidoTool;
use App\AI\Tools\ListarEquiposTool;
use App\AI\Tools\ListarEventosTool;
use App\AI\Tools\ListarTorneosTool;
use App\AI\Tools\RedactarNoticiaTool;
use App\Models\AgentThread;
use Illuminate\Support\Facades\Log;
use Prism\Prism\Exceptions\PrismRateLimitedException;
use Prism\Prism\Facades\Prism;
use Prism\Prism\Facades\Tool;
use Prism\Prism\ValueObjects\Messages\AssistantMessage;
use Prism\Prism\ValueObjects\Messages\UserMessage;
use Throwable;
class GeniusAgentService
{
private const PROVIDER = 'gemini';
private const MAX_STEPS = 6;
private function model(): string
{
return config('services.genius.model', 'gemini-2.5-flash-lite');
}
public function chatPublic(string $message, array $history): string
{
$messages = $this->buildMessages($history, $message);
try {
$response = Prism::text()
->using(self::PROVIDER, $this->model())
->withSystemPrompt(SystemPromptPublic::get())
->withMessages($messages)
->asText();
return $response->text;
} catch (PrismRateLimitedException $e) {
report($e);
return 'Demasiadas consultas en poco tiempo. Por favor, esperá un momento e intentá de nuevo.';
} catch (Throwable $e) {
report($e);
return 'El agente no responde en este momento. Por favor, intentá de nuevo en unos instantes.';
}
}
public function chatAdmin(string $message, AgentThread $thread, bool $isSuperadmin = false): string
{
$messages = $this->buildMessages($thread->messages ?? [], $message);
try {
$response = Prism::text()
->using(self::PROVIDER, $this->model())
->withSystemPrompt(SystemPromptAdmin::get($isSuperadmin))
->withMessages($messages)
->withTools($this->getAdminTools($isSuperadmin))
->withMaxSteps(self::MAX_STEPS)
->asText();
if ($response->text !== '') {
$reply = $response->text;
} else {
Log::warning('Genius admin: empty text from model', [
'finishReason' => isset($response->finishReason)
? (is_object($response->finishReason) ? ($response->finishReason->name ?? get_class($response->finishReason)) : $response->finishReason)
: null,
'steps_count' => is_countable($response->steps ?? null) ? count($response->steps) : null,
'tool_calls' => is_countable($response->toolCalls ?? null) ? count($response->toolCalls) : null,
'tool_results' => is_countable($response->toolResults ?? null) ? count($response->toolResults) : null,
]);
$reply = $this->fallbackFromToolResults($response);
}
$updated = array_merge($thread->messages ?? [], [
['role' => 'user', 'content' => $message],
['role' => 'assistant', 'content' => $reply],
]);
$thread->update(['messages' => $updated]);
return $reply;
} catch (PrismRateLimitedException $e) {
report($e);
return 'Demasiadas consultas en poco tiempo. Por favor, esperá un momento e intentá de nuevo.';
} catch (Throwable $e) {
report($e);
return 'El agente no responde en este momento. Por favor, intentá de nuevo en unos instantes.';
}
}
private function fallbackFromToolResults($response): string
{
$collected = [];
$steps = $response->steps ?? [];
foreach ($steps as $step) {
if (!empty($step->text ?? '')) {
return $step->text;
}
foreach (($step->toolResults ?? []) as $tr) {
$collected[] = [
'tool' => $tr->toolName ?? ($tr->name ?? 'tool'),
'result' => $tr->result ?? null,
];
}
}
if (empty($collected)) {
foreach (($response->toolResults ?? []) as $tr) {
$collected[] = [
'tool' => $tr->toolName ?? ($tr->name ?? 'tool'),
'result' => $tr->result ?? null,
];
}
}
if (empty($collected)) {
return 'El agente no devolvió una respuesta. Revisá storage/logs/laravel.log para el detalle.';
}
$lines = ["Datos obtenidos (la IA no generó un resumen en texto):"];
foreach ($collected as $item) {
$decoded = is_string($item['result']) ? json_decode($item['result'], true) : $item['result'];
$pretty = is_array($decoded) || is_object($decoded)
? json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
: (string) $item['result'];
$lines[] = "\n**{$item['tool']}**:\n{$pretty}";
}
return implode("\n", $lines);
}
/** @return array<int, \Prism\Prism\Contracts\Message> */
private function buildMessages(array $history, string $newMessage): array
{
$limit = (int) config('services.genius.history_limit', 10);
$trimmed = $limit > 0 ? array_slice($history, -$limit * 2) : $history;
$messages = [];
foreach ($trimmed as $entry) {
if (($entry['role'] ?? '') === 'user') {
$messages[] = new UserMessage($entry['content']);
} elseif (($entry['role'] ?? '') === 'assistant') {
$messages[] = new AssistantMessage($entry['content']);
}
}
$messages[] = new UserMessage($newMessage);
return $messages;
}
/** @return array<int, \Prism\Prism\Tool> */
private function getAdminTools(bool $isSuperadmin): array
{
$readOnly = [
Tool::as('listar_torneos')
->for('Lista todos los torneos del sistema con sus IDs y fechas. Úsalo cuando el admin mencione un torneo por nombre para obtener el id_torneo.')
->using(new ListarTorneosTool()),
Tool::as('listar_equipos')
->for('Lista los equipos del sistema. Acepta filtros opcionales por torneo (id_torneo) y grupo.')
->withNumberParameter('id_torneo', 'ID del torneo para filtrar equipos', false)
->withStringParameter('grupo', 'Nombre del grupo (ej. "A", "B") para filtrar dentro del torneo', false)
->using(new ListarEquiposTool()),
Tool::as('listar_eventos')
->for('Lista los partidos/eventos del sistema. Acepta filtros opcionales por rango de fechas y torneo.')
->withStringParameter('fecha_desde', 'Fecha de inicio del rango (formato YYYY-MM-DD)', false)
->withStringParameter('fecha_hasta', 'Fecha de fin del rango (formato YYYY-MM-DD)', false)
->withNumberParameter('id_torneo', 'ID del torneo para filtrar eventos', false)
->using(new ListarEventosTool()),
];
if (!$isSuperadmin) {
return $readOnly;
}
$writeTools = [
Tool::as('crear_partido')
->for('Crea un nuevo partido en el sistema. SOLO ejecutar tras confirmación explícita del superadmin.')
->withNumberParameter('id_equipo_local', 'ID del equipo local')
->withNumberParameter('id_equipo_visitante', 'ID del equipo visitante')
->withStringParameter('fecha_evento', 'Fecha del partido (formato YYYY-MM-DD)')
->withStringParameter('hora_inicio', 'Hora de inicio (formato HH:MM)')
->withStringParameter('hora_fin', 'Hora de fin (formato HH:MM)')
->withStringParameter('sede', 'Nombre de la cancha o sede del partido')
->withNumberParameter('id_torneo', 'ID del torneo al que pertenece el partido')
->using(new CrearPartidoTool()),
Tool::as('cargar_puntaje')
->for('Actualiza el marcador de un partido existente. SOLO ejecutar tras confirmación explícita del superadmin.')
->withStringParameter('id_evento', 'UUID del partido a actualizar')
->withNumberParameter('marcador_local', 'Puntos del equipo local')
->withNumberParameter('marcador_visitante', 'Puntos del equipo visitante')
->using(new CargarPuntajeTool()),
Tool::as('redactar_noticia')
->for('Publica una noticia en el portal OnAPB. SOLO ejecutar tras confirmación explícita del superadmin.')
->withStringParameter('titulo', 'Título de la noticia')
->withStringParameter('contenido', 'Cuerpo completo de la noticia en HTML o texto plano')
->withNumberParameter('id_torneo', 'ID del torneo relacionado (opcional)', false)
->withStringParameter('categoria', 'Categoría de la noticia (opcional)', false)
->using(new RedactarNoticiaTool()),
Tool::as('eliminar_noticia')
->for('Elimina (rollback) una noticia publicada. Usar cuando el superadmin pida deshacer una creación.')
->withNumberParameter('id_noticia', 'ID numérico de la noticia a eliminar')
->using(new EliminarNoticiaTool()),
Tool::as('eliminar_partido')
->for('Elimina (soft delete / rollback) un partido creado. Usar cuando el superadmin pida deshacer.')
->withStringParameter('id_evento', 'UUID del partido a eliminar')
->using(new EliminarPartidoTool()),
];
return array_merge($readOnly, $writeTools);
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
namespace App\Services;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
class ImageOptimizer
{
/**
* Configuracion por carpeta. Una unica fuente de verdad — usada por el
* comando optimize:images y por los uploads de los controllers admin.
*/
public const FOLDERS = [
'carousel' => ['maxWidth' => 1600, 'quality' => 82],
'noticias' => ['maxWidth' => 1200, 'quality' => 82],
'promos' => ['maxWidth' => 1200, 'quality' => 82],
'clubes' => ['maxWidth' => 512, 'quality' => 85],
'sponsors' => ['maxWidth' => 600, 'quality' => 85],
'qr' => ['maxWidth' => 800, 'quality' => 85],
];
private const MIN_RECOMPRESS_BYTES = 100 * 1024;
/**
* Sube el archivo al disk 'public' y lo optimiza in-place.
* Retorna el path relativo (ej: "carousel/abc123.jpg").
*/
public function storeAndOptimize(UploadedFile $file, string $folder): string
{
$path = $file->store($folder, 'public');
if (!extension_loaded('gd')) {
return $path;
}
$cfg = self::FOLDERS[$folder] ?? null;
if (!$cfg) {
return $path;
}
$abs = Storage::disk('public')->path($path);
$this->optimizeFile($abs, $cfg['maxWidth'], $cfg['quality']);
return $path;
}
/**
* Optimiza un archivo en disco (resize + recompress).
* Devuelve [origSize, newSize, newW, newH] o null si no se modifico.
*/
public function optimizeFile(string $file, int $maxWidth, int $quality): ?array
{
if (!file_exists($file)) return null;
$origSize = filesize($file);
$info = @getimagesize($file);
if (!$info) return null;
[$w, $h] = $info;
$needsResize = $w > $maxWidth;
$needsRecomp = $origSize > self::MIN_RECOMPRESS_BYTES;
if (!$needsResize && !$needsRecomp) return null;
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
$img = match ($ext) {
'jpg', 'jpeg' => @imagecreatefromjpeg($file),
'png' => @imagecreatefrompng($file),
'webp' => @imagecreatefromwebp($file),
default => null,
};
if (!$img) return null;
$newW = $w; $newH = $h;
if ($w > $maxWidth) {
$newW = $maxWidth;
$newH = (int) round($h * ($maxWidth / $w));
$resized = imagecreatetruecolor($newW, $newH);
if (in_array($ext, ['png', 'webp'])) {
imagealphablending($resized, false);
imagesavealpha($resized, true);
$transparent = imagecolorallocatealpha($resized, 0, 0, 0, 127);
imagefilledrectangle($resized, 0, 0, $newW, $newH, $transparent);
}
imagecopyresampled($resized, $img, 0, 0, 0, 0, $newW, $newH, $w, $h);
$img = $resized;
}
ob_start();
match ($ext) {
'jpg', 'jpeg' => imagejpeg($img, null, $quality),
'png' => imagepng($img, null, 9),
'webp' => imagewebp($img, null, $quality),
};
$bytes = ob_get_clean();
if (strlen($bytes) >= $origSize) {
return null;
}
file_put_contents($file, $bytes);
return [$origSize, strlen($bytes), $newW, $newH];
}
}
+211
View File
@@ -0,0 +1,211 @@
<?php
namespace App\Services;
use App\Models\Notificacion;
use App\Models\PushSubscription;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Minishlink\WebPush\WebPush;
use Minishlink\WebPush\Subscription;
class NotificacionService
{
/**
* Enviar una notificación in-app a un usuario (jugador o aficionado).
*/
public function enviar(
string $tipo_dest,
string|int $id_dest,
string $tipo,
string $titulo,
string $mensaje,
?string $url = null
): Notificacion {
$notif = Notificacion::create([
'tipo_destinatario' => $tipo_dest,
'id_destinatario' => (string) $id_dest,
'tipo' => $tipo,
'titulo' => $titulo,
'mensaje' => $mensaje,
'url_accion' => $url,
'leida' => false,
'enviada_email' => false,
'creada_en' => now(),
]);
// Intentar envío Push
$this->enviarPush([['tipo' => $tipo_dest, 'id' => $id_dest]], $titulo, $mensaje, $url);
return $notif;
}
/**
* Enviar la misma notificación a múltiples destinatarios.
* $destinatarios = [['tipo' => 'jugador', 'id' => 'XXX'], ...]
*/
public function enviarMasivo(array $destinatarios, string $tipo, string $titulo, string $mensaje, ?string $url = null): int
{
$rows = [];
$ahora = now()->toDateTimeString();
foreach ($destinatarios as $dest) {
$rows[] = [
'tipo_destinatario' => $dest['tipo'],
'id_destinatario' => (string) $dest['id'],
'tipo' => $tipo,
'titulo' => $titulo,
'mensaje' => $mensaje,
'url_accion' => $url,
'leida' => false,
'enviada_email' => false,
'creada_en' => $ahora,
];
}
if (empty($rows)) return 0;
// Insert en chunks para no sobrecargar
foreach (array_chunk($rows, 500) as $chunk) {
Notificacion::insert($chunk);
}
// Intentar envío Push masivo
$this->enviarPush($destinatarios, $titulo, $mensaje, $url);
return count($rows);
}
/**
* Lógica centralizada para enviar Web Push Notifications
*/
private function enviarPush(array $destinatarios, string $titulo, string $mensaje, ?string $url = null): void
{
$vPublic = env('VAPID_PUBLIC_KEY');
$vPrivate = env('VAPID_PRIVATE_KEY');
if (!$vPublic || !$vPrivate) return;
// Buscar todas las suscripciones de estos usuarios
$subsFound = PushSubscription::where(function($query) use ($destinatarios) {
foreach ($destinatarios as $d) {
$query->orWhere(function($q) use ($d) {
$q->where('tipo_usuario', $d['tipo'])
->where('id_usuario', (string)$d['id']);
});
}
})->get();
if ($subsFound->isEmpty()) return;
try {
$auth = [
'VAPID' => [
'subject' => env('APP_URL', 'http://localhost'),
'publicKey' => $vPublic,
'privateKey' => $vPrivate,
],
];
$webPush = new WebPush($auth);
$payload = json_encode([
'title' => $titulo,
'body' => $mensaje,
'url' => $url ?: '/',
]);
foreach ($subsFound as $sub) {
$webPush->queueNotification(
Subscription::create([
'endpoint' => $sub->endpoint,
'publicKey' => $sub->p256dh,
'authToken' => $sub->auth,
]),
$payload
);
}
foreach ($webPush->flush() as $report) {
if (!$report->isSuccess()) {
// Si falló (ej: suscripción expirada), la borramos para no reintentar
if ($report->isSubscriptionExpired()) {
$endpoint = $report->getEndpoint();
PushSubscription::where('endpoint', $endpoint)->delete();
}
}
}
} catch (\Exception $e) {
Log::error("Error enviando Web Push: " . $e->getMessage());
}
}
/**
* Obtener notificaciones no leídas de un usuario.
*/
public function obtenerNoLeidas(string $tipo, string|int $id): Collection
{
return Notificacion::paraUsuario($tipo, $id)
->noLeidas()
->orderByDesc('creada_en')
->limit(50)
->get();
}
/**
* Obtener todas las notificaciones de un usuario (paginadas).
*/
public function obtenerTodas(string $tipo, string|int $id, int $perPage = 20)
{
return Notificacion::paraUsuario($tipo, $id)
->orderByDesc('creada_en')
->paginate($perPage);
}
/**
* Contar notificaciones no leídas.
*/
public function contarNoLeidas(string $tipo, string|int $id): int
{
return Notificacion::paraUsuario($tipo, $id)->noLeidas()->count();
}
/**
* Marcar una notificación como leída (verificando pertenencia).
*/
public function marcarLeida(int $id_notif, string $tipo, string|int $id_dest): bool
{
return (bool) Notificacion::paraUsuario($tipo, $id_dest)
->where('id', $id_notif)
->update(['leida' => true]);
}
/**
* Marcar todas como leídas para un usuario.
*/
public function marcarTodasLeidas(string $tipo, string|int $id_dest): int
{
return Notificacion::paraUsuario($tipo, $id_dest)
->noLeidas()
->update(['leida' => true]);
}
/**
* Eliminar una notificación (verificando pertenencia).
*/
public function eliminar(int $id_notif, string $tipo, string|int $id_dest): bool
{
return (bool) Notificacion::paraUsuario($tipo, $id_dest)
->where('id', $id_notif)
->delete();
}
/**
* Eliminar todas las notificaciones de un usuario.
*/
public function eliminarTodas(string $tipo, string|int $id_dest): int
{
return Notificacion::paraUsuario($tipo, $id_dest)->delete();
}
}
+149
View File
@@ -0,0 +1,149 @@
<?php
namespace App\Services;
use App\Models\Torneo;
use App\Models\Evento;
use Illuminate\Support\Facades\DB;
class TournamentService
{
/**
* Calcula la tabla de posiciones de un torneo.
*
* @param int $idTorneo
* @param bool $onlyRegular Si true, solo toma partidos de fase regular
* @return array
*/
public function getStandings(int $idTorneo, bool $onlyRegular = true): array
{
$torneo = Torneo::with('equipos.club')->findOrFail($idTorneo);
$stats = [];
foreach ($torneo->equipos as $equipo) {
$groupName = $equipo->pivot->grupo ?: ($equipo->categoria . ' ' . $equipo->division);
$stats[$groupName][$equipo->id_equipo] = [
'id' => $equipo->id_equipo,
'nombre' => $equipo->club->nombre ?? 'Equipo',
'logo' => $equipo->club->imagen ?? null,
'categoria' => $equipo->categoria,
'pj' => 0,
'pg' => 0,
'pp' => 0,
'tf' => 0,
'tc' => 0,
'pts' => 0,
];
}
$query = Evento::where('id_torneo', $idTorneo)
->whereNotNull('marcador_local')
->whereNotNull('marcador_visitante');
if ($onlyRegular) {
$query->where('fase', Evento::FASE_REGULAR);
}
$matches = $query->get();
foreach ($matches as $m) {
$localGroup = null;
$visitGroup = null;
foreach ($stats as $group => $teams) {
if (isset($teams[$m->id_equipo_local])) $localGroup = $group;
if (isset($teams[$m->id_equipo_visitante])) $visitGroup = $group;
}
if (!$localGroup || !$visitGroup) continue;
$stats[$localGroup][$m->id_equipo_local]['pj']++;
$stats[$localGroup][$m->id_equipo_local]['tf'] += $m->marcador_local;
$stats[$localGroup][$m->id_equipo_local]['tc'] += $m->marcador_visitante;
$stats[$visitGroup][$m->id_equipo_visitante]['pj']++;
$stats[$visitGroup][$m->id_equipo_visitante]['tf'] += $m->marcador_visitante;
$stats[$visitGroup][$m->id_equipo_visitante]['tc'] += $m->marcador_local;
if ($m->marcador_local > $m->marcador_visitante) {
$stats[$localGroup][$m->id_equipo_local]['pg']++;
$stats[$localGroup][$m->id_equipo_local]['pts'] += 2;
$stats[$visitGroup][$m->id_equipo_visitante]['pp']++;
$stats[$visitGroup][$m->id_equipo_visitante]['pts'] += 1;
} elseif ($m->marcador_visitante > $m->marcador_local) {
$stats[$visitGroup][$m->id_equipo_visitante]['pg']++;
$stats[$visitGroup][$m->id_equipo_visitante]['pts'] += 2;
$stats[$localGroup][$m->id_equipo_local]['pp']++;
$stats[$localGroup][$m->id_equipo_local]['pts'] += 1;
} else {
$stats[$localGroup][$m->id_equipo_local]['pts'] += 1;
$stats[$visitGroup][$m->id_equipo_visitante]['pts'] += 1;
}
}
foreach ($stats as $group => &$teams) {
usort($teams, function($a, $b) {
if ($b['pts'] !== $a['pts']) return $b['pts'] - $a['pts'];
$diffA = $a['tf'] - $a['tc'];
$diffB = $b['tf'] - $b['tc'];
if ($diffB !== $diffA) return $diffB - $diffA;
return $b['tf'] - $a['tf'];
});
}
return $stats;
}
public function getPlayoffBrackets(int $idTorneo): array
{
$playoffs = Evento::where('id_torneo', $idTorneo)
->where('fase', '>', Evento::FASE_REGULAR)
->with(['equipoLocal.club', 'equipoVisitante.club'])
->orderBy('fase')
->orderBy('numero_partido_bracket')
->orderBy('fecha_evento')
->get();
$bracket = [
Evento::FASE_CUARTOS => collect(),
Evento::FASE_SEMIS => collect(),
Evento::FASE_FINAL => collect(),
];
// Agrupar por fase y número de llave (bracket)
$grouped = $playoffs->groupBy(function($item) {
return $item->fase . '-' . $item->numero_partido_bracket;
});
foreach ($grouped as $key => $matches) {
$first = $matches->first();
$fase = $first->fase;
$nro = $first->numero_partido_bracket;
$winsLocal = 0;
$winsVisit = 0;
$finished = 0;
foreach ($matches as $m) {
if ($m->marcador_local !== null && $m->marcador_visitante !== null) {
$finished++;
if ($m->marcador_local > $m->marcador_visitante) $winsLocal++;
elseif ($m->marcador_visitante > $m->marcador_local) $winsVisit++;
}
}
$bracket[$fase][$nro] = [
'matches' => $matches,
'wins_local' => $winsLocal,
'wins_visitante' => $winsVisit,
'total_partidos' => $matches->count(),
'terminados' => $finished,
'equipo_local' => $first->equipoLocal,
'equipo_visitante' => $first->equipoVisitante,
];
}
return $bracket;
}
}