Compare commits
3
Commits
2c2c88bfd6
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66e4a97ace | ||
|
|
281e8aa0e8 | ||
|
|
277d07dfeb |
No files matched your search
@@ -0,0 +1,19 @@
|
|||||||
|
|
||||||
|
# HANA
|
||||||
|
HANA_HOST =
|
||||||
|
HANA_PORT = 30015
|
||||||
|
HANA_USER =
|
||||||
|
HANA_PASSWORD =
|
||||||
|
|
||||||
|
# SMB / NAS
|
||||||
|
SMB_SERVER =
|
||||||
|
SMB_SHARE =
|
||||||
|
SMB_USER =
|
||||||
|
SMB_PASSWORD =
|
||||||
|
SMB_MOUNT_MAC =
|
||||||
|
SMB_MOUNT_LINUX =
|
||||||
|
SMB_MOUNT_WINDOWS =
|
||||||
|
|
||||||
|
LOG_LEVEL = INFO
|
||||||
|
# true = intentar montar la unidad antes de transferir
|
||||||
|
SMB_AUTO_MOUNT = true
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
# ============================================================================
|
||||||
@@ -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]
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
# ============================================================================
|
||||||
Reference in new issue
Block a user