Compare commits
	
		
			19 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 363f71282f | |||
| fb872f0889 | |||
| fbe07b7ea2 | |||
| 2a59edf050 | |||
| 7c5d66665e | |||
| 697f093ef1 | |||
| 5ebf4a4320 | |||
| 67a2f3f449 | |||
| efd0dfb574 | |||
| c10924bc35 | |||
| 24a6fc3849 | |||
| 134c9399e9 | |||
| 9dbf8dbe7e | |||
| c778816efd | |||
| 681809387e | |||
| aadd0b218b | |||
| f087799191 | |||
| e9540fa155 | |||
| 23f0d02fe3 | 
							
								
								
									
										34
									
								
								.env
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								.env
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,34 @@ | |||||||
|  | # ======================================================= | ||||||
|  | # == Fichero de Entorno para la Aplicación de Mercados == | ||||||
|  | # ======================================================= | ||||||
|  | # Este fichero debe estar en /opt/mercados-app/.env en el servidor. | ||||||
|  | # Contiene todos los secretos y configuraciones específicas del entorno. | ||||||
|  |  | ||||||
|  | # --- Conexión a la Base de Datos --- | ||||||
|  | # Cadena de conexión para SQL Server. | ||||||
|  | # IMPORTANTE: El 'Server' debe ser el nombre del servicio de la base de datos en la red Docker compartida. | ||||||
|  | # Según tu arquitectura, es 'db-sqlserver'. | ||||||
|  | ConnectionStrings__DefaultConnection="Server=db-sqlserver;Database=MercadosDb;User ID=mercadosuser;Password=@mercados1351@;TrustServerCertificate=True;Encrypt=False;" | ||||||
|  |  | ||||||
|  | # --- Horarios de Ejecución del Worker (formato Cron) --- | ||||||
|  | Schedules__MercadoAgroganadero="0 10,12,15,18 * * 1-5" | ||||||
|  | Schedules__BCR="30 11 * * 1-5" | ||||||
|  | Schedules__Bolsas="10 11-17 * * 1-5" | ||||||
|  |  | ||||||
|  | # --- Claves de APIs Externas --- | ||||||
|  | ApiKeys__Finnhub="d1jsl99r01ql1h397eh0d1jsl99r01ql1h397ehg" | ||||||
|  | ApiKeys__Bcr__Key="D1782A51-A5FD-EF11-9445-00155D09E201" | ||||||
|  | ApiKeys__Bcr__Secret="da96378186bc5a256fa821fbe79261ec7172dec283214da0aacca41c640f80e3" | ||||||
|  |  | ||||||
|  | # --- Configuración de Email para Alertas (SMTP) --- | ||||||
|  | SmtpSettings__Host="192.168.5.201" | ||||||
|  | SmtpSettings__Port="587" | ||||||
|  | SmtpSettings__User="alertas@eldia.com" | ||||||
|  | SmtpSettings__Pass="@Alertas713550@" | ||||||
|  | SmtpSettings__SenderName="Servicio de Mercados" | ||||||
|  | SmtpSettings__Recipient="tecnica@eldia.com" | ||||||
|  |  | ||||||
|  | # --- Configuración del Entorno de ASP.NET Core --- | ||||||
|  | # Esto asegura que la aplicación se ejecute en modo Producción, | ||||||
|  | # desactivando páginas de error detalladas, etc. | ||||||
|  | ASPNETCORE_ENVIRONMENT="Production" | ||||||
							
								
								
									
										8
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -180,8 +180,6 @@ DocProject/Help/Html2 | |||||||
| DocProject/Help/html | DocProject/Help/html | ||||||
| # DocFx | # DocFx | ||||||
| [Dd]ocs/ | [Dd]ocs/ | ||||||
| docfx.build.json |  | ||||||
| docfx.metadata.json |  | ||||||
|  |  | ||||||
| # Click-Once directory | # Click-Once directory | ||||||
| publish/ | publish/ | ||||||
| @@ -416,4 +414,8 @@ FodyWeavers.xsd | |||||||
| .history/ | .history/ | ||||||
|  |  | ||||||
| # Built Visual Studio Code Extensions | # Built Visual Studio Code Extensions | ||||||
| *.vsix | *.vsix | ||||||
|  |  | ||||||
|  | .env | ||||||
|  | .env | ||||||
|  | .env | ||||||
|   | |||||||
							
								
								
									
										150
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										150
									
								
								README.md
									
									
									
									
									
								
							| @@ -1,3 +1,149 @@ | |||||||
| # Mercados-Web | --- | ||||||
|  |  | ||||||
| API y Worker para la recolección y exposición de datos de mercados financieros. | # Proyecto de Widgets de Mercados Financieros | ||||||
|  |  | ||||||
|  | Este repositorio contiene la solución completa para la recolección, almacenamiento y visualización de datos de mercados financieros. La arquitectura está diseñada para ser robusta, escalable y fácil de mantener, separando las responsabilidades en un backend de .NET, un worker de fondo, y un frontend de React basado en widgets. | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## 🚀 Arquitectura General | ||||||
|  |  | ||||||
|  | El sistema se compone de los siguientes elementos principales: | ||||||
|  |  | ||||||
|  | 1.  **Backend (`Mercados.Api`):** Una API web de .NET 9 que expone los datos almacenados en la base de datos a través de endpoints RESTful. | ||||||
|  | 2.  **Worker Service (`Mercados.Worker`):** Un servicio de fondo de .NET 9 responsable de obtener los datos de fuentes externas (`Finnhub`, `Bolsa de Comercio de Rosario`, `Yahoo Finance` y scraping) de forma programada, procesarlos y guardarlos en la base de datos. | ||||||
|  | 3.  **Frontend (`frontend/`):** Una aplicación de React (construida con Vite + TypeScript) que proporciona un conjunto de "widgets" modulares y portátiles para visualizar los datos. | ||||||
|  | 4.  **Base de Datos (`Mercados.Database`):** Utiliza SQL Server para la persistencia de los datos. El esquema es gestionado a través de migraciones con **FluentMigrator**. | ||||||
|  | 5.  **Documentación(En Proceso) (`docs/`):** Un portal de documentación unificado generado con **DocFX**, **TypeDoc** y **Storybook**. | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## 🛠️ Configuración del Entorno de Desarrollo | ||||||
|  |  | ||||||
|  | Para ejecutar este proyecto en un entorno local, necesitarás tener instalado: | ||||||
|  |  | ||||||
|  | -   **.NET 9 SDK** | ||||||
|  | -   **Node.js** (v20 o superior) y npm | ||||||
|  | -   **SQL Server** (Developer o Express Edition) | ||||||
|  | -   **Docker y Docker Compose** (Opcional, para simular el entorno de producción) | ||||||
|  | -   **DocFX Command Line Tool** (Instalar con: `dotnet tool install -g docfx`) | ||||||
|  |  | ||||||
|  | ### Pasos de Configuración Inicial: | ||||||
|  |  | ||||||
|  | 1.  **Clonar el Repositorio:** | ||||||
|  |     ```bash | ||||||
|  |     git clone [URL_DEL_REPOSITORIO_GITEA] | ||||||
|  |     cd [NOMBRE_DE_LA_CARPETA_DONDE_SE_CLONÓ] | ||||||
|  |     ``` | ||||||
|  |  | ||||||
|  | 2.  **Configurar Secretos del Backend:** | ||||||
|  |     Este proyecto utiliza "User Secrets" para manejar información sensible en desarrollo. Configúralos desde la raíz del repositorio: | ||||||
|  |  | ||||||
|  |     ```bash | ||||||
|  |     # Habilitar secretos para la API y el Worker | ||||||
|  |     dotnet user-secrets init --project src/Mercados.Api | ||||||
|  |     dotnet user-secrets init --project src/Mercados.Worker | ||||||
|  |  | ||||||
|  |     # Establecer los valores (reemplaza con tus datos) | ||||||
|  |     dotnet user-secrets set "ConnectionStrings:DefaultConnection" "Server=...;Database=..." --project src/Mercados.Api | ||||||
|  |     dotnet user-secrets set "ApiKeys:Finnhub" "tu_api_key" --project src/Mercados.Api | ||||||
|  |     # ...añadir el resto de las claves para ApiKeys y SmtpSettings | ||||||
|  |     # Repite el proceso para el Worker | ||||||
|  |     dotnet user-secrets set "ConnectionStrings:DefaultConnection" "..." --project src/Mercados.Worker | ||||||
|  |     # ... | ||||||
|  |     ``` | ||||||
|  |  | ||||||
|  | 3.  **Instalar Dependencias del Frontend:** | ||||||
|  |     ```bash | ||||||
|  |     cd frontend | ||||||
|  |     npm install | ||||||
|  |     cd .. | ||||||
|  |     ``` | ||||||
|  |  | ||||||
|  | 4.  **Crear y Migrar la Base de Datos:** | ||||||
|  |     Simplemente ejecuta la API una vez. La primera vez que se inicie, FluentMigrator creará la base de datos `MercadosDb` y aplicará todas las migraciones necesarias. | ||||||
|  |     ```bash | ||||||
|  |     dotnet run --project src/Mercados.Api | ||||||
|  |     ``` | ||||||
|  |     Puedes detenerla con `Ctrl+C` una vez que veas el mensaje "Application started". | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## 🏃 Cómo Ejecutar el Proyecto | ||||||
|  |  | ||||||
|  | Para trabajar en el proyecto, necesitarás ejecutar los 3 servicios principales en terminales separadas. | ||||||
|  |  | ||||||
|  | 1.  **Iniciar el Backend API:** | ||||||
|  |     ```bash | ||||||
|  |     dotnet run --project src/Mercados.Api | ||||||
|  |     ``` | ||||||
|  |     *La API estará disponible (por defecto) en `http://localhost:5045`.* | ||||||
|  |  | ||||||
|  | 2.  **Iniciar el Worker Service:** | ||||||
|  |     ```bash | ||||||
|  |     dotnet run --project src/Mercados.Worker | ||||||
|  |     ``` | ||||||
|  |     *El worker comenzará a obtener datos según los horarios definidos en `appsettings.json`.* | ||||||
|  |  | ||||||
|  | 3.  **Iniciar el Frontend (Servidor de Desarrollo):** | ||||||
|  |     ```bash | ||||||
|  |     cd frontend | ||||||
|  |     npm run dev | ||||||
|  |     ``` | ||||||
|  |     *La aplicación de React estará disponible en `http://localhost:5173`.* | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## 📚 Documentación | ||||||
|  |  | ||||||
|  | Este proyecto incluye un portal de documentación unificado que cubre tanto el backend como el frontend. | ||||||
|  |  | ||||||
|  | ### Para Generar la Documentación: | ||||||
|  |  | ||||||
|  | 1.  **Compilar el Backend:** | ||||||
|  |     ```bash | ||||||
|  |     # Desde la raíz | ||||||
|  |     dotnet build | ||||||
|  |     ``` | ||||||
|  | 2.  **Generar la documentación TypeScript:** | ||||||
|  |     ```bash | ||||||
|  |     # Desde la carpeta /frontend | ||||||
|  |     npm run doc:ts | ||||||
|  |     ``` | ||||||
|  | 3.  **Construir el sitio DocFX:** | ||||||
|  |     ```bash | ||||||
|  |     # Desde la raíz, usando el archivo de configuración en /docs | ||||||
|  |     docfx docs/docfx.json | ||||||
|  |     ``` | ||||||
|  | 4.  **Visualizar la documentación:** | ||||||
|  |     ```bash | ||||||
|  |     # Desde la raíz | ||||||
|  |     docfx serve docs/_site | ||||||
|  |     ``` | ||||||
|  |     *Abre tu navegador en `http://localhost:8080`.* | ||||||
|  |  | ||||||
|  | ### Para ver la Librería de Componentes Visuales (Storybook): | ||||||
|  |  | ||||||
|  | ```bash | ||||||
|  | # Desde la carpeta /frontend | ||||||
|  | npm run storybook | ||||||
|  | ``` | ||||||
|  | *Abre tu navegador en `http://localhost:6006`.* | ||||||
|  |  | ||||||
|  | --- | ||||||
|  |  | ||||||
|  | ## 🐳 Despliegue en Producción (Docker) | ||||||
|  |  | ||||||
|  | El despliegue está diseñado para ser realizado con Docker y Docker Compose. | ||||||
|  |  | ||||||
|  | 1.  **Construir las imágenes de Docker:** | ||||||
|  |     ```bash | ||||||
|  |     # Desde la raíz del proyecto | ||||||
|  |     docker compose build | ||||||
|  |     ``` | ||||||
|  | 2.  **Lanzar el stack de servicios:** | ||||||
|  |     Asegúrate de tener el archivo `.env` configurado en el servidor de producción antes de ejecutar. | ||||||
|  |     ```bash | ||||||
|  |     docker compose up -d | ||||||
|  |     ``` | ||||||
|  | El `docker-compose.yml` está configurado para conectarse a una red externa `shared-net` para la base de datos y para ser gestionado por un proxy inverso externo como Nginx Proxy Manager. | ||||||
							
								
								
									
										
											BIN
										
									
								
								Widgets-ELDIA-1.1.pdf
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								Widgets-ELDIA-1.1.pdf
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										22
									
								
								docfx.build.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								docfx.build.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,22 @@ | |||||||
|  | { | ||||||
|  |   "build": { | ||||||
|  |     "content": [ | ||||||
|  |       { | ||||||
|  |         "files": [ | ||||||
|  |           "docs/api/**.yml", | ||||||
|  |           "docs/toc.yml", | ||||||
|  |           "docs/index.md" | ||||||
|  |         ] | ||||||
|  |       } | ||||||
|  |     ], | ||||||
|  |     "resource": [], | ||||||
|  |     "output": "docs/_site", | ||||||
|  |     "template": [ | ||||||
|  |       "default", | ||||||
|  |       "modern" | ||||||
|  |     ], | ||||||
|  |     "globalMetadata": { | ||||||
|  |       "_appTitle": "Documentación Proyecto Mercados" | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										19
									
								
								docfx.metadata.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								docfx.metadata.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | |||||||
|  | { | ||||||
|  |   "metadata": [ | ||||||
|  |     { | ||||||
|  |       "src": [ | ||||||
|  |         { | ||||||
|  |           "files": [ | ||||||
|  |             "src/Mercados.Api/Mercados.Api.csproj", | ||||||
|  |             "src/Mercados.Core/Mercados.Core.csproj", | ||||||
|  |             "src/Mercados.Database/Mercados.Database.csproj", | ||||||
|  |             "src/Mercados.Infrastructure/Mercados.Infrastructure.csproj", | ||||||
|  |             "src/Mercados.Worker/Mercados.Worker.csproj" | ||||||
|  |           ], | ||||||
|  |           "src": "."  | ||||||
|  |         } | ||||||
|  |       ], | ||||||
|  |       "dest": "docs/api" | ||||||
|  |     } | ||||||
|  |   ] | ||||||
|  | } | ||||||
| @@ -2,7 +2,7 @@ services: | |||||||
|   # Servicio del Backend API |   # Servicio del Backend API | ||||||
|   mercados-api: |   mercados-api: | ||||||
|     build: |     build: | ||||||
|       context: ./Mercados-Web # Asumiendo que clonaste el repo en esta carpeta |       context: ./Mercados-Web | ||||||
|       dockerfile: src/Mercados.Api/Dockerfile |       dockerfile: src/Mercados.Api/Dockerfile | ||||||
|     container_name: mercados-api |     container_name: mercados-api | ||||||
|     restart: always |     restart: always | ||||||
| @@ -60,4 +60,4 @@ networks: | |||||||
|     driver: bridge |     driver: bridge | ||||||
|  |  | ||||||
|   shared-net: |   shared-net: | ||||||
|     external: true |     external: true | ||||||
|   | |||||||
| @@ -1,23 +1,31 @@ | |||||||
| server { | server { | ||||||
|     listen 80; |     listen 80; | ||||||
|     server_name localhost; |     server_name localhost; | ||||||
|  |  | ||||||
|     # Directorio raíz donde están los archivos de la app |  | ||||||
|     root /usr/share/nginx/html; |     root /usr/share/nginx/html; | ||||||
|     index index.html; |     index index.html; | ||||||
|  |  | ||||||
|     # Configuración para servir los archivos estáticos y manejar el enrutamiento de la SPA |     # --- BLOQUE PARA BOOTSTRAP.JS (MEJORADO) --- | ||||||
|     location / { |     # Se aplica EXCLUSIVAMENTE a la petición de /bootstrap.js | ||||||
|         # Intenta servir el archivo solicitado directamente ($uri), |     location = /bootstrap.js { | ||||||
|         # luego como un directorio ($uri/), |         # Aseguramos que Nginx genere la huella digital ETag | ||||||
|         # y si no encuentra nada, devuelve el index.html |         etag on; | ||||||
|         # Esto es crucial para que el enrutamiento de React funcione. |  | ||||||
|         try_files $uri $uri/ /index.html; |         # Instrucciones explícitas de no cachear | ||||||
|  |         expires -1; | ||||||
|  |         add_header Cache-Control "no-cache, must-revalidate, private"; | ||||||
|  |          | ||||||
|  |         try_files $uri =404; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     # Opcional: optimizaciones para archivos estáticos |     # Bloque para otros activos con hash (con caché agresiva) | ||||||
|     location ~* \.(?:css|js|jpg|jpeg|gif|png|ico|svg|woff|woff2)$ { |     location ~* \.(?:js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { | ||||||
|         expires 1y; |         expires 1y; | ||||||
|         add_header Cache-Control "public"; |         add_header Cache-Control "public"; | ||||||
|  |         try_files $uri =404; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     # Bloque para el enrutamiento de la aplicación React | ||||||
|  |     location / { | ||||||
|  |         try_files $uri $uri/ /index.html; | ||||||
|     } |     } | ||||||
| } | } | ||||||
							
								
								
									
										37
									
								
								frontend/public/bootstrap.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										37
									
								
								frontend/public/bootstrap.js
									
									
									
									
										vendored
									
									
								
							| @@ -1,5 +1,4 @@ | |||||||
| // frontend/public/bootstrap.js | // frontend/public/bootstrap.js | ||||||
|  |  | ||||||
| (function() { | (function() { | ||||||
|   // El dominio donde se alojan los widgets |   // El dominio donde se alojan los widgets | ||||||
|   const WIDGETS_HOST = 'https://widgets.eldia.com'; |   const WIDGETS_HOST = 'https://widgets.eldia.com'; | ||||||
| @@ -27,17 +26,17 @@ | |||||||
|   // Función principal |   // Función principal | ||||||
|   async function initWidgets() { |   async function initWidgets() { | ||||||
|     try { |     try { | ||||||
|       // 1. Obtener el manifest.json para saber los nombres de archivo actuales |       // 1. Obtener el manifest.json | ||||||
|       const response = await fetch(`${WIDGETS_HOST}/manifest.json`); |       const response = await fetch(`${WIDGETS_HOST}/manifest.json`); | ||||||
|       if (!response.ok) { |       if (!response.ok) { | ||||||
|         throw new Error('No se pudo cargar el manifest de los widgets.'); |         throw new Error('No se pudo cargar el manifest de los widgets de mercados.'); | ||||||
|       } |       } | ||||||
|       const manifest = await response.json(); |       const manifest = await response.json(); | ||||||
|  |  | ||||||
|       // 2. Encontrar el punto de entrada principal (nuestro main.tsx) |       // 2. Encontrar el punto de entrada principal | ||||||
|       const entryKey = Object.keys(manifest).find(key => manifest[key].isEntry); |       const entryKey = Object.keys(manifest).find(key => manifest[key].isEntry); | ||||||
|       if (!entryKey) { |       if (!entryKey) { | ||||||
|         throw new Error('No se encontró el punto de entrada en el manifest.'); |         throw new Error('No se encontró el punto de entrada en el manifest de mercados.'); | ||||||
|       } |       } | ||||||
|        |        | ||||||
|       const entry = manifest[entryKey]; |       const entry = manifest[entryKey]; | ||||||
| @@ -51,12 +50,26 @@ | |||||||
|         }); |         }); | ||||||
|       } |       } | ||||||
|  |  | ||||||
|       // 4. Cargar el JS principal y esperar a que esté listo |       // 4. Cargar el JS principal | ||||||
|       await loadScript(jsUrl); |       await loadScript(jsUrl); | ||||||
|  |  | ||||||
|       // 5. Una vez cargado, llamar a la función de renderizado |       // 5. Una vez cargado, llamar a la función de renderizado para CADA WIDGET | ||||||
|       if (window.MercadosWidgets && typeof window.MercadosWidgets.render === 'function') { |       if (window.MercadosWidgets && typeof window.MercadosWidgets.render === 'function') { | ||||||
|         window.MercadosWidgets.render(); |         console.log('Bootstrap Mercados: La función render existe. Renderizando widgets...'); | ||||||
|  |          | ||||||
|  |         // Buscamos los contenedores específicos para los widgets de mercados | ||||||
|  |         const widgetContainers = document.querySelectorAll('[data-mercado-widget]'); | ||||||
|  |  | ||||||
|  |         if (widgetContainers.length === 0) { | ||||||
|  |             console.warn('Bootstrap Mercados: No se encontraron contenedores de widget en la página.'); | ||||||
|  |         } | ||||||
|  |          | ||||||
|  |         widgetContainers.forEach(container => { | ||||||
|  |           // Pasamos el contenedor y su dataset (props) a la función de renderizado | ||||||
|  |           window.MercadosWidgets.render(container, container.dataset); | ||||||
|  |         }); | ||||||
|  |       } else { | ||||||
|  |         console.error('Bootstrap Mercados: La función window.MercadosWidgets.render no fue encontrada.'); | ||||||
|       } |       } | ||||||
|  |  | ||||||
|     } catch (error) { |     } catch (error) { | ||||||
| @@ -64,7 +77,11 @@ | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   // Iniciar todo el proceso |   // Esperamos a que el DOM esté listo para ejecutar | ||||||
|   initWidgets(); |   if (document.readyState === 'loading') { | ||||||
|  |     document.addEventListener('DOMContentLoaded', initWidgets); | ||||||
|  |   } else { | ||||||
|  |     initWidgets(); | ||||||
|  |   } | ||||||
|  |  | ||||||
| })(); | })(); | ||||||
| @@ -27,7 +27,7 @@ const Variacion = ({ value }: { value: number }) => { | |||||||
|   return ( |   return ( | ||||||
|     <Box component="span" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color }}> |     <Box component="span" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color }}> | ||||||
|       <Icon sx={{ fontSize: '1rem', mr: 0.5 }} /> |       <Icon sx={{ fontSize: '1rem', mr: 0.5 }} /> | ||||||
|       <Typography variant="body2" component="span" sx={{ fontWeight: 'bold' }}>{value.toFixed(2)}%</Typography> |       <Typography variant="body2" component="span" sx={{ fontWeight: 'bold' }}>{formatCurrency(value)}%</Typography> | ||||||
|     </Box> |     </Box> | ||||||
|   ); |   ); | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -27,7 +27,7 @@ const Variacion = ({ value }: { value: number }) => { | |||||||
|   return ( |   return ( | ||||||
|     <Box component="span" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color }}> |     <Box component="span" sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', color }}> | ||||||
|       <Icon sx={{ fontSize: '1rem', mr: 0.5 }} /> |       <Icon sx={{ fontSize: '1rem', mr: 0.5 }} /> | ||||||
|       <Typography variant="body2" component="span" sx={{ fontWeight: 'bold' }}>{value.toFixed(2)}%</Typography> |       <Typography variant="body2" component="span" sx={{ fontWeight: 'bold' }}>{formatCurrency(value)}%</Typography> | ||||||
|     </Box> |     </Box> | ||||||
|   ); |   ); | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -1,33 +1,29 @@ | |||||||
| import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button } from '@mui/material'; | import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button, Typography } from '@mui/material'; | ||||||
| import ContentCopyIcon from '@mui/icons-material/ContentCopy'; | import ContentCopyIcon from '@mui/icons-material/ContentCopy'; | ||||||
|  |  | ||||||
| // Importaciones de nuestro proyecto | // Importaciones de nuestro proyecto | ||||||
| import { useApiData } from '../../hooks/useApiData'; | import { useApiData } from '../../hooks/useApiData'; | ||||||
| import { useIsHoliday } from '../../hooks/useIsHoliday'; | import { useIsHoliday } from '../../hooks/useIsHoliday'; | ||||||
| import type { CotizacionGanado } from '../../models/mercadoModels'; | import type { CotizacionGanado } from '../../models/mercadoModels'; | ||||||
| import { formatInteger, formatCurrency, formatFullDateTime } from '../../utils/formatters'; | import { formatInteger, formatFullDateTime } from '../../utils/formatters'; | ||||||
| import { copyToClipboard } from '../../utils/clipboardUtils'; | import { copyToClipboard } from '../../utils/clipboardUtils'; | ||||||
| import { HolidayAlert } from '../common/HolidayAlert'; | import { HolidayAlert } from '../common/HolidayAlert'; | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Función para convertir los datos de la tabla a un formato CSV para el portapapeles. |  * Función para convertir los datos a formato TSV (Tab-Separated Values) | ||||||
|  |  * con el formato específico solicitado por redacción. | ||||||
|  */ |  */ | ||||||
| const toCSV = (headers: string[], data: CotizacionGanado[]) => { | const toTSV = (data: CotizacionGanado[]) => { | ||||||
|     const headerRow = headers.join(';'); |     const dataRows = data.map(row => { | ||||||
|     const dataRows = data.map(row =>  |         // Unimos Categoría y Especificaciones en una sola columna para el copiado | ||||||
|         [ |         const categoriaCompleta = `${row.categoria}/${row.especificaciones}`; | ||||||
|             row.categoria, |         const cabezas = formatInteger(row.cabezas); | ||||||
|             row.especificaciones, |         const importeTotal = formatInteger(row.importeTotal); | ||||||
|             formatCurrency(row.maximo), |  | ||||||
|             formatCurrency(row.minimo), |         return [categoriaCompleta, cabezas, importeTotal].join('\t'); | ||||||
|             formatCurrency(row.mediano), |     }); | ||||||
|             formatInteger(row.cabezas), |  | ||||||
|             formatInteger(row.kilosTotales), |     return dataRows.join('\n'); | ||||||
|             formatInteger(row.importeTotal), |  | ||||||
|             formatFullDateTime(row.fechaRegistro) |  | ||||||
|         ].join(';') |  | ||||||
|     ); |  | ||||||
|     return [headerRow, ...dataRows].join('\n'); |  | ||||||
| }; | }; | ||||||
|  |  | ||||||
| /** | /** | ||||||
| @@ -35,24 +31,21 @@ const toCSV = (headers: string[], data: CotizacionGanado[]) => { | |||||||
|  * diseñado para la página de redacción. |  * diseñado para la página de redacción. | ||||||
|  */ |  */ | ||||||
| export const RawAgroTable = () => { | export const RawAgroTable = () => { | ||||||
|     // Hooks para obtener los datos y el estado de feriado. |  | ||||||
|     const { data, loading: dataLoading, error: dataError } = useApiData<CotizacionGanado[]>('/mercados/agroganadero'); |     const { data, loading: dataLoading, error: dataError } = useApiData<CotizacionGanado[]>('/mercados/agroganadero'); | ||||||
|     const isHoliday = useIsHoliday('BA'); |     const isHoliday = useIsHoliday('BA'); | ||||||
|  |  | ||||||
|     const handleCopy = () => { |     const handleCopy = () => { | ||||||
|         if (!data) return; |         if (!data) return; | ||||||
|         const headers = ["Categoría", "Especificaciones", "Máximo", "Mínimo", "Mediano", "Cabezas", "Kg Total", "Importe Total", "Fecha de Registro"]; |         const tsvData = toTSV(data); | ||||||
|         const csvData = toCSV(headers, data); |  | ||||||
|          |          | ||||||
|         copyToClipboard(csvData) |         copyToClipboard(tsvData) | ||||||
|             .then(() => alert('¡Tabla copiada al portapapeles!')) |             .then(() => alert('Datos del Mercado Agroganadero copiados al portapapeles!')) | ||||||
|             .catch(err => { |             .catch(err => { | ||||||
|                 console.error('Error al copiar:', err); |                 console.error('Error al copiar:', err); | ||||||
|                 alert('Error: No se pudo copiar la tabla.'); |                 alert('Error: No se pudo copiar la tabla.'); | ||||||
|             }); |             }); | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     // Estado de carga unificado. |  | ||||||
|     const isLoading = dataLoading || isHoliday === null; |     const isLoading = dataLoading || isHoliday === null; | ||||||
|  |  | ||||||
|     if (isLoading) return <CircularProgress />; |     if (isLoading) return <CircularProgress />; | ||||||
| @@ -67,43 +60,37 @@ export const RawAgroTable = () => { | |||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
|         <Box> |         <Box> | ||||||
|             {/* Si es feriado, mostramos una alerta informativa encima de la tabla. */} |  | ||||||
|             {isHoliday && ( |             {isHoliday && ( | ||||||
|                 <Box sx={{ mb: 2 }}> |                 <Box sx={{ mb: 2 }}> | ||||||
|                     <HolidayAlert /> |                     <HolidayAlert /> | ||||||
|                 </Box> |                 </Box> | ||||||
|             )} |             )} | ||||||
|  |  | ||||||
|             <Button startIcon={<ContentCopyIcon />} onClick={handleCopy} sx={{ mb: 1 }}> |             <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}> | ||||||
|                 Copiar como CSV |                 <Button startIcon={<ContentCopyIcon />} onClick={handleCopy}> | ||||||
|             </Button> |                     Copiar Datos para Redacción | ||||||
|  |                 </Button> | ||||||
|  |                 <Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}> | ||||||
|  |                     Última actualización: {formatFullDateTime(data[0].fechaRegistro)} | ||||||
|  |                 </Typography> | ||||||
|  |             </Box> | ||||||
|  |  | ||||||
|  |             {/* La tabla ahora muestra solo las columnas requeridas para facilitar la visualización */} | ||||||
|             <TableContainer component={Paper}> |             <TableContainer component={Paper}> | ||||||
|                 <Table size="small"> |                 <Table size="small"> | ||||||
|                     <TableHead> |                     <TableHead> | ||||||
|                         <TableRow> |                         <TableRow> | ||||||
|                             <TableCell>Categoría</TableCell> |                             <TableCell>Categoría / Especificaciones</TableCell> | ||||||
|                             <TableCell>Especificaciones</TableCell> |  | ||||||
|                             <TableCell align="right">Máximo</TableCell> |  | ||||||
|                             <TableCell align="right">Mínimo</TableCell> |  | ||||||
|                             <TableCell align="right">Mediano</TableCell> |  | ||||||
|                             <TableCell align="right">Cabezas</TableCell> |                             <TableCell align="right">Cabezas</TableCell> | ||||||
|                             <TableCell align="right">Kg Total</TableCell> |  | ||||||
|                             <TableCell align="right">Importe Total</TableCell> |                             <TableCell align="right">Importe Total</TableCell> | ||||||
|                             <TableCell>Última Act.</TableCell> |  | ||||||
|                         </TableRow> |                         </TableRow> | ||||||
|                     </TableHead> |                     </TableHead> | ||||||
|                     <TableBody> |                     <TableBody> | ||||||
|                         {data.map(row => ( |                         {data.map(row => ( | ||||||
|                             <TableRow key={row.id}> |                             <TableRow key={row.id}> | ||||||
|                                 <TableCell>{row.categoria}</TableCell> |                                 <TableCell>{row.categoria} / {row.especificaciones}</TableCell> | ||||||
|                                 <TableCell>{row.especificaciones}</TableCell> |  | ||||||
|                                 <TableCell align="right">${formatCurrency(row.maximo)}</TableCell> |  | ||||||
|                                 <TableCell align="right">${formatCurrency(row.minimo)}</TableCell> |  | ||||||
|                                 <TableCell align="right">${formatCurrency(row.mediano)}</TableCell> |  | ||||||
|                                 <TableCell align="right">{formatInteger(row.cabezas)}</TableCell> |                                 <TableCell align="right">{formatInteger(row.cabezas)}</TableCell> | ||||||
|                                 <TableCell align="right">{formatInteger(row.kilosTotales)}</TableCell> |  | ||||||
|                                 <TableCell align="right">${formatInteger(row.importeTotal)}</TableCell> |                                 <TableCell align="right">${formatInteger(row.importeTotal)}</TableCell> | ||||||
|                                 <TableCell>{formatFullDateTime(row.fechaRegistro)}</TableCell> |  | ||||||
|                             </TableRow> |                             </TableRow> | ||||||
|                         ))} |                         ))} | ||||||
|                     </TableBody> |                     </TableBody> | ||||||
|   | |||||||
| @@ -1,105 +1,124 @@ | |||||||
| import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button } from '@mui/material'; | import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button, Typography, Divider } from '@mui/material'; | ||||||
| import ContentCopyIcon from '@mui/icons-material/ContentCopy'; | import ContentCopyIcon from '@mui/icons-material/ContentCopy'; | ||||||
|  |  | ||||||
| // Importaciones de nuestro proyecto |  | ||||||
| import { useApiData } from '../../hooks/useApiData'; | import { useApiData } from '../../hooks/useApiData'; | ||||||
| import { useIsHoliday } from '../../hooks/useIsHoliday'; |  | ||||||
| import type { CotizacionBolsa } from '../../models/mercadoModels'; | import type { CotizacionBolsa } from '../../models/mercadoModels'; | ||||||
| import { formatCurrency, formatFullDateTime } from '../../utils/formatters'; | import { formatCurrency, formatFullDateTime } from '../../utils/formatters'; | ||||||
| import { copyToClipboard } from '../../utils/clipboardUtils'; | import { copyToClipboard } from '../../utils/clipboardUtils'; | ||||||
| import { HolidayAlert } from '../common/HolidayAlert'; | import { TICKERS_PRIORITARIOS_LOCAL } from '../../config/priorityTickers'; | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Función para convertir los datos de la tabla a formato CSV. |  * Función para convertir los datos prioritarios a formato TSV (Tab-Separated Values). | ||||||
|  */ |  */ | ||||||
| const toCSV = (headers: string[], data: CotizacionBolsa[]) => { | const toTSV = (data: CotizacionBolsa[]) => { | ||||||
|     const headerRow = headers.join(';'); |     const dataRows = data.map(row => { | ||||||
|     const dataRows = data.map(row =>  |         // Formateamos el nombre para que quede como "GGAL.BA (GRUPO FINANCIERO GALICIA)" | ||||||
|         [ |         const nombreCompleto = `${row.ticker} (${row.nombreEmpresa || ''})`; | ||||||
|             row.ticker, |         const precio = `$${formatCurrency(row.precioActual)}` | ||||||
|             row.nombreEmpresa, |         const cambio = `${formatCurrency(row.porcentajeCambio)}%` //`${row.porcentajeCambio.toFixed(2)}%` | ||||||
|             formatCurrency(row.precioActual), |  | ||||||
|             formatCurrency(row.cierreAnterior), |         // Unimos los campos con un carácter de tabulación '\t' | ||||||
|             `${row.porcentajeCambio.toFixed(2)}%`, |         return [nombreCompleto, precio, cambio].join('\t'); | ||||||
|             formatFullDateTime(row.fechaRegistro) |     }); | ||||||
|         ].join(';') |  | ||||||
|     ); |     // Unimos todas las filas con un salto de línea | ||||||
|     return [headerRow, ...dataRows].join('\n'); |     return dataRows.join('\n'); | ||||||
| }; | }; | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Componente de tabla de datos crudos para la Bolsa Local (MERVAL y acciones), |  * Componente de tabla de datos crudos para la Bolsa Local, adaptado para redacción. | ||||||
|  * diseñado para la página de redacción. |  | ||||||
|  */ |  */ | ||||||
| export const RawBolsaLocalTable = () => { | export const RawBolsaLocalTable = () => { | ||||||
|     // Hooks para obtener los datos y el estado de feriado. |     const { data, loading, error } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/local'); | ||||||
|     const { data, loading: dataLoading, error: dataError } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/local'); |  | ||||||
|     const isHoliday = useIsHoliday('BA'); |     // Separamos los datos en prioritarios y el resto | ||||||
|  |     const priorityData = data?.filter(d => TICKERS_PRIORITARIOS_LOCAL.includes(d.ticker)) | ||||||
|  |         .sort((a, b) => TICKERS_PRIORITARIOS_LOCAL.indexOf(a.ticker) - TICKERS_PRIORITARIOS_LOCAL.indexOf(b.ticker)); // Mantenemos el orden | ||||||
|  |  | ||||||
|  |     const otherData = data?.filter(d => !TICKERS_PRIORITARIOS_LOCAL.includes(d.ticker)); | ||||||
|  |  | ||||||
|     const handleCopy = () => { |     const handleCopy = () => { | ||||||
|         if (!data) return; |         if (!priorityData) return; | ||||||
|         const headers = ["Ticker", "Nombre", "Último Precio", "Cierre Anterior", "Variación %", "Fecha de Registro"]; |         const tsvData = toTSV(priorityData); | ||||||
|         const csvData = toCSV(headers, data); |  | ||||||
|          |         copyToClipboard(tsvData) | ||||||
|         copyToClipboard(csvData) |             .then(() => alert('Datos prioritarios copiados al portapapeles.')) | ||||||
|             .then(() => alert('¡Tabla copiada al portapapeles!')) |  | ||||||
|             .catch(err => { |             .catch(err => { | ||||||
|                 console.error('Error al copiar:', err); |                 console.error('Error al copiar:', err); | ||||||
|                 alert('Error: No se pudo copiar la tabla.'); |                 alert('Error: No se pudo copiar la tabla.'); | ||||||
|             }); |             }); | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     // Estado de carga unificado. |     if (loading) return <CircularProgress />; | ||||||
|     const isLoading = dataLoading || isHoliday === null; |     if (error) return <Alert severity="error">{error}</Alert>; | ||||||
|  |     if (!data || data.length === 0) return <Alert severity="info">No hay datos disponibles para el mercado local.</Alert>; | ||||||
|     if (isLoading) return <CircularProgress />; |  | ||||||
|     if (dataError) return <Alert severity="error">{dataError}</Alert>; |  | ||||||
|  |  | ||||||
|     if (!data || data.length === 0) { |  | ||||||
|         if (isHoliday) { |  | ||||||
|             return <HolidayAlert />; |  | ||||||
|         } |  | ||||||
|         return <Alert severity="info">No hay datos disponibles para el mercado local.</Alert>; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
|         <Box> |         <Box> | ||||||
|             {/* Si es feriado, mostramos una alerta informativa encima de la tabla. */} |             <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}> | ||||||
|             {isHoliday && ( |                 <Button startIcon={<ContentCopyIcon />} onClick={handleCopy}> | ||||||
|                 <Box sx={{ mb: 2 }}> |                     Copiar Datos Principales | ||||||
|                     <HolidayAlert /> |                 </Button> | ||||||
|                 </Box> |                 <Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}> | ||||||
|             )} |                     Última actualización: {formatFullDateTime(data[0].fechaRegistro)} | ||||||
|  |                 </Typography> | ||||||
|  |             </Box> | ||||||
|  |  | ||||||
|             <Button startIcon={<ContentCopyIcon />} onClick={handleCopy} sx={{ mb: 1 }}> |             {/* Tabla de Datos Prioritarios */} | ||||||
|                 Copiar como CSV |  | ||||||
|             </Button> |  | ||||||
|             <TableContainer component={Paper}> |             <TableContainer component={Paper}> | ||||||
|                 <Table size="small"> |                 <Table size="small"> | ||||||
|                     <TableHead> |                     <TableHead> | ||||||
|                         <TableRow> |                         <TableRow> | ||||||
|                             <TableCell>Ticker</TableCell> |                             <TableCell>Símbolo (Nombre)</TableCell> | ||||||
|                             <TableCell>Nombre</TableCell> |                             <TableCell align="right">Precio Actual</TableCell> | ||||||
|                             <TableCell align="right">Último Precio</TableCell> |                             <TableCell align="right">% Cambio</TableCell> | ||||||
|                             <TableCell align="right">Cierre Anterior</TableCell> |  | ||||||
|                             <TableCell align="right">Variación %</TableCell> |  | ||||||
|                             <TableCell>Última Act.</TableCell> |  | ||||||
|                         </TableRow> |                         </TableRow> | ||||||
|                     </TableHead> |                     </TableHead> | ||||||
|                     <TableBody> |                     <TableBody> | ||||||
|                         {data.map(row => ( |                         {priorityData?.map(row => ( | ||||||
|                             <TableRow key={row.id}> |                             <TableRow key={row.id}> | ||||||
|                                 <TableCell>{row.ticker}</TableCell> |                                 <TableCell> | ||||||
|                                 <TableCell>{row.nombreEmpresa}</TableCell> |                                     <Typography component="span" sx={{ fontWeight: 'bold' }}>{row.ticker}</Typography> | ||||||
|  |                                     <Typography component="span" sx={{ ml: 1, color: 'text.secondary' }}>({row.nombreEmpresa})</Typography> | ||||||
|  |                                 </TableCell> | ||||||
|                                 <TableCell align="right">${formatCurrency(row.precioActual)}</TableCell> |                                 <TableCell align="right">${formatCurrency(row.precioActual)}</TableCell> | ||||||
|                                 <TableCell align="right">${formatCurrency(row.cierreAnterior)}</TableCell> |                                 <TableCell align="right">{formatCurrency(row.porcentajeCambio)}%</TableCell> | ||||||
|                                 <TableCell align="right">{row.porcentajeCambio.toFixed(2)}%</TableCell> |  | ||||||
|                                 <TableCell>{formatFullDateTime(row.fechaRegistro)}</TableCell> |  | ||||||
|                             </TableRow> |                             </TableRow> | ||||||
|                         ))} |                         ))} | ||||||
|                     </TableBody> |                     </TableBody> | ||||||
|                 </Table> |                 </Table> | ||||||
|             </TableContainer> |             </TableContainer> | ||||||
|  |  | ||||||
|  |             {/* Sección para Otros Tickers (solo para consulta) */} | ||||||
|  |             {otherData && otherData.length > 0 && ( | ||||||
|  |                 <Box mt={4}> | ||||||
|  |                     <Divider sx={{ mb: 2 }}> | ||||||
|  |                         <Typography variant="overline">Otros Tickers (Solo Consulta)</Typography> | ||||||
|  |                     </Divider> | ||||||
|  |                     <TableContainer component={Paper}> | ||||||
|  |                         <Table size="small"> | ||||||
|  |                             <TableHead> | ||||||
|  |                                 <TableRow> | ||||||
|  |                                     <TableCell>Símbolo (Nombre)</TableCell> | ||||||
|  |                                     <TableCell align="right">Precio Actual</TableCell> | ||||||
|  |                                     <TableCell align="right">% Cambio</TableCell> | ||||||
|  |                                 </TableRow> | ||||||
|  |                             </TableHead> | ||||||
|  |                             <TableBody> | ||||||
|  |                                 {otherData.map(row => ( | ||||||
|  |                                     <TableRow key={row.id}> | ||||||
|  |                                         <TableCell> | ||||||
|  |                                             <Typography component="span" sx={{ fontWeight: 'bold' }}>{row.ticker}</Typography> | ||||||
|  |                                             <Typography component="span" sx={{ ml: 1, color: 'text.secondary' }}>({row.nombreEmpresa})</Typography> | ||||||
|  |                                         </TableCell> | ||||||
|  |                                         <TableCell align="right">${formatCurrency(row.precioActual)}</TableCell> | ||||||
|  |                                         <TableCell align="right">{formatCurrency(row.porcentajeCambio)}%</TableCell> | ||||||
|  |                                     </TableRow> | ||||||
|  |                                 ))} | ||||||
|  |                             </TableBody> | ||||||
|  |                         </Table> | ||||||
|  |                     </TableContainer> | ||||||
|  |                 </Box> | ||||||
|  |             )} | ||||||
|         </Box> |         </Box> | ||||||
|     ); |     ); | ||||||
| }; | }; | ||||||
| @@ -1,4 +1,4 @@ | |||||||
| import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button } from '@mui/material'; | import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button, Typography, Divider } from '@mui/material'; | ||||||
| import ContentCopyIcon from '@mui/icons-material/ContentCopy'; | import ContentCopyIcon from '@mui/icons-material/ContentCopy'; | ||||||
|  |  | ||||||
| // Importaciones de nuestro proyecto | // Importaciones de nuestro proyecto | ||||||
| @@ -8,98 +8,128 @@ import type { CotizacionBolsa } from '../../models/mercadoModels'; | |||||||
| import { formatCurrency, formatFullDateTime } from '../../utils/formatters'; | import { formatCurrency, formatFullDateTime } from '../../utils/formatters'; | ||||||
| import { copyToClipboard } from '../../utils/clipboardUtils'; | import { copyToClipboard } from '../../utils/clipboardUtils'; | ||||||
| import { HolidayAlert } from '../common/HolidayAlert'; | import { HolidayAlert } from '../common/HolidayAlert'; | ||||||
|  | import { TICKERS_PRIORITARIOS_USA } from '../../config/priorityTickers'; | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Función para convertir los datos de la tabla a formato CSV. |  * Función para convertir los datos prioritarios a formato TSV (Tab-Separated Values). | ||||||
|  */ |  */ | ||||||
| const toCSV = (headers: string[], data: CotizacionBolsa[]) => { | const toTSV = (data: CotizacionBolsa[]) => { | ||||||
|     const headerRow = headers.join(';'); |     const dataRows = data.map(row => { | ||||||
|     const dataRows = data.map(row =>  |         // Formateamos los datos según los requisitos de redacción | ||||||
|         [ |         const nombreCompleto = `${row.ticker} (${row.nombreEmpresa || ''})`; | ||||||
|             row.ticker, |         const precio = `$${formatCurrency(row.precioActual)}`; | ||||||
|             row.nombreEmpresa, |         const cambio = `${formatCurrency(row.porcentajeCambio)}%`; | ||||||
|             formatCurrency(row.precioActual, 'USD'), |  | ||||||
|             formatCurrency(row.cierreAnterior, 'USD'), |         return [nombreCompleto, precio, cambio].join('\t'); | ||||||
|             `${row.porcentajeCambio.toFixed(2)}%`, |     }); | ||||||
|             formatFullDateTime(row.fechaRegistro) |     return dataRows.join('\n'); | ||||||
|         ].join(';') |  | ||||||
|     ); |  | ||||||
|     return [headerRow, ...dataRows].join('\n'); |  | ||||||
| }; | }; | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Componente de tabla de datos crudos para la Bolsa de EEUU y ADRs, |  * Componente de tabla de datos crudos para la Bolsa de EEUU y ADRs, | ||||||
|  * diseñado para la página de redacción. |  * adaptado para las necesidades de redacción. | ||||||
|  */ |  */ | ||||||
| export const RawBolsaUsaTable = () => { | export const RawBolsaUsaTable = () => { | ||||||
|     // Hooks para obtener los datos y el estado de feriado para el mercado de EEUU. |  | ||||||
|     const { data, loading: dataLoading, error: dataError } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/eeuu'); |     const { data, loading: dataLoading, error: dataError } = useApiData<CotizacionBolsa[]>('/mercados/bolsa/eeuu'); | ||||||
|     const isHoliday = useIsHoliday('US'); |     const isHoliday = useIsHoliday('US'); | ||||||
|  |  | ||||||
|  |     // Separamos los datos en prioritarios y el resto, manteniendo el orden de la lista | ||||||
|  |     const priorityData = data?.filter(d => TICKERS_PRIORITARIOS_USA.includes(d.ticker)) | ||||||
|  |         .sort((a, b) => TICKERS_PRIORITARIOS_USA.indexOf(a.ticker) - TICKERS_PRIORITARIOS_USA.indexOf(b.ticker)); | ||||||
|  |  | ||||||
|  |     const otherData = data?.filter(d => !TICKERS_PRIORITARIOS_USA.includes(d.ticker)); | ||||||
|  |  | ||||||
|     const handleCopy = () => { |     const handleCopy = () => { | ||||||
|         if (!data) return; |         if (!priorityData) return; | ||||||
|         const headers = ["Ticker", "Nombre", "Último Precio (USD)", "Cierre Anterior (USD)", "Variación %", "Fecha de Registro"]; |         const tsvData = toTSV(priorityData); | ||||||
|         const csvData = toCSV(headers, data); |  | ||||||
|          |         copyToClipboard(tsvData) | ||||||
|         copyToClipboard(csvData) |             .then(() => alert('Datos prioritarios copiados al portapapeles!')) | ||||||
|             .then(() => alert('¡Tabla copiada al portapapeles!')) |  | ||||||
|             .catch(err => { |             .catch(err => { | ||||||
|                 console.error('Error al copiar:', err); |                 console.error('Error al copiar:', err); | ||||||
|                 alert('Error: No se pudo copiar la tabla.'); |                 alert('Error: No se pudo copiar la tabla.'); | ||||||
|             }); |             }); | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     // Estado de carga unificado. |  | ||||||
|     const isLoading = dataLoading || isHoliday === null; |     const isLoading = dataLoading || isHoliday === null; | ||||||
|  |  | ||||||
|     if (isLoading) return <CircularProgress />; |     if (isLoading) return <CircularProgress />; | ||||||
|     if (dataError) return <Alert severity="error">{dataError}</Alert>; |     if (dataError) return <Alert severity="error">{dataError}</Alert>; | ||||||
|  |  | ||||||
|     if (!data || data.length === 0) { |     if (!data || data.length === 0) { | ||||||
|         if (isHoliday) { |         if (isHoliday) { return <HolidayAlert />; } | ||||||
|             return <HolidayAlert />; |         return <Alert severity="info">No hay datos disponibles para el mercado de EEUU.</Alert>; | ||||||
|         } |  | ||||||
|         return <Alert severity="info">No hay datos disponibles para el mercado de EEUU (el fetcher puede estar desactivado).</Alert>; |  | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
|         <Box> |         <Box> | ||||||
|             {/* Si es feriado, mostramos una alerta informativa encima de la tabla. */} |             {isHoliday && <Box sx={{ mb: 2 }}><HolidayAlert /></Box>} | ||||||
|             {isHoliday && ( |  | ||||||
|                 <Box sx={{ mb: 2 }}> |  | ||||||
|                     <HolidayAlert /> |  | ||||||
|                 </Box> |  | ||||||
|             )} |  | ||||||
|  |  | ||||||
|             <Button startIcon={<ContentCopyIcon />} onClick={handleCopy} sx={{ mb: 1 }}> |             <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}> | ||||||
|                 Copiar como CSV |                 <Button startIcon={<ContentCopyIcon />} onClick={handleCopy}> | ||||||
|             </Button> |                     Copiar Datos Principales | ||||||
|  |                 </Button> | ||||||
|  |                 <Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}> | ||||||
|  |                     Última actualización: {formatFullDateTime(data[0].fechaRegistro)} | ||||||
|  |                 </Typography> | ||||||
|  |             </Box> | ||||||
|  |  | ||||||
|  |             {/* Tabla de Datos Prioritarios */} | ||||||
|             <TableContainer component={Paper}> |             <TableContainer component={Paper}> | ||||||
|                 <Table size="small"> |                 <Table size="small"> | ||||||
|                     <TableHead> |                     <TableHead> | ||||||
|                         <TableRow> |                         <TableRow> | ||||||
|                             <TableCell>Ticker</TableCell> |                             <TableCell>Símbolo (Nombre)</TableCell> | ||||||
|                             <TableCell>Nombre</TableCell> |                             <TableCell align="right">Precio Actual</TableCell> | ||||||
|                             <TableCell align="right">Último Precio</TableCell> |                             <TableCell align="right">% Cambio</TableCell> | ||||||
|                             <TableCell align="right">Cierre Anterior</TableCell> |  | ||||||
|                             <TableCell align="right">Variación %</TableCell> |  | ||||||
|                             <TableCell>Última Act.</TableCell> |  | ||||||
|                         </TableRow> |                         </TableRow> | ||||||
|                     </TableHead> |                     </TableHead> | ||||||
|                     <TableBody> |                     <TableBody> | ||||||
|                         {data.map(row => ( |                         {priorityData?.map(row => ( | ||||||
|                             <TableRow key={row.id}> |                             <TableRow key={row.id}> | ||||||
|                                 <TableCell>{row.ticker}</TableCell> |                                 <TableCell> | ||||||
|                                 <TableCell>{row.nombreEmpresa}</TableCell> |                                     <Typography component="span" sx={{ fontWeight: 'bold' }}>{row.ticker}</Typography> | ||||||
|                                 <TableCell align="right">{formatCurrency(row.precioActual, 'USD')}</TableCell> |                                     <Typography component="span" sx={{ ml: 1, color: 'text.secondary' }}>({row.nombreEmpresa})</Typography> | ||||||
|                                 <TableCell align="right">{formatCurrency(row.cierreAnterior, 'USD')}</TableCell> |                                 </TableCell> | ||||||
|                                 <TableCell align="right">{row.porcentajeCambio.toFixed(2)}%</TableCell> |                                 <TableCell align="right">${formatCurrency(row.precioActual)}</TableCell> | ||||||
|                                 <TableCell>{formatFullDateTime(row.fechaRegistro)}</TableCell> |                                 <TableCell align="right">{formatCurrency(row.porcentajeCambio)}%</TableCell> | ||||||
|                             </TableRow> |                             </TableRow> | ||||||
|                         ))} |                         ))} | ||||||
|                     </TableBody> |                     </TableBody> | ||||||
|                 </Table> |                 </Table> | ||||||
|             </TableContainer> |             </TableContainer> | ||||||
|  |  | ||||||
|  |             {/* Sección para Otros Tickers (solo para consulta) */} | ||||||
|  |             {otherData && otherData.length > 0 && ( | ||||||
|  |                 <Box mt={4}> | ||||||
|  |                     <Divider sx={{ mb: 2 }}> | ||||||
|  |                         <Typography variant="overline">Otros Tickers (Solo Consulta)</Typography> | ||||||
|  |                     </Divider> | ||||||
|  |                     <TableContainer component={Paper}> | ||||||
|  |                         <Table size="small"> | ||||||
|  |                             <TableHead> | ||||||
|  |                                 <TableRow> | ||||||
|  |                                     <TableCell>Símbolo (Nombre)</TableCell> | ||||||
|  |                                     <TableCell align="right">Precio Actual</TableCell> | ||||||
|  |                                     <TableCell align="right">% Cambio</TableCell> | ||||||
|  |                                 </TableRow> | ||||||
|  |                             </TableHead> | ||||||
|  |                             <TableBody> | ||||||
|  |                                 {otherData.map(row => ( | ||||||
|  |                                     <TableRow key={row.id}> | ||||||
|  |                                         <TableCell> | ||||||
|  |                                             <Typography component="span" sx={{ fontWeight: 'bold' }}>{row.ticker}</Typography> | ||||||
|  |                                             <Typography component="span" sx={{ ml: 1, color: 'text.secondary' }}>({row.nombreEmpresa})</Typography> | ||||||
|  |                                         </TableCell> | ||||||
|  |                                         <TableCell align="right">${formatCurrency(row.precioActual)}</TableCell> | ||||||
|  |                                         <TableCell align="right">{formatCurrency(row.porcentajeCambio)}%</TableCell> | ||||||
|  |                                     </TableRow> | ||||||
|  |                                 ))} | ||||||
|  |                             </TableBody> | ||||||
|  |                         </Table> | ||||||
|  |                     </TableContainer> | ||||||
|  |                 </Box> | ||||||
|  |             )} | ||||||
|         </Box> |         </Box> | ||||||
|     ); |     ); | ||||||
| }; | }; | ||||||
| @@ -1,29 +1,32 @@ | |||||||
| import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button } from '@mui/material'; | import { Box, CircularProgress, Alert, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Button, Typography } from '@mui/material'; | ||||||
| import ContentCopyIcon from '@mui/icons-material/ContentCopy'; | import ContentCopyIcon from '@mui/icons-material/ContentCopy'; | ||||||
|  |  | ||||||
| // Importaciones de nuestro proyecto | // Importaciones de nuestro proyecto | ||||||
| import { useApiData } from '../../hooks/useApiData'; | import { useApiData } from '../../hooks/useApiData'; | ||||||
| import { useIsHoliday } from '../../hooks/useIsHoliday'; | import { useIsHoliday } from '../../hooks/useIsHoliday'; | ||||||
| import type { CotizacionGrano } from '../../models/mercadoModels'; | import type { CotizacionGrano } from '../../models/mercadoModels'; | ||||||
| import { formatInteger, formatDateOnly, formatFullDateTime } from '../../utils/formatters'; | import { formatInteger, formatFullDateTime } from '../../utils/formatters'; | ||||||
| import { copyToClipboard } from '../../utils/clipboardUtils'; | import { copyToClipboard } from '../../utils/clipboardUtils'; | ||||||
| import { HolidayAlert } from '../common/HolidayAlert'; | import { HolidayAlert } from '../common/HolidayAlert'; | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Función para convertir los datos de la tabla a formato CSV. |  * Función para convertir los datos a formato TSV (Tab-Separated Values) | ||||||
|  |  * con el formato específico solicitado por redacción. | ||||||
|  */ |  */ | ||||||
| const toCSV = (headers: string[], data: CotizacionGrano[]) => { | const toTSV = (data: CotizacionGrano[]) => { | ||||||
|     const headerRow = headers.join(';'); |     const dataRows = data.map(row => { | ||||||
|     const dataRows = data.map(row =>  |         // Formateamos la variación para que muestre "=" si es cero. | ||||||
|         [ |         const variacion = row.variacionPrecio === 0  | ||||||
|             row.nombre, |             ? '= 0'  | ||||||
|             formatInteger(row.precio), |             : formatInteger(row.variacionPrecio); | ||||||
|             formatInteger(row.variacionPrecio), |  | ||||||
|             formatDateOnly(row.fechaOperacion), |         const precio = formatInteger(row.precio); | ||||||
|             formatFullDateTime(row.fechaRegistro) |  | ||||||
|         ].join(';') |         // Unimos los campos con un carácter de tabulación '\t' | ||||||
|     ); |         return [row.nombre, precio, variacion].join('\t'); | ||||||
|     return [headerRow, ...dataRows].join('\n'); |     }); | ||||||
|  |      | ||||||
|  |     return dataRows.join('\n'); | ||||||
| }; | }; | ||||||
|  |  | ||||||
| /** | /** | ||||||
| @@ -31,24 +34,21 @@ const toCSV = (headers: string[], data: CotizacionGrano[]) => { | |||||||
|  * diseñado para la página de redacción. |  * diseñado para la página de redacción. | ||||||
|  */ |  */ | ||||||
| export const RawGranosTable = () => { | export const RawGranosTable = () => { | ||||||
|     // Hooks para obtener los datos y el estado de feriado para el mercado argentino. |  | ||||||
|     const { data, loading: dataLoading, error: dataError } = useApiData<CotizacionGrano[]>('/mercados/granos'); |     const { data, loading: dataLoading, error: dataError } = useApiData<CotizacionGrano[]>('/mercados/granos'); | ||||||
|     const isHoliday = useIsHoliday('BA'); |     const isHoliday = useIsHoliday('BA'); | ||||||
|  |  | ||||||
|     const handleCopy = () => { |     const handleCopy = () => { | ||||||
|         if (!data) return; |         if (!data) return; | ||||||
|         const headers = ["Grano", "Precio ($/Tn)", "Variación", "Fecha Op.", "Fecha de Registro"]; |         const tsvData = toTSV(data); | ||||||
|         const csvData = toCSV(headers, data); |  | ||||||
|          |          | ||||||
|         copyToClipboard(csvData) |         copyToClipboard(tsvData) | ||||||
|             .then(() => alert('¡Tabla copiada al portapapeles!')) |             .then(() => alert('Datos de Granos copiados al portapapeles!')) | ||||||
|             .catch(err => { |             .catch(err => { | ||||||
|                 console.error('Error al copiar:', err); |                 console.error('Error al copiar:', err); | ||||||
|                 alert('Error: No se pudo copiar la tabla.'); |                 alert('Error: No se pudo copiar la tabla.'); | ||||||
|             }); |             }); | ||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     // Estado de carga unificado. |  | ||||||
|     const isLoading = dataLoading || isHoliday === null; |     const isLoading = dataLoading || isHoliday === null; | ||||||
|  |  | ||||||
|     if (isLoading) return <CircularProgress />; |     if (isLoading) return <CircularProgress />; | ||||||
| @@ -63,16 +63,21 @@ export const RawGranosTable = () => { | |||||||
|  |  | ||||||
|     return ( |     return ( | ||||||
|         <Box> |         <Box> | ||||||
|             {/* Si es feriado, mostramos una alerta informativa encima de la tabla. */} |  | ||||||
|             {isHoliday && ( |             {isHoliday && ( | ||||||
|                 <Box sx={{ mb: 2 }}> |                 <Box sx={{ mb: 2 }}> | ||||||
|                     <HolidayAlert /> |                     <HolidayAlert /> | ||||||
|                 </Box> |                 </Box> | ||||||
|             )} |             )} | ||||||
|  |  | ||||||
|             <Button startIcon={<ContentCopyIcon />} onClick={handleCopy} sx={{ mb: 1 }}> |             <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}> | ||||||
|                 Copiar como CSV |                 <Button startIcon={<ContentCopyIcon />} onClick={handleCopy}> | ||||||
|             </Button> |                     Copiar Datos para Redacción | ||||||
|  |                 </Button> | ||||||
|  |                 <Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}> | ||||||
|  |                     Última actualización: {formatFullDateTime(data[0].fechaRegistro)} | ||||||
|  |                 </Typography> | ||||||
|  |             </Box> | ||||||
|  |              | ||||||
|             <TableContainer component={Paper}> |             <TableContainer component={Paper}> | ||||||
|                 <Table size="small"> |                 <Table size="small"> | ||||||
|                     <TableHead> |                     <TableHead> | ||||||
| @@ -80,8 +85,6 @@ export const RawGranosTable = () => { | |||||||
|                             <TableCell>Grano</TableCell> |                             <TableCell>Grano</TableCell> | ||||||
|                             <TableCell align="right">Precio ($/Tn)</TableCell> |                             <TableCell align="right">Precio ($/Tn)</TableCell> | ||||||
|                             <TableCell align="right">Variación</TableCell> |                             <TableCell align="right">Variación</TableCell> | ||||||
|                             <TableCell>Fecha Op.</TableCell> |  | ||||||
|                             <TableCell>Última Act.</TableCell> |  | ||||||
|                         </TableRow> |                         </TableRow> | ||||||
|                     </TableHead> |                     </TableHead> | ||||||
|                     <TableBody> |                     <TableBody> | ||||||
| @@ -89,9 +92,7 @@ export const RawGranosTable = () => { | |||||||
|                             <TableRow key={row.id}> |                             <TableRow key={row.id}> | ||||||
|                                 <TableCell>{row.nombre}</TableCell> |                                 <TableCell>{row.nombre}</TableCell> | ||||||
|                                 <TableCell align="right">${formatInteger(row.precio)}</TableCell> |                                 <TableCell align="right">${formatInteger(row.precio)}</TableCell> | ||||||
|                                 <TableCell align="right">{formatInteger(row.variacionPrecio)}</TableCell> |                                 <TableCell align="right">{row.variacionPrecio === 0 ? '= 0' : formatInteger(row.variacionPrecio)}</TableCell> | ||||||
|                                 <TableCell>{formatDateOnly(row.fechaOperacion)}</TableCell> |  | ||||||
|                                 <TableCell>{formatFullDateTime(row.fechaRegistro)}</TableCell> |  | ||||||
|                             </TableRow> |                             </TableRow> | ||||||
|                         ))} |                         ))} | ||||||
|                     </TableBody> |                     </TableBody> | ||||||
|   | |||||||
							
								
								
									
										11
									
								
								frontend/src/config/priorityTickers.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								frontend/src/config/priorityTickers.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | |||||||
|  | export const TICKERS_PRIORITARIOS_LOCAL = [ | ||||||
|  |   '^MERV', 'GGAL.BA', 'YPFD.BA', 'PAMP.BA', 'BMA.BA',  | ||||||
|  |   'COME.BA', 'TECO2.BA', 'EDN.BA', 'CRES.BA', 'TXAR.BA',  | ||||||
|  |   'MIRG.BA', 'CEPU.BA', 'LOMA.BA', 'VALO.BA' | ||||||
|  | ]; | ||||||
|  |  | ||||||
|  | // Dejaremos las otras listas aquí para los siguientes componentes | ||||||
|  | export const TICKERS_PRIORITARIOS_USA = [ | ||||||
|  |   'AAPL', 'AMD', 'AMZN', 'BRK-B', 'KO', 'MSFT', 'NVDA', | ||||||
|  |   'GLD', 'XLF', 'XLI', 'XLE', 'XLK', 'MELI' | ||||||
|  | ]; | ||||||
| @@ -5,9 +5,6 @@ using Microsoft.AspNetCore.Mvc; | |||||||
|  |  | ||||||
| namespace Mercados.Api.Controllers | namespace Mercados.Api.Controllers | ||||||
| { | { | ||||||
|     /// <summary> |  | ||||||
|     /// Controlador principal para exponer los datos de los mercados financieros. |  | ||||||
|     /// </summary> |  | ||||||
|     [ApiController] |     [ApiController] | ||||||
|     [Route("api/[controller]")] |     [Route("api/[controller]")] | ||||||
|     public class MercadosController : ControllerBase |     public class MercadosController : ControllerBase | ||||||
| @@ -18,14 +15,7 @@ namespace Mercados.Api.Controllers | |||||||
|         private readonly IHolidayService _holidayService; |         private readonly IHolidayService _holidayService; | ||||||
|         private readonly ILogger<MercadosController> _logger; |         private readonly ILogger<MercadosController> _logger; | ||||||
|  |  | ||||||
|         /// <summary> |         // Inyectamos TODOS los repositorios que necesita el controlador. | ||||||
|         /// Inicializa una nueva instancia del controlador MercadosController. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <param name="bolsaRepo">Repositorio para datos de la bolsa.</param> |  | ||||||
|         /// <param name="granoRepo">Repositorio para datos de granos.</param> |  | ||||||
|         /// <param name="ganadoRepo">Repositorio para datos de ganado.</param> |  | ||||||
|         /// <param name="holidayService">Servicio para consultar feriados.</param> |  | ||||||
|         /// <param name="logger">Servicio de logging.</param> |  | ||||||
|         public MercadosController( |         public MercadosController( | ||||||
|             ICotizacionBolsaRepository bolsaRepo, |             ICotizacionBolsaRepository bolsaRepo, | ||||||
|             ICotizacionGranoRepository granoRepo, |             ICotizacionGranoRepository granoRepo, | ||||||
| @@ -40,10 +30,7 @@ namespace Mercados.Api.Controllers | |||||||
|             _logger = logger; |             _logger = logger; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         /// <summary> |         // --- Endpoint para Agroganadero --- | ||||||
|         /// Obtiene el último parte completo del mercado agroganadero. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <returns>Una colección de objetos CotizacionGanado.</returns> |  | ||||||
|         [HttpGet("agroganadero")] |         [HttpGet("agroganadero")] | ||||||
|         [ProducesResponseType(typeof(IEnumerable<CotizacionGanado>), StatusCodes.Status200OK)] |         [ProducesResponseType(typeof(IEnumerable<CotizacionGanado>), StatusCodes.Status200OK)] | ||||||
|         [ProducesResponseType(StatusCodes.Status500InternalServerError)] |         [ProducesResponseType(StatusCodes.Status500InternalServerError)] | ||||||
| @@ -51,7 +38,7 @@ namespace Mercados.Api.Controllers | |||||||
|         { |         { | ||||||
|             try |             try | ||||||
|             { |             { | ||||||
|                 var data = await _ganadoRepo.ObtenerUltimaTandaAsync(); |                 var data = await _ganadoRepo.ObtenerUltimaTandaDisponibleAsync(); | ||||||
|                 return Ok(data); |                 return Ok(data); | ||||||
|             } |             } | ||||||
|             catch (Exception ex) |             catch (Exception ex) | ||||||
| @@ -61,10 +48,7 @@ namespace Mercados.Api.Controllers | |||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         /// <summary> |         // --- Endpoint para Granos --- | ||||||
|         /// Obtiene las últimas cotizaciones para los principales granos. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <returns>Una colección de objetos CotizacionGrano.</returns> |  | ||||||
|         [HttpGet("granos")] |         [HttpGet("granos")] | ||||||
|         [ProducesResponseType(typeof(IEnumerable<CotizacionGrano>), StatusCodes.Status200OK)] |         [ProducesResponseType(typeof(IEnumerable<CotizacionGrano>), StatusCodes.Status200OK)] | ||||||
|         [ProducesResponseType(StatusCodes.Status500InternalServerError)] |         [ProducesResponseType(StatusCodes.Status500InternalServerError)] | ||||||
| @@ -83,10 +67,6 @@ namespace Mercados.Api.Controllers | |||||||
|         } |         } | ||||||
|  |  | ||||||
|         // --- Endpoints de Bolsa --- |         // --- Endpoints de Bolsa --- | ||||||
|         /// <summary> |  | ||||||
|         /// Obtiene las últimas cotizaciones para el mercado de bolsa de EEUU. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <returns>Una colección de objetos CotizacionBolsa.</returns> |  | ||||||
|         [HttpGet("bolsa/eeuu")] |         [HttpGet("bolsa/eeuu")] | ||||||
|         [ProducesResponseType(typeof(IEnumerable<CotizacionBolsa>), StatusCodes.Status200OK)] |         [ProducesResponseType(typeof(IEnumerable<CotizacionBolsa>), StatusCodes.Status200OK)] | ||||||
|         [ProducesResponseType(StatusCodes.Status500InternalServerError)] |         [ProducesResponseType(StatusCodes.Status500InternalServerError)] | ||||||
| @@ -104,10 +84,6 @@ namespace Mercados.Api.Controllers | |||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Obtiene las últimas cotizaciones para el mercado de bolsa local. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <returns>Una colección de objetos CotizacionBolsa.</returns> |  | ||||||
|         [HttpGet("bolsa/local")] |         [HttpGet("bolsa/local")] | ||||||
|         [ProducesResponseType(typeof(IEnumerable<CotizacionBolsa>), StatusCodes.Status200OK)] |         [ProducesResponseType(typeof(IEnumerable<CotizacionBolsa>), StatusCodes.Status200OK)] | ||||||
|         [ProducesResponseType(StatusCodes.Status500InternalServerError)] |         [ProducesResponseType(StatusCodes.Status500InternalServerError)] | ||||||
| @@ -125,37 +101,23 @@ namespace Mercados.Api.Controllers | |||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         /// <summary> |         [HttpGet("bolsa/history/{ticker}")] | ||||||
|         /// Obtiene el historial de cotizaciones para un ticker específico en un mercado determinado. |         [ProducesResponseType(typeof(IEnumerable<CotizacionBolsa>), StatusCodes.Status200OK)] | ||||||
|         /// </summary> |         [ProducesResponseType(StatusCodes.Status500InternalServerError)] | ||||||
|         /// <param name="ticker">El identificador del ticker.</param> |         public async Task<IActionResult> GetBolsaHistory(string ticker, [FromQuery] string mercado = "Local", [FromQuery] int dias = 30) | ||||||
|         /// <param name="mercado">El nombre del mercado (por defecto "Local").</param> |         { | ||||||
|         /// <param name="dias">Cantidad de días de historial a recuperar (por defecto 30).</param> |             try | ||||||
|         /// <returns>Una colección de objetos CotizacionBolsa.</returns> |             { | ||||||
|                 [HttpGet("bolsa/history/{ticker}")] |                 var data = await _bolsaRepo.ObtenerHistorialPorTickerAsync(ticker, mercado, dias); | ||||||
|                 [ProducesResponseType(typeof(IEnumerable<CotizacionBolsa>), StatusCodes.Status200OK)] |                 return Ok(data); | ||||||
|                 [ProducesResponseType(StatusCodes.Status500InternalServerError)] |             } | ||||||
|                 public async Task<IActionResult> GetBolsaHistory(string ticker, [FromQuery] string mercado = "Local", [FromQuery] int dias = 30) |             catch (Exception ex) | ||||||
|                 { |             { | ||||||
|                     try |                 _logger.LogError(ex, "Error al obtener historial para el ticker {Ticker}.", ticker); | ||||||
|                     { |                 return StatusCode(500, "Ocurrió un error interno en el servidor."); | ||||||
|                         var data = await _bolsaRepo.ObtenerHistorialPorTickerAsync(ticker, mercado, dias); |             } | ||||||
|                         return Ok(data); |         } | ||||||
|                     } |  | ||||||
|                     catch (Exception ex) |  | ||||||
|                     { |  | ||||||
|                         _logger.LogError(ex, "Error al obtener historial para el ticker {Ticker}.", ticker); |  | ||||||
|                         return StatusCode(500, "Ocurrió un error interno en el servidor."); |  | ||||||
|                     } |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Obtiene el historial de cotizaciones para una categoría y especificaciones de ganado en un rango de días. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <param name="categoria">La categoría de ganado.</param> |  | ||||||
|         /// <param name="especificaciones">Las especificaciones del ganado.</param> |  | ||||||
|         /// <param name="dias">Cantidad de días de historial a recuperar (por defecto 30).</param> |  | ||||||
|         /// <returns>Una colección de objetos CotizacionGanado.</returns> |  | ||||||
|         [HttpGet("agroganadero/history")] |         [HttpGet("agroganadero/history")] | ||||||
|         [ProducesResponseType(typeof(IEnumerable<CotizacionGanado>), StatusCodes.Status200OK)] |         [ProducesResponseType(typeof(IEnumerable<CotizacionGanado>), StatusCodes.Status200OK)] | ||||||
|         [ProducesResponseType(StatusCodes.Status500InternalServerError)] |         [ProducesResponseType(StatusCodes.Status500InternalServerError)] | ||||||
| @@ -173,12 +135,6 @@ namespace Mercados.Api.Controllers | |||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Obtiene el historial de cotizaciones para un grano específico en un rango de días. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <param name="nombre">El nombre del grano.</param> |  | ||||||
|         /// <param name="dias">Cantidad de días de historial a recuperar (por defecto 30).</param> |  | ||||||
|         /// <returns>Una colección de objetos CotizacionGrano.</returns> |  | ||||||
|         [HttpGet("granos/history/{nombre}")] |         [HttpGet("granos/history/{nombre}")] | ||||||
|         [ProducesResponseType(typeof(IEnumerable<CotizacionGrano>), StatusCodes.Status200OK)] |         [ProducesResponseType(typeof(IEnumerable<CotizacionGrano>), StatusCodes.Status200OK)] | ||||||
|         [ProducesResponseType(StatusCodes.Status500InternalServerError)] |         [ProducesResponseType(StatusCodes.Status500InternalServerError)] | ||||||
| @@ -196,11 +152,6 @@ namespace Mercados.Api.Controllers | |||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Verifica si la fecha actual es feriado para el mercado especificado. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <param name="mercado">El nombre del mercado a consultar.</param> |  | ||||||
|         /// <returns>True si es feriado, false en caso contrario.</returns> |  | ||||||
|         [HttpGet("es-feriado/{mercado}")] |         [HttpGet("es-feriado/{mercado}")] | ||||||
|         [ProducesResponseType(typeof(bool), StatusCodes.Status200OK)] |         [ProducesResponseType(typeof(bool), StatusCodes.Status200OK)] | ||||||
|         [ProducesResponseType(StatusCodes.Status500InternalServerError)] |         [ProducesResponseType(StatusCodes.Status500InternalServerError)] | ||||||
|   | |||||||
| @@ -5,7 +5,6 @@ | |||||||
|     <Nullable>enable</Nullable> |     <Nullable>enable</Nullable> | ||||||
|     <ImplicitUsings>enable</ImplicitUsings> |     <ImplicitUsings>enable</ImplicitUsings> | ||||||
|     <UserSecretsId>28c6a673-1f1e-4140-aa75-a0d894d1fbc4</UserSecretsId> |     <UserSecretsId>28c6a673-1f1e-4140-aa75-a0d894d1fbc4</UserSecretsId> | ||||||
|     <GenerateDocumentationFile>true</GenerateDocumentationFile> |  | ||||||
|   </PropertyGroup> |   </PropertyGroup> | ||||||
|  |  | ||||||
|   <ItemGroup> |   <ItemGroup> | ||||||
|   | |||||||
| @@ -9,32 +9,19 @@ namespace Mercados.Api.Utils | |||||||
|     /// </summary> |     /// </summary> | ||||||
|     public class UtcDateTimeConverter : JsonConverter<DateTime> |     public class UtcDateTimeConverter : JsonConverter<DateTime> | ||||||
|     { |     { | ||||||
|         /// <summary> |  | ||||||
|         /// Lee un valor DateTime desde el lector JSON y lo convierte a UTC. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <param name="reader">El lector JSON.</param> |  | ||||||
|         /// <param name="typeToConvert">El tipo a convertir.</param> |  | ||||||
|         /// <param name="options">Las opciones de serialización JSON.</param> |  | ||||||
|         /// <returns>El valor DateTime en UTC.</returns> |  | ||||||
|         public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) |         public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) | ||||||
|         { |         { | ||||||
|             // Al leer un string de fecha, nos aseguramos de que se interprete como UTC |             // Al leer un string de fecha, nos aseguramos de que se interprete como UTC | ||||||
|             return reader.GetDateTime().ToUniversalTime(); |             return reader.GetDateTime().ToUniversalTime(); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         /// <summary> |         public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) | ||||||
|         /// Escribe un valor DateTime en formato UTC como una cadena en el escritor JSON. |         { | ||||||
|         /// </summary> |             // Antes de escribir el string, especificamos que el 'Kind' es Utc. | ||||||
|         /// <param name="writer">El escritor JSON.</param> |             // Si ya es Utc, no hace nada. Si es Local o Unspecified, lo trata como si fuera Utc. | ||||||
|         /// <param name="value">El valor DateTime a escribir.</param> |             // Esto es seguro porque sabemos que todas nuestras fechas en la BD son UTC. | ||||||
|         /// <param name="options">Las opciones de serialización JSON.</param> |             var utcValue = DateTime.SpecifyKind(value, DateTimeKind.Utc); | ||||||
|                 public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) |             writer.WriteStringValue(utcValue); | ||||||
|                 { |         } | ||||||
|                     // Antes de escribir el string, especificamos que el 'Kind' es Utc. |  | ||||||
|                     // Si ya es Utc, no hace nada. Si es Local o Unspecified, lo trata como si fuera Utc. |  | ||||||
|                     // Esto es seguro porque sabemos que todas nuestras fechas en la BD son UTC. |  | ||||||
|                     var utcValue = DateTime.SpecifyKind(value, DateTimeKind.Utc); |  | ||||||
|                     writer.WriteStringValue(utcValue); |  | ||||||
|                 } |  | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -1,53 +1,15 @@ | |||||||
| namespace Mercados.Core.Entities | namespace Mercados.Core.Entities | ||||||
| { | { | ||||||
|     /// <summary> |   public class CotizacionBolsa | ||||||
|     /// Representa una única captura de cotización para un activo de la bolsa de valores. |   { | ||||||
|     /// </summary> |     public long Id { get; set; } | ||||||
|     public class CotizacionBolsa |     public string Ticker { get; set; } = string.Empty; // "AAPL", "GGAL.BA", etc. | ||||||
|     { |     public string? NombreEmpresa { get; set; } | ||||||
|         /// <summary> |     public string Mercado { get; set; } = string.Empty; // "EEUU" o "Local" | ||||||
|         /// Identificador único del registro en la base de datos. |     public decimal PrecioActual { get; set; } | ||||||
|         /// </summary> |     public decimal Apertura { get; set; } | ||||||
|         public long Id { get; set; } |     public decimal CierreAnterior { get; set; } | ||||||
|  |     public decimal PorcentajeCambio { get; set; } | ||||||
|         /// <summary> |     public DateTime FechaRegistro { get; set; } | ||||||
|         /// El símbolo o identificador del activo en el mercado (ej. "AAPL", "GGAL.BA"). |   } | ||||||
|         /// </summary> |  | ||||||
|         public string Ticker { get; set; } = string.Empty; |  | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// El nombre completo de la empresa o del activo. |  | ||||||
|         /// </summary> |  | ||||||
|         public string? NombreEmpresa { get; set; } |  | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// El mercado al que pertenece el activo (ej. "EEUU", "Local"). |  | ||||||
|         /// </summary> |  | ||||||
|         public string Mercado { get; set; } = string.Empty; |  | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// El último precio registrado para el activo. |  | ||||||
|         /// </summary> |  | ||||||
|         public decimal PrecioActual { get; set; } |  | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// El precio del activo al inicio de la jornada de mercado. |  | ||||||
|         /// </summary> |  | ||||||
|         public decimal Apertura { get; set; } |  | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// El precio de cierre del activo en la jornada anterior. |  | ||||||
|         /// </summary> |  | ||||||
|         public decimal CierreAnterior { get; set; } |  | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// El cambio porcentual del precio actual con respecto al cierre anterior. |  | ||||||
|         /// </summary> |  | ||||||
|         public decimal PorcentajeCambio { get; set; } |  | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// La fecha y hora (en UTC) en que se registró esta cotización en la base de datos. |  | ||||||
|         /// </summary> |  | ||||||
|         public DateTime FechaRegistro { get; set; } |  | ||||||
|     } |  | ||||||
| } | } | ||||||
| @@ -1,68 +1,18 @@ | |||||||
| namespace Mercados.Core.Entities | namespace Mercados.Core.Entities | ||||||
| { | { | ||||||
|     /// <summary> |   public class CotizacionGanado | ||||||
|     /// Representa una cotización para una categoría de ganado en el Mercado Agroganadero. |   { | ||||||
|     /// </summary> |     public long Id { get; set; } | ||||||
|     public class CotizacionGanado |     public string Categoria { get; set; } = string.Empty; | ||||||
|     { |     public string Especificaciones { get; set; } = string.Empty; | ||||||
|         /// <summary> |     public decimal Maximo { get; set; } | ||||||
|         /// Identificador único del registro en la base de datos. |     public decimal Minimo { get; set; } | ||||||
|         /// </summary> |     public decimal Promedio { get; set; } | ||||||
|         public long Id { get; set; } |     public decimal Mediano { get; set; } | ||||||
|  |     public int Cabezas { get; set; } | ||||||
|         /// <summary> |     public int KilosTotales { get; set; } | ||||||
|         /// La categoría principal del ganado (ej. "NOVILLOS", "VACAS"). |     public int KilosPorCabeza { get; set; } | ||||||
|         /// </summary> |     public decimal ImporteTotal { get; set; } | ||||||
|         public string Categoria { get; set; } = string.Empty; |     public DateTime FechaRegistro { get; set; } | ||||||
|  |   } | ||||||
|         /// <summary> |  | ||||||
|         /// Detalles adicionales sobre la categoría, como raza o peso. |  | ||||||
|         /// </summary> |  | ||||||
|         public string Especificaciones { get; set; } = string.Empty; |  | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// El precio máximo alcanzado para esta categoría en la jornada. |  | ||||||
|         /// </summary> |  | ||||||
|         public decimal Maximo { get; set; } |  | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// El precio mínimo alcanzado para esta categoría en la jornada. |  | ||||||
|         /// </summary> |  | ||||||
|         public decimal Minimo { get; set; } |  | ||||||
|          |  | ||||||
|         /// <summary> |  | ||||||
|         /// El precio promedio ponderado para la categoría. |  | ||||||
|         /// </summary> |  | ||||||
|         public decimal Promedio { get; set; } |  | ||||||
|          |  | ||||||
|         /// <summary> |  | ||||||
|         /// El precio mediano (valor central) registrado para la categoría. |  | ||||||
|         /// </summary> |  | ||||||
|         public decimal Mediano { get; set; } |  | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// El número total de cabezas de ganado comercializadas en esta categoría. |  | ||||||
|         /// </summary> |  | ||||||
|         public int Cabezas { get; set; } |  | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// El peso total en kilogramos de todo el ganado comercializado. |  | ||||||
|         /// </summary> |  | ||||||
|         public int KilosTotales { get; set; } |  | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// El peso promedio por cabeza de ganado. |  | ||||||
|         /// </summary> |  | ||||||
|         public int KilosPorCabeza { get; set; } |  | ||||||
|          |  | ||||||
|         /// <summary> |  | ||||||
|         /// El importe total monetario de las transacciones para esta categoría. |  | ||||||
|         /// </summary> |  | ||||||
|         public decimal ImporteTotal { get; set; } |  | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// La fecha y hora (en UTC) en que se registró esta cotización en la base de datos. |  | ||||||
|         /// </summary> |  | ||||||
|         public DateTime FechaRegistro { get; set; } |  | ||||||
|     } |  | ||||||
| } | } | ||||||
| @@ -1,38 +1,12 @@ | |||||||
| namespace Mercados.Core.Entities | namespace Mercados.Core.Entities | ||||||
| { | { | ||||||
|     /// <summary> |   public class CotizacionGrano | ||||||
|     /// Representa una cotización para un tipo de grano específico. |   { | ||||||
|     /// </summary> |     public long Id { get; set; } | ||||||
|     public class CotizacionGrano |     public string Nombre { get; set; } = string.Empty; // "Soja", "Trigo", etc. | ||||||
|     { |     public decimal Precio { get; set; } | ||||||
|         /// <summary> |     public decimal VariacionPrecio { get; set; } | ||||||
|         /// Identificador único del registro en la base de datos. |     public DateTime FechaOperacion { get; set; } | ||||||
|         /// </summary> |     public DateTime FechaRegistro { get; set; } | ||||||
|         public long Id { get; set; } |   } | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// El nombre del grano (ej. "Soja", "Trigo", "Maíz"). |  | ||||||
|         /// </summary> |  | ||||||
|         public string Nombre { get; set; } = string.Empty; |  | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// El precio de cotización, generalmente por tonelada. |  | ||||||
|         /// </summary> |  | ||||||
|         public decimal Precio { get; set; } |  | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// La variación del precio con respecto a la cotización anterior. |  | ||||||
|         /// </summary> |  | ||||||
|         public decimal VariacionPrecio { get; set; } |  | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// La fecha en que se concertó la operación de la cotización. |  | ||||||
|         /// </summary> |  | ||||||
|         public DateTime FechaOperacion { get; set; } |  | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// La fecha y hora (en UTC) en que se registró esta cotización en la base de datos. |  | ||||||
|         /// </summary> |  | ||||||
|         public DateTime FechaRegistro { get; set; } |  | ||||||
|     } |  | ||||||
| } | } | ||||||
| @@ -1,31 +1,10 @@ | |||||||
| namespace Mercados.Core.Entities | namespace Mercados.Core.Entities | ||||||
| { | { | ||||||
|     /// <summary> |   public class FuenteDato | ||||||
|     /// Representa una fuente de datos externa desde la cual se obtiene información. |   { | ||||||
|     /// Esta entidad se utiliza para auditar y monitorear la salud de los Data Fetchers. |     public long Id { get; set; } | ||||||
|     /// </summary> |     public string Nombre { get; set; } = string.Empty; // "BCR", "Finnhub", "YahooFinance", "MercadoAgroganadero" | ||||||
|     public class FuenteDato |     public DateTime UltimaEjecucionExitosa { get; set; } | ||||||
|     { |     public string? Url { get; set; } | ||||||
|         /// <summary> |   } | ||||||
|         /// Identificador único del registro en la base de datos. |  | ||||||
|         /// </summary> |  | ||||||
|         public long Id { get; set; } |  | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// El nombre único que identifica a la fuente de datos (ej. "BCR", "Finnhub", "YahooFinance", "MercadoAgroganadero"). |  | ||||||
|         /// Este nombre coincide con la propiedad SourceName de la interfaz IDataFetcher. |  | ||||||
|         /// </summary> |  | ||||||
|         public string Nombre { get; set; } = string.Empty; |  | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// La fecha y hora (en UTC) de la última vez que el Data Fetcher correspondiente |  | ||||||
|         /// se ejecutó y completó su tarea exitosamente. |  | ||||||
|         /// </summary> |  | ||||||
|         public DateTime UltimaEjecucionExitosa { get; set; } |  | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// La URL base o principal de la fuente de datos, para referencia. |  | ||||||
|         /// </summary> |  | ||||||
|         public string? Url { get; set; } |  | ||||||
|     } |  | ||||||
| } | } | ||||||
| @@ -1,28 +1,10 @@ | |||||||
| namespace Mercados.Core.Entities | namespace Mercados.Core.Entities | ||||||
| { | { | ||||||
|     /// <summary> |  | ||||||
|     /// Representa un único día feriado para un mercado bursátil específico. |  | ||||||
|     /// </summary> |  | ||||||
|     public class MercadoFeriado |     public class MercadoFeriado | ||||||
|     { |     { | ||||||
|         /// <summary> |  | ||||||
|         /// Identificador único del registro en la base de datos. |  | ||||||
|         /// </summary> |  | ||||||
|         public long Id { get; set; } |         public long Id { get; set; } | ||||||
|  |         public string CodigoMercado { get; set; } = string.Empty; // "US" o "BA" | ||||||
|         /// <summary> |  | ||||||
|         /// El código del mercado al que pertenece el feriado (ej. "US", "BA"). |  | ||||||
|         /// </summary> |  | ||||||
|         public string CodigoMercado { get; set; } = string.Empty; |  | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// La fecha exacta del feriado (la hora no es relevante). |  | ||||||
|         /// </summary> |  | ||||||
|         public DateTime Fecha { get; set; } |         public DateTime Fecha { get; set; } | ||||||
|  |         public string? Nombre { get; set; } // Nombre del feriado, si la API lo provee | ||||||
|         /// <summary> |  | ||||||
|         /// El nombre o la descripción del feriado (si está disponible). |  | ||||||
|         /// </summary> |  | ||||||
|         public string? Nombre { get; set; } |  | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -4,7 +4,6 @@ | |||||||
|     <TargetFramework>net9.0</TargetFramework> |     <TargetFramework>net9.0</TargetFramework> | ||||||
|     <ImplicitUsings>enable</ImplicitUsings> |     <ImplicitUsings>enable</ImplicitUsings> | ||||||
|     <Nullable>enable</Nullable> |     <Nullable>enable</Nullable> | ||||||
|     <GenerateDocumentationFile>true</GenerateDocumentationFile> |  | ||||||
|   </PropertyGroup> |   </PropertyGroup> | ||||||
|  |  | ||||||
| </Project> | </Project> | ||||||
|   | |||||||
| @@ -4,7 +4,6 @@ | |||||||
|     <TargetFramework>net9.0</TargetFramework> |     <TargetFramework>net9.0</TargetFramework> | ||||||
|     <ImplicitUsings>enable</ImplicitUsings> |     <ImplicitUsings>enable</ImplicitUsings> | ||||||
|     <Nullable>enable</Nullable> |     <Nullable>enable</Nullable> | ||||||
|     <GenerateDocumentationFile>true</GenerateDocumentationFile> |  | ||||||
|   </PropertyGroup> |   </PropertyGroup> | ||||||
|  |  | ||||||
|   <ItemGroup> |   <ItemGroup> | ||||||
|   | |||||||
| @@ -2,10 +2,8 @@ using FluentMigrator; | |||||||
|  |  | ||||||
| namespace Mercados.Database.Migrations | namespace Mercados.Database.Migrations | ||||||
| { | { | ||||||
|     /// <summary> |     // El número es la versión única de esta migración. | ||||||
|     /// Migración inicial que crea las tablas necesarias para almacenar |     // Usar un timestamp es una práctica común y segura. | ||||||
|     /// las cotizaciones de ganado, granos, bolsa y fuentes de datos. |  | ||||||
|     /// </summary> |  | ||||||
|     [Migration(20250701113000)] |     [Migration(20250701113000)] | ||||||
|     public class CreateInitialTables : Migration |     public class CreateInitialTables : Migration | ||||||
|     { |     { | ||||||
|   | |||||||
| @@ -2,25 +2,15 @@ using FluentMigrator; | |||||||
|  |  | ||||||
| namespace Mercados.Database.Migrations | namespace Mercados.Database.Migrations | ||||||
| { | { | ||||||
|     /// <summary> |  | ||||||
|     /// Migración que añade la columna 'NombreEmpresa' a la tabla 'CotizacionesBolsa' |  | ||||||
|     /// para almacenar el nombre descriptivo de la acción. |  | ||||||
|     /// </summary> |  | ||||||
|     [Migration(20250702133000)] |     [Migration(20250702133000)] | ||||||
|     public class AddNameToStocks : Migration |     public class AddNameToStocks : Migration | ||||||
|     { |     { | ||||||
|         /// <summary> |  | ||||||
|         /// Aplica la migración, añadiendo la columna 'NombreEmpresa'. |  | ||||||
|         /// </summary> |  | ||||||
|         public override void Up() |         public override void Up() | ||||||
|         { |         { | ||||||
|             Alter.Table("CotizacionesBolsa") |             Alter.Table("CotizacionesBolsa") | ||||||
|                 .AddColumn("NombreEmpresa").AsString(255).Nullable(); |                 .AddColumn("NombreEmpresa").AsString(255).Nullable(); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Revierte la migración, eliminando la columna 'NombreEmpresa'. |  | ||||||
|         /// </summary> |  | ||||||
|         public override void Down() |         public override void Down() | ||||||
|         { |         { | ||||||
|             Delete.Column("NombreEmpresa").FromTable("CotizacionesBolsa"); |             Delete.Column("NombreEmpresa").FromTable("CotizacionesBolsa"); | ||||||
|   | |||||||
| @@ -2,18 +2,11 @@ using FluentMigrator; | |||||||
|  |  | ||||||
| namespace Mercados.Database.Migrations | namespace Mercados.Database.Migrations | ||||||
| { | { | ||||||
|     /// <summary> |  | ||||||
|     /// Migración para crear la tabla 'MercadosFeriados', que almacenará los días no laborables |  | ||||||
|     /// para diferentes mercados bursátiles. |  | ||||||
|     /// </summary> |  | ||||||
|     [Migration(20250714150000)] |     [Migration(20250714150000)] | ||||||
|     public class CreateMercadoFeriadoTable : Migration |     public class CreateMercadoFeriadoTable : Migration | ||||||
|     { |     { | ||||||
|         private const string TableName = "MercadosFeriados"; |         private const string TableName = "MercadosFeriados"; | ||||||
|          |          | ||||||
|         /// <summary> |  | ||||||
|         /// Define la estructura de la tabla 'MercadosFeriados' y crea un índice único. |  | ||||||
|         /// </summary> |  | ||||||
|         public override void Up() |         public override void Up() | ||||||
|         { |         { | ||||||
|             Create.Table(TableName) |             Create.Table(TableName) | ||||||
| @@ -30,9 +23,6 @@ namespace Mercados.Database.Migrations | |||||||
|                 .WithOptions().Unique(); |                 .WithOptions().Unique(); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Revierte la migración eliminando la tabla 'MercadosFeriados'. |  | ||||||
|         /// </summary> |  | ||||||
|         public override void Down() |         public override void Down() | ||||||
|         { |         { | ||||||
|             Delete.Table(TableName); |             Delete.Table(TableName); | ||||||
|   | |||||||
| @@ -8,83 +8,37 @@ using System.Text.Json.Serialization; | |||||||
|  |  | ||||||
| namespace Mercados.Infrastructure.DataFetchers | namespace Mercados.Infrastructure.DataFetchers | ||||||
| { | { | ||||||
|     /// <summary> |  | ||||||
|     /// Implementación de <see cref="IDataFetcher"/> para obtener datos de cotizaciones de granos |  | ||||||
|     /// desde la API de la Bolsa de Comercio de Rosario (BCR). |  | ||||||
|     /// </summary> |  | ||||||
|     public class BcrDataFetcher : IDataFetcher |     public class BcrDataFetcher : IDataFetcher | ||||||
|     { |     { | ||||||
|         #region Clases DTO para la respuesta de la API de BCR |         #region Clases DTO para la respuesta de la API de BCR | ||||||
|         /// <summary> |  | ||||||
|         /// DTO para la respuesta del endpoint de autenticación de BCR. |  | ||||||
|         /// </summary> |  | ||||||
|         private class BcrTokenResponse |         private class BcrTokenResponse | ||||||
|         { |         { | ||||||
|             /// <summary> |  | ||||||
|             /// Contenedor de datos del token. |  | ||||||
|             /// </summary> |  | ||||||
|             [JsonPropertyName("data")] |             [JsonPropertyName("data")] | ||||||
|             public TokenData? Data { get; set; } |             public TokenData? Data { get; set; } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Contiene el token de autenticación. |  | ||||||
|         /// </summary> |  | ||||||
|         private class TokenData |         private class TokenData | ||||||
|         { |         { | ||||||
|             /// <summary> |  | ||||||
|             /// El token JWT para autenticar las solicitudes. |  | ||||||
|             /// </summary> |  | ||||||
|             [JsonPropertyName("token")] |             [JsonPropertyName("token")] | ||||||
|             public string? Token { get; set; } |             public string? Token { get; set; } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// DTO para la respuesta del endpoint de precios de BCR. |  | ||||||
|         /// </summary> |  | ||||||
|         private class BcrPreciosResponse |         private class BcrPreciosResponse | ||||||
|         { |         { | ||||||
|             /// <summary> |  | ||||||
|             /// Lista de precios de granos. |  | ||||||
|             /// </summary> |  | ||||||
|             [JsonPropertyName("data")] |             [JsonPropertyName("data")] | ||||||
|             public List<BcrPrecioItem>? Data { get; set; } |             public List<BcrPrecioItem>? Data { get; set; } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Representa un ítem individual de precio en la respuesta de la API de BCR. |  | ||||||
|         /// </summary> |  | ||||||
|         private class BcrPrecioItem |         private class BcrPrecioItem | ||||||
|         { |         { | ||||||
|             /// <summary> |  | ||||||
|             /// El precio de cotización del grano. |  | ||||||
|             /// </summary> |  | ||||||
|             [JsonPropertyName("precio_Cotizacion")] |             [JsonPropertyName("precio_Cotizacion")] | ||||||
|             public decimal PrecioCotizacion { get; set; } |             public decimal PrecioCotizacion { get; set; } | ||||||
|             /// <summary> |  | ||||||
|             /// La variación del precio respecto a la cotización anterior. |  | ||||||
|             /// </summary> |  | ||||||
|             [JsonPropertyName("variacion_Precio_Cotizacion")] |             [JsonPropertyName("variacion_Precio_Cotizacion")] | ||||||
|             public decimal VariacionPrecioCotizacion { get; set; } |             public decimal VariacionPrecioCotizacion { get; set; } | ||||||
|             /// <summary> |  | ||||||
|             /// La fecha en que se realizó la operación. |  | ||||||
|             /// </summary> |  | ||||||
|             [JsonPropertyName("fecha_Operacion_Pizarra")] |             [JsonPropertyName("fecha_Operacion_Pizarra")] | ||||||
|             public DateTime FechaOperacionPizarra { get; set; } |             public DateTime FechaOperacionPizarra { get; set; } | ||||||
|         } |         } | ||||||
|         #endregion |         #endregion | ||||||
|  |  | ||||||
|         /// <inheritdoc /> |  | ||||||
|         public string SourceName => "BCR"; |         public string SourceName => "BCR"; | ||||||
|          |  | ||||||
|         /// <summary> |  | ||||||
|         /// URL base de la API de BCR. |  | ||||||
|         /// </summary> |  | ||||||
|         private const string BaseUrl = "https://api.bcr.com.ar/gix/v1.0"; |         private const string BaseUrl = "https://api.bcr.com.ar/gix/v1.0"; | ||||||
|          |  | ||||||
|         /// <summary> |  | ||||||
|         /// Mapeo de nombres de granos a sus IDs correspondientes en la API de BCR. |  | ||||||
|         /// </summary> |  | ||||||
|         private readonly Dictionary<string, int> _grainIds = new() |         private readonly Dictionary<string, int> _grainIds = new() | ||||||
|         { |         { | ||||||
|             { "Trigo", 1 }, { "Maiz", 2 }, { "Sorgo", 3 }, { "Girasol", 20 }, { "Soja", 21 } |             { "Trigo", 1 }, { "Maiz", 2 }, { "Sorgo", 3 }, { "Girasol", 20 }, { "Soja", 21 } | ||||||
| @@ -96,14 +50,6 @@ namespace Mercados.Infrastructure.DataFetchers | |||||||
|         private readonly IConfiguration _configuration; |         private readonly IConfiguration _configuration; | ||||||
|         private readonly ILogger<BcrDataFetcher> _logger; |         private readonly ILogger<BcrDataFetcher> _logger; | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Inicializa una nueva instancia de la clase <see cref="BcrDataFetcher"/>. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <param name="httpClientFactory">Fábrica para crear instancias de HttpClient.</param> |  | ||||||
|         /// <param name="cotizacionRepository">Repositorio para guardar las cotizaciones de granos.</param> |  | ||||||
|         /// <param name="fuenteDatoRepository">Repositorio para gestionar la información de la fuente de datos.</param> |  | ||||||
|         /// <param name="configuration">Configuración de la aplicación para acceder a las claves de API.</param> |  | ||||||
|         /// <param name="logger">Logger para registrar información y errores.</param> |  | ||||||
|         public BcrDataFetcher( |         public BcrDataFetcher( | ||||||
|             IHttpClientFactory httpClientFactory, |             IHttpClientFactory httpClientFactory, | ||||||
|             ICotizacionGranoRepository cotizacionRepository, |             ICotizacionGranoRepository cotizacionRepository, | ||||||
| @@ -118,7 +64,19 @@ namespace Mercados.Infrastructure.DataFetchers | |||||||
|             _logger = logger; |             _logger = logger; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         /// <inheritdoc /> |         /// <summary> | ||||||
|  |         /// Formatea el nombre del grano para corregir acentos u otros detalles. | ||||||
|  |         /// </summary> | ||||||
|  |         private string FormatearNombreGrano(string nombreOriginal) | ||||||
|  |         { | ||||||
|  |             if (nombreOriginal.Equals("Maiz", StringComparison.OrdinalIgnoreCase)) | ||||||
|  |             { | ||||||
|  |                 // Devuelve la versión con el caso de la primera letra original, pero con acento. | ||||||
|  |                 return char.IsUpper(nombreOriginal[0]) ? "Maíz" : "maíz"; | ||||||
|  |             }             | ||||||
|  |             return nombreOriginal; // Devuelve el original si no hay ninguna regla | ||||||
|  |         } | ||||||
|  |  | ||||||
|         public async Task<(bool Success, string Message)> FetchDataAsync() |         public async Task<(bool Success, string Message)> FetchDataAsync() | ||||||
|         { |         { | ||||||
|             _logger.LogInformation("Iniciando fetch para {SourceName}.", SourceName); |             _logger.LogInformation("Iniciando fetch para {SourceName}.", SourceName); | ||||||
| @@ -151,7 +109,7 @@ namespace Mercados.Infrastructure.DataFetchers | |||||||
|                     { |                     { | ||||||
|                         cotizaciones.Add(new CotizacionGrano |                         cotizaciones.Add(new CotizacionGrano | ||||||
|                         { |                         { | ||||||
|                             Nombre = grain.Key, |                             Nombre = FormatearNombreGrano(grain.Key),  | ||||||
|                             Precio = latestRecord.PrecioCotizacion, |                             Precio = latestRecord.PrecioCotizacion, | ||||||
|                             VariacionPrecio = latestRecord.VariacionPrecioCotizacion, |                             VariacionPrecio = latestRecord.VariacionPrecioCotizacion, | ||||||
|                             FechaOperacion = latestRecord.FechaOperacionPizarra, |                             FechaOperacion = latestRecord.FechaOperacionPizarra, | ||||||
| @@ -179,11 +137,6 @@ namespace Mercados.Infrastructure.DataFetchers | |||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Obtiene un token de autenticación de la API de BCR. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <param name="client">El cliente HTTP a utilizar para la solicitud.</param> |  | ||||||
|         /// <returns>El token de autenticación como una cadena de texto, o null si la operación falla.</returns> |  | ||||||
|         private async Task<string?> GetAuthTokenAsync(HttpClient client) |         private async Task<string?> GetAuthTokenAsync(HttpClient client) | ||||||
|         { |         { | ||||||
|             var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/Login"); |             var request = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/Login"); | ||||||
| @@ -197,9 +150,6 @@ namespace Mercados.Infrastructure.DataFetchers | |||||||
|             return tokenResponse?.Data?.Token; |             return tokenResponse?.Data?.Token; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Actualiza la información de la fuente de datos en la base de datos, registrando la última ejecución exitosa. |  | ||||||
|         /// </summary> |  | ||||||
|         private async Task UpdateSourceInfoAsync() |         private async Task UpdateSourceInfoAsync() | ||||||
|         { |         { | ||||||
|             var fuente = await _fuenteDatoRepository.ObtenerPorNombreAsync(SourceName); |             var fuente = await _fuenteDatoRepository.ObtenerPorNombreAsync(SourceName); | ||||||
|   | |||||||
| @@ -7,18 +7,8 @@ using System.Net.Http; | |||||||
|  |  | ||||||
| namespace Mercados.Infrastructure.DataFetchers | namespace Mercados.Infrastructure.DataFetchers | ||||||
| { | { | ||||||
|     /// <summary> |  | ||||||
|     /// Implementación de <see cref="IDataFetcher"/> para obtener datos de cotizaciones de bolsa |  | ||||||
|     /// desde la API de Finnhub. |  | ||||||
|     /// </summary> |  | ||||||
|     /// <remarks> |  | ||||||
|     /// Utiliza la librería ThreeFourteen.Finnhub.Client para interactuar con la API. |  | ||||||
|     /// </remarks> |  | ||||||
|     public class FinnhubDataFetcher : IDataFetcher |     public class FinnhubDataFetcher : IDataFetcher | ||||||
|     { |     { | ||||||
|         /// <summary> |  | ||||||
|         /// Nombre de la fuente de datos utilizada por este fetcher. |  | ||||||
|         /// </summary> |  | ||||||
|         public string SourceName => "Finnhub"; |         public string SourceName => "Finnhub"; | ||||||
|         private readonly List<string> _tickers = new() { |         private readonly List<string> _tickers = new() { | ||||||
|             // Tecnológicas y ETFs |             // Tecnológicas y ETFs | ||||||
| @@ -34,17 +24,6 @@ namespace Mercados.Infrastructure.DataFetchers | |||||||
|         private readonly IFuenteDatoRepository _fuenteDatoRepository; |         private readonly IFuenteDatoRepository _fuenteDatoRepository; | ||||||
|         private readonly ILogger<FinnhubDataFetcher> _logger; |         private readonly ILogger<FinnhubDataFetcher> _logger; | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Inicializa una nueva instancia de la clase <see cref="FinnhubDataFetcher"/>. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <param name="configuration">Configuración de la aplicación para acceder a la clave de API de Finnhub.</param> |  | ||||||
|         /// <param name="httpClientFactory">Fábrica para crear instancias de HttpClient.</param> |  | ||||||
|         /// <param name="cotizacionRepository">Repositorio para guardar las cotizaciones de bolsa obtenidas.</param> |  | ||||||
|         /// <param name="fuenteDatoRepository">Repositorio para gestionar la información de la fuente de datos (Finnhub).</param> |  | ||||||
|         /// <param name="logger">Logger para registrar información y errores durante la ejecución.</param> |  | ||||||
|         /// <exception cref="InvalidOperationException"> |  | ||||||
|         /// Se lanza si la clave de API de Finnhub no está configurada en la aplicación. |  | ||||||
|         /// </exception> |  | ||||||
|         public FinnhubDataFetcher( |         public FinnhubDataFetcher( | ||||||
|             IConfiguration configuration, |             IConfiguration configuration, | ||||||
|             IHttpClientFactory httpClientFactory, |             IHttpClientFactory httpClientFactory, | ||||||
| @@ -63,12 +42,6 @@ namespace Mercados.Infrastructure.DataFetchers | |||||||
|             _logger = logger; |             _logger = logger; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Obtiene los datos de cotizaciones de bolsa desde la API de Finnhub para los tickers configurados |  | ||||||
|         /// y los guarda en la base de datos. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <returns>Una tupla que indica si la operación fue exitosa y un mensaje descriptivo del resultado.</returns> |  | ||||||
|         /// <inheritdoc /> |  | ||||||
|         public async Task<(bool Success, string Message)> FetchDataAsync() |         public async Task<(bool Success, string Message)> FetchDataAsync() | ||||||
|         { |         { | ||||||
|             _logger.LogInformation("Iniciando fetch para {SourceName}.", SourceName); |             _logger.LogInformation("Iniciando fetch para {SourceName}.", SourceName); | ||||||
| @@ -115,9 +88,6 @@ namespace Mercados.Infrastructure.DataFetchers | |||||||
|             return (true, $"Proceso completado. Se guardaron {cotizaciones.Count} registros."); |             return (true, $"Proceso completado. Se guardaron {cotizaciones.Count} registros."); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Actualiza la información de la fuente de datos (Finnhub) en la base de datos. |  | ||||||
|         /// </summary> |  | ||||||
|         private async Task UpdateSourceInfoAsync() |         private async Task UpdateSourceInfoAsync() | ||||||
|         { |         { | ||||||
|             var fuente = await _fuenteDatoRepository.ObtenerPorNombreAsync(SourceName); |             var fuente = await _fuenteDatoRepository.ObtenerPorNombreAsync(SourceName); | ||||||
|   | |||||||
| @@ -7,51 +7,24 @@ using System.Text.Json.Serialization; | |||||||
|  |  | ||||||
| namespace Mercados.Infrastructure.DataFetchers | namespace Mercados.Infrastructure.DataFetchers | ||||||
| { | { | ||||||
|     /// <summary> |     // Añadimos las clases DTO necesarias para deserializar la respuesta de Finnhub | ||||||
|     /// DTO para deserializar la respuesta de la API de Finnhub al obtener feriados de mercado. |  | ||||||
|     /// </summary> |  | ||||||
|     public class MarketHolidayResponse |     public class MarketHolidayResponse | ||||||
|     { |     { | ||||||
|         /// <summary> |  | ||||||
|         /// Lista de feriados del mercado. |  | ||||||
|         /// </summary> |  | ||||||
|         [JsonPropertyName("data")] |         [JsonPropertyName("data")] | ||||||
|         public List<MarketHoliday>? Data { get; set; } |         public List<MarketHoliday>? Data { get; set; } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// Representa un feriado de mercado individual en la respuesta de la API de Finnhub. |  | ||||||
|     /// </summary> |  | ||||||
|     public class MarketHoliday |     public class MarketHoliday | ||||||
|     { |     { | ||||||
|         /// <summary> |  | ||||||
|         /// Fecha del feriado en formato de cadena (YYYY-MM-DD). |  | ||||||
|         /// </summary> |  | ||||||
|         [JsonPropertyName("at")] |         [JsonPropertyName("at")] | ||||||
|         public string? At { get; set; } |         public string? At { get; set; } | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Fecha del feriado como <see cref="DateOnly"/>. |  | ||||||
|         /// </summary> |  | ||||||
|         [JsonIgnore] |         [JsonIgnore] | ||||||
|         public DateOnly Date => DateOnly.FromDateTime(DateTime.Parse(At!)); |         public DateOnly Date => DateOnly.FromDateTime(DateTime.Parse(At!)); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// Implementación de <see cref="IDataFetcher"/> para obtener datos de feriados de mercado |  | ||||||
|     /// desde la API de Finnhub. |  | ||||||
|     /// </summary> |  | ||||||
|     public class HolidayDataFetcher : IDataFetcher |     public class HolidayDataFetcher : IDataFetcher | ||||||
|     { |     { | ||||||
|         /// <inheritdoc /> |  | ||||||
|         public string SourceName => "Holidays"; |         public string SourceName => "Holidays"; | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Códigos de mercado para los cuales se obtendrán los feriados. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <remarks> |  | ||||||
|         /// "US" para Estados Unidos, "BA" para Argentina (Bolsa de Comercio de Buenos Aires). |  | ||||||
|         /// </remarks> |  | ||||||
|         private readonly string[] _marketCodes = { "US", "BA" }; |         private readonly string[] _marketCodes = { "US", "BA" }; | ||||||
|  |  | ||||||
|         private readonly IHttpClientFactory _httpClientFactory; |         private readonly IHttpClientFactory _httpClientFactory; | ||||||
| @@ -59,16 +32,6 @@ namespace Mercados.Infrastructure.DataFetchers | |||||||
|         private readonly IConfiguration _configuration; |         private readonly IConfiguration _configuration; | ||||||
|         private readonly ILogger<HolidayDataFetcher> _logger; |         private readonly ILogger<HolidayDataFetcher> _logger; | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Inicializa una nueva instancia de la clase <see cref="HolidayDataFetcher"/>. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <param name="httpClientFactory">Fábrica para crear instancias de HttpClient.</param> |  | ||||||
|         /// <param name="feriadoRepository">Repositorio para gestionar los feriados de mercado en la base de datos.</param> |  | ||||||
|         /// <param name="configuration">Configuración de la aplicación para acceder a la clave de API de Finnhub.</param> |  | ||||||
|         /// <param name="logger">Logger para registrar información y errores durante la ejecución.</param> |  | ||||||
|         /// <exception cref="InvalidOperationException"> |  | ||||||
|         /// Se lanza si la clave de API de Finnhub no está configurada en la aplicación. |  | ||||||
|         /// </exception> |  | ||||||
|         public HolidayDataFetcher( |         public HolidayDataFetcher( | ||||||
|             IHttpClientFactory httpClientFactory, |             IHttpClientFactory httpClientFactory, | ||||||
|             IMercadoFeriadoRepository feriadoRepository, |             IMercadoFeriadoRepository feriadoRepository, | ||||||
| @@ -81,24 +44,14 @@ namespace Mercados.Infrastructure.DataFetchers | |||||||
|             _logger = logger; |             _logger = logger; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Obtiene los datos de feriados de mercado desde la API de Finnhub y los guarda en la base de datos. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <returns>Una tupla que indica si la operación fue exitosa y un mensaje descriptivo del resultado.</returns> |  | ||||||
|         /// <inheritdoc /> |  | ||||||
|         public async Task<(bool Success, string Message)> FetchDataAsync() |         public async Task<(bool Success, string Message)> FetchDataAsync() | ||||||
|         { |         { | ||||||
|             _logger.LogInformation("Iniciando actualización de feriados."); |             _logger.LogInformation("Iniciando actualización de feriados."); | ||||||
|  |  | ||||||
|             // Verificamos que la API Key de Finnhub esté configurada |  | ||||||
|             var apiKey = _configuration["ApiKeys:Finnhub"]; |             var apiKey = _configuration["ApiKeys:Finnhub"]; | ||||||
|             if (string.IsNullOrEmpty(apiKey)) |             if (string.IsNullOrEmpty(apiKey)) return (false, "API Key de Finnhub no configurada."); | ||||||
|             { |  | ||||||
|                 return (false, "API Key de Finnhub no configurada."); |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             var client = _httpClientFactory.CreateClient("FinnhubDataFetcher"); |             var client = _httpClientFactory.CreateClient("FinnhubDataFetcher"); | ||||||
|             // Iteramos sobre cada código de mercado configurado |  | ||||||
|             foreach (var marketCode in _marketCodes) |             foreach (var marketCode in _marketCodes) | ||||||
|             { |             { | ||||||
|                 try |                 try | ||||||
| @@ -106,24 +59,18 @@ namespace Mercados.Infrastructure.DataFetchers | |||||||
|                     var apiUrl = $"https://finnhub.io/api/v1/stock/market-holiday?exchange={marketCode}&token={apiKey}"; |                     var apiUrl = $"https://finnhub.io/api/v1/stock/market-holiday?exchange={marketCode}&token={apiKey}"; | ||||||
|                     // Ahora la deserialización funcionará porque la clase existe |                     // Ahora la deserialización funcionará porque la clase existe | ||||||
|                     var response = await client.GetFromJsonAsync<MarketHolidayResponse>(apiUrl); |                     var response = await client.GetFromJsonAsync<MarketHolidayResponse>(apiUrl); | ||||||
|                      |  | ||||||
|                     // Si obtuvimos datos en la respuesta |  | ||||||
|                     if (response?.Data != null) |                     if (response?.Data != null) | ||||||
|                     { |                     { | ||||||
|                         // Convertimos los datos de la API al formato de nuestra entidad MercadoFeriado |  | ||||||
|                         var nuevosFeriados = response.Data.Select(h => new MercadoFeriado |                         var nuevosFeriados = response.Data.Select(h => new MercadoFeriado | ||||||
|                         { |                         { | ||||||
|                             CodigoMercado = marketCode, |                             CodigoMercado = marketCode, | ||||||
|                             Fecha = h.Date.ToDateTime(TimeOnly.MinValue), |                             Fecha = h.Date.ToDateTime(TimeOnly.MinValue), | ||||||
|                             Nombre = "Feriado Bursátil" |                             Nombre = "Feriado Bursátil" | ||||||
|                         }).ToList(); |                         }).ToList(); | ||||||
|                          |  | ||||||
|                         // Guardamos los feriados en la base de datos, reemplazando los existentes para ese mercado |  | ||||||
|                         await _feriadoRepository.ReemplazarFeriadosPorMercadoAsync(marketCode, nuevosFeriados); |                         await _feriadoRepository.ReemplazarFeriadosPorMercadoAsync(marketCode, nuevosFeriados); | ||||||
|                         _logger.LogInformation( |                         _logger.LogInformation("Feriados para {MarketCode} actualizados exitosamente: {Count} registros.", marketCode, nuevosFeriados.Count); | ||||||
|                             "Feriados para {MarketCode} actualizados exitosamente: {Count} registros.",  |  | ||||||
|                             marketCode,  |  | ||||||
|                             nuevosFeriados.Count); |  | ||||||
|                     } |                     } | ||||||
|                 } |                 } | ||||||
|                 catch (Exception ex) |                 catch (Exception ex) | ||||||
| @@ -131,7 +78,6 @@ namespace Mercados.Infrastructure.DataFetchers | |||||||
|                     _logger.LogError(ex, "Falló la obtención de feriados para {MarketCode}.", marketCode); |                     _logger.LogError(ex, "Falló la obtención de feriados para {MarketCode}.", marketCode); | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|             // Retornamos éxito si el proceso completo se ejecutó sin errores irrecuperables |  | ||||||
|             return (true, "Actualización de feriados completada."); |             return (true, "Actualización de feriados completada."); | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -6,39 +6,16 @@ using System.Globalization; | |||||||
|  |  | ||||||
| namespace Mercados.Infrastructure.DataFetchers | namespace Mercados.Infrastructure.DataFetchers | ||||||
| { | { | ||||||
|     /// <summary> |  | ||||||
|     /// Implementación de <see cref="IDataFetcher"/> para obtener datos de cotizaciones de ganado |  | ||||||
|     /// desde el sitio web de Mercado Agro Ganadero. |  | ||||||
|     /// </summary> |  | ||||||
|     /// <remarks> |  | ||||||
|     /// Utiliza AngleSharp para el parsing del HTML. |  | ||||||
|     /// </remarks> |  | ||||||
|     public class MercadoAgroFetcher : IDataFetcher |     public class MercadoAgroFetcher : IDataFetcher | ||||||
|     { |     { | ||||||
|         /// <inheritdoc /> |  | ||||||
|         public string SourceName => "MercadoAgroganadero"; |         public string SourceName => "MercadoAgroganadero"; | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// URL del sitio web de Mercado Agro Ganadero donde se encuentran las cotizaciones. |  | ||||||
|         /// </summary> |  | ||||||
|         private const string DataUrl = "https://www.mercadoagroganadero.com.ar/dll/hacienda6.dll/haciinfo000225"; |         private const string DataUrl = "https://www.mercadoagroganadero.com.ar/dll/hacienda6.dll/haciinfo000225"; | ||||||
|  |  | ||||||
|         private readonly IHttpClientFactory _httpClientFactory; |         private readonly IHttpClientFactory _httpClientFactory; | ||||||
|         private readonly ICotizacionGanadoRepository _cotizacionRepository; |         private readonly ICotizacionGanadoRepository _cotizacionRepository; | ||||||
|         private readonly IFuenteDatoRepository _fuenteDatoRepository; |         private readonly IFuenteDatoRepository _fuenteDatoRepository; | ||||||
|         private readonly ILogger<MercadoAgroFetcher> _logger; |         private readonly ILogger<MercadoAgroFetcher> _logger; | ||||||
|  |         private readonly TimeZoneInfo _argentinaTimeZone; | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Inicializa una nueva instancia de la clase <see cref="MercadoAgroFetcher"/>. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <param name="httpClientFactory">Fábrica para crear instancias de HttpClient, configuradas con políticas de reintento.</param> |  | ||||||
|         /// <param name="cotizacionRepository">Repositorio para guardar las cotizaciones de ganado obtenidas.</param> |  | ||||||
|         /// <param name="fuenteDatoRepository">Repositorio para gestionar la información de la fuente de datos (Mercado Agro Ganadero).</param> |  | ||||||
|         /// <param name="logger">Logger para registrar información y errores durante la ejecución.</param> |  | ||||||
|         /// <remarks> |  | ||||||
|         /// El constructor requiere una <see cref="IHttpClientFactory"/> que debe tener configurado un cliente HTTP |  | ||||||
|         /// con el nombre "MercadoAgroFetcher", y este cliente debe tener aplicada una política de reintentos (ej. con Polly). |  | ||||||
|         /// </remarks> |  | ||||||
|         public MercadoAgroFetcher( |         public MercadoAgroFetcher( | ||||||
|             IHttpClientFactory httpClientFactory, |             IHttpClientFactory httpClientFactory, | ||||||
|             ICotizacionGanadoRepository cotizacionRepository, |             ICotizacionGanadoRepository cotizacionRepository, | ||||||
| @@ -49,76 +26,100 @@ namespace Mercados.Infrastructure.DataFetchers | |||||||
|             _cotizacionRepository = cotizacionRepository; |             _cotizacionRepository = cotizacionRepository; | ||||||
|             _fuenteDatoRepository = fuenteDatoRepository; |             _fuenteDatoRepository = fuenteDatoRepository; | ||||||
|             _logger = logger; |             _logger = logger; | ||||||
|  |             try | ||||||
|  |             { | ||||||
|  |                 _argentinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("America/Argentina/Buenos_Aires"); | ||||||
|  |             } | ||||||
|  |             catch (TimeZoneNotFoundException) | ||||||
|  |             { | ||||||
|  |                 _argentinaTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Argentina Standard Time"); | ||||||
|  |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Obtiene los datos de cotizaciones de ganado desde el sitio web de Mercado Agro Ganadero, |  | ||||||
|         /// los parsea y los guarda en la base de datos. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <returns>Una tupla que indica si la operación fue exitosa y un mensaje descriptivo del resultado.</returns> |  | ||||||
|         /// <inheritdoc /> |  | ||||||
|         public async Task<(bool Success, string Message)> FetchDataAsync() |         public async Task<(bool Success, string Message)> FetchDataAsync() | ||||||
|         { |         { | ||||||
|             _logger.LogInformation("Iniciando fetch para {SourceName}.", SourceName); |             _logger.LogInformation("Iniciando fetch para {SourceName}.", SourceName); | ||||||
|  |  | ||||||
|             try |             try | ||||||
|             { |             { | ||||||
|                 var htmlContent = await GetHtmlContentAsync(); |                 var htmlContent = await GetHtmlContentAsync(); | ||||||
|                 if (string.IsNullOrEmpty(htmlContent)) |                 if (string.IsNullOrEmpty(htmlContent)) return (false, "No se pudo obtener el contenido HTML."); | ||||||
|  |  | ||||||
|  |                 var cotizacionesNuevas = ParseHtmlToEntities(htmlContent); | ||||||
|  |                 if (!cotizacionesNuevas.Any()) | ||||||
|                 { |                 { | ||||||
|                     return (false, "No se pudo obtener el contenido HTML."); |                     return (true, "Conexión exitosa, pero no se encontraron nuevos datos de ganado."); | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|                 var cotizaciones = ParseHtmlToEntities(htmlContent); |                 var ahoraEnArgentina = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, _argentinaTimeZone); | ||||||
|                 if (!cotizaciones.Any()) |                 var hoy = DateOnly.FromDateTime(ahoraEnArgentina); | ||||||
|  |                 var cotizacionesViejas = await _cotizacionRepository.ObtenerTandaPorFechaAsync(hoy); | ||||||
|  |  | ||||||
|  |                 // Si el número de registros es diferente, sabemos que hay cambios. | ||||||
|  |                 if (cotizacionesViejas.Count() != cotizacionesNuevas.Count) | ||||||
|                 { |                 { | ||||||
|                     // Esto NO es un error crítico, es un estado informativo. |                     _logger.LogInformation("El número de registros de {SourceName} ha cambiado. Actualizando...", SourceName); | ||||||
|                     _logger.LogInformation("La conexión con {SourceName} fue exitosa, pero no se encontraron datos de cotizaciones para procesar.", SourceName); |                 } | ||||||
|                     return (true, "Conexión exitosa, pero no se encontraron nuevos datos."); |                 else | ||||||
|  |                 { | ||||||
|  |                     // Si el número de registros es el mismo, comparamos el contenido. | ||||||
|  |                     // Convertimos los nuevos a un diccionario para una búsqueda rápida por clave. | ||||||
|  |                     var nuevasDict = cotizacionesNuevas.ToDictionary(c => $"{c.Categoria}|{c.Especificaciones}"); | ||||||
|  |  | ||||||
|  |                     bool hayCambios = false; | ||||||
|  |                     foreach (var cotizacionVieja in cotizacionesViejas) | ||||||
|  |                     { | ||||||
|  |                         var clave = $"{cotizacionVieja.Categoria}|{cotizacionVieja.Especificaciones}"; | ||||||
|  |  | ||||||
|  |                         // Buscamos si el registro viejo existe en los nuevos y comparamos los valores. | ||||||
|  |                         if (!nuevasDict.TryGetValue(clave, out var cotizacionNueva) || | ||||||
|  |                             cotizacionVieja.Maximo != cotizacionNueva.Maximo || | ||||||
|  |                             cotizacionVieja.Minimo != cotizacionNueva.Minimo || | ||||||
|  |                             cotizacionVieja.Cabezas != cotizacionNueva.Cabezas || | ||||||
|  |                             cotizacionVieja.KilosTotales != cotizacionNueva.KilosTotales) | ||||||
|  |                         { | ||||||
|  |                             hayCambios = true; | ||||||
|  |                             _logger.LogInformation("Se detectó un cambio en la categoría: {Categoria}", clave); | ||||||
|  |                             break; // Encontramos un cambio, no necesitamos seguir buscando. | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |  | ||||||
|  |                     if (!hayCambios) | ||||||
|  |                     { | ||||||
|  |                         _logger.LogInformation("No se encontraron cambios en los datos de {SourceName}. No se requiere actualización.", SourceName); | ||||||
|  |                         return (true, "Datos sin cambios."); | ||||||
|  |                     } | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|                 await _cotizacionRepository.GuardarMuchosAsync(cotizaciones); |                 _logger.LogInformation("Se detectaron cambios en los datos de {SourceName}. Reemplazando la tanda del día...", SourceName); | ||||||
|  |                 await _cotizacionRepository.ReemplazarTandaDelDiaAsync(hoy, cotizacionesNuevas); | ||||||
|  |  | ||||||
|                 await UpdateSourceInfoAsync(); |                 await UpdateSourceInfoAsync(); | ||||||
|  |  | ||||||
|                 _logger.LogInformation("Fetch para {SourceName} completado exitosamente. Se guardaron {Count} registros.", SourceName, cotizaciones.Count); |                 _logger.LogInformation("Fetch para {SourceName} completado. Se actualizaron {Count} registros.", SourceName, cotizacionesNuevas.Count); | ||||||
|                 return (true, $"Proceso completado. Se guardaron {cotizaciones.Count} registros."); |                 return (true, $"Proceso completado. Se actualizaron {cotizacionesNuevas.Count} registros."); | ||||||
|             } |             } | ||||||
|             catch (Exception ex) |             catch (Exception ex) | ||||||
|             { |             { | ||||||
|                 // Un catch aquí sí es un error real (ej. 404, timeout, etc.) |  | ||||||
|                 _logger.LogError(ex, "Ocurrió un error durante el fetch para {SourceName}.", SourceName); |                 _logger.LogError(ex, "Ocurrió un error durante el fetch para {SourceName}.", SourceName); | ||||||
|                 return (false, $"Error: {ex.Message}"); |                 return (false, $"Error: {ex.Message}"); | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Obtiene el contenido HTML de la página de cotizaciones. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <returns>El contenido HTML como una cadena.</returns> |  | ||||||
|         /// <exception cref="HttpRequestException"> |  | ||||||
|         /// Se lanza si la solicitud HTTP no es exitosa. |  | ||||||
|         /// </exception> |  | ||||||
|         private async Task<string> GetHtmlContentAsync() |         private async Task<string> GetHtmlContentAsync() | ||||||
|         { |         { | ||||||
|             // Pedimos el cliente HTTP con el nombre específico que tiene la política de Polly |             // Pedimos el cliente HTTP con el nombre específico que tiene la política de Polly | ||||||
|             var client = _httpClientFactory.CreateClient("MercadoAgroFetcher"); |             var client = _httpClientFactory.CreateClient("MercadoAgroFetcher"); | ||||||
|  |  | ||||||
|             // Es importante simular un navegador para evitar bloqueos. |             // Es importante simular un navegador para evitar bloqueos. | ||||||
|             client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"); |             client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"); | ||||||
|  |  | ||||||
|             var response = await client.GetAsync(DataUrl); |             var response = await client.GetAsync(DataUrl); | ||||||
|             response.EnsureSuccessStatusCode(); |             response.EnsureSuccessStatusCode(); | ||||||
|  |  | ||||||
|             // El sitio usa una codificación específica, hay que decodificarla correctamente. |             // El sitio usa una codificación específica, hay que decodificarla correctamente. | ||||||
|             var stream = await response.Content.ReadAsStreamAsync(); |             var stream = await response.Content.ReadAsStreamAsync(); | ||||||
|             using var reader = new StreamReader(stream, System.Text.Encoding.GetEncoding("windows-1252")); |             using var reader = new StreamReader(stream, System.Text.Encoding.GetEncoding("windows-1252")); | ||||||
|             return await reader.ReadToEndAsync(); |             return await reader.ReadToEndAsync(); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Parsea el contenido HTML para extraer las cotizaciones de ganado. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <param name="html">El HTML a parsear.</param> |  | ||||||
|         /// <returns>Una lista de entidades <see cref="CotizacionGanado"/>.</returns> |  | ||||||
|         private List<CotizacionGanado> ParseHtmlToEntities(string html) |         private List<CotizacionGanado> ParseHtmlToEntities(string html) | ||||||
|         { |         { | ||||||
|             var config = Configuration.Default; |             var config = Configuration.Default; | ||||||
| @@ -149,7 +150,7 @@ namespace Mercados.Infrastructure.DataFetchers | |||||||
|                         Categoria = celdas[1], |                         Categoria = celdas[1], | ||||||
|                         Especificaciones = $"{celdas[2]} - {celdas[3]}", |                         Especificaciones = $"{celdas[2]} - {celdas[3]}", | ||||||
|                         Maximo = ParseDecimal(celdas[4]), |                         Maximo = ParseDecimal(celdas[4]), | ||||||
|                          Minimo = ParseDecimal(celdas[5]), |                         Minimo = ParseDecimal(celdas[5]), | ||||||
|                         Promedio = ParseDecimal(celdas[6]), |                         Promedio = ParseDecimal(celdas[6]), | ||||||
|                         Mediano = ParseDecimal(celdas[7]), |                         Mediano = ParseDecimal(celdas[7]), | ||||||
|                         Cabezas = ParseInt(celdas[8]), |                         Cabezas = ParseInt(celdas[8]), | ||||||
| @@ -162,24 +163,21 @@ namespace Mercados.Infrastructure.DataFetchers | |||||||
|                 } |                 } | ||||||
|                 catch (Exception ex) |                 catch (Exception ex) | ||||||
|                 { |                 { | ||||||
|                     _logger.LogWarning(ex, "No se pudo parsear una fila de la tabla. Contenido: {RowContent}", |                     _logger.LogWarning(ex, "No se pudo parsear una fila de la tabla. Contenido: {RowContent}", string.Join(" | ", celdas)); | ||||||
|                         string.Join(" | ", celdas)); |  | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             return cotizaciones; |             return cotizaciones; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Actualiza la información de la fuente de datos (Mercado Agro Ganadero) en la base de datos. |  | ||||||
|         /// </summary> |  | ||||||
|         private async Task UpdateSourceInfoAsync() |         private async Task UpdateSourceInfoAsync() | ||||||
|         { |         { | ||||||
|             var fuente = await _fuenteDatoRepository.ObtenerPorNombreAsync(SourceName); |             var fuente = await _fuenteDatoRepository.ObtenerPorNombreAsync(SourceName); | ||||||
|             if (fuente == null) |             if (fuente == null) | ||||||
|             { |             { | ||||||
|                  await _fuenteDatoRepository.CrearAsync(new FuenteDato |                 await _fuenteDatoRepository.CrearAsync(new FuenteDato | ||||||
|                  { |                 { | ||||||
|                      Nombre = SourceName, |                     Nombre = SourceName, | ||||||
|                     Url = DataUrl, |                     Url = DataUrl, | ||||||
|                     UltimaEjecucionExitosa = DateTime.UtcNow |                     UltimaEjecucionExitosa = DateTime.UtcNow | ||||||
|                 }); |                 }); | ||||||
| @@ -193,33 +191,17 @@ namespace Mercados.Infrastructure.DataFetchers | |||||||
|         } |         } | ||||||
|  |  | ||||||
|         // --- Funciones de Ayuda para Parseo --- |         // --- Funciones de Ayuda para Parseo --- | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// <see cref="CultureInfo"/> para el parseo de números en formato "es-AR". |  | ||||||
|         /// </summary> |  | ||||||
|         private readonly CultureInfo _cultureInfo = new CultureInfo("es-AR"); |         private readonly CultureInfo _cultureInfo = new CultureInfo("es-AR"); | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Parsea una cadena a decimal, considerando el formato numérico de Argentina. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <param name="value">La cadena a parsear.</param> |  | ||||||
|         /// <returns>El valor decimal parseado.</returns> |  | ||||||
|         private decimal ParseDecimal(string value) |         private decimal ParseDecimal(string value) | ||||||
|         { |         { | ||||||
|             // El sitio usa '.' como separador de miles y ',' como decimal. |             // El sitio usa '.' como separador de miles y ',' como decimal. | ||||||
|             // Primero quitamos el de miles, luego reemplazamos la coma decimal por un punto. |             // Primero quitamos el de miles, luego reemplazamos la coma decimal por un punto. | ||||||
|             var cleanValue = value.Replace("$", "").Replace(".", "").Trim(); |             var cleanValue = value.Replace("$", "").Replace(".", "").Trim(); | ||||||
|             return decimal.Parse(cleanValue, NumberStyles.AllowDecimalPoint | NumberStyles.AllowLeadingSign, _cultureInfo); |             return decimal.Parse(cleanValue, NumberStyles.AllowDecimalPoint | NumberStyles.AllowLeadingSign, _cultureInfo); | ||||||
|          } |         } | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Parsea una cadena a entero, quitando separadores de miles. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <param name="value">La cadena a parsear.</param> |  | ||||||
|         /// <returns>El valor entero parseado.</returns> |  | ||||||
|         private int ParseInt(string value) |         private int ParseInt(string value) | ||||||
|          { |         { | ||||||
|              return int.Parse(value.Replace(".", ""), CultureInfo.InvariantCulture); |             return int.Parse(value.Replace(".", ""), CultureInfo.InvariantCulture); | ||||||
|          } |         } | ||||||
|      } |     } | ||||||
|  } | } | ||||||
| @@ -1,19 +1,13 @@ | |||||||
| namespace Mercados.Infrastructure.DataFetchers | namespace Mercados.Infrastructure.DataFetchers | ||||||
| { | { | ||||||
|     /// <summary> |  | ||||||
|     /// Clase estática que proporciona un mapeo entre los tickers de acciones y sus nombres descriptivos. |  | ||||||
|     /// </summary> |  | ||||||
|     public static class TickerNameMapping |     public static class TickerNameMapping | ||||||
|     { |     { | ||||||
|         /// <summary> |  | ||||||
|         /// Diccionario privado que almacena los tickers como claves y los nombres de las empresas como valores. |  | ||||||
|         /// La comparación de claves no distingue entre mayúsculas y minúsculas. |  | ||||||
|         /// </summary> |  | ||||||
|         private static readonly Dictionary<string, string> Names = new(StringComparer.OrdinalIgnoreCase) |         private static readonly Dictionary<string, string> Names = new(StringComparer.OrdinalIgnoreCase) | ||||||
|         { |         { | ||||||
|           // USA |           // USA | ||||||
|             { "SPY", "S&P 500 ETF" }, |             { "SPY", "S&P 500 ETF" }, // Cambiado de GSPC a SPY para Finnhub | ||||||
|             { "AAPL", "Apple Inc." }, |             { "AAPL", "Apple Inc." }, | ||||||
|  |             { "MSFT", "Microsoft Corp." }, | ||||||
|             { "AMZN", "Amazon.com, Inc." }, |             { "AMZN", "Amazon.com, Inc." }, | ||||||
|             { "NVDA", "NVIDIA Corp." }, |             { "NVDA", "NVIDIA Corp." }, | ||||||
|             { "AMD", "Advanced Micro Devices" }, |             { "AMD", "Advanced Micro Devices" }, | ||||||
| @@ -25,7 +19,6 @@ namespace Mercados.Infrastructure.DataFetchers | |||||||
|             { "XLE", "Energy Select Sector SPDR" }, |             { "XLE", "Energy Select Sector SPDR" }, | ||||||
|             { "XLK", "Technology Select Sector SPDR" }, |             { "XLK", "Technology Select Sector SPDR" }, | ||||||
|             { "MELI", "MercadoLibre, Inc." }, |             { "MELI", "MercadoLibre, Inc." }, | ||||||
|             { "MSFT", "Microsoft Corp." }, |  | ||||||
|             { "GLOB", "Globant" }, |             { "GLOB", "Globant" }, | ||||||
|              |              | ||||||
|             // ADRs Argentinos que cotizan en EEUU |             // ADRs Argentinos que cotizan en EEUU | ||||||
| @@ -60,15 +53,9 @@ namespace Mercados.Infrastructure.DataFetchers | |||||||
|             { "MELI.BA", "MercadoLibre (CEDEAR)" }, // Aclaramos que es el CEDEAR |             { "MELI.BA", "MercadoLibre (CEDEAR)" }, // Aclaramos que es el CEDEAR | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Obtiene el nombre descriptivo asociado a un ticker. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <param name="ticker">El ticker de la acción (ej. "AAPL").</param> |  | ||||||
|         /// <returns>El nombre completo de la empresa si se encuentra en el mapeo; de lo contrario, null.</returns> |  | ||||||
|         public static string? GetName(string ticker) |         public static string? GetName(string ticker) | ||||||
|         { |         { | ||||||
|             // Devuelve el nombre si existe, o null si no se encuentra la clave. |             return Names.GetValueOrDefault(ticker); | ||||||
|             return Names.TryGetValue(ticker, out var name) ? name : $"Ticker no reconocido: {ticker}"; |  | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -5,24 +5,9 @@ using YahooFinanceApi; | |||||||
|  |  | ||||||
| namespace Mercados.Infrastructure.DataFetchers | namespace Mercados.Infrastructure.DataFetchers | ||||||
| { | { | ||||||
|     /// <summary> |  | ||||||
|     /// Implementación de <see cref="IDataFetcher"/> para obtener datos de cotizaciones de bolsa |  | ||||||
|     /// desde la API de Yahoo Finance. |  | ||||||
|     /// </summary> |  | ||||||
|     /// <remarks> |  | ||||||
|     /// Utiliza la librería YahooFinanceApi para interactuar con la API. |  | ||||||
|     /// </remarks> |  | ||||||
|     public class YahooFinanceDataFetcher : IDataFetcher |     public class YahooFinanceDataFetcher : IDataFetcher | ||||||
|     { |     { | ||||||
|         /// <inheritdoc /> |  | ||||||
|         public string SourceName => "YahooFinance"; |         public string SourceName => "YahooFinance"; | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Lista de tickers a obtener de Yahoo Finance. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <remarks> |  | ||||||
|         /// Incluye el índice S&P 500, acciones del Merval argentino y algunos CEDEARs. |  | ||||||
|         /// </remarks> |  | ||||||
|         private readonly List<string> _tickers = new() { |         private readonly List<string> _tickers = new() { | ||||||
|             "^GSPC", // Índice S&P 500 |             "^GSPC", // Índice S&P 500 | ||||||
|             "^MERV", "GGAL.BA", "YPFD.BA", "PAMP.BA", "BMA.BA", "COME.BA", |             "^MERV", "GGAL.BA", "YPFD.BA", "PAMP.BA", "BMA.BA", "COME.BA", | ||||||
| @@ -30,36 +15,20 @@ namespace Mercados.Infrastructure.DataFetchers | |||||||
|             "CEPU.BA", "LOMA.BA", "VALO.BA", "MELI.BA" |             "CEPU.BA", "LOMA.BA", "VALO.BA", "MELI.BA" | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Diccionario para almacenar el mapeo de tickers con su información de mercado (Local o EEUU). |  | ||||||
|         /// </summary> |  | ||||||
|         private readonly Dictionary<string, string> _tickerMarketMapping = new Dictionary<string, string>(); |  | ||||||
|  |  | ||||||
|         private readonly ICotizacionBolsaRepository _cotizacionRepository; |         private readonly ICotizacionBolsaRepository _cotizacionRepository; | ||||||
|         private readonly IFuenteDatoRepository _fuenteDatoRepository; |         private readonly IFuenteDatoRepository _fuenteDatoRepository; | ||||||
|         private readonly ILogger<YahooFinanceDataFetcher> _logger; |         private readonly ILogger<YahooFinanceDataFetcher> _logger; | ||||||
|  |  | ||||||
|         /// <summary> |         public YahooFinanceDataFetcher( | ||||||
|         /// Inicializa una nueva instancia de la clase <see cref="YahooFinanceDataFetcher"/>. |             ICotizacionBolsaRepository cotizacionRepository, | ||||||
|         /// </summary> |             IFuenteDatoRepository fuenteDatoRepository, | ||||||
|         /// <param name="cotizacionRepository">Repositorio para guardar las cotizaciones de bolsa obtenidas.</param> |             ILogger<YahooFinanceDataFetcher> logger) | ||||||
|         /// <param name="fuenteDatoRepository">Repositorio para gestionar la información de la fuente de datos (Yahoo Finance).</param> |         { | ||||||
|         /// <param name="logger">Logger para registrar información y errores durante la ejecución.</param> |             _cotizacionRepository = cotizacionRepository; | ||||||
|                 public YahooFinanceDataFetcher( |             _fuenteDatoRepository = fuenteDatoRepository; | ||||||
|                     ICotizacionBolsaRepository cotizacionRepository, |             _logger = logger; | ||||||
|                     IFuenteDatoRepository fuenteDatoRepository, |         } | ||||||
|                     ILogger<YahooFinanceDataFetcher> logger) |  | ||||||
|                 { |  | ||||||
|                     _cotizacionRepository = cotizacionRepository; |  | ||||||
|                     _fuenteDatoRepository = fuenteDatoRepository; |  | ||||||
|                     _logger = logger; |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Obtiene los datos de cotizaciones de bolsa desde la API de Yahoo Finance para los tickers configurados |  | ||||||
|         /// y los guarda en la base de datos. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <returns>Una tupla que indica si la operación fue exitosa y un mensaje descriptivo del resultado.</returns> |  | ||||||
|         public async Task<(bool Success, string Message)> FetchDataAsync() |         public async Task<(bool Success, string Message)> FetchDataAsync() | ||||||
|         { |         { | ||||||
|             _logger.LogInformation("Iniciando fetch para {SourceName}.", SourceName); |             _logger.LogInformation("Iniciando fetch para {SourceName}.", SourceName); | ||||||
| @@ -72,7 +41,7 @@ namespace Mercados.Infrastructure.DataFetchers | |||||||
|                 { |                 { | ||||||
|                     if (sec.RegularMarketPrice == 0 || sec.RegularMarketPreviousClose == 0) continue; |                     if (sec.RegularMarketPrice == 0 || sec.RegularMarketPreviousClose == 0) continue; | ||||||
|  |  | ||||||
|                     string mercado = DetermineMarket(sec.Symbol); |                     string mercado = sec.Symbol.EndsWith(".BA") || sec.Symbol == "^MERV" ? "Local" : "EEUU"; | ||||||
|  |  | ||||||
|                     cotizaciones.Add(new CotizacionBolsa |                     cotizaciones.Add(new CotizacionBolsa | ||||||
|                     { |                     { | ||||||
| @@ -106,27 +75,6 @@ namespace Mercados.Infrastructure.DataFetchers | |||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Determina el mercado (Local o EEUU) para un ticker específico. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <param name="symbol">El ticker de la acción.</param> |  | ||||||
|         /// <returns>El mercado al que pertenece el ticker.</returns> |  | ||||||
|         private string DetermineMarket(string symbol) |  | ||||||
|         { |  | ||||||
|             if (_tickerMarketMapping.TryGetValue(symbol, out string? market)) |  | ||||||
|             { |  | ||||||
|                 return market; |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|             // Si no existe en el mapping, determinamos y lo agregamos. |  | ||||||
|             market = symbol.EndsWith(".BA") || symbol == "^MERV" ? "Local" : "EEUU"; |  | ||||||
|             _tickerMarketMapping[symbol] = market; |  | ||||||
|             return market; |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Actualiza la información de la fuente de datos (Yahoo Finance) en la base de datos. |  | ||||||
|         /// </summary> |  | ||||||
|         private async Task UpdateSourceInfoAsync() |         private async Task UpdateSourceInfoAsync() | ||||||
|         { |         { | ||||||
|             var fuente = await _fuenteDatoRepository.ObtenerPorNombreAsync(SourceName); |             var fuente = await _fuenteDatoRepository.ObtenerPorNombreAsync(SourceName); | ||||||
|   | |||||||
| @@ -21,7 +21,6 @@ | |||||||
|     <TargetFramework>net9.0</TargetFramework> |     <TargetFramework>net9.0</TargetFramework> | ||||||
|     <ImplicitUsings>enable</ImplicitUsings> |     <ImplicitUsings>enable</ImplicitUsings> | ||||||
|     <Nullable>enable</Nullable> |     <Nullable>enable</Nullable> | ||||||
|     <GenerateDocumentationFile>true</GenerateDocumentationFile> |  | ||||||
|   </PropertyGroup> |   </PropertyGroup> | ||||||
|  |  | ||||||
| </Project> | </Project> | ||||||
|   | |||||||
| @@ -2,15 +2,8 @@ using System.Data; | |||||||
|  |  | ||||||
| namespace Mercados.Infrastructure.Persistence | namespace Mercados.Infrastructure.Persistence | ||||||
| { | { | ||||||
|     /// <summary> |   public interface IDbConnectionFactory | ||||||
|     /// Define una interfaz para una fábrica de conexiones a la base de datos. |   { | ||||||
|     /// </summary> |     IDbConnection CreateConnection(); | ||||||
|     public interface IDbConnectionFactory |   } | ||||||
|     { |  | ||||||
|         /// <summary> |  | ||||||
|         /// Crea y abre una nueva conexión a la base de datos. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <returns>Un objeto <see cref="IDbConnection"/> representando la conexión abierta.</returns> |  | ||||||
|         IDbConnection CreateConnection(); |  | ||||||
|     } |  | ||||||
| } | } | ||||||
| @@ -4,34 +4,26 @@ using System.Data; | |||||||
|  |  | ||||||
| namespace Mercados.Infrastructure.Persistence.Repositories | namespace Mercados.Infrastructure.Persistence.Repositories | ||||||
| { | { | ||||||
|     /// <inheritdoc cref="ICotizacionBolsaRepository"/> |  | ||||||
|     public class CotizacionBolsaRepository : ICotizacionBolsaRepository |     public class CotizacionBolsaRepository : ICotizacionBolsaRepository | ||||||
|     { |     { | ||||||
|         private readonly IDbConnectionFactory _connectionFactory; |         private readonly IDbConnectionFactory _connectionFactory; | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Inicializa una nueva instancia de la clase <see cref="CotizacionBolsaRepository"/>. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <param name="connectionFactory">Fábrica para crear conexiones a la base de datos.</param> |  | ||||||
|         public CotizacionBolsaRepository(IDbConnectionFactory connectionFactory) |         public CotizacionBolsaRepository(IDbConnectionFactory connectionFactory) | ||||||
|         { |         { | ||||||
|             _connectionFactory = connectionFactory; |             _connectionFactory = connectionFactory; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         /// <inheritdoc /> |  | ||||||
|         public async Task GuardarMuchosAsync(IEnumerable<CotizacionBolsa> cotizaciones) |         public async Task GuardarMuchosAsync(IEnumerable<CotizacionBolsa> cotizaciones) | ||||||
|         { |         { | ||||||
|             using IDbConnection connection = _connectionFactory.CreateConnection(); |             using IDbConnection connection = _connectionFactory.CreateConnection(); | ||||||
|  |  | ||||||
|             const string sql = @"INSERT INTO  |             const string sql = @" | ||||||
|                     CotizacionesBolsa (Ticker, NombreEmpresa, Mercado, PrecioActual, Apertura, CierreAnterior, PorcentajeCambio, FechaRegistro)  |                 INSERT INTO CotizacionesBolsa (Ticker, NombreEmpresa, Mercado, PrecioActual, Apertura, CierreAnterior, PorcentajeCambio, FechaRegistro)  | ||||||
|                 VALUES  |                 VALUES (@Ticker, @NombreEmpresa, @Mercado, @PrecioActual, @Apertura, @CierreAnterior, @PorcentajeCambio, @FechaRegistro);"; | ||||||
|                     (@Ticker, @NombreEmpresa, @Mercado, @PrecioActual, @Apertura, @CierreAnterior, @PorcentajeCambio, @FechaRegistro);"; |  | ||||||
|  |  | ||||||
|             await connection.ExecuteAsync(sql, cotizaciones); |             await connection.ExecuteAsync(sql, cotizaciones); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         /// <inheritdoc /> |  | ||||||
|         public async Task<IEnumerable<CotizacionBolsa>> ObtenerUltimasPorMercadoAsync(string mercado) |         public async Task<IEnumerable<CotizacionBolsa>> ObtenerUltimasPorMercadoAsync(string mercado) | ||||||
|         { |         { | ||||||
|             using IDbConnection connection = _connectionFactory.CreateConnection(); |             using IDbConnection connection = _connectionFactory.CreateConnection(); | ||||||
| @@ -56,7 +48,6 @@ namespace Mercados.Infrastructure.Persistence.Repositories | |||||||
|             return await connection.QueryAsync<CotizacionBolsa>(sql, new { Mercado = mercado }); |             return await connection.QueryAsync<CotizacionBolsa>(sql, new { Mercado = mercado }); | ||||||
|         } |         } | ||||||
|          |          | ||||||
|         /// <inheritdoc /> |  | ||||||
|         public async Task<IEnumerable<CotizacionBolsa>> ObtenerHistorialPorTickerAsync(string ticker, string mercado, int dias) |         public async Task<IEnumerable<CotizacionBolsa>> ObtenerHistorialPorTickerAsync(string ticker, string mercado, int dias) | ||||||
|         { |         { | ||||||
|             using IDbConnection connection = _connectionFactory.CreateConnection(); |             using IDbConnection connection = _connectionFactory.CreateConnection(); | ||||||
|   | |||||||
| @@ -4,29 +4,75 @@ using System.Data; | |||||||
|  |  | ||||||
| namespace Mercados.Infrastructure.Persistence.Repositories | namespace Mercados.Infrastructure.Persistence.Repositories | ||||||
| { | { | ||||||
|     /// <inheritdoc cref="ICotizacionGanadoRepository"/> |  | ||||||
|     public class CotizacionGanadoRepository : ICotizacionGanadoRepository |     public class CotizacionGanadoRepository : ICotizacionGanadoRepository | ||||||
|     { |     { | ||||||
|         private readonly IDbConnectionFactory _connectionFactory; |         private readonly IDbConnectionFactory _connectionFactory; | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Inicializa una nueva instancia de la clase <see cref="CotizacionGanadoRepository"/>. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <param name="connectionFactory">Fábrica para crear conexiones a la base de datos.</param> |  | ||||||
|         public CotizacionGanadoRepository(IDbConnectionFactory connectionFactory) |         public CotizacionGanadoRepository(IDbConnectionFactory connectionFactory) | ||||||
|         { |         { | ||||||
|             _connectionFactory = connectionFactory; |             _connectionFactory = connectionFactory; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         /// <inheritdoc /> |         public async Task<IEnumerable<CotizacionGanado>> ObtenerUltimaTandaDisponibleAsync() | ||||||
|  |         { | ||||||
|  |             using IDbConnection connection = _connectionFactory.CreateConnection(); | ||||||
|  |             // Esta consulta busca la fecha más reciente que tiene registros | ||||||
|  |             // y luego devuelve todos los registros de esa fecha. Es la lógica original y correcta. | ||||||
|  |             const string sql = @" | ||||||
|  |             SELECT * FROM CotizacionesGanado  | ||||||
|  |             WHERE CONVERT(date, FechaRegistro) = ( | ||||||
|  |                 SELECT TOP 1 CONVERT(date, FechaRegistro)  | ||||||
|  |                 FROM CotizacionesGanado  | ||||||
|  |                 ORDER BY FechaRegistro DESC | ||||||
|  |             );"; | ||||||
|  |             return await connection.QueryAsync<CotizacionGanado>(sql); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Este método lo sigue necesitando el Fetcher para comparar los datos del día. | ||||||
|  |         public async Task<IEnumerable<CotizacionGanado>> ObtenerTandaPorFechaAsync(DateOnly fecha) | ||||||
|  |         { | ||||||
|  |             using IDbConnection connection = _connectionFactory.CreateConnection(); | ||||||
|  |             const string sql = @" | ||||||
|  |             SELECT * FROM CotizacionesGanado  | ||||||
|  |             WHERE CONVERT(date, FechaRegistro) = @Fecha;"; | ||||||
|  |             return await connection.QueryAsync<CotizacionGanado>(sql, new { Fecha = fecha.ToString("yyyy-MM-dd") }); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // Nuevo método para hacer un "reemplazo" inteligente | ||||||
|  |         public async Task ReemplazarTandaDelDiaAsync(DateOnly fecha, IEnumerable<CotizacionGanado> nuevasCotizaciones) | ||||||
|  |         { | ||||||
|  |             using IDbConnection connection = _connectionFactory.CreateConnection(); | ||||||
|  |             connection.Open(); | ||||||
|  |             using var transaction = connection.BeginTransaction(); | ||||||
|  |  | ||||||
|  |             try | ||||||
|  |             { | ||||||
|  |                 // Borramos solo los registros del día que vamos a actualizar | ||||||
|  |                 const string deleteSql = "DELETE FROM CotizacionesGanado WHERE CONVERT(date, FechaRegistro) = @Fecha;"; | ||||||
|  |                 await connection.ExecuteAsync(deleteSql, new { Fecha = fecha.ToString("yyyy-MM-dd") }, transaction); | ||||||
|  |  | ||||||
|  |                 // Insertamos la nueva tanda completa | ||||||
|  |                 const string insertSql = @" | ||||||
|  |                 INSERT INTO CotizacionesGanado (Categoria, Especificaciones, Maximo, Minimo, Promedio, Mediano, Cabezas, KilosTotales, KilosPorCabeza, ImporteTotal, FechaRegistro)  | ||||||
|  |                 VALUES (@Categoria, @Especificaciones, @Maximo, @Minimo, @Promedio, @Mediano, @Cabezas, @KilosTotales, @KilosPorCabeza, @ImporteTotal, @FechaRegistro);"; | ||||||
|  |                 await connection.ExecuteAsync(insertSql, nuevasCotizaciones, transaction); | ||||||
|  |  | ||||||
|  |                 transaction.Commit(); | ||||||
|  |             } | ||||||
|  |             catch | ||||||
|  |             { | ||||||
|  |                 transaction.Rollback(); | ||||||
|  |                 throw; | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|         public async Task GuardarMuchosAsync(IEnumerable<CotizacionGanado> cotizaciones) |         public async Task GuardarMuchosAsync(IEnumerable<CotizacionGanado> cotizaciones) | ||||||
|         { |         { | ||||||
|             using IDbConnection connection = _connectionFactory.CreateConnection(); |             using IDbConnection connection = _connectionFactory.CreateConnection(); | ||||||
|  |  | ||||||
|             // Dapper puede insertar una colección de objetos de una sola vez, ¡muy eficiente! |             // Dapper puede insertar una colección de objetos de una sola vez, ¡muy eficiente! | ||||||
|             const string sql = @" |             const string sql = @" | ||||||
|                 INSERT INTO  |                 INSERT INTO CotizacionesGanado ( | ||||||
|                     CotizacionesGanado ( |  | ||||||
|                     Categoria, Especificaciones, Maximo, Minimo, Promedio, Mediano,  |                     Categoria, Especificaciones, Maximo, Minimo, Promedio, Mediano,  | ||||||
|                     Cabezas, KilosTotales, KilosPorCabeza, ImporteTotal, FechaRegistro |                     Cabezas, KilosTotales, KilosPorCabeza, ImporteTotal, FechaRegistro | ||||||
|                 )  |                 )  | ||||||
| @@ -38,22 +84,6 @@ namespace Mercados.Infrastructure.Persistence.Repositories | |||||||
|             await connection.ExecuteAsync(sql, cotizaciones); |             await connection.ExecuteAsync(sql, cotizaciones); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         /// <inheritdoc /> |  | ||||||
|         public async Task<IEnumerable<CotizacionGanado>> ObtenerUltimaTandaAsync() |  | ||||||
|         { |  | ||||||
|             using IDbConnection connection = _connectionFactory.CreateConnection(); |  | ||||||
|  |  | ||||||
|             // Primero, obtenemos la fecha de registro más reciente. |  | ||||||
|             // Luego, seleccionamos todos los registros que tengan esa fecha. |  | ||||||
|             const string sql = @" |  | ||||||
|                 SELECT *  |  | ||||||
|                 FROM CotizacionesGanado  |  | ||||||
|                 WHERE FechaRegistro = (SELECT MAX(FechaRegistro) FROM CotizacionesGanado);"; |  | ||||||
|  |  | ||||||
|             return await connection.QueryAsync<CotizacionGanado>(sql); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         /// <inheritdoc /> |  | ||||||
|         public async Task<IEnumerable<CotizacionGanado>> ObtenerHistorialAsync(string categoria, string especificaciones, int dias) |         public async Task<IEnumerable<CotizacionGanado>> ObtenerHistorialAsync(string categoria, string especificaciones, int dias) | ||||||
|         { |         { | ||||||
|             using IDbConnection connection = _connectionFactory.CreateConnection(); |             using IDbConnection connection = _connectionFactory.CreateConnection(); | ||||||
| @@ -70,10 +100,11 @@ namespace Mercados.Infrastructure.Persistence.Repositories | |||||||
|                 ORDER BY |                 ORDER BY | ||||||
|                     FechaRegistro ASC;"; |                     FechaRegistro ASC;"; | ||||||
|  |  | ||||||
|             return await connection.QueryAsync<CotizacionGanado>(sql, new {  |             return await connection.QueryAsync<CotizacionGanado>(sql, new | ||||||
|                 Categoria = categoria,  |             { | ||||||
|                 Especificaciones = especificaciones,  |                 Categoria = categoria, | ||||||
|                 Dias = dias  |                 Especificaciones = especificaciones, | ||||||
|  |                 Dias = dias | ||||||
|             }); |             }); | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -4,34 +4,25 @@ using System.Data; | |||||||
|  |  | ||||||
| namespace Mercados.Infrastructure.Persistence.Repositories | namespace Mercados.Infrastructure.Persistence.Repositories | ||||||
| { | { | ||||||
|     /// <inheritdoc cref="ICotizacionGranoRepository"/> |  | ||||||
|     public class CotizacionGranoRepository : ICotizacionGranoRepository |     public class CotizacionGranoRepository : ICotizacionGranoRepository | ||||||
|     { |     { | ||||||
|         private readonly IDbConnectionFactory _connectionFactory; |         private readonly IDbConnectionFactory _connectionFactory; | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Inicializa una nueva instancia de la clase <see cref="CotizacionGranoRepository"/>. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <param name="connectionFactory">Fábrica para crear conexiones a la base de datos.</param> |  | ||||||
|         public CotizacionGranoRepository(IDbConnectionFactory connectionFactory) |         public CotizacionGranoRepository(IDbConnectionFactory connectionFactory) | ||||||
|         { |         { | ||||||
|             _connectionFactory = connectionFactory; |             _connectionFactory = connectionFactory; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         /// <inheritdoc /> |  | ||||||
|         public async Task GuardarMuchosAsync(IEnumerable<CotizacionGrano> cotizaciones) |         public async Task GuardarMuchosAsync(IEnumerable<CotizacionGrano> cotizaciones) | ||||||
|         { |         { | ||||||
|             using IDbConnection connection = _connectionFactory.CreateConnection(); |             using IDbConnection connection = _connectionFactory.CreateConnection(); | ||||||
|  |  | ||||||
|             const string sql = @"INSERT INTO  |             const string sql = @" | ||||||
|                     CotizacionesGranos (Nombre, Precio, VariacionPrecio, FechaOperacion, FechaRegistro)  |                 INSERT INTO CotizacionesGranos (Nombre, Precio, VariacionPrecio, FechaOperacion, FechaRegistro)  | ||||||
|                 VALUES  |                 VALUES (@Nombre, @Precio, @VariacionPrecio, @FechaOperacion, @FechaRegistro);"; | ||||||
|                     (@Nombre, @Precio, @VariacionPrecio, @FechaOperacion, @FechaRegistro);"; |  | ||||||
|  |  | ||||||
|             await connection.ExecuteAsync(sql, cotizaciones); |             await connection.ExecuteAsync(sql, cotizaciones); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         /// <inheritdoc /> |  | ||||||
|         public async Task<IEnumerable<CotizacionGrano>> ObtenerUltimasAsync() |         public async Task<IEnumerable<CotizacionGrano>> ObtenerUltimasAsync() | ||||||
|         { |         { | ||||||
|             using IDbConnection connection = _connectionFactory.CreateConnection(); |             using IDbConnection connection = _connectionFactory.CreateConnection(); | ||||||
| @@ -54,7 +45,6 @@ namespace Mercados.Infrastructure.Persistence.Repositories | |||||||
|             return await connection.QueryAsync<CotizacionGrano>(sql); |             return await connection.QueryAsync<CotizacionGrano>(sql); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         /// <inheritdoc /> |  | ||||||
|         public async Task<IEnumerable<CotizacionGrano>> ObtenerHistorialAsync(string nombre, int dias) |         public async Task<IEnumerable<CotizacionGrano>> ObtenerHistorialAsync(string nombre, int dias) | ||||||
|         { |         { | ||||||
|             using IDbConnection connection = _connectionFactory.CreateConnection(); |             using IDbConnection connection = _connectionFactory.CreateConnection(); | ||||||
|   | |||||||
| @@ -4,42 +4,37 @@ using System.Data; | |||||||
|  |  | ||||||
| namespace Mercados.Infrastructure.Persistence.Repositories | namespace Mercados.Infrastructure.Persistence.Repositories | ||||||
| { | { | ||||||
|     /// <inheritdoc cref="IFuenteDatoRepository"/> |  | ||||||
|     public class FuenteDatoRepository : IFuenteDatoRepository |     public class FuenteDatoRepository : IFuenteDatoRepository | ||||||
|     { |     { | ||||||
|         private readonly IDbConnectionFactory _connectionFactory; |         private readonly IDbConnectionFactory _connectionFactory; | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Inicializa una nueva instancia de la clase <see cref="FuenteDatoRepository"/>. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <param name="connectionFactory">Fábrica para crear conexiones a la base de datos.</param> |  | ||||||
|         public FuenteDatoRepository(IDbConnectionFactory connectionFactory) |         public FuenteDatoRepository(IDbConnectionFactory connectionFactory) | ||||||
|         { |         { | ||||||
|             _connectionFactory = connectionFactory; |             _connectionFactory = connectionFactory; | ||||||
|         } |         } | ||||||
|  |          | ||||||
|         /// <inheritdoc /> |  | ||||||
|         public async Task<FuenteDato?> ObtenerPorNombreAsync(string nombre) |         public async Task<FuenteDato?> ObtenerPorNombreAsync(string nombre) | ||||||
|         { |         { | ||||||
|             using IDbConnection connection = _connectionFactory.CreateConnection(); |             using IDbConnection connection = _connectionFactory.CreateConnection(); | ||||||
|             const string sql = "SELECT * FROM FuentesDatos WHERE Nombre = @Nombre;"; |             const string sql = "SELECT * FROM FuentesDatos WHERE Nombre = @Nombre;"; | ||||||
|             return await connection.QuerySingleOrDefaultAsync<FuenteDato>(sql, new { Nombre = nombre }); |             return await connection.QuerySingleOrDefaultAsync<FuenteDato>(sql, new { Nombre = nombre }); | ||||||
|         } |         } | ||||||
|         /// <inheritdoc /> |  | ||||||
|         public async Task CrearAsync(FuenteDato fuenteDato) |         public async Task CrearAsync(FuenteDato fuenteDato) | ||||||
|         { |         { | ||||||
|             using IDbConnection connection = _connectionFactory.CreateConnection(); |             using IDbConnection connection = _connectionFactory.CreateConnection(); | ||||||
|             const string sql = @"INSERT INTO FuentesDatos (Nombre, UltimaEjecucionExitosa, Url)  |             const string sql = @" | ||||||
|  |                 INSERT INTO FuentesDatos (Nombre, UltimaEjecucionExitosa, Url)  | ||||||
|                 VALUES (@Nombre, @UltimaEjecucionExitosa, @Url);"; |                 VALUES (@Nombre, @UltimaEjecucionExitosa, @Url);"; | ||||||
|             await connection.ExecuteAsync(sql, fuenteDato); |             await connection.ExecuteAsync(sql, fuenteDato); | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         /// <inheritdoc /> |  | ||||||
|         public async Task ActualizarAsync(FuenteDato fuenteDato) |         public async Task ActualizarAsync(FuenteDato fuenteDato) | ||||||
|         { |         { | ||||||
|             using IDbConnection connection = _connectionFactory.CreateConnection(); |             using IDbConnection connection = _connectionFactory.CreateConnection(); | ||||||
|             const string sql = @"UPDATE FuentesDatos  |             const string sql = @" | ||||||
|                 SET UltimaEjecucionExitosa = @UltimaEjecucionExitosa, Url = @Url |                 UPDATE FuentesDatos  | ||||||
|  |                 SET UltimaEjecucionExitosa = @UltimaEjecucionExitosa, Url = @Url  | ||||||
|                 WHERE Id = @Id;"; |                 WHERE Id = @Id;"; | ||||||
|             await connection.ExecuteAsync(sql, fuenteDato); |             await connection.ExecuteAsync(sql, fuenteDato); | ||||||
|         } |         } | ||||||
|   | |||||||
| @@ -1,9 +1,6 @@ | |||||||
| namespace Mercados.Infrastructure.Persistence.Repositories | namespace Mercados.Infrastructure.Persistence.Repositories | ||||||
| { | { | ||||||
|     /// <summary> |     // Esta interfaz no es estrictamente necesaria ahora, pero es útil para futuras abstracciones. | ||||||
|     /// Interfaz base marcadora para todos los repositorios. |  | ||||||
|     /// No define miembros, pero sirve para la abstracción y la inyección de dependencias. |  | ||||||
|     /// </summary> |  | ||||||
|     public interface IBaseRepository |     public interface IBaseRepository | ||||||
|     { |     { | ||||||
|     } |     } | ||||||
|   | |||||||
| @@ -2,31 +2,10 @@ using Mercados.Core.Entities; | |||||||
|  |  | ||||||
| namespace Mercados.Infrastructure.Persistence.Repositories | namespace Mercados.Infrastructure.Persistence.Repositories | ||||||
| { | { | ||||||
|     /// <summary> |  | ||||||
|     /// Define el contrato para el repositorio que gestiona las cotizaciones de la bolsa. |  | ||||||
|     /// </summary> |  | ||||||
|     public interface ICotizacionBolsaRepository : IBaseRepository |     public interface ICotizacionBolsaRepository : IBaseRepository | ||||||
|     { |     { | ||||||
|         /// <summary> |  | ||||||
|         /// Guarda una colección de cotizaciones de bolsa en la base de datos de forma masiva. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <param name="cotizaciones">La colección de entidades CotizacionBolsa a guardar.</param> |  | ||||||
|         Task GuardarMuchosAsync(IEnumerable<CotizacionBolsa> cotizaciones); |         Task GuardarMuchosAsync(IEnumerable<CotizacionBolsa> cotizaciones); | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Obtiene la última cotización registrada para cada ticker de un mercado específico. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <param name="mercado">El código del mercado a consultar (ej. "US", "Local").</param> |  | ||||||
|         /// <returns>Una colección con la última cotización de cada activo de ese mercado.</returns> |  | ||||||
|         Task<IEnumerable<CotizacionBolsa>> ObtenerUltimasPorMercadoAsync(string mercado); |         Task<IEnumerable<CotizacionBolsa>> ObtenerUltimasPorMercadoAsync(string mercado); | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Obtiene el historial de cotizaciones para un ticker específico durante un período determinado. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <param name="ticker">El símbolo del activo (ej. "AAPL", "^MERV").</param> |  | ||||||
|         /// <param name="mercado">El mercado al que pertenece el ticker.</param> |  | ||||||
|         /// <param name="dias">El número de días hacia atrás desde hoy para obtener el historial.</param> |  | ||||||
|         /// <returns>Una colección de cotizaciones ordenadas por fecha de forma ascendente.</returns> |  | ||||||
|         Task<IEnumerable<CotizacionBolsa>> ObtenerHistorialPorTickerAsync(string ticker, string mercado, int dias); |         Task<IEnumerable<CotizacionBolsa>> ObtenerHistorialPorTickerAsync(string ticker, string mercado, int dias); | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -2,30 +2,11 @@ using Mercados.Core.Entities; | |||||||
|  |  | ||||||
| namespace Mercados.Infrastructure.Persistence.Repositories | namespace Mercados.Infrastructure.Persistence.Repositories | ||||||
| { | { | ||||||
|     /// <summary> |  | ||||||
|     /// Define el contrato para el repositorio que gestiona las cotizaciones del mercado de ganado. |  | ||||||
|     /// </summary> |  | ||||||
|     public interface ICotizacionGanadoRepository : IBaseRepository |     public interface ICotizacionGanadoRepository : IBaseRepository | ||||||
|     { |     { | ||||||
|         /// <summary> |         Task<IEnumerable<CotizacionGanado>> ObtenerUltimaTandaDisponibleAsync(); | ||||||
|         /// Guarda una colección de cotizaciones de ganado en la base de datos. |         Task ReemplazarTandaDelDiaAsync(DateOnly fecha, IEnumerable<CotizacionGanado> nuevasCotizaciones); | ||||||
|         /// </summary> |  | ||||||
|         /// <param name="cotizaciones">La colección de entidades CotizacionGanado a guardar.</param> |  | ||||||
|         Task GuardarMuchosAsync(IEnumerable<CotizacionGanado> cotizaciones); |  | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Obtiene el último parte completo de cotizaciones del mercado de ganado. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <returns>Una colección de todas las cotizaciones de la última tanda registrada.</returns> |  | ||||||
|         Task<IEnumerable<CotizacionGanado>> ObtenerUltimaTandaAsync(); |  | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Obtiene el historial de cotizaciones para una categoría y especificación de ganado. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <param name="categoria">La categoría principal del ganado (ej. "NOVILLOS").</param> |  | ||||||
|         /// <param name="especificaciones">La especificación detallada del ganado.</param> |  | ||||||
|         /// <param name="dias">El número de días de historial a recuperar.</param> |  | ||||||
|         /// <returns>Una colección de cotizaciones históricas para esa categoría.</returns> |  | ||||||
|         Task<IEnumerable<CotizacionGanado>> ObtenerHistorialAsync(string categoria, string especificaciones, int dias); |         Task<IEnumerable<CotizacionGanado>> ObtenerHistorialAsync(string categoria, string especificaciones, int dias); | ||||||
|  |         Task<IEnumerable<CotizacionGanado>> ObtenerTandaPorFechaAsync(DateOnly fecha); | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -2,29 +2,10 @@ using Mercados.Core.Entities; | |||||||
|  |  | ||||||
| namespace Mercados.Infrastructure.Persistence.Repositories | namespace Mercados.Infrastructure.Persistence.Repositories | ||||||
| { | { | ||||||
|     /// <summary> |  | ||||||
|     /// Define el contrato para el repositorio que gestiona las cotizaciones del mercado de granos. |  | ||||||
|     /// </summary> |  | ||||||
|     public interface ICotizacionGranoRepository : IBaseRepository |     public interface ICotizacionGranoRepository : IBaseRepository | ||||||
|     { |     { | ||||||
|         /// <summary> |  | ||||||
|         /// Guarda una colección de cotizaciones de granos en la base de datos. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <param name="cotizaciones">La colección de entidades CotizacionGrano a guardar.</param> |  | ||||||
|         Task GuardarMuchosAsync(IEnumerable<CotizacionGrano> cotizaciones); |         Task GuardarMuchosAsync(IEnumerable<CotizacionGrano> cotizaciones); | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Obtiene las últimas cotizaciones disponibles para los granos. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <returns>Una colección de las últimas cotizaciones de granos registradas.</returns> |  | ||||||
|         Task<IEnumerable<CotizacionGrano>> ObtenerUltimasAsync(); |         Task<IEnumerable<CotizacionGrano>> ObtenerUltimasAsync(); | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Obtiene el historial de cotizaciones para un grano específico. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <param name="nombre">El nombre del grano (ej. "Soja").</param> |  | ||||||
|         /// <param name="dias">El número de días de historial a recuperar.</param> |  | ||||||
|         /// <returns>Una colección de cotizaciones históricas para el grano especificado.</returns> |  | ||||||
|         Task<IEnumerable<CotizacionGrano>> ObtenerHistorialAsync(string nombre, int dias); |         Task<IEnumerable<CotizacionGrano>> ObtenerHistorialAsync(string nombre, int dias); | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -2,28 +2,10 @@ using Mercados.Core.Entities; | |||||||
|  |  | ||||||
| namespace Mercados.Infrastructure.Persistence.Repositories | namespace Mercados.Infrastructure.Persistence.Repositories | ||||||
| { | { | ||||||
|     /// <summary> |  | ||||||
|     /// Define el contrato para el repositorio que gestiona las fuentes de datos. |  | ||||||
|     /// </summary> |  | ||||||
|     public interface IFuenteDatoRepository : IBaseRepository |     public interface IFuenteDatoRepository : IBaseRepository | ||||||
|     { |     { | ||||||
|         /// <summary> |  | ||||||
|         /// Obtiene una entidad FuenteDato por su nombre único. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <param name="nombre">El nombre de la fuente de datos a buscar.</param> |  | ||||||
|         /// <returns>La entidad FuenteDato si se encuentra; de lo contrario, null.</returns> |  | ||||||
|         Task<FuenteDato?> ObtenerPorNombreAsync(string nombre); |         Task<FuenteDato?> ObtenerPorNombreAsync(string nombre); | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Actualiza una entidad FuenteDato existente en la base de datos. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <param name="fuenteDato">La entidad FuenteDato con los datos actualizados.</param> |  | ||||||
|         Task ActualizarAsync(FuenteDato fuenteDato); |         Task ActualizarAsync(FuenteDato fuenteDato); | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Crea una nueva entidad FuenteDato en la base de datos. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <param name="fuenteDato">La entidad FuenteDato a crear.</param> |  | ||||||
|         Task CrearAsync(FuenteDato fuenteDato); |         Task CrearAsync(FuenteDato fuenteDato); | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -2,23 +2,9 @@ using Mercados.Core.Entities; | |||||||
|  |  | ||||||
| namespace Mercados.Infrastructure.Persistence.Repositories | namespace Mercados.Infrastructure.Persistence.Repositories | ||||||
| { | { | ||||||
|     /// <summary> |  | ||||||
|     /// Define el contrato para el repositorio que gestiona los feriados de los mercados. |  | ||||||
|     /// </summary> |  | ||||||
|     public interface IMercadoFeriadoRepository : IBaseRepository |     public interface IMercadoFeriadoRepository : IBaseRepository | ||||||
|     { |     { | ||||||
|         /// <summary> |  | ||||||
|         /// Obtiene todos los feriados para un mercado y año específicos. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <param name="codigoMercado">El código del mercado para el cual se buscan los feriados.</param> |  | ||||||
|         /// <param name="anio">El año para el cual se desean obtener los feriados.</param> |  | ||||||
|         /// <returns>Una colección de entidades MercadoFeriado para el mercado y año especificados.</returns> |  | ||||||
|         Task<IEnumerable<MercadoFeriado>> ObtenerPorMercadoYAnioAsync(string codigoMercado, int anio); |         Task<IEnumerable<MercadoFeriado>> ObtenerPorMercadoYAnioAsync(string codigoMercado, int anio); | ||||||
|         /// <summary> |  | ||||||
|         /// Reemplaza todos los feriados existentes para un mercado con una nueva lista. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <param name="codigoMercado">El código del mercado cuyos feriados serán reemplazados.</param> |  | ||||||
|         /// <param name="nuevosFeriados">La nueva colección de feriados que se guardará.</param> |  | ||||||
|         Task ReemplazarFeriadosPorMercadoAsync(string codigoMercado, IEnumerable<MercadoFeriado> nuevosFeriados); |         Task ReemplazarFeriadosPorMercadoAsync(string codigoMercado, IEnumerable<MercadoFeriado> nuevosFeriados); | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -4,35 +4,24 @@ using System.Data; | |||||||
|  |  | ||||||
| namespace Mercados.Infrastructure.Persistence.Repositories | namespace Mercados.Infrastructure.Persistence.Repositories | ||||||
| { | { | ||||||
|     /// <inheritdoc cref="IMercadoFeriadoRepository"/> |  | ||||||
|     public class MercadoFeriadoRepository : IMercadoFeriadoRepository |     public class MercadoFeriadoRepository : IMercadoFeriadoRepository | ||||||
|     { |     { | ||||||
|         private readonly IDbConnectionFactory _connectionFactory; |         private readonly IDbConnectionFactory _connectionFactory; | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Inicializa una nueva instancia de la clase <see cref="MercadoFeriadoRepository"/>. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <param name="connectionFactory">Fábrica para crear conexiones a la base de datos.</param> |  | ||||||
|         public MercadoFeriadoRepository(IDbConnectionFactory connectionFactory) |         public MercadoFeriadoRepository(IDbConnectionFactory connectionFactory) | ||||||
|         { |         { | ||||||
|             _connectionFactory = connectionFactory; |             _connectionFactory = connectionFactory; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         /// <inheritdoc /> |  | ||||||
|         public async Task<IEnumerable<MercadoFeriado>> ObtenerPorMercadoYAnioAsync(string codigoMercado, int anio) |         public async Task<IEnumerable<MercadoFeriado>> ObtenerPorMercadoYAnioAsync(string codigoMercado, int anio) | ||||||
|         { |         { | ||||||
|             using IDbConnection connection = _connectionFactory.CreateConnection(); |             using IDbConnection connection = _connectionFactory.CreateConnection(); | ||||||
|             const string sql = @"SELECT *  |             const string sql = @" | ||||||
|                 FROM MercadosFeriados  |                 SELECT * FROM MercadosFeriados  | ||||||
|                 WHERE CodigoMercado = @CodigoMercado AND YEAR(Fecha) = @Anio;"; |                 WHERE CodigoMercado = @CodigoMercado AND YEAR(Fecha) = @Anio;"; | ||||||
|             return await connection.QueryAsync<MercadoFeriado>(sql, new  |             return await connection.QueryAsync<MercadoFeriado>(sql, new { CodigoMercado = codigoMercado, Anio = anio }); | ||||||
|             {  |  | ||||||
|                 CodigoMercado = codigoMercado,  |  | ||||||
|                 Anio = anio  |  | ||||||
|             }); |  | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         /// <inheritdoc /> |  | ||||||
|         public async Task ReemplazarFeriadosPorMercadoAsync(string codigoMercado, IEnumerable<MercadoFeriado> nuevosFeriados) |         public async Task ReemplazarFeriadosPorMercadoAsync(string codigoMercado, IEnumerable<MercadoFeriado> nuevosFeriados) | ||||||
|         { |         { | ||||||
|             using IDbConnection connection = _connectionFactory.CreateConnection(); |             using IDbConnection connection = _connectionFactory.CreateConnection(); | ||||||
| @@ -41,31 +30,25 @@ namespace Mercados.Infrastructure.Persistence.Repositories | |||||||
|  |  | ||||||
|             try |             try | ||||||
|             { |             { | ||||||
|                 // Obtenemos el año del primer feriado (asumimos que todos son del mismo año) |                 // Borramos todos los feriados del año en curso para ese mercado | ||||||
|                 var anio = nuevosFeriados.FirstOrDefault()?.Fecha.Year; |                 var anio = nuevosFeriados.FirstOrDefault()?.Fecha.Year; | ||||||
|                 if (!anio.HasValue) return; // Si no hay feriados, no hay nada que hacer |                 if (anio.HasValue) | ||||||
|  |  | ||||||
|                 // 1. Borrar los feriados existentes para ese mercado |  | ||||||
|                 const string deleteSql = "DELETE FROM MercadosFeriados WHERE CodigoMercado = @CodigoMercado;"; |  | ||||||
|                 await connection.ExecuteAsync(deleteSql, new { CodigoMercado = codigoMercado }, transaction); |  | ||||||
|  |  | ||||||
|                 // 2. Insertar los nuevos feriados |  | ||||||
|                 if (nuevosFeriados.Any()) |  | ||||||
|                 { |                 { | ||||||
|                     const string insertSql = @"INSERT INTO MercadosFeriados (CodigoMercado, Fecha, Nombre) |                     const string deleteSql = "DELETE FROM MercadosFeriados WHERE CodigoMercado = @CodigoMercado AND YEAR(Fecha) = @Anio;"; | ||||||
|                         VALUES (@CodigoMercado, @Fecha, @Nombre);"; |                     await connection.ExecuteAsync(deleteSql, new { CodigoMercado = codigoMercado, Anio = anio.Value }, transaction); | ||||||
|                     await connection.ExecuteAsync(insertSql, nuevosFeriados, transaction); |  | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|                 // Si todo sale bien, confirmar la transacción |                 // Insertamos los nuevos | ||||||
|  |                 const string insertSql = @" | ||||||
|  |                     INSERT INTO MercadosFeriados (CodigoMercado, Fecha, Nombre)  | ||||||
|  |                     VALUES (@CodigoMercado, @Fecha, @Nombre);"; | ||||||
|  |                 await connection.ExecuteAsync(insertSql, nuevosFeriados, transaction); | ||||||
|  |  | ||||||
|                 transaction.Commit(); |                 transaction.Commit(); | ||||||
|             } |             } | ||||||
|             catch |             catch | ||||||
|             { |             { | ||||||
|                 // Si hay algún error, deshacer la transacción para no dejar datos inconsistentes |  | ||||||
|                 transaction.Rollback(); |                 transaction.Rollback(); | ||||||
|  |  | ||||||
|                 // Relanzar la excepción para que el llamador sepa que algo falló |  | ||||||
|                 throw; |                 throw; | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|   | |||||||
| @@ -5,17 +5,10 @@ using System.Data; | |||||||
|  |  | ||||||
| namespace Mercados.Infrastructure | namespace Mercados.Infrastructure | ||||||
| { | { | ||||||
|   /// <summary> |  | ||||||
|   /// Proporciona una fábrica para crear conexiones a la base de datos SQL. |  | ||||||
|   /// </summary> |  | ||||||
|   public class SqlConnectionFactory : IDbConnectionFactory |   public class SqlConnectionFactory : IDbConnectionFactory | ||||||
|   { |   { | ||||||
|     private readonly string _connectionString; |     private readonly string _connectionString; | ||||||
|  |  | ||||||
|     /// <summary> |  | ||||||
|     /// Inicializa una nueva instancia de la clase <see cref="SqlConnectionFactory"/>. |  | ||||||
|     /// </summary> |  | ||||||
|     /// <param name="configuration">La configuración de la aplicación desde donde se obtiene la cadena de conexión.</param> |  | ||||||
|     public SqlConnectionFactory(IConfiguration configuration) |     public SqlConnectionFactory(IConfiguration configuration) | ||||||
|     { |     { | ||||||
|       // Variable de entorno 'DB_CONNECTION_STRING' si está disponible, |       // Variable de entorno 'DB_CONNECTION_STRING' si está disponible, | ||||||
| @@ -24,10 +17,9 @@ namespace Mercados.Infrastructure | |||||||
|           ?? throw new ArgumentNullException(nameof(configuration), "La cadena de conexión 'DefaultConnection' no fue encontrada."); |           ?? throw new ArgumentNullException(nameof(configuration), "La cadena de conexión 'DefaultConnection' no fue encontrada."); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|         /// <inheritdoc /> |     public IDbConnection CreateConnection() | ||||||
|         public IDbConnection CreateConnection() |  | ||||||
|     { |     { | ||||||
|       return new SqlConnection(_connectionString); |       return new SqlConnection(_connectionString); | ||||||
|     } |     } | ||||||
| } |   } | ||||||
| } | } | ||||||
| @@ -6,26 +6,17 @@ using MimeKit; | |||||||
|  |  | ||||||
| namespace Mercados.Infrastructure.Services | namespace Mercados.Infrastructure.Services | ||||||
| { | { | ||||||
|     /// <summary> |  | ||||||
|     /// Servicio que gestiona el envío de notificaciones por correo electrónico. |  | ||||||
|     /// </summary> |  | ||||||
|     public class EmailNotificationService : INotificationService |     public class EmailNotificationService : INotificationService | ||||||
|     { |     { | ||||||
|         private readonly ILogger<EmailNotificationService> _logger; |         private readonly ILogger<EmailNotificationService> _logger; | ||||||
|         private readonly IConfiguration _configuration; |         private readonly IConfiguration _configuration; | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Inicializa una nueva instancia de la clase <see cref="EmailNotificationService"/>. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <param name="logger">Logger para registrar información y errores.</param> |  | ||||||
|         /// <param name="configuration">Configuración de la aplicación para obtener los ajustes SMTP.</param> |  | ||||||
|         public EmailNotificationService(ILogger<EmailNotificationService> logger, IConfiguration configuration) |         public EmailNotificationService(ILogger<EmailNotificationService> logger, IConfiguration configuration) | ||||||
|         { |         { | ||||||
|             _logger = logger; |             _logger = logger; | ||||||
|             _configuration = configuration; |             _configuration = configuration; | ||||||
|         } |         } | ||||||
|  |  | ||||||
|         /// <inheritdoc /> |  | ||||||
|         public async Task SendFailureAlertAsync(string subject, string message, DateTime? eventTimeUtc = null) |         public async Task SendFailureAlertAsync(string subject, string message, DateTime? eventTimeUtc = null) | ||||||
|         { |         { | ||||||
|             // Leemos la configuración de forma segura desde IConfiguration (que a su vez lee el .env) |             // Leemos la configuración de forma segura desde IConfiguration (que a su vez lee el .env) | ||||||
|   | |||||||
| @@ -5,41 +5,26 @@ using Microsoft.Extensions.Logging; | |||||||
|  |  | ||||||
| namespace Mercados.Infrastructure.Services | namespace Mercados.Infrastructure.Services | ||||||
| { | { | ||||||
|     /// <summary> |     public class FinnhubHolidayService : IHolidayService | ||||||
|     /// Servicio para consultar si una fecha es feriado de mercado utilizando la base de datos interna. |  | ||||||
|     /// </summary> |  | ||||||
|         public class FinnhubHolidayService : IHolidayService |  | ||||||
|     { |     { | ||||||
|         private readonly IMercadoFeriadoRepository _feriadoRepository; |         private readonly IMercadoFeriadoRepository _feriadoRepository; | ||||||
|         private readonly IMemoryCache _cache; |         private readonly IMemoryCache _cache; | ||||||
|         private readonly ILogger<FinnhubHolidayService> _logger; |         private readonly ILogger<FinnhubHolidayService> _logger; | ||||||
|  |  | ||||||
|         /// <summary> |         public FinnhubHolidayService( | ||||||
|         /// Inicializa una nueva instancia de la clase <see cref="FinnhubHolidayService"/>. |             IMercadoFeriadoRepository feriadoRepository, | ||||||
|         /// </summary> |             IMemoryCache cache, | ||||||
|         /// <param name="feriadoRepository">Repositorio para acceder a los feriados de mercado.</param> |             ILogger<FinnhubHolidayService> logger) | ||||||
|         /// <param name="cache">Caché en memoria para almacenar los feriados.</param> |         { | ||||||
|         /// <param name="logger">Logger para registrar información y errores.</param> |             _feriadoRepository = feriadoRepository; | ||||||
|                 public FinnhubHolidayService( |             _cache = cache; | ||||||
|                     IMercadoFeriadoRepository feriadoRepository, |             _logger = logger; | ||||||
|                     IMemoryCache cache, |         } | ||||||
|                     ILogger<FinnhubHolidayService> logger) |  | ||||||
|                 { |  | ||||||
|                     _feriadoRepository = feriadoRepository; |  | ||||||
|                     _cache = cache; |  | ||||||
|                     _logger = logger; |  | ||||||
|                 } |  | ||||||
|  |  | ||||||
|         /// <summary> |         public async Task<bool> IsMarketHolidayAsync(string marketCode, DateTime date) | ||||||
|         /// Determina si una fecha específica es feriado de mercado para el código de mercado proporcionado. |         { | ||||||
|         /// </summary> |             var dateOnly = DateOnly.FromDateTime(date); | ||||||
|         /// <param name="marketCode">Código del mercado a consultar.</param> |             var cacheKey = $"holidays_{marketCode}_{date.Year}"; | ||||||
|         /// <param name="date">Fecha a verificar.</param> |  | ||||||
|         /// <returns>True si la fecha es feriado de mercado; de lo contrario, false.</returns> |  | ||||||
|                 public async Task<bool> IsMarketHolidayAsync(string marketCode, DateTime date) |  | ||||||
|                 { |  | ||||||
|                     var dateOnly = DateOnly.FromDateTime(date); |  | ||||||
|                     var cacheKey = $"holidays_{marketCode}_{date.Year}"; |  | ||||||
|  |  | ||||||
|             if (!_cache.TryGetValue(cacheKey, out HashSet<DateOnly>? holidays)) |             if (!_cache.TryGetValue(cacheKey, out HashSet<DateOnly>? holidays)) | ||||||
|             { |             { | ||||||
|   | |||||||
| @@ -10,7 +10,6 @@ namespace Mercados.Infrastructure.Services | |||||||
|         /// </summary> |         /// </summary> | ||||||
|         /// <param name="subject">El título de la alerta.</param> |         /// <param name="subject">El título de la alerta.</param> | ||||||
|         /// <param name="message">El mensaje detallado del error.</param> |         /// <param name="message">El mensaje detallado del error.</param> | ||||||
|         /// <param name="eventTimeUtc">La fecha y hora UTC del evento (opcional).</param> |  | ||||||
|         Task SendFailureAlertAsync(string subject, string message, DateTime? eventTimeUtc = null); |         Task SendFailureAlertAsync(string subject, string message, DateTime? eventTimeUtc = null); | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -14,48 +14,23 @@ namespace Mercados.Worker | |||||||
|         private readonly ILogger<DataFetchingService> _logger; |         private readonly ILogger<DataFetchingService> _logger; | ||||||
|         private readonly IServiceProvider _serviceProvider; |         private readonly IServiceProvider _serviceProvider; | ||||||
|         private readonly TimeZoneInfo _argentinaTimeZone; |         private readonly TimeZoneInfo _argentinaTimeZone; | ||||||
|  |          | ||||||
|         /// <summary> |         // Expresiones Cron | ||||||
|         /// Expresión Cron para la tarea de Mercado Agroganadero. |  | ||||||
|         /// </summary> |  | ||||||
|         private readonly CronExpression _agroSchedule; |         private readonly CronExpression _agroSchedule; | ||||||
|         /// <summary> |  | ||||||
|         /// Expresión Cron para la tarea de la Bolsa de Comercio de Rosario (BCR). |  | ||||||
|         /// </summary> |  | ||||||
|         private readonly CronExpression _bcrSchedule; |         private readonly CronExpression _bcrSchedule; | ||||||
|         /// <summary> |  | ||||||
|         /// Expresión Cron para la tarea de las Bolsas (Finnhub y Yahoo Finance). |  | ||||||
|         /// </summary> |  | ||||||
|         private readonly CronExpression _bolsasSchedule; |         private readonly CronExpression _bolsasSchedule; | ||||||
|         /// <summary> |  | ||||||
|         /// Expresión Cron para la tarea de actualización de feriados. |  | ||||||
|         /// </summary> |  | ||||||
|         private readonly CronExpression _holidaysSchedule; |         private readonly CronExpression _holidaysSchedule; | ||||||
|  |  | ||||||
|         /// <summary>Próxima hora de ejecución programada para la tarea de Mercado Agroganadero.</summary> |         // Próximas ejecuciones | ||||||
|         private DateTime? _nextAgroRun; |         private DateTime? _nextAgroRun; | ||||||
|         /// <summary>Próxima hora de ejecución programada para la tarea de BCR.</summary> |  | ||||||
|         private DateTime? _nextBcrRun; |         private DateTime? _nextBcrRun; | ||||||
|         /// <summary>Próxima hora de ejecución programada para la tarea de Bolsas.</summary> |  | ||||||
|         private DateTime? _nextBolsasRun; |         private DateTime? _nextBolsasRun; | ||||||
|         /// <summary>Próxima hora de ejecución programada para la tarea de Feriados.</summary> |  | ||||||
|         private DateTime? _nextHolidaysRun; |         private DateTime? _nextHolidaysRun; | ||||||
|  |  | ||||||
|         /// <summary> |  | ||||||
|         /// Almacena la última vez que se envió una alerta para una tarea específica, para evitar spam. |  | ||||||
|         /// </summary> |  | ||||||
|         private readonly Dictionary<string, DateTime> _lastAlertSent = new(); |         private readonly Dictionary<string, DateTime> _lastAlertSent = new(); | ||||||
|         /// <summary> |  | ||||||
|         /// Período de tiempo durante el cual no se enviarán alertas repetidas para la misma tarea. |  | ||||||
|         /// </summary> |  | ||||||
|         private readonly TimeSpan _alertSilencePeriod = TimeSpan.FromHours(4); |         private readonly TimeSpan _alertSilencePeriod = TimeSpan.FromHours(4); | ||||||
|  |  | ||||||
|         /// <summary> |         // Eliminamos IHolidayService del constructor | ||||||
|         /// Inicializa una nueva instancia de la clase <see cref="DataFetchingService"/>. |  | ||||||
|         /// </summary> |  | ||||||
|         /// <param name="logger">Logger para registrar información y eventos.</param> |  | ||||||
|         /// <param name="serviceProvider">Proveedor de servicios para la inyección de dependencias con scope.</param> |  | ||||||
|         /// <param name="configuration">Configuración de la aplicación para obtener los schedules de Cron.</param> |  | ||||||
|         public DataFetchingService( |         public DataFetchingService( | ||||||
|             ILogger<DataFetchingService> logger, |             ILogger<DataFetchingService> logger, | ||||||
|             IServiceProvider serviceProvider, |             IServiceProvider serviceProvider, | ||||||
| @@ -85,10 +60,8 @@ namespace Mercados.Worker | |||||||
|         } |         } | ||||||
|  |  | ||||||
|         /// <summary> |         /// <summary> | ||||||
|         /// Método principal del servicio que se ejecuta en segundo plano. Contiene el bucle |         /// Método principal del servicio. Se ejecuta una vez cuando el servicio arranca. | ||||||
|         /// principal que verifica periódicamente si se debe ejecutar alguna tarea programada. |  | ||||||
|         /// </summary> |         /// </summary> | ||||||
|         /// <param name="stoppingToken">Token de cancelación para detener el servicio de forma segura.</param> |  | ||||||
|         protected override async Task ExecuteAsync(CancellationToken stoppingToken) |         protected override async Task ExecuteAsync(CancellationToken stoppingToken) | ||||||
|         { |         { | ||||||
|             _logger.LogInformation("🚀 Servicio de Fetching iniciado a las: {time}", DateTimeOffset.Now); |             _logger.LogInformation("🚀 Servicio de Fetching iniciado a las: {time}", DateTimeOffset.Now); | ||||||
| @@ -181,9 +154,6 @@ namespace Mercados.Worker | |||||||
|         /// <summary> |         /// <summary> | ||||||
|         /// Ejecuta un fetcher específico por su nombre, gestionando el scope de DI y las notificaciones. |         /// Ejecuta un fetcher específico por su nombre, gestionando el scope de DI y las notificaciones. | ||||||
|         /// </summary> |         /// </summary> | ||||||
|         /// <param name="sourceName">El nombre del <see cref="IDataFetcher"/> a ejecutar.</param> |  | ||||||
|         /// <param name="stoppingToken">Token de cancelación para detener la operación si el servicio se está parando.</param> |  | ||||||
|         /// <remarks>Este método crea un nuevo scope de DI para resolver los servicios necesarios.</remarks> |  | ||||||
|         private async Task RunFetcherByNameAsync(string sourceName, CancellationToken stoppingToken) |         private async Task RunFetcherByNameAsync(string sourceName, CancellationToken stoppingToken) | ||||||
|         { |         { | ||||||
|             if (stoppingToken.IsCancellationRequested) return; |             if (stoppingToken.IsCancellationRequested) return; | ||||||
| @@ -223,8 +193,6 @@ namespace Mercados.Worker | |||||||
|         /// <summary> |         /// <summary> | ||||||
|         /// Ejecuta todos los fetchers en paralelo al iniciar el servicio. |         /// Ejecuta todos los fetchers en paralelo al iniciar el servicio. | ||||||
|         /// </summary> |         /// </summary> | ||||||
|         /// <param name="stoppingToken">Token de cancelación para detener la operación si el servicio se está parando.</param> |  | ||||||
|         /// <remarks>Esta función se usa principalmente para una ejecución de prueba al arrancar.</remarks> |  | ||||||
|         private async Task RunAllFetchersAsync(CancellationToken stoppingToken) |         private async Task RunAllFetchersAsync(CancellationToken stoppingToken) | ||||||
|         { |         { | ||||||
|             _logger.LogInformation("Ejecutando todos los fetchers al iniciar en paralelo..."); |             _logger.LogInformation("Ejecutando todos los fetchers al iniciar en paralelo..."); | ||||||
| @@ -243,8 +211,6 @@ namespace Mercados.Worker | |||||||
|         /// <summary> |         /// <summary> | ||||||
|         /// Determina si se debe enviar una alerta o si está en período de silencio. |         /// Determina si se debe enviar una alerta o si está en período de silencio. | ||||||
|         /// </summary> |         /// </summary> | ||||||
|         /// <param name="taskName">El nombre de la tarea que podría generar la alerta.</param> |  | ||||||
|         /// <returns>True si se debe enviar la alerta; de lo contrario, false.</returns> |  | ||||||
|         private bool ShouldSendAlert(string taskName) |         private bool ShouldSendAlert(string taskName) | ||||||
|         { |         { | ||||||
|             if (!_lastAlertSent.ContainsKey(taskName)) |             if (!_lastAlertSent.ContainsKey(taskName)) | ||||||
| @@ -258,13 +224,8 @@ namespace Mercados.Worker | |||||||
|  |  | ||||||
|         #endregion |         #endregion | ||||||
|  |  | ||||||
|         /// <summary> |         // Creamos una única función para comprobar feriados que obtiene el servicio | ||||||
|         /// Comprueba si una fecha dada es feriado para un mercado específico. |         // desde un scope. | ||||||
|         /// </summary> |  | ||||||
|         /// <param name="marketCode">El código del mercado (ej. "US", "BA").</param> |  | ||||||
|         /// <param name="date">La fecha a comprobar.</param> |  | ||||||
|         /// <returns>True si es feriado, false si no lo es o si ocurre un error.</returns> |  | ||||||
|         /// <remarks>Este método resuelve el <see cref="IHolidayService"/> desde un nuevo scope de DI para cada llamada.</remarks> |  | ||||||
|         private async Task<bool> IsMarketHolidayAsync(string marketCode, DateTime date) |         private async Task<bool> IsMarketHolidayAsync(string marketCode, DateTime date) | ||||||
|         { |         { | ||||||
|             using var scope = _serviceProvider.CreateScope(); |             using var scope = _serviceProvider.CreateScope(); | ||||||
|   | |||||||
| @@ -5,7 +5,6 @@ | |||||||
|     <Nullable>enable</Nullable> |     <Nullable>enable</Nullable> | ||||||
|     <ImplicitUsings>enable</ImplicitUsings> |     <ImplicitUsings>enable</ImplicitUsings> | ||||||
|     <UserSecretsId>dotnet-Mercados.Worker-0e9a6e84-c8fb-400b-ba7b-193d9981a046</UserSecretsId> |     <UserSecretsId>dotnet-Mercados.Worker-0e9a6e84-c8fb-400b-ba7b-193d9981a046</UserSecretsId> | ||||||
|     <GenerateDocumentationFile>true</GenerateDocumentationFile> |  | ||||||
|   </PropertyGroup> |   </PropertyGroup> | ||||||
|  |  | ||||||
|   <ItemGroup> |   <ItemGroup> | ||||||
|   | |||||||
| @@ -1,7 +1,7 @@ | |||||||
| { | { | ||||||
|   "Logging": { |   "Logging": { | ||||||
|     "LogLevel": { |     "LogLevel": { | ||||||
|       "Default": "Information", |       "Default": "Debug", | ||||||
|       "Microsoft.Hosting.Lifetime": "Information" |       "Microsoft.Hosting.Lifetime": "Information" | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -10,7 +10,7 @@ | |||||||
|     "DefaultConnection": "" |     "DefaultConnection": "" | ||||||
|   }, |   }, | ||||||
|   "Schedules": { |   "Schedules": { | ||||||
|     "MercadoAgroganadero": "0 11 * * 1-5", |     "MercadoAgroganadero": "0 11,15,18,21 * * 1-5", | ||||||
|     "BCR": "30 11 * * 1-5", |     "BCR": "30 11 * * 1-5", | ||||||
|     "Bolsas": "10 11-17 * * 1-5", |     "Bolsas": "10 11-17 * * 1-5", | ||||||
|     "Holidays": "0 2 * * 1" |     "Holidays": "0 2 * * 1" | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user