Compare commits

..
2 Commits
Author SHA1 Message Date
George 281e8aa0e8 Merge origin/main: conserva la migración a servicio persistente (descarta el enfoque anterior de cron del host, ya obsoleto) 2026-08-25 09:49:41 -05:00
George 277d07dfeb Migra el servicio a arquitectura Docker persistente y mueve credenciales HANA a .env
- Reemplaza config_loader/config_model/hana_client/logger_utilities/outlook_utilities
  por core.py (pipeline funcional), hana_manager.py, logger_manager.py, scheduler.py y
  descargador_sovos.py (Playwright/Firefox) según la plantilla de servicios.
- main.py pasa a loop continuo dirigido por scheduler.json, con manejo de SIGINT/SIGTERM.
- Agrega Dockerfile, docker-compose.yml (restart unless-stopped) y certs/ para despliegue.
- Agrega smb_transfer.py para la transferencia a carpeta compartida.
- Saca las credenciales de HANA de config.json y las mueve a .env (HANA_HOST/PORT/USER/PASSWORD).
2026-08-25 09:29:35 -05:00
11 changed files with 187 additions and 170 deletions

No files matched your search

+1
View File
@@ -11,6 +11,7 @@ dbapi/
# Datos y logs # Datos y logs
downloads/ downloads/
logs/ logs/
cron_docker.log
# SO / IDE # SO / IDE
.DS_Store .DS_Store
+39 -117
View File
@@ -3,142 +3,64 @@
Servicio Python de **descarga de comprobantes electrónicos (CPE) desde Sovos/SUNAT**. Servicio Python de **descarga de comprobantes electrónicos (CPE) desde Sovos/SUNAT**.
Sigue la estructura de la plantilla `~/Desarrollo/Python/plantilla-servicio/`. Sigue la estructura de la plantilla `~/Desarrollo/Python/plantilla-servicio/`.
## Estructura de archivos ## Estructura
``` ```
main.py # entrada delgada: try/except alrededor de ejecutar() main.py # entrada delgada: while loop dirigido por scheduler.json, llama ejecutar()
core.py # pipeline funcional: describir_servicio/obtener_datos/procesar_y_descargar/ejecutar core.py # pipeline funcional: describir_servicio/obtener_datos/procesar_y_descargar/ejecutar
logger_manager.py # reutilizable (logging niveles + árbol; TimedRotatingFileHandler, rotación diaria, 30 días de retención) scheduler.py # reutilizable (franja activa / próximo inicio a partir de scheduler.json)
logger_manager.py # reutilizable (logging niveles + árbol; TimedRotatingFileHandler 30 días)
hana_manager.py # reutilizable (HANA multi-schema, obtener_pendientes, marcar) hana_manager.py # reutilizable (HANA multi-schema, obtener_pendientes, marcar)
descargador_sovos.py # específico: descarga PDF/XML en Sovos con Playwright (Firefox) descargador_sovos.py # específico: descarga PDF/XML en Sovos con Playwright (Firefox)
smb_transfer.py # reutilizable (detectar/montar SMB + rsync por empresa) smb_transfer.py # reutilizable (detectar/montar SMB + rsync por empresa)
config.json # hana, rucs, consulta.dias, empresas(locked), files config.json # hana, rucs, consulta.dias, empresas(locked), files
scheduler.json # HORARIOS ([hora_ini,min_ini,hora_fin,min_fin,modo]) + DIAS_ACTIVOS
.env # SMB_* + SMB_AUTO_MOUNT + LOG_LEVEL .env # SMB_* + SMB_AUTO_MOUNT + LOG_LEVEL
Dockerfile # imagen (Playwright+Firefox ya incluidos, CA Fortinet, rsync) Dockerfile # imagen (Playwright+Firefox ya incluidos, CA Fortinet, rsync)
docker-compose.yml # servicio `descarga-cpe`, monta config/downloads/logs/SMB docker-compose.yml # servicio `descarga-cpe` persistente (restart: unless-stopped)
crontab-host # cron del HOST (macOS o Linux) que dispara `docker compose run --rm`
certs/fortinet-root.crt # CA de inspección SSL de la red (FG100FTK22018356) certs/fortinet-root.crt # CA de inspección SSL de la red (FG100FTK22018356)
``` ```
## Arquitectura: Docker ## Reglas
Todo corre **solo en Docker** — no hay venv nativa ni LaunchAgent en el proyecto.
El contenedor es una **pasada única**: arranca, procesa, y se destruye (`--rm`); nunca
queda corriendo en loop. No agregar un `while`/scheduler dentro de la app — si el
contenedor falla, la siguiente corrida de cron simplemente reintenta.
```mermaid
flowchart LR
subgraph HOST["Host (macOS o Linux)"]
CRON["cron\n(crontab-host)"]
FILES[("config.json · downloads/ · logs/\nSMB ya montado en el host")]
end
subgraph CONTAINER["Contenedor Docker (una pasada, --rm)"]
APP["main.py → core.ejecutar()"]
end
CRON -- "docker compose run --rm" --> APP
FILES -. "volumes" .- APP
APP -- "hdbcli" --> HANA[("SAP HANA")]
APP -- "Playwright + Firefox" --> SOVOS[("Sovos / SUNAT")]
APP -- "rsync" --> SMB[("Carpeta SMB / NAS")]
```
- El contenedor fija `TZ=America/Lima`, así que los timestamps del log y cualquier
lógica sensible a fecha/hora quedan en horario de Perú, sin importar la zona
horaria del host.
- La imagen base (`mcr.microsoft.com/playwright/python:v1.62.0-jammy`) ya trae
Chromium y Firefox instalados, en la misma versión que `playwright==1.62.0` de
`requirements.txt`. Si se sube la versión de Playwright, hay que subir también
el tag de la imagen en el `Dockerfile` (si no, Firefox no va a coincidir).
- `certs/fortinet-root.crt` se instala en el contenedor porque la red de la oficina
tiene un Fortinet haciendo inspección SSL; sin esa CA, `pip` y las requests HTTPS
fallarían por certificado no confiable.
- `main.py` envuelve `ejecutar()` en un `try/except` que loguea cualquier excepción
no controlada (`exc_info=True`) antes de salir con código 1, para que los crashes
también queden en `logs/descarga-cpe.log` (ya rotado) en vez de perderse en el
mail local de cron. Por eso no hace falta redirigir el stdout de cron a un
segundo archivo de log — sería un mecanismo de rotación redundante.
## Arquitectura: Cron (programación)
El **host** (no el contenedor) decide cuándo correr. `crontab-host` dispara
`docker compose run --rm descarga-cpe` en estas franjas, lunes a viernes:
| Franja | Frecuencia |
|---------------|------------|
| 06:00–13:00 | cada 20 min |
| 13:00–14:00 | cada 30 min |
| 14:00–21:00 | cada 20 min |
| 21:00–23:00 | cada 1 hora (21:00 y 22:00) |
Fuera de esas franjas y en fines de semana no corre nada; retoma a las 06:00 del
siguiente día hábil.
Flujo de una corrida individual:
```mermaid
flowchart TD
A["Cron: horario programado"] --> B["docker compose run --rm descarga-cpe"]
B --> C["Contenedor arranca → python main.py"]
C --> D["core.ejecutar()"]
D --> E{"¿Hay empresas activas?\n(config.json, locked=false)"}
E -->|No| Z["Log: sin empresas activas, termina"]
E -->|Sí| F["Por cada empresa activa"]
F --> G["obtener_datos(empresa)\nHANA: SELECT pendientes"]
G --> H{"¿Hay documentos\npendientes?"}
H -->|No| F
H -->|Sí| I["descargar_documentos_pendientes()\nPlaywright + Firefox → Sovos"]
I --> J["Marca U_PGX_PDF / U_PGX_XML\nen HANA (hana_manager)"]
J --> F
F -->|Todas procesadas| K["transferir_a_smb()\nrsync → carpeta SMB/NAS"]
K --> L["Contenedor se destruye (--rm)"]
C -.->|"excepción no controlada"| M["main.py: try/except\nloguea y sale con código 1"]
```
Bloqueos de red (FortiGuard Web Filter, p.ej. categoría "Finance and Banking" en
`public-einvoicing.sovos.com`) no son bug de la app: se resuelven en el FortiGate,
no reintentando ni parcheando el código.
### Configurar el cron del host (`crontab-host`)
1. Editar `crontab-host` y ajustar:
- `PROY`: ruta absoluta del proyecto en esa máquina.
- `SMB_HOST_MOUNT`: ruta donde esa máquina ya tiene montada la carpeta SMB/NAS
(macOS: normalmente algo bajo `/Volumes/...` o `/Users/<usuario>/...` si se
montó a mano; Linux: p.ej. `/mnt/filesap`).
- `PATH`: cron corre con un `PATH` mínimo que no incluye `/usr/local/bin`, donde
suele vivir el binario `docker` de Docker Desktop — sin esto, cada corrida
falla con `docker: command not found`. Confirmar la ruta real con
`which docker` en una terminal normal y reflejarla en esta línea.
2. Instalar el crontab (reemplaza todo el crontab del usuario actual):
```bash
crontab crontab-host
```
Para revisar qué quedó cargado: `crontab -l`. Para editarlo en caliente:
`crontab -e` (abre el crontab activo en el editor por defecto, no el archivo
del repo).
3. **macOS**: dar Full Disk Access a `/usr/sbin/cron` en Ajustes del Sistema →
Privacidad y Seguridad → Acceso Total al Disco. Sin esto, cron no puede leer/
escribir dentro de `~/Documents` (o donde esté el proyecto) y las corridas
fallan en silencio.
4. Verificar que corre: revisar `logs/descarga-cpe.log` después del próximo
horario programado, o forzar una corrida manual con la variable ya seteada:
```bash
cd $PROY && SMB_HOST_MOUNT=/ruta/real docker compose run --rm descarga-cpe
```
## Reglas de código
1. Empresas en `config.json` con `{ nombre, schema, locked, smb_subfolder }`. 1. Empresas en `config.json` con `{ nombre, schema, locked, smb_subfolder }`.
`locked: true` = no ejecutar. Descargas en `downloads/<nombre>/`. `locked: true` = no ejecutar. Descargas en `downloads/<nombre>/`.
2. Credenciales en `.env`, nunca en git. 2. Credenciales en `.env`, nunca en git.
3. Logging con `logger_manager.get_logger(...)` y niveles info/warning/error (sin `print`). 3. Logging con `logger_manager.get_logger(...)` y niveles info/warning/error (sin `print`).
4. `core.py` es funcional (sin clases): cada servicio reescribe `describir_servicio()`, 4. `core.py` es funcional (sin clases): cada servicio reescribe `describir_servicio()`,
`obtener_datos(empresa)` y `procesar_y_descargar(datos, dir_empresa)`; `obtener_datos(empresa)` y `procesar_y_descargar(datos, dir_empresa)`;
`main.py` siempre delgado (solo llama a `ejecutar()`). `main.py` siempre delgado (solo llama a `ejecutar()`; no reescribe lógica de
negocio — su única lógica propia es el loop/scheduler de la regla 8).
5. Después de descargar PDF/XML se marca en HANA (`U_PGX_PDF`/`U_PGX_XML`) por schema. 5. Después de descargar PDF/XML se marca en HANA (`U_PGX_PDF`/`U_PGX_XML`) por schema.
6. Transferencia con `smb_transfer.transferir_a_smb(...)`; si el SMB no está montado 6. Transferencia con `smb_transfer.transferir_a_smb(...)`; si el SMB no está montado
conserva los archivos (no los borra). conserva los archivos (no los borra).
7. Verificar: `python -m py_compile main.py core.py hana_manager.py descargador_sovos.py smb_transfer.py logger_manager.py` 7. Verificar: `python -m py_compile main.py core.py scheduler.py hana_manager.py descargador_sovos.py smb_transfer.py logger_manager.py`
y correr `docker compose run --rm descarga-cpe`. y correr `docker compose up -d descarga-cpe` (revisar con `docker compose logs -f`).
8. **Solo Docker, servicio persistente** (no hay venv nativa ni LaunchAgent/cron del
HOST en el proyecto): `docker-compose.yml` usa `restart: unless-stopped` y
`main.py` mantiene un `while` continuo — en cada ciclo llama a `ejecutar()`
(pipeline de `core.py`) y luego duerme según el modo de la franja horaria activa.
`scheduler.py` lee `scheduler.json` (releído en cada ciclo, así se pueden editar
horarios sin reiniciar el contenedor) y responde:
- si el día/hora actual cae dentro de una franja de `HORARIOS`, su `modo`
(`"rapido"` → dormir 10 min, `"full"` → dormir 1 hora; mapeo fijo en
`INTERVALOS_POR_MODO` dentro de `scheduler.py`, no en el JSON);
- si no, el inicio de la próxima franja activa (respetando `DIAS_ACTIVOS`),
y `main.py` duerme exactamente hasta ese instante.
El sueño se hace en pasos de 1s (`_dormir_interrumpible`) y `main.py` registra
handlers de `SIGINT`/`SIGTERM` para poder frenar el loop y salir limpio ante
`docker compose stop/down`, en vez de que Docker tenga que matarlo por timeout.
Una excepción no controlada en un ciclo se loguea (`exc_info=True`, va a
`logs/descarga-cpe.log` con su rotación de 30 días) y el loop reintenta tras
60s — ya no hay cron externo que reintente por nosotros.
Como el contenedor ya no es efímero, `docker-compose.yml` limita el driver
`json-file` de Docker (`max-size: 10m`, `max-file: 5`) para que el log propio
del contenedor (stdout) no crezca sin límite; esto es independiente de la
rotación de `logs/descarga-cpe.log`, que maneja `logger_manager.py`.
9. `scheduler.json` define `HORARIOS` (lista de `[hora_ini, min_ini, hora_fin,
min_fin, modo]`, `modo` es `"rapido"` o `"full"`) y `DIAS_ACTIVOS` (0=lunes …
6=domingo, según `datetime.weekday()`). Fuera de esas franjas y días no se
ejecuta nada.
10. Bloqueos de red (FortiGuard Web Filter, p.ej. categoría "Finance and Banking" en
`public-einvoicing.sovos.com`) no son bug de la app: se resuelven en el FortiGate,
no reintentando ni parcheando el código.
+1 -1
View File
@@ -29,5 +29,5 @@ RUN pip install --no-cache-dir -r requirements.txt \
COPY . . COPY . .
# Una ejecución por proceso; el cron del host levanta el contenedor por disparo. # Servicio persistente: main.py corre en loop (ver scheduler.py/scheduler.json).
CMD ["python", "main.py"] CMD ["python", "main.py"]
+4 -10
View File
@@ -1,10 +1,4 @@
{ {
"hana": {
"host": "192.168.1.175",
"port": 30015,
"user": "SYSTEM",
"password": "SAPB1Admin.25"
},
"rucs": [ "rucs": [
"20100047218", "20100047218",
"20100130204", "20100130204",
@@ -23,25 +17,25 @@
{ {
"nombre": "elavent", "nombre": "elavent",
"schema": "SBO_ELAVENT", "schema": "SBO_ELAVENT",
"locked": false, "locked": true,
"smb_subfolder": "COMPRAS/ELAVENT/ADJUNTOS_CORREOS/2025-03" "smb_subfolder": "COMPRAS/ELAVENT/ADJUNTOS_CORREOS/2025-03"
}, },
{ {
"nombre": "safena", "nombre": "safena",
"schema": "SBO_SAFENA", "schema": "SBO_SAFENA",
"locked": false, "locked": true,
"smb_subfolder": "COMPRAS/SAFENA/ADJUNTOS_CORREOS/2025-03" "smb_subfolder": "COMPRAS/SAFENA/ADJUNTOS_CORREOS/2025-03"
}, },
{ {
"nombre": "cirugia", "nombre": "cirugia",
"schema": "SBO_CIRUGIA", "schema": "SBO_CIRUGIA",
"locked": false, "locked": true,
"smb_subfolder": "COMPRAS/CIRUGIA/ADJUNTOS_CORREOS/2025-03" "smb_subfolder": "COMPRAS/CIRUGIA/ADJUNTOS_CORREOS/2025-03"
}, },
{ {
"nombre": "franquicia", "nombre": "franquicia",
"schema": "SBO_FRANQUICIA", "schema": "SBO_FRANQUICIA",
"locked": false, "locked": true,
"smb_subfolder": "COMPRAS/FRANQUICIA/ADJUNTOS_CORREOS/2025-03" "smb_subfolder": "COMPRAS/FRANQUICIA/ADJUNTOS_CORREOS/2025-03"
} }
], ],
+3 -2
View File
@@ -12,7 +12,7 @@ from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
from logger_manager import get_logger from logger_manager import get_logger
from hana_manager import obtener_pendientes, cerrar_conexion from hana_manager import obtener_pendientes, cerrar_conexion, obtener_config_hana
from descargador_sovos import descargar_documentos_pendientes from descargador_sovos import descargar_documentos_pendientes
from smb_transfer import get_mount_base, transferir_a_smb from smb_transfer import get_mount_base, transferir_a_smb
@@ -64,7 +64,8 @@ def ejecutar(cfg: dict | None = None):
logger.info("=" * 60) logger.info("=" * 60)
logger.info(f"🚀 {describir_servicio()}") logger.info(f"🚀 {describir_servicio()}")
logger.info(f"💾 HANA: {cfg['hana']['host']}:{cfg['hana']['port']}") cfg_hana = obtener_config_hana()
logger.info(f"💾 HANA: {cfg_hana['host']}:{cfg_hana['port']}")
logger.info("=" * 60) logger.info("=" * 60)
logger.info(f"🏢 Empresas configuradas: {len(cfg.get('empresas', []))}") logger.info(f"🏢 Empresas configuradas: {len(cfg.get('empresas', []))}")
logger.info(f"📁 Descargas base: {base}") logger.info(f"📁 Descargas base: {base}")
-26
View File
@@ -1,26 +0,0 @@
# Crontab del HOST (macOS o Linux) — Docker.
# Levanta el contenedor solo durante cada ejecución de la descarga CPE.
# Lunes a viernes. Horario:
# 06:00-13:00 c/20min · 13:00-14:00 c/30min · 14:00-21:00 c/20min · 21:00-23:00 c/1h (21:00 y 22:00)
# Fuera de esas franjas y sábado/domingo no se ejecuta nada; recién retoma
# a las 06:00 del siguiente día hábil.
#
# Ajustar PROY a la ruta absoluta del proyecto y SMB_HOST_MOUNT al punto de
# montaje SMB del host (macOS: /Users/<usuario>/FileSAP · Linux: /mnt/filesap).
SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
PROY=/Users/george/Documents/docker/proyectos/descarga_cpe_sovos
SMB_HOST_MOUNT=/Users/george/FileSAP
# 06:00-13:00 cada 20 min
*/20 6-12 * * 1-5 cd $PROY && SMB_HOST_MOUNT=$SMB_HOST_MOUNT docker compose run --rm descarga-cpe
# 13:00-14:00 cada 30 min
0,30 13 * * 1-5 cd $PROY && SMB_HOST_MOUNT=$SMB_HOST_MOUNT docker compose run --rm descarga-cpe
# 14:00-21:00 cada 20 min
*/20 14-20 * * 1-5 cd $PROY && SMB_HOST_MOUNT=$SMB_HOST_MOUNT docker compose run --rm descarga-cpe
# 21:00-23:00 cada 1 hora (21:00 y 22:00; 23:00 es solo el límite de fin)
0 21-22 * * 1-5 cd $PROY && SMB_HOST_MOUNT=$SMB_HOST_MOUNT docker compose run --rm descarga-cpe
# macOS: dar Full Disk Access a /usr/sbin/cron en Ajustes del Sistema > Privacidad.
# El log (con rotación de 30 días) queda en logs/descarga-cpe.log; main.py
# captura y loguea ahí cualquier excepción no controlada.
+10 -4
View File
@@ -1,12 +1,18 @@
services: services:
descarga-cpe: descarga-cpe:
build: . build: .
restart: unless-stopped
stop_grace_period: 15s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
env_file: env_file:
- .env - .env
volumes: volumes:
- ./config.json:/app/config.json:ro - ./config.json:/app/config.json:ro
- ./scheduler.json:/app/scheduler.json:ro
- ./downloads:/app/downloads - ./downloads:/app/downloads
- ./logs:/app/logs - ./logs:/app/logs
- ${SMB_HOST_MOUNT:-/Users/george/FileSAP}:/mnt/FileSAP - ${SMB_HOST_MOUNT:-/Users/george/FileSAP}:/mnt/FileSAP
@@ -15,5 +21,5 @@ services:
# Dentro del contenedor la unidad está siempre en /mnt/FileSAP # Dentro del contenedor la unidad está siempre en /mnt/FileSAP
# (independiente del punto de montaje del host: macOS Ubuntu). # (independiente del punto de montaje del host: macOS Ubuntu).
- SMB_MOUNT_LINUX=/mnt/FileSAP - SMB_MOUNT_LINUX=/mnt/FileSAP
# Sin restart: el cron del host levanta el contenedor con # Servicio persistente: main.py corre en loop dirigido por scheduler.json
# `docker compose run --rm` solo durante cada ejecución. # (ver CLAUDE.md regla 8). Se levanta una sola vez con `docker compose up -d`.
+10 -3
View File
@@ -18,11 +18,18 @@ _cfg_hana = None
def _cargar_config(): def _cargar_config():
global _cfg_hana global _cfg_hana
if _cfg_hana is None: if _cfg_hana is None:
with open(CONFIG_FILE, encoding="utf-8") as f: _cfg_hana = {
data = json.load(f) "host": os.getenv("HANA_HOST"),
_cfg_hana = data["hana"] "port": int(os.getenv("HANA_PORT", "30015")),
"user": os.getenv("HANA_USER"),
"password": os.getenv("HANA_PASSWORD"),
}
return _cfg_hana return _cfg_hana
def obtener_config_hana() -> dict:
return _cargar_config()
# ============================================================================ # ============================================================================
def get_connection(): def get_connection():
global _conn global _conn
+49 -7
View File
@@ -1,14 +1,56 @@
"""main.py — entrada delgada. Llama a ejecutar() definido en core.py.""" """main.py — entrada delgada. Bucle continuo dirigido por scheduler.json."""
import sys import signal
import time
from datetime import datetime
from core import ejecutar from core import ejecutar
from scheduler import cargar_scheduler, franja_actual, proximo_inicio
from logger_manager import get_logger from logger_manager import get_logger
logger = get_logger("main")
_running = True
# ============================================================================
def _signal_handler(signum, frame):
global _running
logger.info("⏹️ Señal de parada recibida. Deteniendo tras el ciclo actual...")
_running = False
def _dormir_interrumpible(segundos: float):
"""Duerme en pasos de 1s para reaccionar rápido a SIGTERM/SIGINT."""
restante = int(segundos)
while restante > 0 and _running:
time.sleep(min(1, restante))
restante -= 1
# ============================================================================ # ============================================================================
if __name__ == "__main__": if __name__ == "__main__":
try: signal.signal(signal.SIGINT, _signal_handler)
ejecutar() signal.signal(signal.SIGTERM, _signal_handler)
except Exception:
get_logger("main").error("Excepción no controlada", exc_info=True) while _running:
sys.exit(1) try:
cfg_scheduler = cargar_scheduler()
ahora = datetime.now()
franja = franja_actual(cfg_scheduler, ahora)
if franja is None:
proximo = proximo_inicio(cfg_scheduler, ahora)
segundos = max((proximo - ahora).total_seconds(), 0)
logger.info(
f"⏸️ Fuera de horario. Próxima franja: {proximo:%Y-%m-%d %H:%M} "
f"(durmiendo {int(segundos)}s)"
)
_dormir_interrumpible(segundos)
continue
ejecutar()
_dormir_interrumpible(franja["intervalo"])
except Exception:
logger.error("Excepción no controlada en el ciclo", exc_info=True)
_dormir_interrumpible(60)
logger.info("👋 Proceso detenido.")
# ============================================================================ # ============================================================================
+9
View File
@@ -0,0 +1,9 @@
{
"HORARIOS": [
[6, 0, 13, 15, "rapido"],
[13, 15, 14, 0, "full"],
[14, 0, 17, 30, "rapido"],
[17, 30, 21, 30, "full"]
],
"DIAS_ACTIVOS": [0, 1, 2, 3, 4]
}
+61
View File
@@ -0,0 +1,61 @@
"""scheduler.py — franja horaria activa para el while loop de main.py.
Lee scheduler.json (HORARIOS + DIAS_ACTIVOS) y responde dos preguntas:
* franja_actual(): si ahora cae dentro de una franja, con qué modo.
* proximo_inicio(): si no, cuándo empieza la próxima franja activa.
"""
import json
from pathlib import Path
from datetime import datetime, timedelta
# ============================================================================
SCHEDULER_FILE = Path(__file__).parent / "scheduler.json"
INTERVALOS_POR_MODO = {
"rapido": 10 * 60,
"full": 60 * 60,
}
# ============================================================================
def cargar_scheduler(path: Path | None = None) -> dict:
p = path or SCHEDULER_FILE
with open(p, encoding="utf-8") as f:
return json.load(f)
def _minutos(hora: int, minuto: int) -> int:
return hora * 60 + minuto
def franja_actual(cfg: dict, ahora: datetime) -> dict | None:
if ahora.weekday() not in cfg.get("DIAS_ACTIVOS", []):
return None
minutos_ahora = _minutos(ahora.hour, ahora.minute)
for h_ini, m_ini, h_fin, m_fin, modo in cfg.get("HORARIOS", []):
if _minutos(h_ini, m_ini) <= minutos_ahora < _minutos(h_fin, m_fin):
return {"modo": modo, "intervalo": INTERVALOS_POR_MODO.get(modo, 600)}
return None
def proximo_inicio(cfg: dict, ahora: datetime) -> datetime:
dias_activos = cfg.get("DIAS_ACTIVOS", [])
horarios = cfg.get("HORARIOS", [])
if not dias_activos or not horarios:
raise ValueError("scheduler.json sin DIAS_ACTIVOS o HORARIOS configurados")
for delta in range(0, 8):
candidato = ahora + timedelta(days=delta)
if candidato.weekday() not in dias_activos:
continue
inicios = []
for h_ini, m_ini, h_fin, m_fin, _modo in horarios:
inicio = candidato.replace(hour=h_ini, minute=m_ini, second=0, microsecond=0)
if delta == 0 and inicio <= ahora:
continue
inicios.append(inicio)
if inicios:
return min(inicios)
raise ValueError("No se encontró una próxima franja activa en los próximos 7 días")
# ============================================================================