commit cf8d9fbbb228861e3e63f2a794a7f76e45a6571c Author: George Date: Wed May 20 16:38:48 2026 -0500 primer commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0ea519d --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.pyc +.env +venv/ +.DS_Store +.vscode/ +.idea/ +downloads/ +logs/ diff --git a/config.json b/config.json new file mode 100644 index 0000000..1963ea1 --- /dev/null +++ b/config.json @@ -0,0 +1,25 @@ +{ + "hana": { + "host": "192.168.1.175", + "port": 30015, + "user": "SYSTEM", + "password": "SAPB1Admin.25", + "schema": "SBO_TAGUMEDICA_21" + }, + "files":{ + "path":"downloads" + }, + "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 + }, + "mail":{ + "sender":"cpe@tagumedica.com", + "to":["cpe@tagumedica.com"] + } + +} diff --git a/config_loader.py b/config_loader.py new file mode 100644 index 0000000..bf31083 --- /dev/null +++ b/config_loader.py @@ -0,0 +1,35 @@ +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'] +) + +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 new file mode 100644 index 0000000..23b9e2b --- /dev/null +++ b/config_model.py @@ -0,0 +1,41 @@ +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) + +@dataclass +class Config: + hana: HanaConfig + files: FilesConfig + outlook: OutlookConfig + mail: MailConfig diff --git a/documentos.json b/documentos.json new file mode 100644 index 0000000..bf0bba5 --- /dev/null +++ b/documentos.json @@ -0,0 +1,131 @@ +[ + {"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 new file mode 100644 index 0000000..686dace --- /dev/null +++ b/hana_client.py @@ -0,0 +1,75 @@ +#!/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 TOP 50 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/logger_utilities.py b/logger_utilities.py new file mode 100644 index 0000000..6d05b9e --- /dev/null +++ b/logger_utilities.py @@ -0,0 +1,70 @@ +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 new file mode 100644 index 0000000..bf9b190 --- /dev/null +++ b/main.py @@ -0,0 +1,259 @@ + +import os +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 + +######################################################## +def main(): + registros = hana_client.obtener_registros(days=150) + + 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) + + # 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') + print(f"\n🔍 PROCESANDO REGISTRO EN HANA: {doc_num} | Tipo: {ind} | Folio: {f_pref}-{f_num}") + + 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 diff --git a/outlook_utilities.py b/outlook_utilities.py new file mode 100644 index 0000000..f4ad736 --- /dev/null +++ b/outlook_utilities.py @@ -0,0 +1,119 @@ +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