7.1 KiB
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.
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 queplaywright==1.62.0derequirements.txt. Si se sube la versión de Playwright, hay que subir también el tag de la imagen en elDockerfile(si no, Firefox no va a coincidir). certs/fortinet-root.crtse instala en el contenedor porque la red de la oficina tiene un Fortinet haciendo inspección SSL; sin esa CA,pipy las requests HTTPS fallarían por certificado no confiable.main.pyenvuelveejecutar()en untry/exceptque loguea cualquier excepción no controlada (exc_info=True) antes de salir con código 1, para que los crashes también queden enlogs/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:
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)
- Editar
crontab-hosty 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 unPATHmínimo que no incluye/usr/local/bin, donde suele vivir el binariodockerde Docker Desktop — sin esto, cada corrida falla condocker: command not found. Confirmar la ruta real conwhich dockeren una terminal normal y reflejarla en esta línea.
- Instalar el crontab (reemplaza todo el crontab del usuario actual):
Para revisar qué quedó cargado:
crontab crontab-hostcrontab -l. Para editarlo en caliente:crontab -e(abre el crontab activo en el editor por defecto, no el archivo del repo). - macOS: dar Full Disk Access a
/usr/sbin/cronen 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. - Verificar que corre: revisar
logs/descarga-cpe.logdespués del próximo horario programado, o forzar una corrida manual con la variable ya seteada:cd $PROY && SMB_HOST_MOUNT=/ruta/real docker compose run --rm descarga-cpe
Reglas de código
- Empresas en
config.jsoncon{ nombre, schema, locked, smb_subfolder }.locked: true= no ejecutar. Descargas endownloads/<nombre>/. - Credenciales en
.env, nunca en git. - Logging con
logger_manager.get_logger(...)y niveles info/warning/error (sinprint). core.pyes funcional (sin clases): cada servicio reescribedescribir_servicio(),obtener_datos(empresa)yprocesar_y_descargar(datos, dir_empresa);main.pysiempre delgado (solo llama aejecutar()).- Después de descargar PDF/XML se marca en HANA (
U_PGX_PDF/U_PGX_XML) por schema. - Transferencia con
smb_transfer.transferir_a_smb(...); si el SMB no está montado conserva los archivos (no los borra). - Verificar:
python -m py_compile main.py core.py hana_manager.py descargador_sovos.py smb_transfer.py logger_manager.pyy correrdocker compose run --rm descarga-cpe.