Saltar al contenido
Ende Sun
Volver al blog

Backend y arquitectura

Tareas nocturnas en Symfony: comandos de consola, cron y EasyAdmin en un SaaS de gimnasios

5 min de lectura

Tareas nocturnas en Symfony: comandos de consola, cron y EasyAdmin en un SaaS de gimnasios

No todas las funcionalidades de un SaaS empiezan con un clic. Parte del trabajo más útil ocurre de noche, cuando nadie mira: cerrar lo que ha caducado, limpiar y mantener los datos coherentes para que las pantallas tengan sentido a la mañana siguiente.

En este artículo repaso un ejemplo real de MasMusculo, la plataforma de gestión de gimnasios en la que trabajo en Osunasport: cómo se gestionan las rutinas de entrenamiento en el back office con EasyAdmin y cómo un comando de consola de Symfony, ejecutado cada noche, cierra las rutinas cuyo tiempo ya ha terminado. Los nombres de entidades y clases están simplificados.

El dominio: dos tipos de «rutina»

Lo primero que tuve que tener claro es que hay dos conceptos distintos a los que se llama «rutina»:

  • Rutina plantilla (Routine): la crea un entrenador. Tiene nombre, objetivo, intensidad, número de días de entrenamiento por semana y, para cada día, una lista de ejercicios (series, repeticiones, descanso). Los ejercicios salen de un catálogo vinculado a máquinas y grupos musculares.
  • Rutina asignada (MemberRoutine): una plantilla que un empleado asigna a un socio concreto, con fecha de inicio y, en algún momento, fecha de fin.

La rutina actual de un socio es simplemente la rutina asignada cuya fecha de fin sigue siendo null. Cuando se asigna una nueva, la anterior activa recibe como fecha de fin la fecha del momento. Cada asignación guarda además quién la creó: una referencia al empleado y una copia de su email; la referencia pasa a null si ese empleado se elimina más adelante, para no perder el historial.

El back office: EasyAdmin en la práctica

La parte de administración está hecha con EasyAdmin (versión 3 en este proyecto). Su modelo está muy orientado a CRUD y encaja bien con este tipo de catálogos. Hay dos tipos de clases:

  • Dashboards, que definen sobre todo el menú de cada panel.
  • Controladores CRUD, uno por entidad, que definen campos, acciones, filtros y búsqueda.

Un controlador CRUD típico para el catálogo de ejercicios agrupa los campos en paneles y muestra campos distintos según la página:

public function configureFields(string $pageName): iterable
{
    yield IdField::new('id')->hideOnForm();

    yield FormField::addPanel('General')->addCssClass('col-md-8');
    yield TextField::new('name')->setColumns(12);
    yield BooleanField::new('active');
    yield TextareaField::new('description')->setColumns(12)->hideOnIndex();

    yield FormField::addPanel('Relations')->addCssClass('col-md-12');
    yield AssociationField::new('machines')->setColumns(4);
    yield AssociationField::new('muscleGroups')->setColumns(4);
}

public function configureFilters(Filters $filters): Filters
{
    return $filters->add('active')->add('muscleGroups');
}

Algunas cosas que me ahorraron tiempo:

  • hideOnIndex(), onlyOnDetail(), hideOnForm() y similares permiten tener listados cortos y formularios completos.
  • FormField::addTab() divide los formularios largos. El formulario de rutinas usa una pestaña para los datos generales y otra para la composición de la rutina, día a día.
  • Los campos no tienen por qué corresponder a una propiedad guardada. Si la entidad expone un getter como getFullName(), un TextField::new('fullName') lo mostrará, siguiendo las convenciones de PropertyAccess de Symfony. Para campos solo de entrada en un formulario de asignación, la misma idea funciona con campos de formulario no mapeados.

Hay un detalle del formulario de rutinas que importa más adelante: cada día de entrenamiento es un campo de colección propio y, al enviar, EasyAdmin llama al setter de la entidad una vez por día. Ahí se escondía un bug sutil de Doctrine (una colección filtrada en una copia en lugar de modificarse en su sitio), que cuento en otro artículo.

El problema: rutinas que nunca terminan

Asignar una rutina nueva cierra la anterior, pero a muchos socios no se les asigna otra a tiempo. Su rutina tiene fecha de inicio y una duración prevista, pero su fecha de fin se queda en null para siempre, así que sigue apareciendo como «actual» mucho después de lo que debería.

La plataforma ya tenía un comando de consola que se ejecuta cada noche con una lista de tareas de mantenimiento. La tarea consistía en añadir un paso más: buscar las rutinas asignadas sin fecha de fin que ya deberían haber terminado, teniendo en cuenta su fecha de inicio y su duración, y ponerles como fecha de fin el momento en que se ejecuta el comando.

El comando

Los comandos de consola de Symfony son servicios, así que sus dependencias se inyectan por el constructor. Algo que conviene recordar: las entidades no son servicios, así que no se pueden inyectar. Se inyectan el repositorio y el entity manager, y las entidades se obtienen a través de ellos.

El trabajo se dividió en tres piezas pequeñas:

  1. Un método en la entidad para cerrarse a sí misma.
  2. Un método en el repositorio que devuelve las candidatas.
  3. El paso del comando que las une y persiste el resultado.
// Entidad
public function isExpired(\DateTimeImmutable $now): bool
{
    return $this->endDate === null
        && $this->startDate->modify("+{$this->durationDays} days") <= $now;
}

public function close(\DateTimeImmutable $at): void
{
    $this->endDate = $at;
}
// Repositorio: rutinas abiertas que ya han empezado
public function findOpenStartedBefore(\DateTimeImmutable $now): array
{
    return $this->createQueryBuilder('r')
        ->where('r.endDate IS NULL')
        ->andWhere('r.startDate <= :now')
        ->setParameter('now', $now)
        ->getQuery()
        ->getResult();
}
#[AsCommand(name: 'app:nightly-maintenance')]
final class NightlyMaintenanceCommand extends Command
{
    public function __construct(
        private readonly MemberRoutineRepository $routines,
        private readonly EntityManagerInterface $em,
    ) {
        parent::__construct();
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $now = new \DateTimeImmutable();
        $closed = 0;

        foreach ($this->routines->findOpenStartedBefore($now) as $routine) {
            if ($routine->isExpired($now)) {
                $routine->close($now);
                $closed++;
            }
        }

        $this->em->flush();
        $output->writeln("Closed routines: {$closed}");

        return Command::SUCCESS;
    }
}

Aquí la duración se expresa en días para simplificar el ejemplo. La decisión de diseño importante es dónde vive cada regla: la consulta acota las candidatas y la entidad decide si de verdad ha caducado, de modo que la lógica de fechas se queda en PHP y no depende de funciones de fecha propias de cada base de datos.

Ejecutarlo cada noche

En desarrollo, el comando se ejecuta como cualquier otro:

php bin/console app:nightly-maintenance
php bin/console list   # para ver todos los comandos disponibles

En producción lo lanza el programador de tareas del servidor una vez por noche. Una entrada de crontab a modo de ejemplo:

0 3 * * * cd /ruta/a/la/app && php bin/console app:nightly-maintenance --env=prod

Como solo toca rutinas que siguen abiertas, ejecutarlo dos veces no hace daño: la segunda ejecución no encuentra nada que cerrar.

Lo que me llevo

  • Modela los estados de forma explícita. «Rutina actual = fecha de fin null» es una regla sencilla, pero solo funciona si algo garantiza que la fecha de fin acaba asignándose.
  • Mantén los comandos finos. El comando orquesta, el repositorio selecciona y la entidad conoce sus propias reglas. Así cada pieza es fácil de leer y de probar por separado.
  • Haz que las tareas nocturnas sean idempotentes. Una tarea que puede ejecutarse dos veces sin problema es una tarea que puedes relanzar a mano a las nueve de la mañana sin preocuparte.
  • EasyAdmin es productivo cuando te apoyas en sus convenciones (paneles, pestañas, campos por página, filtros) y solo bajas a código propio donde el dominio de verdad lo necesita.