Antes de empezar
Sigues en el repo inventario-lab. Necesitas terminado el P3 (modelos, servicios, CLI) — la API de P4–P6 no participa: el vigía habla directo con la capa de servicios, y en Entiende el código discutimos por qué. Tu repo debería verse así:
inventario-lab/
├── src/inventario/
│ ├── models.py Ubicacion, Reactivo, Lote, Movimiento
│ ├── db.py engine + get_session (INVENTARIO_DB_URL)
│ ├── errors.py StockInsuficiente, LoteNoExiste
│ ├── services.py crear_*, agregar_lote, registrar_salida, lotes_por_vencer…
│ ├── cli.py inv add-*, take, expiring, find
│ └── api/ (P4–P6; hoy no la tocamos)
├── migrations/ alembic
└── tests/
Para la parte de Telegram necesitas una cuenta de Telegram (gratis). Para el paso 8 en la nube, el proyecto GCP que creaste en el P6. Nada nuevo que instalar: httpx ya está en el proyecto desde P5.
Crea la rama de trabajo:
git switch main
git pull
git switch -c feat/vigia
Construcción paso a paso
Paso 1 Las tablas Aviso y Latido
La idempotencia no es magia: es memoria. El vigía recuerda a quién ya avisó en una tabla, y antes de avisar consulta esa memoria. Y el latido es otra tabla mínima: cuándo corrió por última vez cada proceso automático.
src/inventario/models.py (agregar al final)class Aviso(SQLModel, table=True):
"""Registro de avisos enviados. Es lo que hace idempotente al vigía."""
id: int | None = Field(default=None, primary_key=True)
lote_id: int = Field(foreign_key="lote.id")
tipo: str # "vence_30d", "vence_7d", "vencido"
enviado_en: datetime
class Latido(SQLModel, table=True):
"""Última corrida de cada proceso automático. Un registro por proceso."""
id: int | None = Field(default=None, primary_key=True)
proceso: str = Field(unique=True)
ultima_corrida: datetime
detalle: str | None = None
El esquema cambió, así que toca migración, como aprendiste en el P3:
uv run alembic revision --autogenerate -m "tablas aviso y latido"
uv run alembic upgrade head
La salida es como la del P3: Generating migrations/versions/…py … done y luego Running upgrade … -> …. Revisa el archivo generado antes del upgrade: debe crear exactamente dos tablas.
Por qué tipo es un string y no un booleano. Hoy hay un solo tipo de aviso ("vence_30d"). Mañana querrás avisar a 7 días y cuando ya venció, y el mismo lote puede recibir los tres avisos, uno de cada tipo. Una fila por (lote, tipo) lo modela sin tocar el esquema de nuevo. Es la cantidad justa de futuro: ni "por si acaso", ni pintarte a un rincón.
Paso 2 El protocolo Notificador
El vigía no debe saber si avisa por Telegram, por consola o por correo. Solo necesita algo con un método enviar(texto):
src/inventario/notificadores.py"""Canales por los que el vigía avisa. Todos cumplen el mismo protocolo: enviar(texto)."""
import os
import httpx
class NotificadorConsola:
"""Imprime el aviso. Sirve para desarrollo y para tests."""
def enviar(self, texto: str) -> None:
print(texto)
class TelegramNotificador:
"""Manda el aviso a un chat de Telegram usando la API HTTP del bot."""
def __init__(self, token: str | None = None, chat_id: str | None = None, timeout: float = 10.0):
self.token = token or os.environ["TELEGRAM_BOT_TOKEN"]
self.chat_id = chat_id or os.environ["TELEGRAM_CHAT_ID"]
self.timeout = timeout
def enviar(self, texto: str) -> None:
url = f"https://api.telegram.org/bot{self.token}/sendMessage"
respuesta = httpx.post(
url, json={"chat_id": self.chat_id, "text": texto}, timeout=self.timeout
)
respuesta.raise_for_status()
Fíjate en lo que NO hay. Ninguna de las dos clases hereda de nada, y no existe una
NotificadorBase. En Python basta con que tengan el mismo método (
duck typing); el
Protocol que declararemos en el vigía solo le pone nombre al contrato para que el type checker lo verifique. Compara con la alternativa de una
clase abstracta: válida también, pero aquí no aporta nada. Anota la decisión.
Paso 3 Los tests: la idempotencia se define antes de existir
Igual que en el P0: primero el contrato, después la implementación. Para testear no mandamos nada de verdad; usamos un notificador falso que solo recuerda qué le pidieron enviar, y otro que falla a propósito:
tests/test_vigia.pyfrom datetime import timedelta
import pytest
from sqlmodel import select
from inventario import services
from inventario.models import Aviso, Latido
from inventario.vigia import enviar_con_reintentos, revisar_vencimientos
class NotificadorFalso:
"""Guarda lo que le piden enviar, sin mandar nada."""
def __init__(self):
self.enviados: list[str] = []
def enviar(self, texto: str) -> None:
self.enviados.append(texto)
class NotificadorQueFalla:
"""Falla las primeras `veces` llamadas y luego funciona."""
def __init__(self, veces: int):
self.veces = veces
self.llamadas = 0
def enviar(self, texto: str) -> None:
self.llamadas += 1
if self.llamadas <= self.veces:
raise ConnectionError("Telegram no responde")
def _lab_con_lotes(session):
hoy = services.hoy()
ubi = services.crear_ubicacion(session, "Congelador -20 A")
taq = services.crear_reactivo(session, "Taq polimerasa", "µL")
etoh = services.crear_reactivo(session, "Etanol 96%", "mL")
aga = services.crear_reactivo(session, "Agarosa", "g")
services.agregar_lote(session, taq.id, ubi.id, "TAQ-2401", 500, hoy + timedelta(days=10))
services.agregar_lote(session, etoh.id, ubi.id, "ETOH-2312", 2500, hoy + timedelta(days=20))
services.agregar_lote(session, aga.id, ubi.id, "AGA-2405", 250, hoy + timedelta(days=200))
def test_avisa_solo_lotes_por_vencer(session):
_lab_con_lotes(session)
notificador = NotificadorFalso()
avisados = revisar_vencimientos(session, notificador, dias=30)
assert avisados == 2
assert len(notificador.enviados) == 1
assert "TAQ-2401" in notificador.enviados[0]
assert "AGA-2405" not in notificador.enviados[0]
def test_es_idempotente_tres_corridas_un_mensaje(session):
_lab_con_lotes(session)
notificador = NotificadorFalso()
resultados = [revisar_vencimientos(session, notificador, dias=30) for _ in range(3)]
assert resultados == [2, 0, 0]
assert len(notificador.enviados) == 1
def test_lote_nuevo_genera_aviso_nuevo_sin_repetir_los_viejos(session):
_lab_con_lotes(session)
notificador = NotificadorFalso()
revisar_vencimientos(session, notificador, dias=30)
services.agregar_lote(session, 1, 1, "TAQ-2402", 200, services.hoy() + timedelta(days=5))
avisados = revisar_vencimientos(session, notificador, dias=30)
assert avisados == 1
assert len(notificador.enviados) == 2
assert "TAQ-2402" in notificador.enviados[1]
assert "ETOH-2312" not in notificador.enviados[1]
def test_sin_lotes_no_envia_pero_registra_latido(session):
notificador = NotificadorFalso()
assert revisar_vencimientos(session, notificador, dias=30) == 0
assert notificador.enviados == []
latido = session.exec(select(Latido).where(Latido.proceso == "vigia")).one()
assert "lotes=0" in latido.detalle
def test_reintenta_con_espera_exponencial():
notificador = NotificadorQueFalla(veces=2)
esperas: list[float] = []
intento = enviar_con_reintentos(
notificador, "hola", intentos=5, espera_base=1.0, dormir=esperas.append
)
assert intento == 3
assert esperas == [1.0, 2.0]
def test_reintentos_agotados_lanza_y_no_registra_aviso(session):
_lab_con_lotes(session)
notificador = NotificadorQueFalla(veces=99)
with pytest.raises(ConnectionError):
revisar_vencimientos(
session, notificador, dias=30, intentos=3, espera_base=0, dormir=lambda s: None
)
assert notificador.llamadas == 3
assert session.exec(select(Aviso)).all() == []
Dos detalles nuevos frente a tests anteriores. Primero, services.hoy(): si tu services.py del P3 usaba date.today() suelto, este es el momento de centralizarlo en una función con zona horaria explícita (el porqué está en Entiende el código). Segundo, el parámetro dormir: los tests de reintentos no esperan de verdad; le pasan una función que solo anota cuánto se habría dormido. Sin eso, el último test tardaría 15 segundos.
uv run pytest tests/test_vigia.py
Deberías ver (rojo, correcto)E ModuleNotFoundError: No module named 'inventario.vigia'
Paso 4 El vigía
src/inventario/vigia.py"""El vigía: revisa los lotes por vencer y avisa. Corre solo; nadie está mirando."""
import logging
import time
from collections.abc import Callable
from datetime import UTC, datetime
from typing import Protocol
from sqlmodel import Session, select
from inventario import services
from inventario.models import Aviso, Latido, Lote, Reactivo
log = logging.getLogger("inventario.vigia")
class Notificador(Protocol):
def enviar(self, texto: str) -> None: ...
def enviar_con_reintentos(
notificador: Notificador,
texto: str,
intentos: int = 5,
espera_base: float = 1.0,
dormir: Callable[[float], None] = time.sleep,
) -> int:
"""Intenta enviar hasta `intentos` veces con espera exponencial (1, 2, 4, 8… s).
Devuelve el número del intento que funcionó. Si se agotan, relanza el último error.
"""
for intento in range(1, intentos + 1):
try:
notificador.enviar(texto)
return intento
except Exception as exc:
log.warning("envío falló (intento %d/%d): %s", intento, intentos, exc)
if intento == intentos:
raise
dormir(espera_base * 2 ** (intento - 1))
raise AssertionError("inalcanzable")
def formatear_aviso(session: Session, lotes: list[Lote], dias: int) -> str:
lineas = [f"Reactivos que vencen en los próximos {dias} días:"]
for lote in lotes:
reactivo = session.get(Reactivo, lote.reactivo_id)
lineas.append(
f"- {lote.codigo} · {reactivo.nombre} · {lote.cantidad:g} {reactivo.unidad}"
f" · vence {lote.vencimiento.isoformat()}"
)
return "\n".join(lineas)
def registrar_latido(session: Session, proceso: str, detalle: str) -> None:
latido = session.exec(select(Latido).where(Latido.proceso == proceso)).first()
ahora = datetime.now(UTC)
if latido is None:
latido = Latido(proceso=proceso, ultima_corrida=ahora, detalle=detalle)
else:
latido.ultima_corrida = ahora
latido.detalle = detalle
session.add(latido)
session.commit()
def revisar_vencimientos(
session: Session,
notificador: Notificador,
dias: int = 30,
intentos: int = 5,
espera_base: float = 1.0,
dormir: Callable[[float], None] = time.sleep,
) -> int:
"""Avisa de los lotes que vencen en `dias` días y que aún no fueron avisados.
Devuelve cuántos lotes se avisaron en esta corrida (0 si no había nada nuevo).
Idempotente: correrla dos veces seguidas manda un solo aviso.
"""
inicio = time.perf_counter()
tipo = f"vence_{dias}d"
lotes = services.lotes_por_vencer(session, dias)
ya_avisados = set(session.exec(select(Aviso.lote_id).where(Aviso.tipo == tipo)))
pendientes = [lote for lote in lotes if lote.id not in ya_avisados]
if pendientes:
texto = formatear_aviso(session, pendientes, dias)
# Primero enviar, luego registrar. Si el proceso muere entre ambos, mañana
# se repite el aviso: preferimos un duplicado a un silencio. Ver DECISIONES.md.
enviar_con_reintentos(notificador, texto, intentos, espera_base, dormir)
ahora = datetime.now(UTC)
for lote in pendientes:
session.add(Aviso(lote_id=lote.id, tipo=tipo, enviado_en=ahora))
session.commit()
duracion_ms = round((time.perf_counter() - inicio) * 1000)
detalle = f"lotes={len(lotes)} nuevos={len(pendientes)} duracion_ms={duracion_ms}"
registrar_latido(session, "vigia", detalle)
log.info("vigia dias=%d %s", dias, detalle)
return len(pendientes)
uv run pytest tests/test_vigia.py -v
Deberías vertests/test_vigia.py::test_avisa_solo_lotes_por_vencer PASSED [ 16%]
tests/test_vigia.py::test_es_idempotente_tres_corridas_un_mensaje PASSED [ 33%]
tests/test_vigia.py::test_lote_nuevo_genera_aviso_nuevo_sin_repetir_los_viejos PASSED [ 50%]
tests/test_vigia.py::test_sin_lotes_no_envia_pero_registra_latido PASSED [ 66%]
tests/test_vigia.py::test_reintenta_con_espera_exponencial PASSED [ 83%]
tests/test_vigia.py::test_reintentos_agotados_lanza_y_no_registra_aviso PASSED [100%]
============================== 6 passed in 0.05s ===============================
El comentario más importante del proyecto está en el medio de la función. Léelo de nuevo:
¿Qué pasa si el proceso muere justo ahí?
Entre enviar_con_reintentos(...) y session.commit() hay una ventana. Si el proceso muere ahí (corte de luz, el contenedor lo matan), el mensaje ya salió pero el Aviso no se registró: mañana se repite el aviso. ¿Y el orden inverso? Registrar primero y enviar después: si muere en la ventana, el Aviso quedó registrado pero el mensaje nunca salió — silencio para siempre. No existe el orden perfecto sin infraestructura extra (colas con confirmación, transacciones distribuidas). Se elige el fallo tolerable: un duplicado molesta, un silencio pudre reactivos. Escribe esta decisión en DECISIONES.md; es la respuesta a una de las casillas de "Listo cuando".
uv run ruff check . && uv run ruff format .
git add . && git commit -m "Vigía de vencimientos: idempotente, con reintentos y latido"
Paso 5 CLI: inv vigia y inv vigia-estado
Dos comandos nuevos en src/inventario/cli.py: el vigía en sí, y el que responde "¿sigue vivo?" leyendo el latido:
src/inventario/cli.py (agregar)from datetime import datetime, timedelta, timezone
from sqlmodel import select
from inventario.models import Latido
from inventario.notificadores import NotificadorConsola, TelegramNotificador
from inventario.vigia import revisar_vencimientos
@app.command("vigia")
def vigia(
dias: int = typer.Option(30, "--dias"),
canal: str = typer.Option("consola", "--canal", help="consola | telegram"),
) -> None:
"""Revisa vencimientos y avisa una sola vez por lote. Pensado para correr solo, cada día."""
notificador = TelegramNotificador() if canal == "telegram" else NotificadorConsola()
with get_session() as s:
avisados = revisar_vencimientos(s, notificador, dias=dias)
typer.echo(f"avisados={avisados}")
@app.command("vigia-estado")
def vigia_estado(max_horas: int = typer.Option(26, "--max-horas")) -> None:
"""¿Sigue vivo el vigía? Muestra el latido y falla si es más viejo que --max-horas."""
with get_session() as s:
latido = s.exec(select(Latido).where(Latido.proceso == "vigia")).first()
if latido is None:
typer.echo("el vigía nunca corrió")
raise typer.Exit(code=1)
ultima = latido.ultima_corrida.replace(tzinfo=timezone.utc)
edad = datetime.now(timezone.utc) - ultima
typer.echo(
f"última corrida {ultima.isoformat()} ({edad.total_seconds() / 3600:.1f} h) · {latido.detalle}"
)
if edad > timedelta(hours=max_horas):
typer.echo("ALERTA: el vigía lleva demasiado sin correr")
raise typer.Exit(code=2)
Pruébalo de punta a punta con los datos del laboratorio (ajusta las fechas: una a ~10 días, otra a ~20, otra lejos):
uv run inv add-lote 1 1 TAQ-2401 500 2026-08-26
uv run inv add-lote 2 1 ETOH-2312 2500 2026-09-05
uv run inv add-lote 3 1 AGA-2405 250 2027-03-04
uv run inv vigia
uv run inv vigia
uv run inv vigia
Deberías ver (1ª corrida avisa; 2ª y 3ª no)2026-08-16 07:34:45 INFO inventario.vigia vigia dias=30 lotes=2 nuevos=2 duracion_ms=4
Reactivos que vencen en los próximos 30 días:
- TAQ-2401 · Taq polimerasa · 500 µL · vence 2026-08-26
- ETOH-2312 · Etanol 96% · 2500 mL · vence 2026-09-05
avisados=2
2026-08-16 07:34:45 INFO inventario.vigia vigia dias=30 lotes=2 nuevos=0 duracion_ms=2
avisados=0
2026-08-16 07:34:45 INFO inventario.vigia vigia dias=30 lotes=2 nuevos=0 duracion_ms=2
avisados=0
Deberías verúltima corrida 2026-08-16T12:34:45.442236+00:00 (0.0 h) · lotes=2 nuevos=0 duracion_ms=2
Por qué el log dice lotes=2 nuevos=0 y no solo "ok". Un log útil responde las preguntas del futuro: ¿cuántos vio? ¿cuántos avisó? ¿cuánto tardó? Cuando en un mes te preguntes "¿por qué no avisó del lote X?", la diferencia entre lotes=2 nuevos=0 (lo vio y ya estaba avisado) y lotes=0 (no lo vio: problema de datos o de fecha) te ahorra una hora. Y fíjate: --max-horas por defecto es 26, no 24 — un job diario con un deploy o un reinicio de por medio se atrasa minutos; alertar por eso es ruido.
Paso 6 Telegram de verdad
Crear un bot toma dos minutos y no requiere programar nada:
- En Telegram, busca @BotFather (el verificado) y envíale
/newbot. Te pide un nombre ("Vigía del lab") y un username que termine en bot (alexandra_vigia_bot). Te responde con el token: algo como 8123456789:AAF…. Es un secreto: va en .env, jamás en el repo (P6).
- Ábrele chat a tu bot (búscalo por su username) y mándale cualquier mensaje. Sin esto el bot no puede escribirte: los bots no inician conversaciones.
- Obtén tu
chat_id preguntándole a la API qué mensajes recibió:
curl -s "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getUpdates" | python3 -m json.tool
Deberías ver (busca "chat" → "id")"chat": {
"id": 123456789,
"first_name": "Alexandra",
…
Guarda ambos en tu .env (y las claves sin valor en .env.example, como siempre):
.env (local, ignorado por git)TELEGRAM_BOT_TOKEN=8123456789:AAF…
TELEGRAM_CHAT_ID=123456789
Y pruébalo. Borra antes los avisos ya registrados para forzar un envío (o agrega un lote nuevo por vencer):
uv run inv vigia --canal telegram
El mensaje debe aparecer en tu chat. Ahora la parte más valiosa del paso: ensaya el fallo. Pon un token falso y mira al vigía pelear y rendirse:
TELEGRAM_BOT_TOKEN=123:token-falso uv run inv vigia --canal telegram
Deberías ver (reintentos con espera creciente, luego se rinde; ~23 s en total)WARNING inventario.vigia envío falló (intento 1/5): Client error '401 Unauthorized' for url
'https://api.telegram.org/bot123:token-falso/sendMessage'
WARNING inventario.vigia envío falló (intento 2/5): _ssl.c:993: The handshake operation timed out
WARNING inventario.vigia envío falló (intento 3/5): Client error '401 Unauthorized' for url …
WARNING inventario.vigia envío falló (intento 4/5): [Errno 54] Connection reset by peer
…
HTTPStatusError: Client error '401 Unauthorized' for url
'https://api.telegram.org/bot123:token-falso/sendMessage'
Tres cosas para observar en esa salida (es real, de esta guía): los fallos son variados (401, timeout de TLS, conexión reseteada — así se ve la red de verdad), cada intento quedó en el log con su número, y al final el error explotó en vez de esconderse. Comprueba lo más importante: que ningún Aviso se registró — el envío no ocurrió, así que mañana lo reintentará entero. El test test_reintentos_agotados… verifica exactamente esto.
git add . && git commit -m "Canal Telegram y comandos vigia/vigia-estado"
Paso 7 Docker y compose
La imagen es la misma del proyecto (no una nueva): solo cambia el comando. Dos ajustes al Dockerfile que ya tienes:
Dockerfile (ajustes)RUN uv sync --frozen
# El entorno que creó uv queda primero en el PATH: "inv" es el script instalado por pyproject
ENV PATH="/app/.venv/bin:$PATH"
CMD ["inv", "vigia"]
compose.yaml (agregar servicio)services:
vigia:
build: .
command: ["inv", "vigia", "--canal", "consola"]
environment:
INVENTARIO_DB_URL: sqlite:////data/inventario.db
INVENTARIO_TZ: America/Lima
volumes:
- datos:/data
volumes:
datos:
docker build -t inventario .
docker run --rm -v "$PWD/inventario.db:/app/inventario.db" inventario
docker run --rm -v "$PWD/inventario.db:/app/inventario.db" inventario inv vigia-estado --max-horas 0
Deberías ver (ya no avisa: la memoria está en la DB montada; y --max-horas 0 fuerza la alerta, exit 2)avisados=0
2026-08-16 12:38:49 INFO inventario.vigia vigia dias=30 lotes=2 nuevos=0 duracion_ms=2
última corrida 2026-08-16T12:38:49.173411+00:00 (0.0 h) · lotes=2 nuevos=0 duracion_ms=2
ALERTA: el vigía lleva demasiado sin correr
Ojo con el primer docker run que hagas sin el volumen: verás lotes=0. No es un bug del vigía: el contenedor trae su propio sistema de archivos, y sqlite:///inventario.db (ruta relativa) crea una base nueva y vacía adentro. Es la trampa de rutas del P0/P3 en su versión de procesos: en producción la URL de la DB siempre viene del entorno y apunta a un lugar persistente.
Paso 8 Programarlo: cron local, luego la nube
Primero en tu máquina, para entender el mecanismo. crontab -e abre tu tabla de tareas; agrega una línea:
crontab (una línea)# min hora día mes día-semana comando
0 7 * * * cd /Users/alexandra/inventario-lab && /usr/local/bin/docker run --rm -v "$PWD/inventario.db:/app/inventario.db" --env-file .env inventario inv vigia --canal telegram >> vigia.log 2>&1
Léela en voz alta: "a las 7:00 de cada día". Los cinco campos son minuto, hora, día del mes, mes, día de la semana; * significa "todos". La ruta absoluta a docker y el cd no son manía: cron corre con un entorno mínimo, sin tu PATH ni tu carpeta actual (trampa clásica, ver abajo). El >> vigia.log 2>&1 guarda lo que el proceso diga, porque no habrá nadie mirando la pantalla.
Ahora la nube, donde no depende de que tu laptop esté prendida. En Google Cloud el par estándar es Cloud Run Jobs (un contenedor que corre y termina, a diferencia del servicio del P6 que atiende requests) + Cloud Scheduler (el cron gestionado). Con el proyecto GCP del P6:
# La imagen ya está en Artifact Registry desde P6; si no: gcloud builds submit --tag $IMAGEN
gcloud run jobs create vigia-job \
--region southamerica-west1 \
--image $IMAGEN \
--command inv --args "vigia,--canal,telegram" \
--set-env-vars INVENTARIO_DB_URL=$DB_URL,INVENTARIO_TZ=America/Lima \
--set-secrets TELEGRAM_BOT_TOKEN=telegram-token:latest,TELEGRAM_CHAT_ID=telegram-chat:latest
# Pruébalo una vez a mano:
gcloud run jobs execute vigia-job --region southamerica-west1 --wait
# Y prográmalo: Scheduler dispara el job cada día a las 7:00 de Lima,
# autenticándose con OIDC (una cuenta de servicio con permiso de invocar el job):
gcloud scheduler jobs create http vigia-diario \
--location southamerica-west1 \
--schedule "0 7 * * *" \
--time-zone "America/Lima" \
--uri "https://run.googleapis.com/v2/projects/$PROYECTO/locations/southamerica-west1/jobs/vigia-job:run" \
--http-method POST \
--oidc-service-account-email vigia-scheduler@$PROYECTO.iam.gserviceaccount.com
Los secretos van en Secret Manager (gcloud secrets create telegram-token --data-file=-), nunca en --set-env-vars: regla del P6. Y fíjate en --time-zone: sin él, el "0 7" sería en UTC y tu aviso llegaría a las 2 de la mañana.
Queda la última pieza del oficio: vigilar al vigilante. El latido ya existe; algo tiene que mirarlo. La opción mínima honesta: el propio inv vigia-estado corriendo en otro job programado unas horas después, que al fallar (exit 2) deja el error en los logs de Cloud Run, donde ya tienes alertas desde P6. La opción profesional que conocerás después se llama dead man's switch (servicios como Healthchecks.io: si el vigía no "toca" una URL cada día, ellos te avisan). Anota en DECISIONES.md cuál elegiste y qué agujero le queda: si Telegram entero está caído, ¿quién te avisa de que no te avisó?
Extensión opcional: el vigía de precios
El mismo esqueleto sirve para vigilar el precio de un reactivo en la web de un proveedor: un job diario que descarga la página (BeautifulSoup), extrae el precio, lo guarda y avisa si bajó. Las reglas de cortesía no son opcionales: revisa robots.txt y los términos del sitio, identifícate con un User-Agent honesto, una visita al día (jamás en bucle), solo páginas públicas. Y si el sitio ofrece API, se usa la API.
git add . && git commit -m "Programación del vigía: cron local y Cloud Run Jobs + Scheduler"
git push -u origin feat/vigia
gh pr create --title "Vigía de vencimientos" --reviewer rotorrest
Paso 9 Limpieza (nube)
Como en los tutoriales de GCP: lo que no borres se queda, y a veces cobra. Cuando termines de experimentar, o si quieres pausar el vigía en la nube:
# Pausar solo el disparo diario (el job queda, ya no corre):
gcloud scheduler jobs pause vigia-diario --location southamerica-west1
# O borrar ambos:
gcloud scheduler jobs delete vigia-diario --location southamerica-west1
gcloud run jobs delete vigia-job --region southamerica-west1
El cron local se quita con crontab -e borrando la línea. Verifica con crontab -l.
Entiende el código
Estructura al terminar
src/inventario/
├── models.py + Aviso, Latido
├── vigia.py revisar_vencimientos, enviar_con_reintentos, latido
├── notificadores.py NotificadorConsola, TelegramNotificador
└── cli.py + inv vigia, inv vigia-estado
tests/test_vigia.py 6 tests: idempotencia, reintentos, latido
compose.yaml + servicio vigia con volumen
migrations/versions/ + la migración de Aviso y Latido
La idempotencia, dibujada
Cada corrida hace: (1) ¿qué lotes vencen pronto? — consulta a services, la de siempre; (2) ¿a cuáles ya avisé? — consulta a Aviso; (3) la resta entre ambos conjuntos es lo pendiente. Si está vacía, no envía nada. "Correr el vigía" se vuelve seguro de repetir porque guarda memoria de sí mismo, no porque alguien prometa correrlo una sola vez. Esa es la definición operativa de idempotencia, y el patrón (una tabla de "ya lo hice" + la resta) te va a servir de nuevo en la ingesta del P9 y en cualquier robot que toque el mundo.
Por qué inyectar dormir y notificador
revisar_vencimientos no crea sus dependencias: las recibe. En producción, notificador es Telegram y dormir es time.sleep; en tests, uno falso que anota y una función que no espera. Se llama inyección de dependencias y no necesita frameworks: son parámetros con valores por defecto. La señal de que está bien hecho: el test de "reintenta con esperas 1 y 2" corre en milisegundos.
La matemática del backoff
Esperas espera_base × 2^(intento−1): 1, 2, 4, 8 segundos — ~15 s de espera antes de rendirse con 5 intentos. ¿Por qué creciente y no 5 seguidos? Porque si el servicio está caído, martillarlo no lo levanta (y si todos sus clientes martillan a la vez, lo rematan). ¿Por qué un límite? Porque un job que reintenta para siempre es un job colgado que nadie nota: mejor fallar ruidosamente y que el error llegue a los logs. Las librerías (tenacity) hacen esto con un decorador y le suman jitter (aleatoriedad para desincronizar clientes); lo escribiste a mano una vez para saber qué hay dentro.
¿Y por qué no llama a la API del P4?
El vigía podría consumir GET /lotes/por-vencer por HTTP, como el tablero del P5. Elegimos que use services directo: vive en el mismo repo, comparte las reglas de negocio, y le quitas una dependencia de red a un proceso cuyo tema es precisamente sobrevivir a la red (la dependencia frágil que sí le queda —Telegram— ya te dio bastante trabajo). El costo: el vigía necesita ver la base de datos. Si mañana viviera en otra máquina sin acceso a la DB, la API sería el camino. Las dos opciones son defendibles; lo indefendible es no saber por qué elegiste la tuya. A DECISIONES.md.
services.hoy() y la zona horaria
"Vence en 30 días" depende de qué día es hoy — ¿hoy dónde? Un servidor en la nube vive en UTC: a las 19:01 de Lima ya es "mañana" en UTC, y un lote puede entrar o salir de la ventana de aviso según quién pregunte. Por eso hoy() se calcula con zona explícita (INVENTARIO_TZ, default America/Lima) y los timestamps se guardan en UTC:
src/inventario/services.py (si no lo hiciste en P3)def hoy() -> date:
"""La fecha de hoy en la zona del laboratorio, no la del servidor (que suele ser UTC)."""
zona = ZoneInfo(os.getenv("INVENTARIO_TZ", "America/Lima"))
return datetime.now(zona).date()
Es la versión en miniatura de un incidente real que Rodrigo te puede contar con cicatrices (está en Antipatrones: fechas sin zona).
Con Claude
El esqueleto del vigía (protocolo, backoff, tabla de avisos) lo escribiste a mano: son los conceptos del proyecto. De aquí en adelante Claude puede escribir variantes (el aviso a 7 días, otro canal) mientras tú revisas contra los tests. Pedidos que valen la pena:
Para entender
Explícame la diferencia entre at-most-once, at-least-once y exactly-once en entrega de mensajes. ¿Cuál es mi vigía y por qué exactly-once es tan difícil?
¿Por qué Protocol en vez de una clase abstracta para Notificador? ¿Cuándo preferirías ABC? Un ejemplo de cada lado.
Aquí está mi revisar_vencimientos. Hazme de abogado del diablo: ¿en qué escenarios concretos manda un aviso duplicado, y en cuáles se queda callado indebidamente?
Para construir (tú revisas el diff contra los tests)
Agrega el tipo de aviso "vence_7d" reutilizando revisar_vencimientos. No toques los tests existentes; agrega los que falten.
Propón un NotificadorCorreo con el mismo protocolo, usando smtplib de la librería estándar. Antes de escribirlo, dime qué configuración necesitaría y de dónde debería salir.