From 2c2c88bfd68f32ef2f148976b8a53b730396e295 Mon Sep 17 00:00:00 2001 From: George Date: Thu, 13 Aug 2026 08:08:07 -0500 Subject: [PATCH] =?UTF-8?q?proyecto=20cargado=20en=20Docker=20y=20ejecut?= =?UTF-8?q?=C3=A1ndose=20con=20crontrab=20en=20macpro?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 24 ++++ .gitignore | 15 ++- CLAUDE.md | 144 +++++++++++++++++++++ Dockerfile | 33 +++++ certs/fortinet-root.crt | 46 +++++++ config.json | 59 ++++++--- config_loader.py | 36 ------ config_model.py | 42 ------- core.py | 111 ++++++++++++++++ crontab-host | 26 ++++ descargador_sovos.py | 145 +++++++++++++++++++++ docker-compose.yml | 19 +++ documentos.json | 131 ------------------- hana_client.py | 75 ----------- hana_manager.py | 155 +++++++++++++++++++++++ logger_manager.py | 116 +++++++++++++++++ logger_utilities.py | 70 ----------- main.py | 271 ++-------------------------------------- outlook_utilities.py | 119 ------------------ requirements.txt | 3 + smb_transfer.py | 182 +++++++++++++++++++++++++++ 21 files changed, 1068 insertions(+), 754 deletions(-) create mode 100644 .dockerignore create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 certs/fortinet-root.crt delete mode 100644 config_loader.py delete mode 100644 config_model.py create mode 100644 core.py create mode 100644 crontab-host create mode 100644 descargador_sovos.py create mode 100644 docker-compose.yml delete mode 100644 documentos.json delete mode 100644 hana_client.py create mode 100644 hana_manager.py create mode 100644 logger_manager.py delete mode 100644 logger_utilities.py delete mode 100644 outlook_utilities.py create mode 100644 requirements.txt create mode 100644 smb_transfer.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..34f85f6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,24 @@ +# Dependencias / virtualenvs +sovos-venv/ +venv/ +__pycache__/ +*.pyc + +# Secretos +.env + +# Datos y logs +downloads/ +logs/ + +# Git / IDE / SO +.git/ +.gitignore +.DS_Store +.vscode/ +.idea/ + +# Docker (evita copiar la imagen/contexto a sí mismos) +Dockerfile +.dockerignore +docker-compose.yml \ No newline at end of file diff --git a/.gitignore b/.gitignore index 0ea519d..9dfe4ba 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,18 @@ +# Python __pycache__/ *.pyc -.env venv/ +sovos-venv/ +dbapi/ + +# Secretos +.env + +# Datos y logs +downloads/ +logs/ + +# SO / IDE .DS_Store .vscode/ .idea/ -downloads/ -logs/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e22b778 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,144 @@ +# 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`. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5c8090d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +FROM mcr.microsoft.com/playwright/python:v1.62.0-jammy + +# Zona horaria del contenedor + +ENV DEBIAN_FRONTEND=noninteractive +ENV TZ=America/Lima + +# rsync para la transferencia a la carpeta compartida (SMB) +RUN apt-get update \ + && apt-get install -y --no-install-recommends tzdata rsync \ + && ln -fs /usr/share/zoneinfo/$TZ /etc/localtime \ + && dpkg-reconfigure -f noninteractive tzdata \ + && rm -rf /var/lib/apt/lists/* + +# CA de inspección SSL de la red (Fortinet FG100FTK22018356) para pip y HTTPS. +COPY certs/fortinet-root.crt /usr/local/share/ca-certificates/fortinet-root.crt +RUN update-ca-certificates + +ENV REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt +ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt + +WORKDIR /app + +# Chromium y Firefox ya vienen con la imagen oficial de Playwright; solo +# instalamos las dependencias Python y quitamos webkit (se usa Firefox). +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt \ + && rm -rf /ms-playwright/webkit-* + +COPY . . + +# Una ejecución por proceso; el cron del host levanta el contenedor por disparo. +CMD ["python", "main.py"] \ No newline at end of file diff --git a/certs/fortinet-root.crt b/certs/fortinet-root.crt new file mode 100644 index 0000000..eaddbf0 --- /dev/null +++ b/certs/fortinet-root.crt @@ -0,0 +1,46 @@ +-----BEGIN CERTIFICATE----- +MIID5jCCAs6gAwIBAgIIcqsuaCG6ihYwDQYJKoZIhvcNAQELBQAwgakxCzAJBgNV +BAYTAlVTMRMwEQYDVQQIDApDYWxpZm9ybmlhMRIwEAYDVQQHDAlTdW5ueXZhbGUx +ETAPBgNVBAoMCEZvcnRpbmV0MR4wHAYDVQQLDBVDZXJ0aWZpY2F0ZSBBdXRob3Jp +dHkxGTAXBgNVBAMMEEZHMTAwRlRLMjIwMTgzNTYxIzAhBgkqhkiG9w0BCQEWFHN1 +cHBvcnRAZm9ydGluZXQuY29tMB4XDTIzMTEwODIyMDI1OVoXDTMzMTEwODIyMDI1 +OVowgakxCzAJBgNVBAYTAlVTMRMwEQYDVQQIDApDYWxpZm9ybmlhMRIwEAYDVQQH +DAlTdW5ueXZhbGUxETAPBgNVBAoMCEZvcnRpbmV0MR4wHAYDVQQLDBVDZXJ0aWZp +Y2F0ZSBBdXRob3JpdHkxGTAXBgNVBAMMEEZHMTAwRlRLMjIwMTgzNTYxIzAhBgkq +hkiG9w0BCQEWFHN1cHBvcnRAZm9ydGluZXQuY29tMIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAsrShPQseQ9YJCOD/sSNMzhpiaTNVXwfreefYqZj7MVH6 +d+J4LJjU5NzPmPgnOdLC3Z2RdGwra793PglS2rfTxf9t8pFpGGz0f2sLwSPgQqEQ +MoGfw7rk/HwZd2dZCGcTwtD8s1MZCIS8kzmMC2SXIxBI1AzVJIN3tTNfrhINXlW2 +v8VCRkmFIimGssO8EZxnS0W6NoXLFSjJdmjlnp9nOV4Oo0oDqwPzW++i1cEORi8z +qwlgJZGvhUkfnU7PZFhMsdHAZ4D72gndUOpgkvhOLoKdFBbgryUG8kGtRCVnYkNC +mRSVgn+I8badJw+8fF2htoQwwPTOT22Mgkg4EjoyJQIDAQABoxAwDjAMBgNVHRME +BTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCGMHAy1dPwMefNQAd4tNoMazRV7E6B +7cAYNx+p4dFVoI2AeZfQQzuMvkjSsFELJWBSun2sYJhGIIKRld3r8og080Cs8oCb +rWpSsk+Urvypoej+47rAgMQj+0ysRiKTiIfRbbXLmgWOGRKtsOl+OfCjzTnZfz3l +NdUh2JQsg8FJfc932rb6OLWRdU5K5GHtAv6zwOFEyjzUlqe6/MKMDS/0A6ckZhTq +1YCbxZ15mr1KhVPjgP5PMAqoF+qPRHiWUDM7w0VTpgRrrxuNCp6pIpVFbDzJuEpK +ZBY5bE8gywizxCSzm3lf9ceHvwMbKIQmrtIAgcTvOYilDRlfU8TTBMc3 +-----END CERTIFICATE----- +--- +Server certificate +subject=CN=pypi.org +issuer=C=US, ST=California, L=Sunnyvale, O=Fortinet, OU=Certificate Authority, CN=FG100FTK22018356, emailAddress=support@fortinet.com +--- +No client certificate CA names sent +Peer signing digest: SHA256 +Peer signature type: rsa_pss_rsae_sha256 +Peer Temp Key: X25519, 253 bits +--- +SSL handshake has read 3031 bytes and written 1603 bytes +Verification: OK +--- +New, TLSv1.3, Cipher is TLS_AES_128_GCM_SHA256 +Protocol: TLSv1.3 +Server public key is 2048 bit +This TLS version forbids renegotiation. +Compression: NONE +Expansion: NONE +No ALPN negotiated +Early data was not sent +Verify return code: 0 (ok) +--- diff --git a/config.json b/config.json index b8fbcd2..6bc1f84 100644 --- a/config.json +++ b/config.json @@ -3,24 +3,49 @@ "host": "192.168.1.175", "port": 30015, "user": "SYSTEM", - "password": "SAPB1Admin.25", - "schema": "SBO_TAGUMEDICA_21" + "password": "SAPB1Admin.25" }, - "files":{ - "path":"downloads" + "rucs": [ + "20100047218", + "20100130204", + "20100053455" + ], + "consulta": { + "dias": 30 }, - "outlook": { - "client_id": "957c324b-3da3-43ed-b912-ca52fb5d3128", - "client_secret": "Guc8Q~u6FH3xBKpxJ-hQ7QSpRWbnvmX.LnWr-dw7", - "tenant_id": "76f77b2f-6fc3-42a0-ae83-df05508405d6", - "download_folder": "./adjuntos", - "verify_ssl": false, - "ca_bundle": null + "empresas": [ + { + "nombre": "tagumedica", + "schema": "SBO_TAGUMEDICA_21", + "locked": false, + "smb_subfolder": "COMPRAS/TAGUMEDICA/ADJUNTOS_CORREOS/2025-03" }, - "mail":{ - "sender":"cpe@tagumedica.com", - "to":["cpe@tagumedica.com"], - "days":2 + { + "nombre": "elavent", + "schema": "SBO_ELAVENT", + "locked": false, + "smb_subfolder": "COMPRAS/ELAVENT/ADJUNTOS_CORREOS/2025-03" + }, + { + "nombre": "safena", + "schema": "SBO_SAFENA", + "locked": false, + "smb_subfolder": "COMPRAS/SAFENA/ADJUNTOS_CORREOS/2025-03" + }, + { + "nombre": "cirugia", + "schema": "SBO_CIRUGIA", + "locked": false, + "smb_subfolder": "COMPRAS/CIRUGIA/ADJUNTOS_CORREOS/2025-03" + }, + { + "nombre": "franquicia", + "schema": "SBO_FRANQUICIA", + "locked": false, + "smb_subfolder": "COMPRAS/FRANQUICIA/ADJUNTOS_CORREOS/2025-03" } - -} + ], + "files": { + "path": "downloads" + } +} \ No newline at end of file diff --git a/config_loader.py b/config_loader.py deleted file mode 100644 index 75b1a99..0000000 --- a/config_loader.py +++ /dev/null @@ -1,36 +0,0 @@ -import json -from pathlib import Path -from config_model import Config, HanaConfig, FilesConfig, OutlookConfig, MailConfig - -######################################################## -CONFIG_PATH = Path(__file__).resolve().parent / "config.json" - -with open(CONFIG_PATH, 'r', encoding='utf-8') as f: - data = json.load(f) - -# Instantiate dataclasses -hana = HanaConfig(**data.get('hana', {})) -files = FilesConfig(**data.get('files', {})) -outlook = OutlookConfig( - client_id=data['outlook']['client_id'], - client_secret=data['outlook']['client_secret'], - tenant_id=data['outlook']['tenant_id'], - download_folder=data['outlook']['download_folder'], - verify_ssl=data['outlook'].get('verify_ssl', True), - ca_bundle=data['outlook'].get('ca_bundle') -) - -mail = MailConfig( - sender=data['mail']['sender'], - to=data['mail']['to'], - days=data['mail']['days'] -) - -CONFIG = Config( - hana=hana, - files=files, - outlook=outlook, - mail=mail -) - -######################################################## \ No newline at end of file diff --git a/config_model.py b/config_model.py deleted file mode 100644 index b90d0e3..0000000 --- a/config_model.py +++ /dev/null @@ -1,42 +0,0 @@ -from dataclasses import dataclass, field -from typing import List, Optional - -@dataclass -class HanaConfig: - host: str = "" - port: int = 30015 - user: str = "" - password: str = "" - schema: str = "" - - @property - def server(self) -> str: - return self.host - -@dataclass -class FilesConfig: - path: str = "" - -# Nueva configuración para Outlook -@dataclass -class OutlookConfig: - client_id: str = "" - client_secret: str = "" - tenant_id: str = "" - download_folder: str = "" - verify_ssl: bool = True - ca_bundle: Optional[str] = None - -# Configuración de correo (mail) -@dataclass -class MailConfig: - sender: str = "" - to: List[str] = field(default_factory=list) - days: int = 2 - -@dataclass -class Config: - hana: HanaConfig - files: FilesConfig - outlook: OutlookConfig - mail: MailConfig diff --git a/core.py b/core.py new file mode 100644 index 0000000..7bba9b0 --- /dev/null +++ b/core.py @@ -0,0 +1,111 @@ +""" +core.py — Pipeline funcional del servicio de descarga CPE (Sovos). + +Reescribe las funciones de la plantilla: + * describir_servicio() : banner del log. + * obtener_datos() : consulta HANA multi-schema. + * procesar_y_descargar(): descarga PDF/XML de Sovos y marca en HANA. +""" +import os +import json +from pathlib import Path +from dotenv import load_dotenv + +from logger_manager import get_logger +from hana_manager import obtener_pendientes, cerrar_conexion +from descargador_sovos import descargar_documentos_pendientes +from smb_transfer import get_mount_base, transferir_a_smb + +# ============================================================================ +load_dotenv() + +logger = get_logger("core") + +CONFIG_FILE = Path(os.getenv("CONFIG_PATH", Path(__file__).parent / "config.json")) + +# ============================================================================ +def _cargar_config(path: str | None = None) -> dict: + p = Path(path) if path else CONFIG_FILE + with open(p, encoding="utf-8") as f: + return json.load(f) + + +CFG = _cargar_config() + + +def empresas_activas(cfg: dict) -> list[dict]: + return [e for e in cfg.get("empresas", []) if not e.get("locked", False)] + +# ============================================================================ +# Específico del servicio +# ============================================================================ +def describir_servicio() -> str: + return "DESCARGA COMPROBANTES ELECTRÓNICOS (SUNAT)" + + +def obtener_datos(empresa: dict) -> list[dict]: + schema = empresa["schema"] + rucs = CFG.get("rucs", []) + dias = CFG.get("consulta", {}).get("dias", 240) + logger.info(f" 💾 Consultando HANA schema={schema}") + return obtener_pendientes(schema, rucs, dias) + + +def procesar_y_descargar(datos: list[dict], dir_empresa: Path) -> dict: + return descargar_documentos_pendientes(datos, dir_empresa) + +# ============================================================================ +# Pipeline (genérico) +# ============================================================================ +def ejecutar(cfg: dict | None = None): + cfg = cfg if cfg is not None else CFG + base = Path(cfg.get("files", {}).get("path", "downloads")).resolve() + activas = empresas_activas(cfg) + + logger.info("=" * 60) + logger.info(f"🚀 {describir_servicio()}") + logger.info(f"💾 HANA: {cfg['hana']['host']}:{cfg['hana']['port']}") + logger.info("=" * 60) + logger.info(f"🏢 Empresas configuradas: {len(cfg.get('empresas', []))}") + logger.info(f"📁 Descargas base: {base}") + + if not activas: + logger.info("⏭️ No hay empresas activas (todas LOCKED). Fin.") + return + + # LOOP 1: datos + procesamiento por empresa + for emp in activas: + nombre = emp["nombre"] + dir_empresa = base / nombre + logger.info("#" * 60) + logger.info(f"# 🏢 Empresa: {nombre}") + logger.info("#" * 60) + + datos = obtener_datos(emp) + if not datos: + logger.info(" ✅ No hay documentos pendientes") + continue + logger.info(f" 📄 Documentos pendientes obtenidos: {len(datos)}") + procesar_y_descargar(datos, dir_empresa) + + # LOOP 2: transferencia a carpeta compartida (conserva si no está montada) + logger.info("=" * 60) + logger.info("🔁 Transferencia a carpeta compartida (SMB)") + logger.info("=" * 60) + mount_base = get_mount_base() + auto_mount = os.getenv("SMB_AUTO_MOUNT", "false").lower() == "true" + logger.info(f" Montaje base: {mount_base} | automontaje: {auto_mount}") + resultados = transferir_a_smb(activas, mount_base, auto_mount) + + logger.info("📊 Resumen transferencias:") + for r in resultados: + logger.info(f" • {r['empresa']}: {r['estado']}") + + logger.info("\n👋 Proceso finalizado.") + cerrar_conexion() + + +# ============================================================================ +if __name__ == "__main__": + ejecutar() +# ============================================================================ \ No newline at end of file diff --git a/crontab-host b/crontab-host new file mode 100644 index 0000000..df4609c --- /dev/null +++ b/crontab-host @@ -0,0 +1,26 @@ +# 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//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. diff --git a/descargador_sovos.py b/descargador_sovos.py new file mode 100644 index 0000000..a7f32f7 --- /dev/null +++ b/descargador_sovos.py @@ -0,0 +1,145 @@ +import os +import time +from pathlib import Path +from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout + +from hana_manager import marcar_pdf_descargado, marcar_xml_descargado +from logger_manager import get_logger + +logger = get_logger("descargador") + +# ============================================================================ +URL_SOVOS = "https://public-einvoicing.sovos.com/pe" + +# ============================================================================ +def descargar_comprobante(page, ruc, tipo, serie, numero, correlativo, monto, fecha, download_dir): + page.goto(URL_SOVOS, wait_until="domcontentloaded", timeout=30000) + + try: + page.locator('input[name="senderTaxId"]').wait_for(state="visible", timeout=30000) + except Exception as e: + try: + snippet = (page.inner_text("body") or "")[:600].replace("\n", " ") + except Exception: + snippet = "" + logger.error(f" ❌ No se encontró el campo RUC. URL={page.url}") + logger.error(f" 🔎 Contenido visible: {snippet}") + raise + + page.fill('input[name="senderTaxId"]', ruc) + + page.click('#mui-component-select-invoiceTypeCode') + page.wait_for_selector(f'li[role="option"][data-value="{tipo}"]', timeout=5000) + page.click(f'li[role="option"][data-value="{tipo}"]') + time.sleep(0.5) + + page.fill('input[name="correlative"]', correlativo) + + page.fill('input[name="payableAmount"]', str(monto)) + + date_input = page.locator('//input[contains(@placeholder, "Ej: 202")]') + date_input.click() + date_input.fill(fecha) + time.sleep(0.5) + + submit_btn = page.locator('//button[contains(text(), "Ver documento")]') + if submit_btn.is_disabled(): + raise ValueError("Botón 'Ver documento' deshabilitado. Verifique los campos.") + + submit_btn.click() + + download_btn = page.locator("[data-testid='btn-download-document']") + errorLocator = page.locator(".sovosSnackbar--error, :text('Document not found')") + + try: + download_btn.wait_for(state="visible", timeout=15000) + except PlaywrightTimeout: + if errorLocator.count() > 0 and errorLocator.first.is_visible(): + raise FileNotFoundError("El comprobante no existe en el portal de Sovos (Document not found).") + raise TimeoutError("Tiempo de espera agotado sin encontrar el comprobante ni error.") + + nombre_base = f"{ruc}-{tipo}-{serie}-{numero}" + + with page.expect_download(timeout=30000) as download_info: + download_btn.click() + download_pdf = download_info.value + pdf_path = Path(download_dir) / f"{nombre_base}.pdf" + download_pdf.save_as(str(pdf_path)) + + page.click('button:has-text(".pdf"), button:has-text(".xml")') + time.sleep(1) + page.click('li[role="menuitem"]:has-text(".xml")') + page.wait_for_selector('button:has-text(".xml")', timeout=5000) + + with page.expect_download(timeout=30000) as download_info: + download_btn.click() + download_xml = download_info.value + xml_path = Path(download_dir) / f"{nombre_base}.xml" + download_xml.save_as(str(xml_path)) + + return [pdf_path.name, xml_path.name] + +# ============================================================================ +def descargar_documentos_pendientes(docs, download_dir): + Path(download_dir).mkdir(parents=True, exist_ok=True) + + docs_descargar = [ + d for d in docs + if (d.get("tienePDF") or "N").strip() == "N" + or (d.get("tieneXML") or "N").strip() == "N" + ] + + if not docs_descargar: + logger.info(" ✅ No hay documentos para descargar") + return {"exitosos": 0, "fallidos": 0, "archivos": []} + + logger.info(f"\n📥 Descargando {len(docs_descargar)} comprobantes desde Sovos...") + exitosos = 0 + fallidos = 0 + archivos_descargados = [] + + with sync_playwright() as p: + browser = p.firefox.launch(headless=True) + context = browser.new_context(accept_downloads=True, ignore_https_errors=True) + page = context.new_page() + + for idx, doc in enumerate(docs_descargar): + ruc = (doc.get("LicTradNum") or "").strip() + tipo = (doc.get("Indicator") or "").strip() + serie = (doc.get("FolioPref") or "").strip() + numero_raw = doc.get("FolioNum") or 0 + numero = str(int(numero_raw)) + monto = doc.get("DocTotal") or 0 + fecha = (doc.get("TaxDate") or "").strip() + correlativo = f"{serie}-{numero}" + schema = (doc.get("schema") or "").strip() + docentry = doc.get("DocEntry") + + logger.info(f" [{idx+1}/{len(docs_descargar)}] DocEntry={docentry} DocNum={doc.get('DocNum')} " + f"{ruc}-{tipo}-{correlativo}") + + try: + archivos = descargar_comprobante( + page, ruc, tipo, serie, numero, correlativo, monto, fecha, download_dir + ) + exitosos += 1 + archivos_descargados.extend(archivos) + logger.info(f" ✅ Descargado: PDF={archivos[0] if len(archivos) > 0 else ''} " + f"| XML={archivos[1] if len(archivos) > 1 else ''}") + + if schema and docentry is not None: + if (doc.get("tienePDF") or "N").strip() == "N": + marcar_pdf_descargado(schema, docentry, doc.get("urlPDF"), archivos[0] if len(archivos) > 0 else "") + if (doc.get("tieneXML") or "N").strip() == "N": + marcar_xml_descargado(schema, docentry, doc.get("urlXML"), archivos[1] if len(archivos) > 1 else "") + + time.sleep(1.5) + except Exception as e: + fallidos += 1 + logger.error(f" ❌ Error: {e}") + + browser.close() + + logger.info(f"\n📊 Resultado: ✅ {exitosos} éxitos | ❌ {fallidos} fallidos") + return {"exitosos": exitosos, "fallidos": fallidos, "archivos": archivos_descargados} +# ============================================================================ \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7b69cb7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +services: + descarga-cpe: + build: . + + env_file: + - .env + + volumes: + - ./config.json:/app/config.json:ro + - ./downloads:/app/downloads + - ./logs:/app/logs + - ${SMB_HOST_MOUNT:-/Users/george/FileSAP}:/mnt/FileSAP + environment: + - TZ=America/Lima + # Dentro del contenedor la unidad está siempre en /mnt/FileSAP + # (independiente del punto de montaje del host: macOS Ubuntu). + - SMB_MOUNT_LINUX=/mnt/FileSAP + # Sin restart: el cron del host levanta el contenedor con + # `docker compose run --rm` solo durante cada ejecución. \ No newline at end of file diff --git a/documentos.json b/documentos.json deleted file mode 100644 index bf0bba5..0000000 --- a/documentos.json +++ /dev/null @@ -1,131 +0,0 @@ -[ - {"Fecha": "2026-03-09", "Tipo": "01", "Folio": "FB03-2140049", "Total": 255.60, "RUC": "20100047218"}, - {"Fecha": "2026-03-09", "Tipo": "01", "Folio": "FB03-2140074", "Total": 187.87, "RUC": "20100047218"}, - {"Fecha": "2026-03-31", "Tipo": "01", "Folio": "FB03-2175811", "Total": 139.44, "RUC": "20100047218"}, - {"Fecha": "2026-03-27", "Tipo": "01", "Folio": "FB03-2174046", "Total": 198.12, "RUC": "20100047218"}, - {"Fecha": "2026-03-27", "Tipo": "01", "Folio": "FB03-2174021", "Total": 263.42, "RUC": "20100047218"}, - {"Fecha": "2026-03-26", "Tipo": "01", "Folio": "FB03-2171060", "Total": 325.99, "RUC": "20100047218"}, - {"Fecha": "2026-03-25", "Tipo": "01", "Folio": "FB03-2167938", "Total": 138.72, "RUC": "20100047218"}, - {"Fecha": "2026-03-24", "Tipo": "01", "Folio": "FB03-2167436", "Total": 247.16, "RUC": "20100047218"}, - {"Fecha": "2026-03-23", "Tipo": "01", "Folio": "FB03-2164070", "Total": 187.76, "RUC": "20100047218"}, - {"Fecha": "2026-03-23", "Tipo": "01", "Folio": "FB03-2164043", "Total": 187.76, "RUC": "20100047218"}, - {"Fecha": "2026-03-23", "Tipo": "01", "Folio": "FB03-2164016", "Total": 146.03, "RUC": "20100047218"}, - {"Fecha": "2026-03-23", "Tipo": "01", "Folio": "FB03-2163989", "Total": 264.25, "RUC": "20100047218"}, - {"Fecha": "2026-03-23", "Tipo": "01", "Folio": "FB03-2163962", "Total": 159.13, "RUC": "20100047218"}, - {"Fecha": "2026-03-12", "Tipo": "01", "Folio": "FB03-2148003", "Total": 260.45, "RUC": "20100047218"}, - {"Fecha": "2026-03-06", "Tipo": "01", "Folio": "FB01-2754860", "Total": 343.20, "RUC": "20100047218"}, - {"Fecha": "2026-03-07", "Tipo": "01", "Folio": "FI01-18763215", "Total": 591.29, "RUC": "20100047218"}, - {"Fecha": "2026-03-07", "Tipo": "01", "Folio": "FN01-43415089", "Total": 5.32, "RUC": "20100047218"}, - {"Fecha": "2026-04-05", "Tipo": "01", "Folio": "FC01-655990", "Total": 24884.08, "RUC": "20100047218"}, - {"Fecha": "2026-03-04", "Tipo": "01", "Folio": "FF01-3652057", "Total": 20.40, "RUC": "20100047218"}, - {"Fecha": "2026-03-04", "Tipo": "01", "Folio": "FF01-3651957", "Total": 34.97, "RUC": "20100047218"}, - {"Fecha": "2026-03-04", "Tipo": "01", "Folio": "FF01-3651857", "Total": 48.32, "RUC": "20100047218"}, - {"Fecha": "2026-03-04", "Tipo": "01", "Folio": "FF01-3651757", "Total": 40.65, "RUC": "20100047218"}, - {"Fecha": "2026-03-04", "Tipo": "01", "Folio": "FF01-3652957", "Total": 67.73, "RUC": "20100047218"}, - {"Fecha": "2026-03-04", "Tipo": "01", "Folio": "FF01-3651607", "Total": 18.56, "RUC": "20100047218"}, - {"Fecha": "2026-03-04", "Tipo": "01", "Folio": "FF01-3651407", "Total": 33.22, "RUC": "20100047218"}, - {"Fecha": "2026-03-04", "Tipo": "01", "Folio": "FF01-3651507", "Total": 38.82, "RUC": "20100047218"}, - {"Fecha": "2026-03-04", "Tipo": "01", "Folio": "FF01-3651657", "Total": 5.08, "RUC": "20100047218"}, - {"Fecha": "2026-03-04", "Tipo": "01", "Folio": "FF01-3666008", "Total": 9.54, "RUC": "20100047218"}, - {"Fecha": "2026-03-04", "Tipo": "01", "Folio": "FF01-3666058", "Total": 18.83, "RUC": "20100047218"}, - {"Fecha": "2026-03-04", "Tipo": "01", "Folio": "FF01-3666108", "Total": 73.18, "RUC": "20100047218"}, - {"Fecha": "2026-03-04", "Tipo": "01", "Folio": "FF01-3666158", "Total": 23.38, "RUC": "20100047218"}, - {"Fecha": "2026-03-04", "Tipo": "01", "Folio": "FF01-3666208", "Total": 1.75, "RUC": "20100047218"}, - {"Fecha": "2026-03-04", "Tipo": "01", "Folio": "FF01-3666258", "Total": 122.27, "RUC": "20100047218"}, - {"Fecha": "2026-03-04", "Tipo": "01", "Folio": "FF01-3666308", "Total": 39.63, "RUC": "20100047218"}, - {"Fecha": "2026-03-04", "Tipo": "01", "Folio": "FF01-3666358", "Total": 105.17, "RUC": "20100047218"}, - {"Fecha": "2026-03-04", "Tipo": "01", "Folio": "FF01-3666408", "Total": 11.98, "RUC": "20100047218"}, - {"Fecha": "2026-03-04", "Tipo": "01", "Folio": "FF01-3666458", "Total": 13.16, "RUC": "20100047218"}, - {"Fecha": "2026-03-04", "Tipo": "01", "Folio": "FF01-3666508", "Total": 37.88, "RUC": "20100047218"}, - {"Fecha": "2026-03-04", "Tipo": "01", "Folio": "FF01-3652857", "Total": 1500.93, "RUC": "20100047218"}, - {"Fecha": "2026-03-04", "Tipo": "01", "Folio": "FF01-3652707", "Total": 119.35, "RUC": "20100047218"}, - {"Fecha": "2026-03-04", "Tipo": "01", "Folio": "FF01-3652657", "Total": 27.02, "RUC": "20100047218"}, - {"Fecha": "2026-03-04", "Tipo": "01", "Folio": "FF01-3652557", "Total": 44.44, "RUC": "20100047218"}, - {"Fecha": "2026-03-04", "Tipo": "01", "Folio": "FF01-3652457", "Total": 24.64, "RUC": "20100047218"}, - {"Fecha": "2026-03-04", "Tipo": "01", "Folio": "FF01-3652357", "Total": 7.90, "RUC": "20100047218"}, - {"Fecha": "2026-03-04", "Tipo": "01", "Folio": "FF01-3652257", "Total": 26.20, "RUC": "20100047218"}, - {"Fecha": "2026-03-04", "Tipo": "01", "Folio": "FF01-3652157", "Total": 14.40, "RUC": "20100047218"}, - {"Fecha": "2026-03-13", "Tipo": "01", "Folio": "FF01-7858371", "Total": 20.50, "RUC": "20100047218"}, - {"Fecha": "2026-03-16", "Tipo": "01", "Folio": "FC01-591576", "Total": 153.50, "RUC": "20100047218"}, - {"Fecha": "2026-03-16", "Tipo": "01", "Folio": "FC01-591578", "Total": 299.94, "RUC": "20100047218"}, - {"Fecha": "2026-03-05", "Tipo": "01", "Folio": "FC03-6519623", "Total": 90.70, "RUC": "20100047218"}, - {"Fecha": "2026-03-05", "Tipo": "01", "Folio": "FC01-530088", "Total": 74.56, "RUC": "20100047218"}, - {"Fecha": "2026-03-10", "Tipo": "01", "Folio": "FF01-7834626", "Total": 26.38, "RUC": "20100047218"}, - {"Fecha": "2026-03-12", "Tipo": "01", "Folio": "FC03-6547872", "Total": 43056.77, "RUC": "20100047218"}, - {"Fecha": "2026-03-11", "Tipo": "01", "Folio": "FC03-6543416", "Total": 99.57, "RUC": "20100047218"}, - {"Fecha": "2026-03-10", "Tipo": "01", "Folio": "FO01-7834725", "Total": 124.30, "RUC": "20100047218"}, - {"Fecha": "2026-03-10", "Tipo": "01", "Folio": "FN01-7804676", "Total": 29.60, "RUC": "20100047218"}, - {"Fecha": "2026-03-10", "Tipo": "01", "Folio": "FN01-53361710", "Total": 109.00, "RUC": "20100047218"}, - {"Fecha": "2026-03-10", "Tipo": "01", "Folio": "FO01-7834675", "Total": 29.60, "RUC": "20100047218"}, - {"Fecha": "2026-03-10", "Tipo": "01", "Folio": "FO01-7804626", "Total": 28.63, "RUC": "20100047218"}, - {"Fecha": "2026-03-23", "Tipo": "01", "Folio": "FC03-6591174", "Total": 122.98, "RUC": "20100047218"}, - {"Fecha": "2026-03-24", "Tipo": "01", "Folio": "FF01-7905388", "Total": 20.50, "RUC": "20100047218"}, - {"Fecha": "2026-03-05", "Tipo": "01", "Folio": "FO01-7905087", "Total": 20.50, "RUC": "20100047218"}, - {"Fecha": "2026-03-24", "Tipo": "01", "Folio": "FO01-79005487", "Total": 29.94, "RUC": "20100047218"}, - {"Fecha": "2026-03-18", "Tipo": "01", "Folio": "FC03-5565306", "Total": 304.53, "RUC": "20100047218"}, - {"Fecha": "2026-03-18", "Tipo": "01", "Folio": "FC01-592173", "Total": 153.50, "RUC": "20100047218"}, - {"Fecha": "2026-03-17", "Tipo": "01", "Folio": "FN01-63422478", "Total": 10.00, "RUC": "20100047218"}, - {"Fecha": "2026-03-16", "Tipo": "01", "Folio": "FC01-7861432", "Total": 52.16, "RUC": "20100047218"}, - {"Fecha": "2026-03-16", "Tipo": "01", "Folio": "FF01-7861392", "Total": 104.38, "RUC": "20100047218"}, - {"Fecha": "2026-03-16", "Tipo": "01", "Folio": "FN01-43592288", "Total": 11.30, "RUC": "20100047218"}, - {"Fecha": "2026-03-16", "Tipo": "01", "Folio": "FC03-6559540", "Total": 82.50, "RUC": "20100047218"}, - {"Fecha": "2026-03-16", "Tipo": "01", "Folio": "FC03-6559492", "Total": 85.01, "RUC": "20100047218"}, - {"Fecha": "2026-03-24", "Tipo": "01", "Folio": "FN01-44177547", "Total": 1.00, "RUC": "20100047218"}, - {"Fecha": "2026-03-24", "Tipo": "01", "Folio": "FN01-44144049", "Total": 1.00, "RUC": "20100047218"}, - {"Fecha": "2026-03-24", "Tipo": "01", "Folio": "FC03-6596247", "Total": 50.00, "RUC": "20100047218"}, - {"Fecha": "2026-03-23", "Tipo": "01", "Folio": "FF01-7895690", "Total": 20.50, "RUC": "20100047218"}, - {"Fecha": "2026-03-23", "Tipo": "01", "Folio": "FC03-6591214", "Total": 50.00, "RUC": "20100047218"}, - {"Fecha": "2026-03-23", "Tipo": "01", "Folio": "FC03-6591248", "Total": 50.00, "RUC": "20100047218"}, - {"Fecha": "2026-03-24", "Tipo": "01", "Folio": "FN01-53361710", "Total": 22993.89, "RUC": "20100047218"}, - {"Fecha": "2026-03-31", "Tipo": "01", "Folio": "FO01-7939416", "Total": 32.93, "RUC": "20100047218"}, - {"Fecha": "2026-03-31", "Tipo": "01", "Folio": "FN01-143493572", "Total": 1.00, "RUC": "20100047218"}, - {"Fecha": "2026-03-12", "Tipo": "01", "Folio": "FO01-7850651", "Total": 28.63, "RUC": "20100047218"}, - {"Fecha": "2026-03-12", "Tipo": "01", "Folio": "FO01-7850701", "Total": 33.36, "RUC": "20100047218"}, - {"Fecha": "2026-03-12", "Tipo": "01", "Folio": "FO01-7850751", "Total": 20.50, "RUC": "20100047218"}, - {"Fecha": "2026-03-10", "Tipo": "01", "Folio": "FC01-590267", "Total": 208.25, "RUC": "20100047218"}, - {"Fecha": "2026-03-05", "Tipo": "01", "Folio": "FO01-7817287", "Total": 26.38, "RUC": "20100047218"}, - {"Fecha": "2026-03-05", "Tipo": "01", "Folio": "FC01-590270", "Total": 273.61, "RUC": "20100047218"}, - {"Fecha": "2026-03-10", "Tipo": "01", "Folio": "FC01-590276", "Total": 710.32, "RUC": "20100047218"}, - {"Fecha": "2026-03-10", "Tipo": "01", "Folio": "FC01-590276", "Total": 544.64, "RUC": "20100047218"}, - {"Fecha": "2026-03-10", "Tipo": "01", "Folio": "FC03-6539674", "Total": 196.42, "RUC": "20100047218"}, - {"Fecha": "2026-03-10", "Tipo": "01", "Folio": "FC03-6539719", "Total": 50.00, "RUC": "20100047218"}, - {"Fecha": "2026-03-04", "Tipo": "01", "Folio": "FF01-3666558", "Total": 13.16, "RUC": "20100047218"}, - {"Fecha": "2026-03-04", "Tipo": "01", "Folio": "FF01-3666608", "Total": 143.16, "RUC": "20100047218"}, - {"Fecha": "2026-03-04", "Tipo": "01", "Folio": "FF01-3666658", "Total": 8.53, "RUC": "20100047218"}, - {"Fecha": "2026-03-04", "Tipo": "01", "Folio": "FF01-3666708", "Total": 34.70, "RUC": "20100047218"}, - {"Fecha": "2026-03-04", "Tipo": "01", "Folio": "FF01-3666758", "Total": 32.50, "RUC": "20100047218"}, - {"Fecha": "2026-03-04", "Tipo": "01", "Folio": "FF01-3666808", "Total": 13.16, "RUC": "20100047218"}, - {"Fecha": "2026-03-04", "Tipo": "01", "Folio": "FF01-3666858", "Total": 13.16, "RUC": "20100047218"}, - {"Fecha": "2026-03-04", "Tipo": "01", "Folio": "FF01-3666908", "Total": 24.80, "RUC": "20100047218"}, - {"Fecha": "2026-03-04", "Tipo": "01", "Folio": "FF01-3667108", "Total": 13.97, "RUC": "20100047218"}, - {"Fecha": "2026-03-06", "Tipo": "01", "Folio": "FC03-6522400", "Total": 1360.40, "RUC": "20100047218"}, - {"Fecha": "2026-03-06", "Tipo": "01", "Folio": "FC03-6522450", "Total": 120.04, "RUC": "20100047218"}, - {"Fecha": "2026-03-04", "Tipo": "01", "Folio": "FF01-3651107", "Total": 43.92, "RUC": "20100047218"}, - {"Fecha": "2026-03-04", "Tipo": "01", "Folio": "FF01-3651207", "Total": 250.35, "RUC": "20100047218"}, - {"Fecha": "2026-03-04", "Tipo": "01", "Folio": "FF01-3651307", "Total": 15.24, "RUC": "20100047218"}, - {"Fecha": "2026-03-04", "Tipo": "01", "Folio": "FF01-3651507", "Total": 56.57, "RUC": "20100047218"}, - {"Fecha": "2026-03-24", "Tipo": "01", "Folio": "FO01-79005586", "Total": 68.02, "RUC": "20100047218"}, - {"Fecha": "2026-03-20", "Tipo": "01", "Folio": "FO01-7890803", "Total": 77.35, "RUC": "20100047218"}, - {"Fecha": "2026-03-19", "Tipo": "01", "Folio": "FC03-6581706", "Total": 145.10, "RUC": "20100047218"}, - {"Fecha": "2026-03-19", "Tipo": "01", "Folio": "FO01-7886392", "Total": 20.50, "RUC": "20100047218"}, - {"Fecha": "2026-03-25", "Tipo": "01", "Folio": "FC03-6604683", "Total": 121.71, "RUC": "20100047218"}, - {"Fecha": "2026-03-19", "Tipo": "01", "Folio": "FC01-596051", "Total": 198.04, "RUC": "20100047218"}, - {"Fecha": "2026-03-18", "Tipo": "01", "Folio": "FC01-7886328", "Total": 232.05, "RUC": "20100047218"}, - {"Fecha": "2026-03-18", "Tipo": "01", "Folio": "FO01-7876989", "Total": 94.40, "RUC": "20100047218"}, - {"Fecha": "2026-03-18", "Tipo": "01", "Folio": "FC03-6591706", "Total": 20.50, "RUC": "20100047218"}, - {"Fecha": "2026-03-27", "Tipo": "01", "Folio": "FO01-7923629", "Total": 20.50, "RUC": "20100047218"}, - {"Fecha": "2026-03-18", "Tipo": "01", "Folio": "FO01-7876939", "Total": 29.60, "RUC": "20100047218"}, - {"Fecha": "2026-03-18", "Tipo": "01", "Folio": "FN01-43408889", "Total": 10.00, "RUC": "20100047218"}, - {"Fecha": "2026-03-08", "Tipo": "01", "Folio": "FD0-6433581", "Total": 16.50, "RUC": "20100047218"}, - {"Fecha": "2026-03-07", "Tipo": "01", "Folio": "FD0-6433608", "Total": 13.43, "RUC": "20100047218"}, - {"Fecha": "2026-03-09", "Tipo": "01", "Folio": "FN01-88644088", "Total": 186.88, "RUC": "20100047218"}, - {"Fecha": "2026-03-09", "Tipo": "01", "Folio": "FN01-43415139", "Total": 7.30, "RUC": "20100047218"}, - {"Fecha": "2026-03-06", "Tipo": "01", "Folio": "FO01-7823739", "Total": 28.63, "RUC": "20100047218"}, - {"Fecha": "2026-03-06", "Tipo": "01", "Folio": "FC03-7832739", "Total": 563.77, "RUC": "20100047218"}, - {"Fecha": "2026-03-10", "Tipo": "01", "Folio": "FC01-590264", "Total": 29.10, "RUC": "20100047218"}, - {"Fecha": "2026-03-10", "Tipo": "01", "Folio": "FC01-590264", "Total": 157.00, "RUC": "20100047218"}, - {"Fecha": "2026-03-27", "Tipo": "01", "Folio": "FO01-7923978", "Total": 41.00, "RUC": "20100047218"}, - {"Fecha": "2026-03-27", "Tipo": "01", "Folio": "FC03-6624790", "Total": 244.70, "RUC": "20100047218"}, - {"Fecha": "2026-03-18", "Tipo": "01", "Folio": "FC03-6565394", "Total": 81.52, "RUC": "20100047218"}, - {"Fecha": "2026-03-18", "Tipo": "01", "Folio": "FC03-6565350", "Total": 87.15, "RUC": "20100047218"} -] \ No newline at end of file diff --git a/hana_client.py b/hana_client.py deleted file mode 100644 index e8b134d..0000000 --- a/hana_client.py +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env python3 -import os -import sys -import json -import argparse -from datetime import datetime, date -from hdbcli import dbapi -from config_loader import CONFIG - -######################################################## -def get_connection(): - """Crea y retorna una conexión a SAP HANA.""" - try: - conn = dbapi.connect( - address=CONFIG.hana.server, - port=CONFIG.hana.port, - user=CONFIG.hana.user, - password=CONFIG.hana.password - ) - return conn - except Exception as e: - print(f"Error conectando a HANA: {e}") - return None - -######################################################## -def obtener_registros(days=30): - """ - Se conecta a la base de datos SAP HANA, realiza una consulta a la tabla OPCH, - y formatea el resultado para que coincida con el esquema de documentos.json. - """ - - schema = CONFIG.hana.schema - query = f""" - SELECT T0."DocNum", T0."DocEntry", TO_VARCHAR(T0."DocDate",'YYYY-MM-DD') "DocDate" - , TO_VARCHAR(T0."TaxDate",'YYYY-MM-DD') "TaxDate", T0."CardName", T0."FolioPref", T0."FolioNum", T0."Indicator", T0."DocCur" - , CASE T0."DocCur" WHEN 'SOL' THEN T0."DocTotal" ELSE T0."DocTotalFC" END "DocTotal" - , T0."U_PGX_PDF" "PDF", T0."U_PGX_XML" "XML", T0."LicTradNum" - FROM "{schema}".OPCH T0 - WHERE T0."CANCELED" = 'N' - AND T0."LicTradNum" = '20100047218' - AND (T0."U_PGX_PDF" = 'N' OR T0."U_PGX_XML" = 'N') - AND T0."CreateDate" >= ADD_DAYS(CURRENT_DATE, -{days}); - """ - - try: - print(f"Conectando al servidor SAP HANA ({CONFIG.hana.server}:{CONFIG.hana.port})...") - connection = get_connection() - if not connection: - raise ConnectionError("No se pudo establecer la conexión a SAP HANA.") - - cursor = connection.cursor() - cursor.execute(query) - - # Obtener nombres de columnas - columns = [column[0] for column in cursor.description] - - # Convertir resultados a lista de diccionarios - results = [] - for row in cursor.fetchall(): - results.append(dict(zip(columns, row))) - - cursor.close() - return results - - except Exception as e: - print(f"[ERROR] Ocurrió un error en la conexión o consulta a HANA: {e}") - return [] - finally: - if cursor: - cursor.close() - if connection: - connection.close() - print("Conexión con SAP HANA cerrada.") - -######################################################## \ No newline at end of file diff --git a/hana_manager.py b/hana_manager.py new file mode 100644 index 0000000..56391b1 --- /dev/null +++ b/hana_manager.py @@ -0,0 +1,155 @@ +import os +import json +from pathlib import Path +from dotenv import load_dotenv +from logger_manager import get_logger + +# ============================================================================ +load_dotenv() + +logger = get_logger("hana") + +CONFIG_FILE = Path(__file__).parent / "config.json" + +_conn = None +_cfg_hana = None + +# ============================================================================ +def _cargar_config(): + global _cfg_hana + if _cfg_hana is None: + with open(CONFIG_FILE, encoding="utf-8") as f: + data = json.load(f) + _cfg_hana = data["hana"] + return _cfg_hana + +# ============================================================================ +def get_connection(): + global _conn + if _conn is None: + try: + cfg = _cargar_config() + from hdbcli import dbapi + _conn = dbapi.connect( + address=cfg["host"], + port=cfg["port"], + user=cfg["user"], + password=cfg["password"] + ) + except Exception as e: + logger.error(f" ❌ Error conectando a HANA: {e}") + return None + return _conn + +# ============================================================================ +def _nombres_rucs(rucs) -> str: + if not rucs: + return "" + return ",".join(f"'{r}'" for r in rucs) + +# ============================================================================ +def obtener_pendientes(schema: str, rucs: list[str], days: int = 240) -> list[dict]: + conn = get_connection() + if not conn: + return [] + + filtro_rucs = _nombres_rucs(rucs) + cond_rucs = f"AND T0.\"LicTradNum\" IN ({filtro_rucs})" if filtro_rucs else "" + + try: + query = f""" + SELECT T0."DocNum", T0."DocEntry", TO_VARCHAR(T0."DocDate",'YYYY-MM-DD') "DocDate" + , TO_VARCHAR(T0."TaxDate",'YYYY-MM-DD') "TaxDate", T0."CardName", T0."FolioPref", T0."FolioNum", T0."Indicator", T0."DocCur" + , CASE T0."DocCur" WHEN 'SOL' THEN T0."DocTotal" ELSE T0."DocTotalFC" END "DocTotal" + , T0."U_PGX_REPIMP" "urlPDF" + , T0."U_PGX_PDF" "tienePDF" + , T0."U_PGX_REPIMPXML" "urlXML" + , T0."U_PGX_XML" "tieneXML" + , T0."LicTradNum" + FROM "{schema}".OPCH T0 + WHERE T0."CANCELED" = 'N' + {cond_rucs} + AND T0."Indicator" IN ('01', '02','08') + AND (IFNULL(T0."U_PGX_PDF",'N') = 'N' OR IFNULL(T0."U_PGX_XML",'N') = 'N') + AND T0."CreateDate" >= ADD_DAYS(CURRENT_DATE, -{days}); + """ + cursor = conn.cursor() + cursor.execute(query) + columns = [desc[0] for desc in cursor.description] + rows = cursor.fetchall() + cursor.close() + result = [dict(zip(columns, row)) for row in rows] + for d in result: + d["schema"] = schema + return result + except Exception as e: + logger.error(f" ❌ Error en query HANA: {e}") + return [] + +# ============================================================================ +def marcar_pdf_descargado(schema: str, docentry: int, url: str, ruta: str): + conn = get_connection() + if not conn: + return False + try: + cursor = conn.cursor() + cursor.execute(f""" + UPDATE "{schema}".OPCH + SET "U_PGX_PDF" = 'Y' + WHERE "DocEntry" = ? + """, (docentry,)) + conn.commit() + cursor.close() + return True + except Exception as e: + logger.error(f" ❌ Error actualizando PDF HANA: {e}") + return False + +# ============================================================================ +def marcar_xml_descargado(schema: str, docentry: int, url: str, ruta: str): + conn = get_connection() + if not conn: + return False + try: + cursor = conn.cursor() + cursor.execute(f""" + UPDATE "{schema}".OPCH + SET "U_PGX_XML" = 'Y' + WHERE "DocEntry" = ? + """, (docentry,)) + conn.commit() + cursor.close() + return True + except Exception as e: + logger.error(f" ❌ Error actualizando XML HANA: {e}") + return False + +# ============================================================================ +def cerrar_conexion(): + global _conn + if _conn: + try: + _conn.close() + except: + pass + _conn = None + +# ============================================================================ +if __name__ == "__main__": + logger.info("=" * 60) + logger.info("📋 HANA MANAGER - Prueba") + logger.info("=" * 60) + with open(CONFIG_FILE, encoding="utf-8") as f: + cfg = json.load(f) + for emp in cfg["empresas"]: + logger.info(f"\n🏢 Empresa: {emp['nombre']} (schema={emp['schema']})") + docs = obtener_pendientes(emp["schema"], cfg.get("rucs", []), cfg.get("consulta", {}).get("dias", 240)) + logger.info(f" 📄 Documentos pendientes: {len(docs)}") + for d in docs[:5]: + logger.info(f" DocEntry={d['DocEntry']} DocNum={d['DocNum']} " + f"DocDate={d['DocDate']} Indicator={d['Indicator']} " + f"Folio={d['FolioPref']}-{d['FolioNum']} " + f"PDF={d['tienePDF']} XML={d['tieneXML']}") + if len(docs) > 5: + logger.info(f" ... y {len(docs) - 5} más") +# ============================================================================ diff --git a/logger_manager.py b/logger_manager.py new file mode 100644 index 0000000..f10b9ca --- /dev/null +++ b/logger_manager.py @@ -0,0 +1,116 @@ +import logging +import contextvars +import os +from contextlib import contextmanager +from logging.handlers import TimedRotatingFileHandler +from pathlib import Path + +LOG_DIR = Path(__file__).parent / "logs" +LOG_FILE = LOG_DIR / "descarga-cpe.log" +_LOG_CONFIGURED = False + +_stack: contextvars.ContextVar[list] = contextvars.ContextVar("log_stack", default=[]) +# ============================================================================ + +class TreeFormatter(logging.Formatter): + def format(self, record): + return f"{self.formatTime(record)} ─── {record.getMessage()}" + + def formatTime(self, record, datefmt=None): + import datetime + return datetime.datetime.fromtimestamp(record.created).strftime("%Y-%m-%d %H:%M:%S") + +# ============================================================================ +def _build_prefix(stack, is_last): + if not stack: + return "" + parts = [] + for sib_is_last in stack[:-1]: + parts.append(" " if sib_is_last else "│ ") + parts.append("└── " if is_last else "├── ") + return "".join(parts) + +# ============================================================================ +class _TaskContext: + def __init__(self, logger, msg, emoji, is_last): + self._logger = logger + self._msg = msg + self._emoji = emoji + self._is_last = is_last + + def __enter__(self): + stack = _stack.get() + prefix = _build_prefix(stack, self._is_last) + emoji_str = f"{self._emoji} " if self._emoji else "" + self._logger._logger.info(f"{prefix}{emoji_str}{self._msg}") + _stack.set(stack + [self._is_last]) + return TaskLogger(self._logger._logger) + + def __exit__(self, exc_type, exc_val, exc_tb): + _stack.set(_stack.get()[:-1]) + +# ============================================================================ +class TaskLogger: + def __init__(self, logger): + self._logger = logger + + @contextmanager + def task(self, msg, emoji=""): + with _TaskContext(self, msg, emoji, is_last=False): + yield + + @contextmanager + def last_task(self, msg, emoji=""): + with _TaskContext(self, msg, emoji, is_last=True): + yield + + def info(self, msg, *args, _last=False, **kwargs): + self._log(logging.INFO, msg, args, kwargs, _last) + + def warning(self, msg, *args, _last=False, **kwargs): + self._log(logging.WARNING, msg, args, kwargs, _last) + + def error(self, msg, *args, _last=False, **kwargs): + self._log(logging.ERROR, msg, args, kwargs, _last) + + def _log(self, level, msg, args, kwargs, is_last): + stack = _stack.get() + prefix = _build_prefix(stack, is_last) + self._logger.log(level, f"{prefix}{msg}", *args, **kwargs) + +# ============================================================================ +def setup_logging(level=None, console=True): + global _LOG_CONFIGURED + if _LOG_CONFIGURED: + return + + if level is None: + level_name = os.getenv("LOG_LEVEL", "INFO").upper() + level = getattr(logging, level_name, logging.INFO) + + LOG_DIR.mkdir(exist_ok=True) + + formatter = TreeFormatter() + + file_handler = TimedRotatingFileHandler( + LOG_FILE, + when="midnight", + interval=1, + backupCount=30, + encoding="utf-8", + ) + file_handler.setFormatter(formatter) + + handlers = [file_handler] + if console: + console_handler = logging.StreamHandler() + console_handler.setFormatter(formatter) + handlers.append(console_handler) + + logging.basicConfig(level=level, handlers=handlers, force=True) + _LOG_CONFIGURED = True + +# ============================================================================ +def get_logger(name="descarga-cpe"): + setup_logging() + return TaskLogger(logging.getLogger(name)) \ No newline at end of file diff --git a/logger_utilities.py b/logger_utilities.py deleted file mode 100644 index 6d05b9e..0000000 --- a/logger_utilities.py +++ /dev/null @@ -1,70 +0,0 @@ -import sys -import os -from datetime import datetime, timedelta - -######################################################## -class MultiStream: - """ - Clase para redirigir la salida a múltiples flujos (consola y archivo). - """ - def __init__(self, *streams): - self.streams = streams - - def write(self, data): - for stream in self.streams: - stream.write(data) - stream.flush() - - def flush(self): - for stream in self.streams: - stream.flush() - -######################################################## -def setup_logger(): - """ - Configura la redirección de stdout y stderr a un archivo de log y a la consola. - Retorna la ruta del archivo de log creado. - """ - log_dir = "logs" - if not os.path.exists(log_dir): - os.makedirs(log_dir) - - # Nombre del archivo: YYYY-MM-DD_HH-MM-SS.log - timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - log_filename = f"{timestamp}.log" - log_path = os.path.join(log_dir, log_filename) - - # Abrir archivo en modo append con encoding utf-8 para soportar iconos - log_file = open(log_path, "a", encoding="utf-8") - - # Redirigir stdout y stderr - sys.stdout = MultiStream(sys.stdout, log_file) - sys.stderr = MultiStream(sys.stderr, log_file) - - return log_path - -######################################################## -def limpiar_logs_antiguos(dias=7): - """ - Elimina archivos de log con más de 'n' días de antigüedad. - """ - log_dir = "logs" - if not os.path.exists(log_dir): - return - - limite = datetime.now() - timedelta(days=dias) - count = 0 - - for archivo in os.listdir(log_dir): - ruta_archivo = os.path.join(log_dir, archivo) - if os.path.isfile(ruta_archivo) and archivo.endswith(".log"): - fecha_archivo = datetime.fromtimestamp(os.path.getmtime(ruta_archivo)) - if fecha_archivo < limite: - try: - os.remove(ruta_archivo) - count += 1 - except Exception as e: - print(f"⚠️ Error al eliminar log {archivo}: {e}") - - if count > 0: - print(f"🧹 Se eliminaron {count} archivos de log antiguos (más de {dias} días).") diff --git a/main.py b/main.py index 746d52a..d810a13 100644 --- a/main.py +++ b/main.py @@ -1,265 +1,14 @@ - -import os +"""main.py — entrada delgada. Llama a ejecutar() definido en core.py.""" import sys -import json -import time -import logger_utilities -import hana_client -from datetime import datetime, timedelta -from config_loader import CONFIG -import outlook_utilities -import argparse -from selenium import webdriver -from selenium.webdriver.chrome.options import Options -from selenium.webdriver.common.by import By -from selenium.webdriver.common.keys import Keys -from selenium.webdriver.support.ui import WebDriverWait -from selenium.webdriver.support import expected_conditions as EC -######################################################## -def descargar_comprobante(driver, wait, ruc, tipo, correlativo, monto, fecha, download_dir): - url = "https://public-einvoicing.sovos.com/pe" - driver.get(url) - - # Esperar a que la aplicación cargue el input del RUC - wait.until(EC.presence_of_element_located((By.NAME, "senderTaxId"))) - - # 1. Completar RUC - ruc_input = driver.find_element(By.NAME, "senderTaxId") - ruc_input.clear() - ruc_input.send_keys(ruc) - - # 2. Seleccionar Tipo Comprobante - select_trigger = driver.find_element(By.ID, "mui-component-select-invoiceTypeCode") - select_trigger.click() - - # Esperar a que la opción esté disponible y seleccionarla - option_xpath = f"//li[@role='option' and @data-value='{tipo}']" - option_element = wait.until(EC.element_to_be_clickable((By.XPATH, option_xpath))) - option_element.click() - time.sleep(0.5) - - # 3. Completar Folio / Correlativo - correlative_input = driver.find_element(By.NAME, "correlative") - correlative_input.clear() - correlative_input.send_keys(correlativo) - - # 4. Completar Monto Total - amount_input = driver.find_element(By.NAME, "payableAmount") - amount_input.clear() - amount_input.send_keys(str(monto)) - - # 5. Completar Fecha de emisión - date_input = driver.find_element(By.XPATH, "//input[contains(@placeholder, 'Ej: 202')]") - driver.execute_script("arguments[0].click();", date_input) - - # Seleccionar todo y reemplazar con la fecha - date_input.send_keys(Keys.COMMAND, 'a') - date_input.send_keys(Keys.BACKSPACE) - date_input.send_keys(fecha) - time.sleep(0.5) - - # 6. Enviar Formulario - submit_btn = driver.find_element(By.XPATH, "//button[contains(text(), 'Ver documento')]") - if not submit_btn.is_enabled(): - raise ValueError("El botón 'Ver documento' está deshabilitado. Verifique que los campos sean válidos.") - - driver.execute_script("arguments[0].click();", submit_btn) - - # Esperar a que la página de resultados cargue o muestre error de "Document not found" - download_btn_xpath = "//*[@data-testid='btn-download-document']" - error_xpath = "//*[contains(@class, 'sovosSnackbar--error') or contains(text(), 'Document not found')]" - - download_btn_present = False - error_present = False - - for _ in range(30): # Esperar hasta 15 segundos máximo (30 * 0.5s) - btn_elements = driver.find_elements(By.XPATH, download_btn_xpath) - if btn_elements and btn_elements[0].is_displayed(): - download_btn_present = True - break - - err_elements = driver.find_elements(By.XPATH, error_xpath) - if err_elements and err_elements[0].is_displayed(): - error_present = True - break - - time.sleep(0.5) - - if error_present: - raise FileNotFoundError("El comprobante no existe en el portal de Sovos (Document not found).") - - if not download_btn_present: - raise TimeoutError("Se superó el tiempo de espera sin encontrar el comprobante ni un mensaje de error.") - - # 7. Descargar PDF (Activo por defecto) - download_btn = driver.find_element(By.XPATH, download_btn_xpath) - driver.execute_script("arguments[0].click();", download_btn) - - # 8. Cambiar a formato XML y Descargar - doc_sel_btn = driver.find_element(By.XPATH, "//button[contains(., '.pdf') or contains(., '.xml')]") - driver.execute_script("arguments[0].click();", doc_sel_btn) - time.sleep(1) - - xml_option = wait.until(EC.element_to_be_clickable((By.XPATH, "//li[@role='menuitem' and contains(., '.xml')]"))) - driver.execute_script("arguments[0].click();", xml_option) - - # Esperar a que el selector se actualice y muestre XML - wait.until(EC.text_to_be_present_in_element((By.XPATH, "//button[contains(@class, 'sovosButton')]"), ".xml")) - - download_btn = driver.find_element(By.XPATH, download_btn_xpath) - driver.execute_script("arguments[0].click();", download_btn) - - # Generar patrón esperado para verificar (ej. serie = FB03, número con padding a 8 dígitos) - serie_num_pattern = correlativo.replace("-", "") - if "-" in correlativo: - parts = correlativo.split("-") - if len(parts) == 2: - serie_num_pattern = f"{parts[0]}{parts[1].zfill(8)}".lower() - - # Esperar a que las descargas finalicen en el directorio - pdf_found = False - xml_found = False - for attempt in range(15): - files = os.listdir(download_dir) - pdf_found = any(f.endswith(".pdf") and serie_num_pattern in f.replace("-", "").lower() for f in files) - xml_found = any(f.endswith(".xml") and serie_num_pattern in f.replace("-", "").lower() for f in files) - if pdf_found and xml_found: - break - time.sleep(1) - - # Listar descargas completadas - files = os.listdir(download_dir) - downloaded_files = [f for f in files if (f.endswith(".pdf") or f.endswith(".xml")) and serie_num_pattern in f.replace("-", "").lower()] - - if not pdf_found or not xml_found: - raise FileNotFoundError(f"No se pudieron descargar los archivos correspondientes al correlativo: {correlative}") - - return downloaded_files +from core import ejecutar +from logger_manager import get_logger -######################################################## -def main(): - dias_consulta = CONFIG.mail.days - registros = hana_client.obtener_registros(days=dias_consulta) - - download_dir = CONFIG.files.path - os.makedirs(download_dir, exist_ok=True) - - print("=" * 60) - print("⚙️ CONFIGURACIÓN DE DESCARGA SOVOS") - print(f"📁 Directorio Destino: {download_dir}") - print(f"📊 Total comprobantes a procesar: {len(registros)}") - print("=" * 60) - - if not registros or len(registros) == 0: - print("\n⚠️ No se encontraron registros en SAP HANA para los últimos", dias_consulta, "días.") - return - - # Configurar opciones de Chrome - chrome_options = Options() - chrome_options.add_argument("--headless") - chrome_options.add_argument("--no-sandbox") - chrome_options.add_argument("--disable-dev-shm-usage") - chrome_options.add_argument("--disable-gpu") - chrome_options.add_argument("--blink-settings=imagesEnabled=false") - chrome_options.add_argument("--disable-features=InsecureDownloadWarnings") - - prefs = { - "download.default_directory": download_dir, - "download.prompt_for_download": False, - "download.directory_upgrade": True, - "safebrowsing.enabled": False, - "profile.default_content_setting_values.automatic_downloads": 1 - } - chrome_options.add_experimental_option("prefs", prefs) - - print("\n🌐 [1/2] Iniciando navegador Chrome en modo headless...") - driver = webdriver.Chrome(options=chrome_options) - - # Configurar descargas en modo Headless vía CDP - driver.execute_cdp_cmd("Page.setDownloadBehavior", { - "behavior": "allow", - "downloadPath": download_dir - }) - - wait = WebDriverWait(driver, 15) - - exitosos = 0 - fallidos = 0 - archivos_descargados = [] - - try: - for index, registro in enumerate(registros, start=1): - doc_num = registro.get('DocNum') - ind = registro.get('Indicator') - f_pref = registro.get('FolioPref') - f_num = registro.get('FolioNum') - tax_date = registro.get('TaxDate') - total = registro.get('DocTotal') - print(f"\n🔍 PROCESANDO REGISTRO EN HANA: {doc_num} | Tipo: {ind} | Folio: {f_pref}-{f_num} | Fecha: {tax_date} | Total: {total}") - - ruc = registro.get("LicTradNum") - tipo = registro.get("Indicator") - folio = f_pref + "-" + str(f_num) - total = registro.get("DocTotal") - fecha = registro.get("TaxDate") - - print(f"⚡ [{index}/{len(registros)}] Descargando Comprobante: {folio} (RUC: {ruc})") - - try: - archivos = descargar_comprobante(driver, wait, ruc, tipo, folio, total, fecha, download_dir) - exitosos += 1 - archivos_descargados.extend(archivos) - print(f" ✅ Comprobante {folio} descargado correctamente.") - # Pequeño delay entre descargas - time.sleep(1.5) - except Exception as e: - fallidos += 1 - print(f" ❌ Error al descargar {folio}: {e}") - - finally: - print("\n🔌 Cerrando navegador...") - driver.quit() - - print("=" * 60) - print("📊 RESUMEN DE PROCESAMIENTO") - print(f" ✅ Procesados con éxito: {exitosos}") - print(f" ❌ Fallidos: {fallidos}") - print("=" * 60) - - # Enviar correo con archivos descargados - if archivos_descargados: - try: - # Convertir nombres de archivo a rutas completas - attachment_paths = [os.path.join(download_dir, f) for f in archivos_descargados] - token = outlook_utilities.get_access_token() - subject = "Comprobantes descargados automáticamente" - html_body = f"

Se adjuntan {len(attachment_paths)} comprobantes descargados.

" - outlook_utilities.send_email(token, CONFIG.mail.sender, CONFIG.mail.to, subject, html_body, attachment_paths=attachment_paths) - except Exception as email_err: - print(f"⚠️ Error al enviar correo: {email_err}") - - #if fallidos > 0: - # sys.exit(1) - -######################################################## +# ============================================================================ if __name__ == "__main__": - - # Configurar Logger (Redirige stdout a archivo y consola) - log_path = logger_utilities.setup_logger() - logger_utilities.limpiar_logs_antiguos(dias=30) - - inicio_ejecucion = datetime.now() - print("="*60) - print(f"🚀 INICIO DE EJECUCIÓN: {inicio_ejecucion.strftime('%Y-%m-%d %H:%M:%S')}") - print(f"📄 Log guardado en: {log_path}") - print("="*60) - - main() - - fin_ejecucion = datetime.now() - duracion = fin_ejecucion - inicio_ejecucion - print("\n" + "="*60) - print(f"🏁 FIN DE EJECUCIÓN: {fin_ejecucion.strftime('%Y-%m-%d %H:%M:%S')}") - print(f"⏱️ Duración total: {str(duracion).split('.')[0]}") # Sin microsegundos - print("="*60) \ No newline at end of file + try: + ejecutar() + except Exception: + get_logger("main").error("Excepción no controlada", exc_info=True) + sys.exit(1) +# ============================================================================ \ No newline at end of file diff --git a/outlook_utilities.py b/outlook_utilities.py deleted file mode 100644 index f4ad736..0000000 --- a/outlook_utilities.py +++ /dev/null @@ -1,119 +0,0 @@ -import msal -import requests -import warnings -import base64 -import os -from datetime import datetime, timedelta -from config_loader import CONFIG - -warnings.filterwarnings("ignore") - -######################################################## -def get_access_token(): - """ - Obtiene un token de acceso de Microsoft Graph usando las credenciales - configuradas en config.json (Client ID, Client Secret y Tenant ID). - """ - if not CONFIG or not CONFIG.outlook: - raise ValueError("La configuración de Outlook no está cargada correctamente.") - - authority = f"https://login.microsoftonline.com/{CONFIG.outlook.tenant_id}" - - app = msal.ConfidentialClientApplication( - CONFIG.outlook.client_id, - authority=authority, - client_credential=CONFIG.outlook.client_secret, - verify=CONFIG.outlook.verify_ssl - ) - - scopes = ["https://graph.microsoft.com/.default"] - result = app.acquire_token_for_client(scopes=scopes) - - if "access_token" in result: - return result["access_token"] - else: - error = result.get("error") - error_description = result.get("error_description") - correlation_id = result.get("correlation_id") - raise Exception(f"Error al obtener token: {error} - {error_description} (ID: {correlation_id})") - -######################################################## -def send_email(access_token, from_email, to_recipients, subject, html_body, attachment_paths=None): - """ - Envía un correo electrónico con cuerpo HTML y múltiples adjuntos PDF. - - :param access_token: Token de acceso de Microsoft Graph API. - :param from_email: Correo electrónico del remitente (cuenta de Office 365). - :param to_recipients: Lista de correos electrónicos de los destinatarios. - :param subject: Asunto del correo. - :param html_body: Contenido del correo en formato HTML. - :param attachment_paths: Lista de rutas a los archivos PDF que se adjuntarán. - :return: True si se envió correctamente, False en caso contrario. - """ - url = f"https://graph.microsoft.com/v1.0/users/{from_email}/sendMail" - headers = { - 'Authorization': f'Bearer {access_token}', - 'Content-Type': 'application/json' - } - - # Configuración de SSL desde CONFIG - outlook_config = CONFIG.outlook - verify = outlook_config.verify_ssl if outlook_config.ca_bundle is None else outlook_config.ca_bundle - - # Construir lista de destinatarios para Graph API - recipients_payload = [] - for email in to_recipients: - recipients_payload.append({ - "emailAddress": { - "address": email - } - }) - - # Construir lista de adjuntos - attachments_payload = [] - if attachment_paths: - for path in attachment_paths: - if os.path.exists(path): - with open(path, "rb") as f: - content_bytes = f.read() - - # Codificar el contenido en Base64 - encoded_content = base64.b64encode(content_bytes).decode('utf-8') - filename = os.path.basename(path) - - attachments_payload.append({ - "@odata.type": "#microsoft.graph.fileAttachment", - "name": filename, - "contentType": "application/pdf", - "contentBytes": encoded_content - }) - else: - print(f"Advertencia: El archivo no existe y no se adjuntará: {path}") - - # Estructura del mensaje para la API - email_data = { - "message": { - "subject": subject, - "body": { - "contentType": "HTML", - "content": html_body - }, - "toRecipients": recipients_payload, - "attachments": attachments_payload - }, - "saveToSentItems": "true" - } - - try: - response = requests.post(url, headers=headers, json=email_data, verify=verify) - if response.status_code == 202: - print(f"SUCCESS: Correo enviado exitosamente a: {', '.join(to_recipients)}") - return True - else: - print(f"ERROR: Al enviar correo: {response.status_code} - {response.text}") - return False - except Exception as e: - print(f"ERROR: Excepcion al enviar correo: {e}") - return False - -######################################################## \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ef45b19 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +python-dotenv==1.2.2 +hdbcli +playwright==1.62.0 \ No newline at end of file diff --git a/smb_transfer.py b/smb_transfer.py new file mode 100644 index 0000000..1cbab57 --- /dev/null +++ b/smb_transfer.py @@ -0,0 +1,182 @@ +import os +import sys +import subprocess +from pathlib import Path +from dotenv import load_dotenv +from logger_manager import get_logger + +# ============================================================================ +load_dotenv() + +logger = get_logger("smb") + +# ============================================================================ +def get_mount_base() -> str: + """Base de montaje SMB según la plataforma (.env).""" + if sys.platform == "darwin": + return os.getenv("SMB_MOUNT_MAC", "/Volumes/FileSAP") + return os.getenv("SMB_MOUNT_LINUX", "/mnt/filesap") + + +def _creds() -> dict: + return { + "server": os.getenv("SMB_SERVER", ""), + "share": os.getenv("SMB_SHARE", ""), + "user": os.getenv("SMB_USER", ""), + "password": os.getenv("SMB_PASSWORD", ""), + } + +# ============================================================================ +def detectar_montaje(mount_base: str) -> bool: + """Verifica que el punto de montaje exista y esté montado de verdad.""" + p = Path(mount_base) + if not p.is_dir(): + return False + if os.path.ismount(mount_base): + return True + try: + return any(p.iterdir()) + except PermissionError: + return False + +# ============================================================================ +def convertir_unc_a_local(ruta_unc: str, mount_base: str | None = None) -> str | None: + """Convierte una ruta UNC (\\servidor\\recurso\\carpeta\\archivo.pdf) a ruta + local sobre el montaje SMB actual.""" + ruta_unc = (ruta_unc or "").strip() + if len(ruta_unc) < 5: + return None + base = mount_base or get_mount_base() + if not base: + return None + path = ruta_unc.replace("\\", "/").lstrip("/") + partes = path.split("/") + if len(partes) < 2: + return None + ruta_relativa = "/".join(partes[2:]) + return os.path.join(base, ruta_relativa) + +# ============================================================================ +def montar_smb(mount_base: str) -> tuple[bool, str]: + """Intenta montar la unidad SMB sin sudo (usuario con privilegios).""" + creds = _creds() + if not creds["server"] or not creds["share"]: + return False, "Faltan SMB_SERVER/SMB_SHARE en .env." + + Path(mount_base).mkdir(parents=True, exist_ok=True) + + try: + if sys.platform == "darwin": + url = f"//{creds['user']}:{creds['password']}@{creds['server']}/{creds['share']}" + result = subprocess.run( + ["mount_smbfs", url, mount_base], + capture_output=True, text=True, + ) + else: + opts = f"username={creds['user']},password={creds['password']}" + result = subprocess.run( + ["mount", "-t", "cifs", f"//{creds['server']}/{creds['share']}", mount_base, "-o", opts], + capture_output=True, text=True, + ) + except FileNotFoundError: + return False, "Comando de montaje no disponible en el sistema." + + if result.returncode != 0: + return False, result.stderr.strip() or "Error montando SMB (código %d)." % result.returncode + + return True, f"Unidad montada en {mount_base}." + +# ============================================================================ +def _rsync_cmd(destino: str) -> list[str]: + return [ + "rsync", "-rtv", "--progress", + "--remove-source-files", + "--include=*.pdf", + "--include=*.xml", + "--exclude=*", + "./", + f"{destino}/", + ] + +# ============================================================================ +def transferir_empresa(empresa: dict, mount_base: str, auto_mount: bool = False) -> dict: + """Transfiere downloads// hacia // con rsync. + + Si el SMB no está montado: si auto_mount está habilitado se intenta montar; + si falla (o no está habilitado), se conservan los archivos localmente. + """ + nombre = empresa["nombre"] + origen = Path("downloads") / nombre + + if not origen.is_dir(): + return {"empresa": nombre, "estado": "sin_descargas", "movidos": 0} + + if not detectar_montaje(mount_base): + if auto_mount: + ok, msg = montar_smb(mount_base) + if ok: + logger.info(f" 🔌 {msg}") + else: + logger.warning(f" ⚠️ No se pudo montar SMB ({mount_base}): {msg}. " + f"Archivos conservados en {origen}.") + return {"empresa": nombre, "estado": "smb_no_montado", "movidos": 0} + else: + logger.warning(f" ⚠️ Empresa '{nombre}': SMB NO montado ({mount_base}). " + f"Transferencia omitida, archivos conservados en {origen}.") + return {"empresa": nombre, "estado": "smb_no_montado", "movidos": 0} + + destino = Path(mount_base) / empresa["smb_subfolder"] + try: + destino.mkdir(parents=True, exist_ok=True) + except Exception as e: + logger.warning(f" ⚠️ Empresa '{nombre}': no se pudo crear {destino}: {e}. " + f"Transferencia omitida, archivos conservados.") + return {"empresa": nombre, "estado": "error_destino", "movidos": 0} + + logger.info(f" 🔁 Empresa '{nombre}': rsync {origen}/ -> {destino}/") + try: + result = subprocess.run( + _rsync_cmd(str(destino)), + cwd=str(origen), + capture_output=True, + text=True, + ) + except FileNotFoundError: + logger.error(" ❌ rsync no está instalado en el sistema.") + return {"empresa": nombre, "estado": "rsync_faltante", "movidos": 0} + + if result.returncode != 0: + logger.error(f" ❌ Error rsync ({nombre}):\n{result.stderr.strip()}") + return {"empresa": nombre, "estado": "error_rsync", "movidos": 0} + + restantes = [f for f in origen.iterdir() if f.is_file()] + movidos = len(restantes) == 0 + estado = "ok" if movidos else "parcial" + logger.info(f" ✅ Transferencia {estado} ({nombre}). Quedan {len(restantes)} archivos locales.") + return {"empresa": nombre, "estado": estado, "movidos": movidos} + +# ============================================================================ +def transferir_a_smb(empresas: list[dict], mount_base: str, auto_mount: bool = False) -> list[dict]: + resultados = [] + for emp in empresas: + if emp.get("locked", False): + logger.info(f" ⏭️ Empresa '{emp['nombre']}' bloqueada (LOCKED), omitiendo transferencia.") + continue + resultados.append(transferir_empresa(emp, mount_base, auto_mount)) + return resultados + +# ============================================================================ +if __name__ == "__main__": + import json + + base = get_mount_base() + if "--mount" in sys.argv: + ok, msg = montar_smb(base) + print(f"{'✅' if ok else '❌'} {msg}") + else: + cfg = json.load(open(Path(__file__).parent / "config.json", encoding="utf-8")) + activas = [e for e in cfg.get("empresas", []) if not e.get("locked", False)] + auto = os.getenv("SMB_AUTO_MOUNT", "false").lower() == "true" + for r in transferir_a_smb(activas, base, auto): + print(r) +# ============================================================================ \ No newline at end of file