# CLAUDE.md — Convenciones del proyecto Servicio Python de **descarga de comprobantes electrónicos (CPE) desde Sovos/SUNAT**. Sigue la estructura de la plantilla `~/Desarrollo/Python/plantilla-servicio/`. ## Estructura de archivos ``` main.py # entrada delgada: try/except alrededor de 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) hana_manager.py # reutilizable (HANA multi-schema, obtener_pendientes, marcar) descargador_sovos.py # específico: descarga PDF/XML en Sovos con Playwright (Firefox) smb_transfer.py # reutilizable (detectar/montar SMB + rsync por empresa) config.json # hana, rucs, consulta.dias, empresas(locked), files .env # SMB_* + SMB_AUTO_MOUNT + LOG_LEVEL Dockerfile # imagen (Playwright+Firefox ya incluidos, CA Fortinet, rsync) docker-compose.yml # servicio `descarga-cpe`, monta config/downloads/logs/SMB 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) ``` ## Arquitectura: Docker 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//...` 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 }`. `locked: true` = no ejecutar. Descargas en `downloads//`. 2. Credenciales en `.env`, nunca en git. 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()`, `obtener_datos(empresa)` y `procesar_y_descargar(datos, dir_empresa)`; `main.py` siempre delgado (solo llama a `ejecutar()`). 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 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` y correr `docker compose run --rm descarga-cpe`.