commit 83f704c85d7ead3453ee597a999059e732e70ceb Author: GEORGE Date: Mon May 18 09:28:55 2026 -0500 primer commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a446c4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +.env +venv/ +.DS_Store +.vscode/ +.ide/ diff --git a/config.json b/config.json new file mode 100644 index 0000000..055faef --- /dev/null +++ b/config.json @@ -0,0 +1,35 @@ +{ + "hana": { + "server": "192.168.1.175", + "port": 30015, + "database": "SBO_TAGUMEDICA_21", + "user": "SYSTEM", + "password": "SAPB1Admin.25" + }, + "outlook": { + "client_id": "957c324b-3da3-43ed-b912-ca52fb5d3128", + "client_secret": "Guc8Q~u6FH3xBKpxJ-hQ7QSpRWbnvmX.LnWr-dw7", + "tenant_id": "76f77b2f-6fc3-42a0-ae83-df05508405d6", + "attachments_folder": "attachments", + "verify_ssl": false, + "ca_bundle": null, + "from_email": "business.intelligence@tagumedica.com", + "to_email": [ + "jefes-area-comercial-tgm@tagumedica.com", + "luis.wu@tagumedica.com", + "ricardo.palacios@tagumedica.com", + "eduard.samson@tagumedica.com", + "business.intelligence@tagumedica.com", + "hugo.condori@tagumedica.com", + "marco.marinos@tagumedica.com", + "natalie.arambulo@tagumedica.com" + ] + }, + "crystal": { + "path": "C:/Windows/Microsoft.NET/assembly/GAC_MSIL", + "version": "v4.0_13.0.4000.0__692fbea5521e1304" + }, + "powerbi": { + "url": "https://app.powerbi.com/view?r=eyJrIjoiMGNjOGVjYWUtYzcwNi00OTJiLWE5MjItNzJhMzRjYmFmNjA1IiwidCI6Ijc2Zjc3YjJmLTZmYzMtNDJhMC1hZTgzLWRmMDU1MDg0MDVkNiIsImMiOjR9" + } +} \ No newline at end of file diff --git a/config_final.json b/config_final.json new file mode 100644 index 0000000..9dae98e --- /dev/null +++ b/config_final.json @@ -0,0 +1,31 @@ +{ + "hana": { + "server": "192.168.1.175", + "port": 30015, + "database": "SBO_TAGUMEDICA_21", + "user": "SYSTEM", + "password": "SAPB1Admin.25" + }, + "outlook": { + "client_id": "957c324b-3da3-43ed-b912-ca52fb5d3128", + "client_secret": "Guc8Q~u6FH3xBKpxJ-hQ7QSpRWbnvmX.LnWr-dw7", + "tenant_id": "76f77b2f-6fc3-42a0-ae83-df05508405d6", + "attachments_folder": "attachments", + "verify_ssl": false, + "ca_bundle": null, + "from_email": "george.calle@tagumedica.com", + "to_email": [ + "jefes-area-comercial-tgm@tagumedica.com", + "luis.wu@tagumedica.com", + "ricardo.palacios@tagumedica.com", + "eduard.samson@tagumedica.com", + "business.intelligence@tagumedica.com", + "hugo.condori@tagumedica.com", + "marco.marinos@tagumedica.com", + "natalie.arambulo@tagumedica.com" + ] + }, + "crystal": { + "path": "C:/Windows/Microsoft.NET/assembly/GAC_MSIL" + } +} \ No newline at end of file diff --git a/config_loader.py b/config_loader.py new file mode 100644 index 0000000..96a71de --- /dev/null +++ b/config_loader.py @@ -0,0 +1,16 @@ +from config_model import Config +import os + +# Definir la ruta del archivo de configuración +CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.json") + +def load_config(): + """Carga la configuración desde el archivo JSON.""" + try: + return Config.load(CONFIG_PATH) + except Exception as e: + print(f"ERROR: Al cargar la configuracion: {e}") + raise e + +# Variable global CONFIG disponible para otros módulos +CONFIG = load_config() diff --git a/config_model.py b/config_model.py new file mode 100644 index 0000000..55ab471 --- /dev/null +++ b/config_model.py @@ -0,0 +1,82 @@ +from dataclasses import dataclass +from typing import Optional +import json +import os + +@dataclass +class HanaConfig: + server: str + port: int + database: str + user: str + password: str + +@dataclass +class OutlookConfig: + client_id: str + client_secret: str + tenant_id: str + from_email: str + to_email: list[str] + attachments_folder: str = "./attachments" + verify_ssl: bool = True + ca_bundle: Optional[str] = None + +@dataclass +class CrystalConfig: + path: str + version: str + +@dataclass +class PowerbiConfig: + url: str + +@dataclass +class Config: + hana: HanaConfig + outlook: OutlookConfig + crystal: CrystalConfig + powerbi: PowerbiConfig + + @staticmethod + def load(path: str = "config.json") -> 'Config': + if not os.path.exists(path): + raise FileNotFoundError(f"No se encontró el archivo de configuración: {path}") + + with open(path, 'r', encoding='utf-8') as f: + data = json.load(f) + + hana_data = data.get('hana', {}) + outlook_data = data.get('outlook', {}) + crystal_data = data.get('crystal', {}) + + # Validar campos requeridos para HANA + required_hana = ['server', 'port', 'database', 'user', 'password'] + for field in required_hana: + if field not in hana_data: + raise KeyError(f"Falta el campo requerido '{field}' en la sección 'hana' de {path}") + + # Validar campos requeridos para Outlook + required_outlook = ['client_id', 'client_secret', 'tenant_id', 'from_email', 'to_email'] + for field in required_outlook: + if field not in outlook_data: + raise KeyError(f"Falta el campo requerido '{field}' en la sección 'outlook' de {path}") + + # Validar campos requeridos para Crystal + required_crystal = ['path', 'version'] + for field in required_crystal: + if field not in crystal_data: + raise KeyError(f"Falta el campo requerido '{field}' en la sección 'crystal' de {path}") + + powerbi_data = data.get('powerbi', {}) + required_powerbi = ['url'] + for field in required_powerbi: + if field not in powerbi_data: + raise KeyError(f"Falta el campo requerido '{field}' en la sección 'powerbi' de {path}") + + hana = HanaConfig(**hana_data) + outlook = OutlookConfig(**outlook_data) + crystal = CrystalConfig(**crystal_data) + powerbi=PowerbiConfig(**powerbi_data) + + return Config(hana=hana, outlook=outlook, crystal=crystal,powerbi=powerbi) diff --git a/correo_preview.html b/correo_preview.html new file mode 100644 index 0000000..69b1813 --- /dev/null +++ b/correo_preview.html @@ -0,0 +1,36 @@ + + + + + + Previsualización del Correo + + + +
+

--- PREVISUALIZACIÓN DEL CORREO --- +

+
+ +

Informe Automático de Ventas

+

Buenos días

+

Me complace compartir con ustedes el reporte de avance de ventas correspondiente al + 13/05/2026.

+

La información se encuentra disponible en el siguiente dashboard:

+

+ + Ver reporte Power BI + +

+
+

Quedamos atentos a cualquier consulta o comentario.

+
+

Saludos cordiales,

+

Equipo de Inteligencia de Negocios

+

Este es un mensaje generado automáticamente por el + sistema de reportes.

+
+ + + \ No newline at end of file diff --git a/logger_utilities.py b/logger_utilities.py new file mode 100644 index 0000000..a6f9eb5 --- /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..9f28f12 --- /dev/null +++ b/main.py @@ -0,0 +1,214 @@ +import os +import sys +import clr +from datetime import datetime, timedelta, timezone +from pathlib import Path +import logger_utilities + +# Importar configuración y utilidades personalizadas +from outlook import get_access_token, send_email +from config_loader import CONFIG + +# --- CRYSTAL REPORTS RUNTIME INITIALIZATION --- +######################################################## +def init_crystal_runtime(): + """ + Inicializa el runtime de Crystal Reports cargando las DLLs necesarias desde el GAC. + Se basa en la ruta configurada en config.json para determinar la versión. + """ + # gac_path = r"C:\Windows\Microsoft.NET\assembly\GAC_MSIL" + gac_path = CONFIG.crystal.path + + # La versión del runtime de Crystal Reports instalada en este sistema + version_folder = CONFIG.crystal.version + + dlls = [ + os.path.join(gac_path, "CrystalDecisions.CrystalReports.Engine", version_folder, "CrystalDecisions.CrystalReports.Engine.dll"), + os.path.join(gac_path, "CrystalDecisions.Shared", version_folder, "CrystalDecisions.Shared.dll"), + os.path.join(gac_path, "CrystalDecisions.ReportSource", version_folder, "CrystalDecisions.ReportSource.dll") + ] + + for dll in dlls: + if not os.path.exists(dll): + print(f"CRITICAL ERROR: No se encontró la DLL necesaria: {dll}") + print("Verifique que el SAP Crystal Reports Runtime esté instalado correctamente.") + sys.exit(1) + clr.AddReference(dll) + +######################################################## +# Inicializar el runtime antes de importar los tipos de .NET +init_crystal_runtime() + +# Importar tipos de .NET después de cargar las referencias +# pyrefly: ignore [missing-import] +from System import DateTime +# pyrefly: ignore [missing-import] +from CrystalDecisions.CrystalReports.Engine import ReportDocument +# pyrefly: ignore [missing-import] +from CrystalDecisions.Shared import ExportFormatType + +# --- HELPERS --- +######################################################## +def configure_database(report): + """ + Configura las credenciales de la base de datos HANA para el reporte y todas sus sub-tablas. + """ + hc = CONFIG.hana + # Login general para el reporte + report.SetDatabaseLogon(hc.user, hc.password, hc.server, hc.database) + + # Asegurar conexión en cada tabla individual + for table in report.Database.Tables: + conn_info = table.LogOnInfo.ConnectionInfo + conn_info.ServerName = hc.server + conn_info.DatabaseName = hc.database + conn_info.UserID = hc.user + conn_info.Password = hc.password + table.ApplyLogOnInfo(table.LogOnInfo) + +######################################################## +def ejecutar_reporte(rpt_filename: str, pdf_path: Path, params: dict) -> bool: + """ + Carga un reporte .rpt, aplica parámetros y lo exporta a PDF. + + :param rpt_filename: Nombre o ruta del archivo .rpt + :param pdf_path: Ruta de salida del archivo PDF + :param params: Diccionario de parámetros {Nombre: Valor} + :return: True si tuvo éxito, False en caso contrario + """ + print(f"--- Generando: {pdf_path.name} ---") + + report = ReportDocument() + try: + report.Load(rpt_filename) + configure_database(report) + + # Asignar parámetros dinámicamente + for key, value in params.items(): + report.SetParameterValue(key, value) + + # Exportar a disco + report.ExportToDisk(ExportFormatType.PortableDocFormat, str(pdf_path)) + print(f"SUCCESS: Reporte guardado en {pdf_path}") + return True + except Exception as e: + print(f"ERROR: Falló la ejecución del reporte '{rpt_filename}': {e}") + return False + finally: + # Siempre liberar recursos de Crystal Reports + report.Close() + report.Dispose() + +######################################################## +def get_report_dates(): + """ + Calcula las fechas de reporte: Ayer y el primer día del mes actual. + Retorna objetos System.DateTime compatibles con Crystal Reports. + """ + now = datetime.now(timezone.utc) + yesterday = now - timedelta(days=1) + + # Objetos .NET DateTime + dt_yesterday = DateTime(yesterday.year, yesterday.month, yesterday.day) + dt_month_start = DateTime(yesterday.year, yesterday.month, 1) + + return dt_month_start, dt_yesterday + +# --- PROCESO PRINCIPAL --- +######################################################## +def main(): + print("====================================================") + print("INICIANDO AUTOMATIZACIÓN DE CRYSTAL REPORTS") + print(f"Fecha/Hora: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print("====================================================") + + # 1. Autenticación con Microsoft Graph + access_token = get_access_token() + if not access_token: + print("ABORT: Error crítico de autenticación con Outlook.") + return + + # 2. Preparación del Entorno + base_path = Path(__file__).parent + output_dir = base_path / CONFIG.outlook.attachments_folder + output_dir.mkdir(parents=True, exist_ok=True) + + # 3. Cálculo de Fechas + dt_month_start, dt_yesterday = get_report_dates() + str_ayer = dt_yesterday.ToString("dd/MM/yyyy") + str_inicio = dt_month_start.ToString("dd/MM/yyyy") + + # 4. Generación de Reportes + rpt_file = "venta_presupuesto_linea.rpt" + adjuntos = [] + + # REPORTE 1: Venta del día de ayer + pdf_yesterday = output_dir / f"venta_presupuesto_linea_{dt_yesterday.ToString('dd_MM_yyyy')}.pdf" + if ejecutar_reporte(rpt_file, pdf_yesterday, {"FecIni": dt_yesterday, "FecFin": dt_yesterday}): + adjuntos.append(str(pdf_yesterday)) + + # REPORTE 2: Acumulado del mes actual (MTD) + pdf_mtd = output_dir / f"venta_presupuesto_linea_{dt_month_start.ToString('dd_MM_yyyy')}_al_{dt_yesterday.ToString('dd_MM_yyyy')}.pdf" + if ejecutar_reporte(rpt_file, pdf_mtd, {"FecIni": dt_month_start, "FecFin": dt_yesterday}): + adjuntos.append(str(pdf_mtd)) + + # 5. Envío de Correo Electrónico + if adjuntos: + subject = f"Informe de Venta de Presupuestos por Línea - {str_ayer}" + body = f""" + + +

Informe Automático de Ventas por Línea

+

Buenos días

+

Me complace compartir con ustedes el reporte de avance de ventas por línea correspondiente al {str_ayer}.

+

La información se encuentra disponible en el siguiente dashboard:

+

+ + Ver reporte Power BI + +

+
+

Quedamos atentos a cualquier consulta o comentario.

+
+

Saludos cordiales,

+

Equipo de Inteligencia de Negocios

+

Este es un mensaje generado automáticamente por el sistema de reportes.

+ + + """ + success = send_email( + access_token=access_token, + from_email=CONFIG.outlook.from_email, + to_recipients=CONFIG.outlook.to_email, + subject=subject, + html_body=body, + attachment_paths=adjuntos + ) + if success: + print("PROCESO FINALIZADO: Correo enviado correctamente.") + else: + print("ERROR: No se generaron archivos PDF. No hay nada que enviar.") + +######################################################## +if __name__ == "__main__": + try: + # 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) + except Exception as e: + print(f"FATAL MAIN ERROR: {e}") diff --git a/outlook.py b/outlook.py new file mode 100644 index 0000000..82b7981 --- /dev/null +++ b/outlook.py @@ -0,0 +1,179 @@ +import requests +import msal +import os +import base64 +import warnings +from datetime import datetime, timedelta, timezone +from config_loader import CONFIG + +warnings.filterwarnings("ignore") +######################################################## +def get_access_token(): + """Obtiene el token de acceso usando MSAL (Client Credentials Flow).""" + outlook_config = CONFIG.outlook + client_id = outlook_config.client_id + client_secret = outlook_config.client_secret + tenant_id = outlook_config.tenant_id + authority = f"https://login.microsoftonline.com/{tenant_id}" + + # Manejo de SSL + verify_ssl = outlook_config.verify_ssl + ca_bundle = outlook_config.ca_bundle + + # MSAL usa requests internamente, podemos pasar proxies/verify + # a través de la configuración si fuera necesario, pero msal tiene su propia forma + # de configurar el http_client. Por simplicidad, si verify_ssl es False, + # podemos intentar configurar el ambiente o pasar un cliente custom. + + app = msal.ConfidentialClientApplication( + client_id, + authority=authority, + client_credential=client_secret, + verify=verify_ssl if ca_bundle is None else ca_bundle + ) + + # El scope para Graph API en modo aplicación suele ser 'https://graph.microsoft.com/.default' + scopes = ["https://graph.microsoft.com/.default"] + + try: + result = app.acquire_token_silent(scopes, account=None) + if not result: + result = app.acquire_token_for_client(scopes=scopes) + + if "access_token" in result: + return result["access_token"] + else: + raise Exception(f"Error obteniendo token: {result.get('error_description')}") + except Exception as e: + print(f"Error obteniendo token: {e}") + return None + +######################################################## +def get_messages(access_token, user_email, date_filter=None, end_date_filter=None): + """Obtiene todos los mensajes de un correo específico, manejando la paginación de Graph API.""" + url = f"https://graph.microsoft.com/v1.0/users/{user_email}/messages" + headers = { + 'Authorization': f'Bearer {access_token}', + 'Content-Type': 'application/json' + } + + outlook_config = CONFIG.outlook + verify = outlook_config.verify_ssl if outlook_config.ca_bundle is None else outlook_config.ca_bundle + + # Solo los mensajes que contengan adjuntos + filter_query = 'hasAttachments eq true' + + if date_filter: + filter_query += f" and receivedDateTime ge {date_filter}" + + if end_date_filter: + filter_query += f" and receivedDateTime le {end_date_filter}" + + params = { + '$filter': filter_query, + '$select': 'id,subject,receivedDateTime,hasAttachments,from', + '$top': 50 # Podemos pedir hasta 999, pero 50 es un buen default para probar paginación + } + + all_messages = [] + next_link = url + + while next_link: + # En la primera iteración usamos url + params. + # En las siguientes, next_link ya trae los filtros y el token de skip en la URL. + if next_link == url: + response = requests.get(next_link, headers=headers, params=params, verify=verify) + else: + response = requests.get(next_link, headers=headers, verify=verify) + + if response.status_code == 200: + data = response.json() + messages = data.get('value', []) + all_messages.extend(messages) + next_link = data.get('@odata.nextLink') + else: + print(f"Error obteniendo mensajes para {user_email}: {response.text}") + break + + return all_messages + +######################################################## +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 + +######################################################## diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9646de8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,15 @@ +# Requerimientos para la ejecución de la aplicación + +# --- DEPENDENCIAS DE PYTHON (INSTALABLES VIA PIP) --- +pythonnet>=3.0.0 +requests>=2.28.0 +msal>=1.20.0 + +# --- DEPENDENCIAS DE SISTEMA OPERATIVO WINDOWS (NO INSTALABLES POR PIP) --- +# IMPORTANTE: Los siguientes componentes deben instalarse a nivel sistema. +# +# 1. SAP Crystal Reports, developer version for Microsoft Visual Studio o SAP Crystal Reports Runtime engine for .NET Framework +# (Instalar la versión de 32 o 64 bits de acuerdo a la versión de Python que estés ejecutando). +# +# 2. SAP HANA Client (Driver ODBC) +# (Requerido para la conexión a la base de datos). diff --git a/test_load.py b/test_load.py new file mode 100644 index 0000000..90ce35e --- /dev/null +++ b/test_load.py @@ -0,0 +1,32 @@ +import clr +import sys +from pathlib import Path + +# Try to find the DLL +gac_path = r"C:\Windows\Microsoft.NET\assembly\GAC_MSIL\CrystalDecisions.CrystalReports.Engine\v4.0_13.0.4000.0__692fbea5521e1304\CrystalDecisions.CrystalReports.Engine.dll" +print(f"Checking path: {gac_path}") +print(f"Exists: {Path(gac_path).exists()}") + +try: + print("Attempting to add reference...") + # Try adding by full path first + clr.AddReference(gac_path) + print("Reference added successfully by path.") +except Exception as e: + print(f"Failed to add reference by path: {e}") + try: + print("Attempting to add reference by name...") + clr.AddReference("CrystalDecisions.CrystalReports.Engine") + print("Reference added successfully by name.") + except Exception as e2: + print(f"Failed to add reference by name: {e2}") + +try: + print("Attempting import...") + # pyrefly: ignore [missing-import] + from CrystalDecisions.CrystalReports.Engine import ReportDocument + print("Import successful!") +except Exception as e: + print(f"Import failed: {e}") + import traceback + traceback.print_exc() diff --git a/venta_presupuesto_linea.rpt b/venta_presupuesto_linea.rpt new file mode 100644 index 0000000..245856e Binary files /dev/null and b/venta_presupuesto_linea.rpt differ