Migra el servicio a arquitectura Docker persistente y mueve credenciales HANA a .env

- Reemplaza config_loader/config_model/hana_client/logger_utilities/outlook_utilities
  por core.py (pipeline funcional), hana_manager.py, logger_manager.py, scheduler.py y
  descargador_sovos.py (Playwright/Firefox) según la plantilla de servicios.
- main.py pasa a loop continuo dirigido por scheduler.json, con manejo de SIGINT/SIGTERM.
- Agrega Dockerfile, docker-compose.yml (restart unless-stopped) y certs/ para despliegue.
- Agrega smb_transfer.py para la transferencia a carpeta compartida.
- Saca las credenciales de HANA de config.json y las mueve a .env (HANA_HOST/PORT/USER/PASSWORD).
This commit is contained in:
George committed 2026-08-25 09:29:35 -05:00
1 parent 1503d6a2ec
commit 277d07dfeb
22 files changed
+1079 -748

No files matched your search

+24
View File
@@ -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
+13 -3
View File
@@ -1,9 +1,19 @@
# Python
__pycache__/ __pycache__/
*.pyc *.pyc
.env
venv/ venv/
sovos-venv/
dbapi/
# Secretos
.env
# Datos y logs
downloads/
logs/
cron_docker.log
# SO / IDE
.DS_Store .DS_Store
.vscode/ .vscode/
.idea/ .idea/
downloads/
logs/
+66
View File
@@ -0,0 +1,66 @@
# 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
```
main.py # entrada delgada: while loop dirigido por scheduler.json, llama ejecutar()
core.py # pipeline funcional: describir_servicio/obtener_datos/procesar_y_descargar/ejecutar
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)
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
scheduler.json # HORARIOS ([hora_ini,min_ini,hora_fin,min_fin,modo]) + DIAS_ACTIVOS
.env # SMB_* + SMB_AUTO_MOUNT + LOG_LEVEL
Dockerfile # imagen (Playwright+Firefox ya incluidos, CA Fortinet, rsync)
docker-compose.yml # servicio `descarga-cpe` persistente (restart: unless-stopped)
certs/fortinet-root.crt # CA de inspección SSL de la red (FG100FTK22018356)
```
## Reglas
1. Empresas en `config.json` con `{ nombre, schema, locked, smb_subfolder }`.
`locked: true` = no ejecutar. Descargas en `downloads/<nombre>/`.
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()`; 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.
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 scheduler.py hana_manager.py descargador_sovos.py smb_transfer.py logger_manager.py`
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.
+33
View File
@@ -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 . .
# Servicio persistente: main.py corre en loop (ver scheduler.py/scheduler.json).
CMD ["python", "main.py"]
+46
View File
@@ -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)
---
+39 -20
View File
@@ -1,26 +1,45 @@
{ {
"hana": { "rucs": [
"host": "192.168.1.175", "20100047218",
"port": 30015, "20100130204",
"user": "SYSTEM", "20100053455"
"password": "SAPB1Admin.25", ],
"schema": "SBO_TAGUMEDICA_21" "consulta": {
"dias": 30
}, },
"files":{ "empresas": [
"path":"downloads" {
"nombre": "tagumedica",
"schema": "SBO_TAGUMEDICA_21",
"locked": false,
"smb_subfolder": "COMPRAS/TAGUMEDICA/ADJUNTOS_CORREOS/2025-03"
}, },
"outlook": { {
"client_id": "957c324b-3da3-43ed-b912-ca52fb5d3128", "nombre": "elavent",
"client_secret": "Guc8Q~u6FH3xBKpxJ-hQ7QSpRWbnvmX.LnWr-dw7", "schema": "SBO_ELAVENT",
"tenant_id": "76f77b2f-6fc3-42a0-ae83-df05508405d6", "locked": true,
"download_folder": "./adjuntos", "smb_subfolder": "COMPRAS/ELAVENT/ADJUNTOS_CORREOS/2025-03"
"verify_ssl": false,
"ca_bundle": null
}, },
"mail":{ {
"sender":"cpe@tagumedica.com", "nombre": "safena",
"to":["cpe@tagumedica.com"], "schema": "SBO_SAFENA",
"days":2 "locked": true,
"smb_subfolder": "COMPRAS/SAFENA/ADJUNTOS_CORREOS/2025-03"
},
{
"nombre": "cirugia",
"schema": "SBO_CIRUGIA",
"locked": true,
"smb_subfolder": "COMPRAS/CIRUGIA/ADJUNTOS_CORREOS/2025-03"
},
{
"nombre": "franquicia",
"schema": "SBO_FRANQUICIA",
"locked": true,
"smb_subfolder": "COMPRAS/FRANQUICIA/ADJUNTOS_CORREOS/2025-03"
}
],
"files": {
"path": "downloads"
} }
} }
-36
View File
@@ -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
)
########################################################
-42
View File
@@ -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
+112
View File
@@ -0,0 +1,112 @@
"""
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, obtener_config_hana
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()}")
cfg_hana = obtener_config_hana()
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()
# ============================================================================
+145
View File
@@ -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 = "<no pude leer el body>"
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}
# ============================================================================
+25
View File
@@ -0,0 +1,25 @@
services:
descarga-cpe:
build: .
restart: unless-stopped
stop_grace_period: 15s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
env_file:
- .env
volumes:
- ./config.json:/app/config.json:ro
- ./scheduler.json:/app/scheduler.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
# Servicio persistente: main.py corre en loop dirigido por scheduler.json
# (ver CLAUDE.md regla 8). Se levanta una sola vez con `docker compose up -d`.
-131
View File
@@ -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"}
]
-75
View File
@@ -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.")
########################################################
+162
View File
@@ -0,0 +1,162 @@
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:
_cfg_hana = {
"host": os.getenv("HANA_HOST"),
"port": int(os.getenv("HANA_PORT", "30015")),
"user": os.getenv("HANA_USER"),
"password": os.getenv("HANA_PASSWORD"),
}
return _cfg_hana
def obtener_config_hana() -> dict:
return _cargar_config()
# ============================================================================
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")
# ============================================================================
+116
View File
@@ -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))
-70
View File
@@ -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).")
+43 -252
View File
@@ -1,265 +1,56 @@
"""main.py — entrada delgada. Bucle continuo dirigido por scheduler.json."""
import os import signal
import sys
import json
import time import time
import logger_utilities from datetime import datetime
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
######################################################## from core import ejecutar
def descargar_comprobante(driver, wait, ruc, tipo, correlativo, monto, fecha, download_dir): from scheduler import cargar_scheduler, franja_actual, proximo_inicio
url = "https://public-einvoicing.sovos.com/pe" from logger_manager import get_logger
driver.get(url)
# Esperar a que la aplicación cargue el input del RUC logger = get_logger("main")
wait.until(EC.presence_of_element_located((By.NAME, "senderTaxId")))
# 1. Completar RUC _running = True
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") def _signal_handler(signum, frame):
select_trigger.click() global _running
logger.info("⏹️ Señal de parada recibida. Deteniendo tras el ciclo actual...")
_running = False
# 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 def _dormir_interrumpible(segundos: float):
correlative_input = driver.find_element(By.NAME, "correlative") """Duerme en pasos de 1s para reaccionar rápido a SIGTERM/SIGINT."""
correlative_input.clear() restante = int(segundos)
correlative_input.send_keys(correlativo) while restante > 0 and _running:
time.sleep(min(1, restante))
restante -= 1
# 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
########################################################
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"<p>Se adjuntan {len(attachment_paths)} comprobantes descargados.</p>"
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__": if __name__ == "__main__":
signal.signal(signal.SIGINT, _signal_handler)
signal.signal(signal.SIGTERM, _signal_handler)
# Configurar Logger (Redirige stdout a archivo y consola) while _running:
log_path = logger_utilities.setup_logger() try:
logger_utilities.limpiar_logs_antiguos(dias=30) cfg_scheduler = cargar_scheduler()
ahora = datetime.now()
franja = franja_actual(cfg_scheduler, ahora)
inicio_ejecucion = datetime.now() if franja is None:
print("="*60) proximo = proximo_inicio(cfg_scheduler, ahora)
print(f"🚀 INICIO DE EJECUCIÓN: {inicio_ejecucion.strftime('%Y-%m-%d %H:%M:%S')}") segundos = max((proximo - ahora).total_seconds(), 0)
print(f"📄 Log guardado en: {log_path}") logger.info(
print("="*60) f"⏸️ Fuera de horario. Próxima franja: {proximo:%Y-%m-%d %H:%M} "
f"(durmiendo {int(segundos)}s)"
)
_dormir_interrumpible(segundos)
continue
main() ejecutar()
_dormir_interrumpible(franja["intervalo"])
except Exception:
logger.error("Excepción no controlada en el ciclo", exc_info=True)
_dormir_interrumpible(60)
fin_ejecucion = datetime.now() logger.info("👋 Proceso detenido.")
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)
-119
View File
@@ -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
########################################################
+3
View File
@@ -0,0 +1,3 @@
python-dotenv==1.2.2
hdbcli
playwright==1.62.0
+9
View File
@@ -0,0 +1,9 @@
{
"HORARIOS": [
[6, 0, 13, 15, "rapido"],
[13, 15, 14, 0, "full"],
[14, 0, 17, 30, "rapido"],
[17, 30, 21, 30, "full"]
],
"DIAS_ACTIVOS": [0, 1, 2, 3, 4]
}
+61
View File
@@ -0,0 +1,61 @@
"""scheduler.py — franja horaria activa para el while loop de main.py.
Lee scheduler.json (HORARIOS + DIAS_ACTIVOS) y responde dos preguntas:
* franja_actual(): si ahora cae dentro de una franja, con qué modo.
* proximo_inicio(): si no, cuándo empieza la próxima franja activa.
"""
import json
from pathlib import Path
from datetime import datetime, timedelta
# ============================================================================
SCHEDULER_FILE = Path(__file__).parent / "scheduler.json"
INTERVALOS_POR_MODO = {
"rapido": 10 * 60,
"full": 60 * 60,
}
# ============================================================================
def cargar_scheduler(path: Path | None = None) -> dict:
p = path or SCHEDULER_FILE
with open(p, encoding="utf-8") as f:
return json.load(f)
def _minutos(hora: int, minuto: int) -> int:
return hora * 60 + minuto
def franja_actual(cfg: dict, ahora: datetime) -> dict | None:
if ahora.weekday() not in cfg.get("DIAS_ACTIVOS", []):
return None
minutos_ahora = _minutos(ahora.hour, ahora.minute)
for h_ini, m_ini, h_fin, m_fin, modo in cfg.get("HORARIOS", []):
if _minutos(h_ini, m_ini) <= minutos_ahora < _minutos(h_fin, m_fin):
return {"modo": modo, "intervalo": INTERVALOS_POR_MODO.get(modo, 600)}
return None
def proximo_inicio(cfg: dict, ahora: datetime) -> datetime:
dias_activos = cfg.get("DIAS_ACTIVOS", [])
horarios = cfg.get("HORARIOS", [])
if not dias_activos or not horarios:
raise ValueError("scheduler.json sin DIAS_ACTIVOS o HORARIOS configurados")
for delta in range(0, 8):
candidato = ahora + timedelta(days=delta)
if candidato.weekday() not in dias_activos:
continue
inicios = []
for h_ini, m_ini, h_fin, m_fin, _modo in horarios:
inicio = candidato.replace(hour=h_ini, minute=m_ini, second=0, microsecond=0)
if delta == 0 and inicio <= ahora:
continue
inicios.append(inicio)
if inicios:
return min(inicios)
raise ValueError("No se encontró una próxima franja activa en los próximos 7 días")
# ============================================================================
+182
View File
@@ -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/<nombre>/ hacia <mount_base>/<smb_subfolder>/ 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)
# ============================================================================