primer commit

This commit is contained in:
GEORGE committed 2026-05-18 09:28:55 -05:00
commit 83f704c85d
12 files changed
+717

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
__pycache__/
*.pyc
.env
venv/
.DS_Store
.vscode/
.ide/
+35
View File
@@ -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"
}
}
+31
View File
@@ -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"
}
}
+16
View File
@@ -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()
+82
View File
@@ -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)
+36
View File
@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Previsualización del Correo</title>
</head>
<body style="font-family: Arial, sans-serif; padding: 20px;">
<div style="max-width: 600px; margin: auto; border: 1px solid #ddd; padding: 20px; border-radius: 8px;">
<p style="color: gray; font-size: 0.8em; text-align: center; margin-top: 0;">--- PREVISUALIZACIÓN DEL CORREO ---
</p>
<hr style="border: 0; border-top: 1px solid #eee; margin-bottom: 20px;">
<h2 style="color: #2e6c80;">Informe Automático de Ventas</h2>
<p>Buenos días</p>
<p>Me complace compartir con ustedes el reporte de avance de ventas correspondiente al
<strong>13/05/2026</strong>.</p>
<p>La información se encuentra disponible en el siguiente dashboard:</p>
<p>
<a href="https://app.powerbi.com/view?r=eyJrIjoiNWQwY2E4Y2MtNmUyYy00Njc4LWI2YjUtOWQ3Zjc5ZjllNTM0IiwidCI6Ijc2Zjc3YjJmLTZmYzMtNDJhMC1hZTgzLWRmMDU1MDg0MDVkNiIsImMiOjR9"
target="_blank" style="color: #0056b3; text-decoration: none; font-weight: bold;">
Ver reporte Power BI
</a>
</p>
<div style="height: 20px;"></div>
<p>Quedamos atentos a cualquier consulta o comentario.</p>
<br>
<p style="font-weight: bold; margin-bottom: 0;">Saludos cordiales,</p>
<p style="font-weight: bold; margin-top: 5px;">Equipo de Inteligencia de Negocios</p>
<p style="font-size: 0.9em; color: #666; margin-top: 30px;">Este es un mensaje generado automáticamente por el
sistema de reportes.</p>
</div>
</body>
</html>
+70
View File
@@ -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).")
+214
View File
@@ -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"""
<html>
<body style="font-family: Arial, sans-serif;">
<h2 style="color: #2e6c80;">Informe Automático de Ventas por Línea</h2>
<p>Buenos días</p>
<p>Me complace compartir con ustedes el reporte de avance de ventas por línea correspondiente al {str_ayer}.</p>
<p>La información se encuentra disponible en el siguiente dashboard:</p>
<p>
<a href="{CONFIG.powerbi.url}" target="_blank">
Ver reporte Power BI
</a>
</p>
<div style="height: 20px;"></div>
<p>Quedamos atentos a cualquier consulta o comentario.</p>
<br>
<p style="font-weight: bold;">Saludos cordiales,</p>
<p style="font-weight: bold;">Equipo de Inteligencia de Negocios</p>
<p style="font-size: 0.9em; color: #666;">Este es un mensaje generado automáticamente por el sistema de reportes.</p>
</body>
</html>
"""
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}")
+179
View File
@@ -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
########################################################
+15
View File
@@ -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).
+32
View File
@@ -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()
Binary file not shown.