⏰ Modulo con Cron — tareas automatizadas

Actualizado: 2025-01-15
ℹ️
Compatibilidad

Este patron funciona en PS 1.7+ y 8.x. En PS 9.x se recomienda usar Symfony Messenger para tareas async, pero el patron cron sigue siendo valido.

#Concepto: cron en PrestaShop

PrestaShop no tiene un sistema de cron interno. Los modulos exponen URLs que el servidor ejecuta periodicamente via crontab del sistema operativo. El modulo oficial cronjobs facilita esto, pero puedes crear tu propio endpoint sin depender de el.

#Estructura del modulo

Arbol de archivos
text
modules/ecom_crontasks/
├── ecom_crontasks.php          # Modulo principal
├── controllers/
│   └── front/
│       └── cron.php            # Endpoint publico para crontab
├── classes/
│   ├── CronTaskRunner.php      # Logica de ejecucion
│   └── CronLog.php             # ObjectModel para logs
├── sql/
│   ├── install.sql
│   └── uninstall.sql
└── views/
    └── templates/admin/
        └── configure.tpl       # Panel de configuracion

#Registro del cron task

ecom_crontasks.php — modulo principal
php
<?php
class Ecom_crontasks extends Module
{
    public function __construct()
    {
        $this->name = 'ecom_crontasks';
        $this->tab = 'administration';
        $this->version = '1.0.0';
        $this->author = 'Ecom Experts';
        $this->need_instance = 0;
        $this->bootstrap = true;
        parent::__construct();
        $this->displayName = $this->trans('Cron Tasks Manager', [], 'Modules.Ecomcrontasks.Admin');
        $this->description = $this->trans('Automated tasks: cart cleanup, stock sync, email alerts.', [], 'Modules.Ecomcrontasks.Admin');
    }

    public function install()
    {
        // Crear tabla de logs
        $sql = 'CREATE TABLE IF NOT EXISTS `' . _DB_PREFIX_ . 'ecom_cron_log` (
            `id_cron_log` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
            `task_name` VARCHAR(100) NOT NULL,
            `status` ENUM("success","error","running") NOT NULL DEFAULT "running",
            `items_processed` INT(10) UNSIGNED NOT NULL DEFAULT 0,
            `execution_time` FLOAT NOT NULL DEFAULT 0,
            `message` TEXT,
            `date_add` DATETIME NOT NULL,
            PRIMARY KEY (`id_cron_log`),
            KEY `task_date` (`task_name`, `date_add`)
        ) ENGINE=' . _MYSQL_ENGINE_ . ' DEFAULT CHARSET=utf8mb4;';

        return parent::install()
            && Db::getInstance()->execute($sql)
            && Configuration::updateValue('ECOM_CRON_SECRET', Tools::passwdGen(32))
            && Configuration::updateValue('ECOM_CRON_CART_DAYS', 7)
            && Configuration::updateValue('ECOM_CRON_STOCK_ALERT', 5);
    }

    public function getContent()
    {
        $output = '';
        if (Tools::isSubmit('submitCronConfig')) {
            Configuration::updateValue('ECOM_CRON_CART_DAYS', (int) Tools::getValue('ECOM_CRON_CART_DAYS'));
            Configuration::updateValue('ECOM_CRON_STOCK_ALERT', (int) Tools::getValue('ECOM_CRON_STOCK_ALERT'));
            $output .= $this->displayConfirmation('Configuracion guardada.');
        }

        // Mostrar la URL del cron al admin
        $cronUrl = $this->context->link->getModuleLink(
            $this->name, 'cron',
            ['secure_key' => Configuration::get('ECOM_CRON_SECRET')],
            true
        );

        $output .= $this->displayConfirmation(
            'URL para crontab: <code>' . $cronUrl . '</code>'
        );

        return $output . $this->renderForm();
    }
}

#Controller del cron

controllers/front/cron.php — endpoint seguro
php
<?php
class Ecom_crontasksCronModuleFrontController extends ModuleFrontController
{
    public $ajax = true;
    public $auth = false;

    /** Tiempo maximo de ejecucion (5 min) */
    const MAX_EXECUTION_TIME = 300;

    public function initContent()
    {
        // Validar clave secreta
        $key = Tools::getValue('secure_key');
        if ($key !== Configuration::get('ECOM_CRON_SECRET')) {
            $this->respond(['error' => 'Unauthorized'], 403);
            return;
        }

        // Evitar timeout
        set_time_limit(self::MAX_EXECUTION_TIME);
        ignore_user_abort(true);

        $task = Tools::getValue('task', 'all');
        $results = [];

        switch ($task) {
            case 'clean_carts':
                $results[] = $this->taskCleanCarts();
                break;
            case 'stock_alerts':
                $results[] = $this->taskStockAlerts();
                break;
            case 'all':
                $results[] = $this->taskCleanCarts();
                $results[] = $this->taskStockAlerts();
                break;
            default:
                $this->respond(['error' => 'Task not found: ' . $task], 400);
                return;
        }

        $this->respond(['success' => true, 'tasks' => $results]);
    }

    /**
     * Limpiar carritos abandonados
     */
    protected function taskCleanCarts(): array
    {
        $start = microtime(true);
        $days = (int) Configuration::get('ECOM_CRON_CART_DAYS') ?: 7;
        $dateLimit = date('Y-m-d H:i:s', strtotime("-{$days} days"));

        // Carritos no convertidos en pedido, sin sesion activa
        $sql = 'DELETE c FROM `' . _DB_PREFIX_ . 'cart` c
                LEFT JOIN `' . _DB_PREFIX_ . 'orders` o ON c.id_cart = o.id_cart
                WHERE o.id_order IS NULL
                AND c.date_upd < \'' . pSQL($dateLimit) . '\'
                AND c.id_customer = 0';

        Db::getInstance()->execute($sql);
        $affected = Db::getInstance()->Affected_Rows();

        $this->logTask('clean_carts', 'success', $affected, microtime(true) - $start);

        return [
            'task'      => 'clean_carts',
            'deleted'   => $affected,
            'threshold' => "{$days} days",
        ];
    }

    /**
     * Alertas de stock bajo
     */
    protected function taskStockAlerts(): array
    {
        $start = microtime(true);
        $threshold = (int) Configuration::get('ECOM_CRON_STOCK_ALERT') ?: 5;

        $products = Db::getInstance()->executeS(
            (new DbQuery())
                ->select('p.id_product, pl.name, sa.quantity')
                ->from('product', 'p')
                ->innerJoin('product_lang', 'pl',
                    'p.id_product = pl.id_product AND pl.id_lang = ' . (int) Configuration::get('PS_LANG_DEFAULT'))
                ->innerJoin('stock_available', 'sa',
                    'p.id_product = sa.id_product AND sa.id_product_attribute = 0')
                ->innerJoin('product_shop', 'ps',
                    'p.id_product = ps.id_product AND ps.id_shop = 1')
                ->where('ps.active = 1')
                ->where('sa.quantity <= ' . $threshold)
                ->where('sa.quantity > 0')
                ->orderBy('sa.quantity ASC')
                ->build()
        );

        // Enviar email al admin si hay productos con stock bajo
        if (!empty($products)) {
            $this->sendStockAlertEmail($products, $threshold);
        }

        $this->logTask('stock_alerts', 'success', count($products ?? []), microtime(true) - $start);

        return [
            'task'     => 'stock_alerts',
            'products' => count($products ?? []),
            'threshold'=> $threshold,
        ];
    }

    protected function logTask(string $name, string $status, int $items, float $time): void
    {
        Db::getInstance()->insert('ecom_cron_log', [
            'task_name'       => pSQL($name),
            'status'          => pSQL($status),
            'items_processed' => $items,
            'execution_time'  => round($time, 3),
            'date_add'        => date('Y-m-d H:i:s'),
        ]);
    }

    protected function respond(array $data, int $code = 200): void
    {
        http_response_code($code);
        header('Content-Type: application/json');
        echo json_encode($data, JSON_UNESCAPED_UNICODE);
        exit;
    }
}

#Ejemplo: limpiar carritos abandonados

La tarea clean_carts elimina carritos de invitados (id_customer = 0) que no se convirtieron en pedido y llevan mas de X dias sin actividad. Es seguro porque solo toca carritos sin pedido asociado y sin cliente registrado.

⚠️
Cuidado con carritos de clientes registrados

No elimines carritos de clientes registrados (id_customer > 0) sin avisarles primero. Podrian tener un carrito guardado intencionalmente. Para esos, envia un email de recordatorio antes de limpiar.

#Configuracion del crontab

Crontab del servidor
bash
# Ejecutar todas las tareas cada 6 horas
0 */6 * * * curl -s "https://mitienda.com/module/ecom_crontasks/cron?secure_key=TU_CLAVE_SECRETA" > /dev/null 2>&1

# O ejecutar tareas individuales:
# Limpiar carritos cada dia a las 3am
0 3 * * * curl -s "https://mitienda.com/module/ecom_crontasks/cron?secure_key=TU_CLAVE&task=clean_carts" > /dev/null

# Alertas de stock cada 2 horas
0 */2 * * * curl -s "https://mitienda.com/module/ecom_crontasks/cron?secure_key=TU_CLAVE&task=stock_alerts" > /dev/null

# Alternativa con wget:
0 */6 * * * wget -q -O /dev/null "https://mitienda.com/module/ecom_crontasks/cron?secure_key=TU_CLAVE"

#Logging y monitorizacion

Siempre registra la ejecucion de tareas cron. Sin logs, es imposible saber si las tareas se ejecutan correctamente. La tabla ecom_cron_log almacena: nombre de tarea, estado, items procesados, tiempo de ejecucion y timestamp.

Consultar logs del cron
sql
-- Ultimas 20 ejecuciones
SELECT task_name, status, items_processed, execution_time, date_add
FROM ps_ecom_cron_log
ORDER BY date_add DESC
LIMIT 20;

-- Errores de los ultimos 7 dias
SELECT * FROM ps_ecom_cron_log
WHERE status = 'error'
AND date_add > DATE_SUB(NOW(), INTERVAL 7 DAY);

-- Estadisticas por tarea
SELECT task_name,
       COUNT(*) as runs,
       SUM(items_processed) as total_items,
       AVG(execution_time) as avg_time,
       MAX(date_add) as last_run
FROM ps_ecom_cron_log
GROUP BY task_name;
Descargar en Markdown Pensado para pegar en ChatGPT, Claude u otra IA. Incluye solo el contenido de esta pagina.