HerméticaDocumentación técnica

Operación

Procesos programados

El sistema tiene dos tareas periódicas. Una está bien alojada en la nube; la otra corre hoy en la laptop del desarrollador y debe migrarse antes del traspaso. Esta página documenta ambas y deja la configuración lista para copiar y pegar.

1. Inventario de tareas periódicas

TareaFrecuenciaDisparaDónde corre hoy
Importación desde Nisira
Sincroniza clientes, órdenes de venta y órdenes de producción del ERP.
Cada 30 min
06:00 – 21:30 (Lima)
GET /api/dispatch/import/ Laptop del desarrollador
crontab local con curl
Notificación de tareas vencidas
Correo a los responsables del Portal Gerencial.
Diaria POST /api/portal/cron/overdue/
cabecera X-Cron-Secret
Google Cloud Scheduler

Por qué esto es urgente. Si la laptop está apagada, suspendida o sin red, la importación no corre y las pantallas de Despachos, Calendario, MRP y PAOM se quedan congeladas en el último dato importado — sin ningún error visible, simplemente con información vieja. Es el punto único de falla más serio del sistema y hay que eliminarlo como parte del traspaso.

2. La tarea a migrar, tal como está hoy

# crontab de la laptop
*/30 6-21 * * * /usr/bin/curl -sS \
  "https://lu-hermetica-comercial-backend-….run.app/api/dispatch/import/" \
  >> /Users/…/dispatch_import_cron.log 2>&1

El endpoint hace todo el trabajo del lado del servidor: pide un token al datawarehouse y ejecuta los comandos import_group e import_dispatch. Devuelve un JSON con el resultado. Datos observados de una corrida real:

2 – 4 min

Duración típica de una corrida completa.

~2.000 / ~5.000

Órdenes de venta y de producción actualizadas por corrida.

Bloqueo propio

ImportStatus.acquire() impide que dos importaciones se solapen.

Ese bloqueo del lado del servidor es importante para el diseño del cron: no hace falta que el disparador garantice exclusión mutua. Si por lo que fuera se dispara dos veces, la segunda corrida se rechaza sola.

3. Solución: un Cloudflare Worker con disparador cron

Cloudflare Workers admite Cron Triggers: el Worker se ejecuta según una expresión cron sin que exista ninguna petición HTTP. Es la opción natural aquí porque el frontend ya vive en Cloudflare, no requiere infraestructura nueva y entra en el plan gratuito.

flowchart LR
    T["Cron Trigger de Cloudflare
UTC"] --> W["Worker: hermetica-cron
handler scheduled()"] W -->|"GET /api/dispatch/import/
+ cabecera X-Cron-Secret"| R["Cloud Run
backend"] R -->|"token + REST"| N[("Datawarehouse Nisira")] R --> D[("PostgreSQL")] W -.->|"logs + métricas"| O["Workers Observability
wrangler tail"] classDef cfx fill:#fdf0e3,stroke:#e08b2f,color:#7a4a10 classDef gcpx fill:#e8f1fb,stroke:#4285f4,color:#0b3d78 classDef store fill:#eaf6ef,stroke:#34a853,color:#0d5228 class T,W,O cfx class R gcpx class N,D store
El Worker no hace trabajo pesado: solo dispara el endpoint y registra el resultado. Toda la lógica sigue en el backend.

3.1 · Estructura del proyecto

hermetica-cron/
├── wrangler.jsonc
└── src/
    └── index.js

3.2 · wrangler.jsonc

{
  "name": "hermetica-cron",
  "main": "src/index.js",
  "compatibility_date": "2025-12-18",

  // Cloudflare interpreta las expresiones cron SIEMPRE en UTC.
  // Lima es UTC-5 y no tiene horario de verano, así que la ventana
  // local 06:00–21:30 se parte en dos rangos UTC:
  //   11:00–23:30 UTC → 06:00–18:30 Lima
  //   00:00–02:30 UTC → 19:00–21:30 Lima
  // Total: 32 ejecuciones diarias, idéntico al crontab actual.
  "triggers": {
    "crons": ["*/30 11-23 * * *", "*/30 0-2 * * *"]
  },

  "vars": {
    "API_BASE": "https://lu-hermetica-comercial-backend-XXXXXXXX.us-central1.run.app",
    "TIMEOUT_MS": "300000"
  },

  "observability": { "enabled": true }
}

El secreto no va aquí. CRON_SECRET se carga aparte con wrangler secret put CRON_SECRET; queda cifrado y no se puede volver a leer desde el panel. Nunca ponerlo en vars, que es texto plano visible.

3.3 · src/index.js

/**
 * Disparador de la importación Nisira → sistema comercial Hermética.
 * Reemplaza al crontab que corría en la laptop del desarrollador.
 *
 * - scheduled(): lo invoca el Cron Trigger de Cloudflare.
 * - fetch():     disparo manual protegido, para pruebas y recuperación.
 */

const IMPORT_PATH = "/api/dispatch/import/";

async function runImport(env, origen) {
  const url = `${env.API_BASE}${IMPORT_PATH}`;
  const inicio = Date.now();

  // Dos intentos: un error de red puntual no debe costar media hora de datos.
  for (let intento = 1; intento <= 2; intento++) {
    try {
      const res = await fetch(url, {
        method: "GET",
        headers: {
          "X-Cron-Secret": env.CRON_SECRET ?? "",
          "User-Agent": "hermetica-cron-worker",
        },
        // El backend corre en Cloud Run con timeout de 300 s: no tiene
        // sentido esperar más que eso.
        signal: AbortSignal.timeout(Number(env.TIMEOUT_MS ?? 300000)),
      });

      const cuerpo = await res.text();
      const segundos = ((Date.now() - inicio) / 1000).toFixed(1);

      if (!res.ok) {
        console.error(
          `[cron] ${origen} intento ${intento} HTTP ${res.status} en ${segundos}s`,
          cuerpo.slice(0, 500)
        );
        if (intento === 1) continue;
        return { ok: false, status: res.status, segundos };
      }

      // El endpoint responde JSON con el resumen de la importación.
      let resumen = cuerpo.slice(0, 300);
      try {
        const j = JSON.parse(cuerpo);
        resumen = j.message ?? j.status ?? resumen;
      } catch { /* respuesta no-JSON: se registra el texto crudo */ }

      console.log(`[cron] ${origen} OK en ${segundos}s — ${resumen}`);
      return { ok: true, status: res.status, segundos, resumen };

    } catch (err) {
      const segundos = ((Date.now() - inicio) / 1000).toFixed(1);
      console.error(`[cron] ${origen} intento ${intento} falló tras ${segundos}s: ${err}`);
      if (intento === 2) return { ok: false, error: String(err), segundos };
    }
  }
}

export default {
  // Lo invoca el Cron Trigger. waitUntil mantiene vivo al Worker
  // mientras la importación termina (puede tardar varios minutos).
  async scheduled(event, env, ctx) {
    ctx.waitUntil(runImport(env, `cron ${event.cron}`));
  },

  // Disparo manual: útil para verificar el despliegue o recuperar una
  // ventana perdida sin esperar al siguiente ciclo.
  async fetch(request, env) {
    const { pathname } = new URL(request.url);

    if (pathname === "/health") {
      return Response.json({ service: "hermetica-cron", ok: true });
    }

    if (pathname === "/run") {
      if (request.headers.get("X-Cron-Secret") !== env.CRON_SECRET) {
        return new Response("forbidden", { status: 403 });
      }
      return Response.json(await runImport(env, "manual"));
    }

    return new Response("not found", { status: 404 });
  },
};

3.4 · Despliegue

# Autenticarse una sola vez
wrangler login

# Cargar el secreto (queda cifrado; no se puede volver a leer)
wrangler secret put CRON_SECRET

# Publicar
wrangler deploy

# Verificar que los disparadores quedaron registrados
wrangler triggers deploy

3.5 · Verificación

# 1. Disparo manual, sin esperar al cron
curl -sS -H "X-Cron-Secret: EL_SECRETO" \
  https://hermetica-cron.TU_SUBDOMINIO.workers.dev/run

# 2. Logs en vivo (incluye las corridas del cron)
wrangler tail hermetica-cron --format pretty

# 3. Simular una corrida programada en local
wrangler dev --test-scheduled
curl "http://localhost:8787/__scheduled?cron=*/30+11-23+*+*+*"

En el panel de Cloudflare, Workers & Pages → hermetica-cron → Settings → Trigger Events muestra las próximas ejecuciones programadas, y Observability → Logs el histórico con la salida de console.log.

Cómo saber que quedó bien. Además de los logs del Worker, el propio sistema lo delata: la pantalla de Despachos muestra la marca de tiempo de la última importación. Si avanza cada media hora con la laptop apagada, la migración funcionó.

4. Cortar el cron viejo

Recién cuando el Worker haya corrido correctamente durante un día completo, retirar el cron de la laptop. Hacer las dos cosas a la vez deja una ventana sin importación si algo sale mal.

crontab -l                       # confirmar la línea a eliminar
crontab -l | grep -v 'dispatch/import' | crontab -
crontab -l                       # confirmar que ya no está

5. Endurecer el endpoint

El Worker ya envía la cabecera X-Cron-Secret, pero hoy el backend no la valida: GET /api/dispatch/import/ es público y cualquiera puede dispararlo. El cambio es pequeño y reutiliza el mecanismo que ya existe en portal_gerencial/cron.py:

# dispatch/views.py
from django.conf import settings

@api_view(["GET"])
def run_dispatch_import(request):
    secret = getattr(settings, "CRON_SECRET", "")
    if secret:
        recibido = request.headers.get("X-Cron-Secret") or request.query_params.get("secret")
        if recibido != secret:
            return Response({"detail": "no autorizado"}, status=403)
    ...

Escrito así, la validación solo se activa si CRON_SECRET está configurada, de modo que se puede desplegar el backend primero y activar la protección después, sin ventana de corte.

6. Alternativa: Cloud Scheduler

Si se prefiere concentrar todo en Google Cloud —donde ya vive el otro cron— el equivalente es una sola orden. Funciona igual de bien; la ventaja del Worker es que no depende del mismo proveedor que el backend, así que sigue disparando aunque haya un problema de permisos o facturación en el proyecto de GCP.

gcloud scheduler jobs create http hermetica-import-dispatch \
  --location=us-central1 \
  --schedule="*/30 6-21 * * *" \
  --time-zone="America/Lima" \
  --uri="https://SERVICIO.run.app/api/dispatch/import/" \
  --http-method=GET \
  --headers="X-Cron-Secret=EL_SECRETO" \
  --attempt-deadline=300s

Cloud Scheduler acepta zona horaria, así que aquí la expresión cron se escribe directamente en hora de Lima, sin la conversión a UTC.

7. Recrear el cron del Portal Gerencial

Al migrar de cuenta de GCP hay que volver a crear el segundo cron, que hoy sí está en la nube. Debe usar el mismo valor que la variable CRON_SECRET del servicio de Cloud Run:

gcloud scheduler jobs create http hermetica-overdue-notifications \
  --location=us-central1 \
  --schedule="0 8 * * *" \
  --time-zone="America/Lima" \
  --uri="https://SERVICIO.run.app/api/portal/cron/overdue/" \
  --http-method=POST \
  --headers="X-Cron-Secret=EL_SECRETO"