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
+1081 -750

No files matched your search

+43 -252
View File
@@ -1,265 +1,56 @@
import os
import sys
import json
"""main.py — entrada delgada. Bucle continuo dirigido por scheduler.json."""
import signal
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
from datetime import datetime
########################################################
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 scheduler import cargar_scheduler, franja_actual, proximo_inicio
from logger_manager import get_logger
########################################################
def main():
dias_consulta = CONFIG.mail.days
registros = hana_client.obtener_registros(days=dias_consulta)
logger = get_logger("main")
download_dir = CONFIG.files.path
os.makedirs(download_dir, exist_ok=True)
_running = 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)
# ============================================================================
def _signal_handler(signum, frame):
global _running
logger.info("⏹️ Señal de parada recibida. Deteniendo tras el ciclo actual...")
_running = False
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}")
def _dormir_interrumpible(segundos: float):
"""Duerme en pasos de 1s para reaccionar rápido a SIGTERM/SIGINT."""
restante = int(segundos)
while restante > 0 and _running:
time.sleep(min(1, restante))
restante -= 1
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__":
signal.signal(signal.SIGINT, _signal_handler)
signal.signal(signal.SIGTERM, _signal_handler)
# Configurar Logger (Redirige stdout a archivo y consola)
log_path = logger_utilities.setup_logger()
logger_utilities.limpiar_logs_antiguos(dias=30)
while _running:
try:
cfg_scheduler = cargar_scheduler()
ahora = datetime.now()
franja = franja_actual(cfg_scheduler, ahora)
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)
if franja is None:
proximo = proximo_inicio(cfg_scheduler, ahora)
segundos = max((proximo - ahora).total_seconds(), 0)
logger.info(
f"⏸️ Fuera de horario. Próxima franja: {proximo:%Y-%m-%d %H:%M} "
f"(durmiendo {int(segundos)}s)"
)
_dormir_interrumpible(segundos)
continue
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()
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)
logger.info("👋 Proceso detenido.")
# ============================================================================