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
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class CleanupOldEvents extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:cleanup-old-events';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Elimina eventos y QRs antiguos según la configuración del sistema.';
/**
* Execute the console command.
*/
public function handle()
{
$dias = \App\Models\Configuracion::get('dias_expiracion_eventos', 30);
$fechaLimite = now()->subDays((int)$dias);
$eventosAEliminar = \App\Models\Evento::withTrashed()
->where('fecha_evento', '<', $fechaLimite->toDateString())
->get();
$total = $eventosAEliminar->count();
if ($total === 0) {
$this->info("No hay eventos antiguos para eliminar.");
return;
}
foreach ($eventosAEliminar as $evento) {
/** @var \App\Models\Evento $evento */
// Eliminar QRs asociados
$evento->qrCodes()->delete();
// Ya no eliminamos el evento para mantener registro de puntos y goleadores
// $evento->delete();
}
$this->info("Se han limpiado los QRs de $total eventos antiguos (Antigüedad > $dias días). Los eventos permanecen en el sistema.");
}
}
+196
View File
@@ -0,0 +1,196 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class OptimizeImages extends Command
{
protected $signature = 'optimize:images
{--apply : Aplica cambios. Sin esta flag corre en modo dry-run.}
{--folder= : Solo procesa esta carpeta (carousel|sponsors|qr|promos|noticias|clubes).}
{--no-backup : No crea backup de los originales.}';
protected $description = 'Comprime y redimensiona imagenes en storage/app/public/. Por defecto dry-run.';
private array $config = [
'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],
];
public function handle(): int
{
$apply = (bool) $this->option('apply');
$only = $this->option('folder');
$noBackup = (bool) $this->option('no-backup');
if (!extension_loaded('gd')) {
$this->error('La extension GD de PHP no esta instalada.');
return self::FAILURE;
}
// Usa la config real del disk 'public' (respeta override de Hostinger)
$base = realpath(config('filesystems.disks.public.root')) ?: config('filesystems.disks.public.root');
if (!is_dir($base)) {
$this->error("No existe: $base");
return self::FAILURE;
}
$this->line("Base: $base");
$backupBase = $base . '/_backup_optimize';
if ($apply && !$noBackup && !is_dir($backupBase)) {
mkdir($backupBase, 0755, true);
}
$this->line('');
$this->line($apply
? '<fg=red>MODO APLICACION</> — los archivos seran reemplazados.'
: '<fg=yellow>MODO DRY-RUN</> — no se modifica nada. Pasa --apply para aplicar.');
$this->line('');
$totalOrig = 0; $totalNew = 0; $totalProcessed = 0; $totalSkipped = 0;
foreach ($this->config as $folder => $cfg) {
if ($only && $only !== $folder) continue;
$path = $base . '/' . $folder;
if (!is_dir($path)) {
$this->warn(" - $folder/ (no existe, skip)");
continue;
}
$this->info("Carpeta: $folder/ (max {$cfg['maxWidth']}px, q={$cfg['quality']})");
$files = glob($path . '/*.{jpg,jpeg,png,webp,JPG,JPEG,PNG,WEBP}', GLOB_BRACE);
$folderOrig = 0; $folderNew = 0; $folderProcessed = 0; $folderSkipped = 0;
foreach ($files as $file) {
$origSize = filesize($file);
$totalOrig += $origSize; $folderOrig += $origSize;
$info = @getimagesize($file);
if (!$info) { $folderSkipped++; $totalSkipped++; continue; }
[$w, $h] = $info;
$needsResize = $w > $cfg['maxWidth'];
$needsRecomp = $origSize > 100 * 1024; // skip si ya pesa <100KB
if (!$needsResize && !$needsRecomp) {
$folderSkipped++; $totalSkipped++; $totalNew += $origSize; $folderNew += $origSize;
continue;
}
$result = $this->processImage($file, $cfg['maxWidth'], $cfg['quality']);
if ($result === null) {
$folderSkipped++; $totalSkipped++; $totalNew += $origSize; $folderNew += $origSize;
continue;
}
[$newBytes, $newW, $newH] = $result;
if ($newBytes >= $origSize) {
$folderSkipped++; $totalSkipped++; $totalNew += $origSize; $folderNew += $origSize;
$this->line(sprintf(" - %-40s %dKB no mejora, skip", basename($file), $origSize / 1024));
continue;
}
$folderProcessed++; $totalProcessed++;
$totalNew += $newBytes; $folderNew += $newBytes;
$reduction = round((1 - $newBytes / $origSize) * 100, 1);
$this->line(sprintf(
" %s %-40s %dKB -> %dKB (-%s%%) %dx%d -> %dx%d",
$apply ? '<fg=green>OK</>' : '<fg=cyan>--</>',
basename($file), $origSize / 1024, $newBytes / 1024, $reduction,
$w, $h, $newW, $newH
));
if ($apply) {
if (!$noBackup) {
$backupDir = $backupBase . '/' . $folder;
if (!is_dir($backupDir)) mkdir($backupDir, 0755, true);
copy($file, $backupDir . '/' . basename($file));
}
file_put_contents($file, $result[3]);
}
}
$this->line(sprintf(
" -> %s files procesados, %s skip, %s -> %s (-%s%%)",
$folderProcessed, $folderSkipped,
$this->fmt($folderOrig), $this->fmt($folderNew),
$folderOrig > 0 ? round((1 - $folderNew / max($folderOrig, 1)) * 100, 1) : 0
));
$this->line('');
}
$this->line(str_repeat('=', 60));
$this->info(sprintf(
'TOTAL: %d procesados, %d skip. %s -> %s (ahorro: %s, -%s%%)',
$totalProcessed, $totalSkipped,
$this->fmt($totalOrig), $this->fmt($totalNew),
$this->fmt($totalOrig - $totalNew),
$totalOrig > 0 ? round((1 - $totalNew / max($totalOrig, 1)) * 100, 1) : 0
));
if (!$apply && $totalProcessed > 0) {
$this->line('');
$this->warn('Para aplicar realmente: php artisan optimize:images --apply');
}
return self::SUCCESS;
}
private function processImage(string $file, int $maxWidth, int $quality): ?array
{
$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;
$w = imagesx($img); $h = imagesy($img);
$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();
return [strlen($bytes), $newW, $newH, $bytes];
}
private function fmt(int $bytes): string
{
if ($bytes < 1024) return $bytes . 'B';
if ($bytes < 1024 * 1024) return round($bytes / 1024, 1) . 'KB';
return round($bytes / 1024 / 1024, 2) . 'MB';
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Console\Commands;
use App\Models\AgentThread;
use Illuminate\Console\Command;
class PurgeAgentThreads extends Command
{
protected $signature = 'agent:purge-threads';
protected $description = 'Elimina los hilos de conversación del agente que hayan expirado.';
public function handle(): int
{
$deleted = AgentThread::where('expires_at', '<', now())->delete();
$this->info("Se eliminaron {$deleted} hilo(s) expirado(s).");
return self::SUCCESS;
}
}
@@ -0,0 +1,86 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Models\Evento;
use App\Models\EquipoSeguimiento;
use App\Services\NotificacionService;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
class RecordatorioPartidos extends Command
{
protected $signature = 'notificaciones:recordatorio-partido {--test : Simula sin guardar}';
protected $description = 'Envía recordatorios 48hs antes de cada partido a sus seguidores';
public function handle(NotificacionService $notifService): int
{
$desde = Carbon::now()->addHours(24);
$hasta = Carbon::now()->addHours(50); // ventana de ~26hs para no perder ninguno
$eventos = Evento::with(['equipoLocal.club', 'equipoVisitante.club'])
->whereNotNull('id_equipo_local')
->whereNotNull('id_equipo_visitante')
->whereBetween('fecha_evento', [$desde->toDateString(), $hasta->toDateString()])
->get();
if ($eventos->isEmpty()) {
$this->info('No hay partidos en las próximas 48hs.');
return self::SUCCESS;
}
$totalNotif = 0;
foreach ($eventos as $evento) {
$nombreLocal = $evento->equipoLocal->club->nombre ?? '?';
$nombreVisitante = $evento->equipoVisitante->club->nombre ?? '?';
$fechaStr = $evento->fecha_evento->format('d/m/Y');
$horaStr = $evento->hora_inicio ? $evento->hora_inicio->format('H:i') : '';
$sedeStr = $evento->sede ? " | {$evento->sede}" : '';
$titulo = "⏰ Partido mañana: {$nombreLocal} vs {$nombreVisitante}";
$mensaje = "Recordatorio: el partido es el {$fechaStr}" . ($horaStr ? " a las {$horaStr}" : '') . $sedeStr . '.';
$url = '/eventos/' . $evento->id_evento;
// Recolectar destinatarios
$idEquipos = array_filter([$evento->id_equipo_local, $evento->id_equipo_visitante]);
$destinatarios = [];
$yaAgregados = [];
$seguimientos = EquipoSeguimiento::whereIn('id_equipo', $idEquipos)->get();
foreach ($seguimientos as $s) {
$key = $s->tipo_usuario . ':' . $s->id_usuario;
if (!isset($yaAgregados[$key])) {
$destinatarios[] = ['tipo' => $s->tipo_usuario, 'id' => $s->id_usuario];
$yaAgregados[$key] = true;
}
}
$jugadores = \DB::table('jugador_equipo')
->whereIn('id_equipo', $idEquipos)
->pluck('id_jugador');
foreach ($jugadores as $idJ) {
$key = 'jugador:' . $idJ;
if (!isset($yaAgregados[$key])) {
$destinatarios[] = ['tipo' => 'jugador', 'id' => $idJ];
$yaAgregados[$key] = true;
}
}
if (empty($destinatarios)) continue;
if ($this->option('test')) {
$this->line(" [TEST] Evento {$evento->id_evento}: {$titulo}" . count($destinatarios) . " destinatarios");
} else {
$notifService->enviarMasivo($destinatarios, 'partido', $titulo, $mensaje, $url);
}
$totalNotif += count($destinatarios);
}
$this->info("✅ Recordatorios enviados: {$eventos->count()} partidos, {$totalNotif} notificaciones.");
return self::SUCCESS;
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Models\Evento;
use App\Models\Configuracion;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
class ReporteSemanal extends Command
{
protected $signature = 'reportes:semanal {--dry-run : Solo muestra el contenido sin enviar}';
protected $description = 'Genera y envía el reporte semanal de actividad a los administradores';
public function handle(): int
{
$ahora = Carbon::now();
$semanaAnteriorDesde = $ahora->copy()->subDays(7)->startOfDay();
$semanaAnteriorHasta = $ahora->copy()->subDay()->endOfDay();
$proximaSemanaHasta = $ahora->copy()->addDays(7)->endOfDay();
// ── Partidos jugados la semana anterior ──
$jugados = Evento::with(['equipoLocal.club', 'equipoVisitante.club'])
->whereBetween('fecha_evento', [$semanaAnteriorDesde->toDateString(), $semanaAnteriorHasta->toDateString()])
->whereNotNull('marcador_local')
->get();
// ── Próximos partidos ──
$proximos = Evento::with(['equipoLocal.club', 'equipoVisitante.club'])
->where('fecha_evento', '>=', $ahora->toDateString())
->where('fecha_evento', '<=', $proximaSemanaHasta->toDateString())
->orderBy('fecha_evento')
->orderBy('hora_inicio')
->get();
// ── Top goleadores ──
$topGoleadores = DB::table('evento_jugador')
->join('jugadores', 'evento_jugador.id_jugador', '=', 'jugadores.id_jugador')
->selectRaw('jugadores.nombre, jugadores.apellido, SUM(evento_jugador.puntos) as total_puntos, COUNT(evento_jugador.id_evento) as partidos')
->groupBy('jugadores.id_jugador', 'jugadores.nombre', 'jugadores.apellido')
->orderByDesc('total_puntos')
->limit(10)
->get();
// ── QRs de la semana ──
$qrsSemana = DB::table('qr_codes')
->where('creado', '>=', $semanaAnteriorDesde)
->count();
$qrsValidados = DB::table('qr_codes')
->where('creado', '>=', $semanaAnteriorDesde)
->where('escaneos_restantes', 0)
->count();
$data = compact('jugados', 'proximos', 'topGoleadores', 'qrsSemana', 'qrsValidados', 'semanaAnteriorDesde', 'semanaAnteriorHasta');
if ($this->option('dry-run')) {
$this->info('=== REPORTE SEMANAL (DRY RUN) ===');
$this->line("Partidos jugados: {$jugados->count()}");
$this->line("Próximos partidos: {$proximos->count()}");
$this->line("Top goleador: " . ($topGoleadores->first() ? $topGoleadores->first()->apellido . ' (' . $topGoleadores->first()->total_puntos . ' pts)' : 'N/A'));
$this->line("QRs generados: {$qrsSemana} | Validados: {$qrsValidados}");
return self::SUCCESS;
}
// 1. Priorizar email configurado en sistema
$emails = [];
$configEmail = \App\Models\Configuracion::get('email_reportes');
if ($configEmail) {
$emails[] = $configEmail;
}
// 2. Si no hay, buscar superadmins (rol=1)
if (empty($emails)) {
$emails = DB::table('admin_users')->where('rol', 1)->whereNotNull('email')->pluck('email')->toArray();
}
// 3. Fallback: email del .env
if (empty($emails)) {
$fallback = config('mail.from.address');
if ($fallback) $emails = [$fallback];
}
if (empty($emails)) {
$this->error('No hay destinatarios configurados para enviar el reporte.');
return self::FAILURE;
}
try {
Mail::send('emails.reporte_semanal', $data, function ($mail) use ($emails, $ahora) {
$mail->to($emails)
->subject("📊 Reporte Semanal ONAPB — Semana del " . $ahora->startOfWeek()->format('d/m'));
});
$this->info("✅ Reporte enviado a: " . implode(', ', $emails));
} catch (\Exception $e) {
Log::error('Error enviando reporte semanal: ' . $e->getMessage());
$this->error('Error: ' . $e->getMessage());
return self::FAILURE;
}
return self::SUCCESS;
}
}